diff --git a/.github/scripts/create_changelog.sh b/.github/scripts/create_changelog.sh index 8d2a35936..9b2731590 100755 --- a/.github/scripts/create_changelog.sh +++ b/.github/scripts/create_changelog.sh @@ -10,5 +10,5 @@ latest_verion=$(git describe --tags --abbrev=0) if [[ $RELEASE_MASTER -eq 1 ]]; then auto-changelog --stdout -t changelog-master.hbs --starting-version "$master_version" --commit-limit 3 else - auto-changelog --stdout --starting-version "$latest_verion" --unreleased --commit-limit 0 + auto-changelog --stdout --starting-version "$latest_verion" --unreleased-only --commit-limit 0 fi \ No newline at end of file diff --git a/bazarr/api/badges/badges.py b/bazarr/api/badges/badges.py index 7d65f586f..834460deb 100644 --- a/bazarr/api/badges/badges.py +++ b/bazarr/api/badges/badges.py @@ -8,12 +8,13 @@ from flask_restx import Resource, Namespace, fields from app.database import get_exclusion_clause, TableEpisodes, TableShows, TableMovies from app.get_providers import get_throttled_providers from app.signalr_client import sonarr_signalr_client, radarr_signalr_client +from app.announcements import get_all_announcements from utilities.health import get_health_issues from ..utils import authenticate api_ns_badges = Namespace('Badges', description='Get badges count to update the UI (episodes and movies wanted ' - 'subtitles, providers with issues and health issues.') + 'subtitles, providers with issues, health issues and announcements.') @api_ns_badges.route('badges') @@ -25,6 +26,7 @@ class Badges(Resource): 'status': fields.Integer(), 'sonarr_signalr': fields.String(), 'radarr_signalr': fields.String(), + 'announcements': fields.Integer(), }) @authenticate @@ -62,5 +64,6 @@ class Badges(Resource): "status": health_issues, 'sonarr_signalr': "LIVE" if sonarr_signalr_client.connected else "", 'radarr_signalr': "LIVE" if radarr_signalr_client.connected else "", + 'announcements': len(get_all_announcements()), } return result diff --git a/bazarr/api/episodes/blacklist.py b/bazarr/api/episodes/blacklist.py index 3e8115b0c..6f6774e1e 100644 --- a/bazarr/api/episodes/blacklist.py +++ b/bazarr/api/episodes/blacklist.py @@ -1,6 +1,5 @@ # coding=utf-8 -import datetime import pretty from flask_restx import Resource, Namespace, reqparse, fields @@ -13,7 +12,7 @@ from subtitles.mass_download import episode_download_subtitles from app.event_handler import event_stream from api.swaggerui import subtitles_language_model -from ..utils import authenticate, postprocessEpisode +from ..utils import authenticate, postprocess api_ns_episodes_blacklist = Namespace('Episodes Blacklist', description='List, add or remove subtitles to or from ' 'episodes blacklist') @@ -59,18 +58,17 @@ class EpisodesBlacklist(Resource): TableBlacklist.timestamp)\ .join(TableEpisodes, on=(TableBlacklist.sonarr_episode_id == TableEpisodes.sonarrEpisodeId))\ .join(TableShows, on=(TableBlacklist.sonarr_series_id == TableShows.sonarrSeriesId))\ - .order_by(TableBlacklist.timestamp.desc())\ - .limit(length)\ - .offset(start)\ - .dicts() - data = list(data) + .order_by(TableBlacklist.timestamp.desc()) + if length > 0: + data = data.limit(length).offset(start) + data = list(data.dicts()) for item in data: # Make timestamp pretty - item["parsed_timestamp"] = datetime.datetime.fromtimestamp(int(item['timestamp'])).strftime('%x %X') - item.update({'timestamp': pretty.date(datetime.datetime.fromtimestamp(item['timestamp']))}) + item["parsed_timestamp"] = item['timestamp'].strftime('%x %X') + item.update({'timestamp': pretty.date(item['timestamp'])}) - postprocessEpisode(item) + postprocess(item) return data diff --git a/bazarr/api/episodes/episodes.py b/bazarr/api/episodes/episodes.py index d04ae0297..379c73fc6 100644 --- a/bazarr/api/episodes/episodes.py +++ b/bazarr/api/episodes/episodes.py @@ -5,7 +5,7 @@ from flask_restx import Resource, Namespace, reqparse, fields from app.database import TableEpisodes from api.swaggerui import subtitles_model, subtitles_language_model, audio_language_model -from ..utils import authenticate, postprocessEpisode +from ..utils import authenticate, postprocess api_ns_episodes = Namespace('Episodes', description='List episodes metadata for specific series or episodes.') @@ -68,6 +68,6 @@ class Episodes(Resource): result = list(result) for item in result: - postprocessEpisode(item) + postprocess(item) return result diff --git a/bazarr/api/episodes/episodes_subtitles.py b/bazarr/api/episodes/episodes_subtitles.py index 300a58abc..74818a7cf 100644 --- a/bazarr/api/episodes/episodes_subtitles.py +++ b/bazarr/api/episodes/episodes_subtitles.py @@ -42,13 +42,14 @@ class EpisodesSubtitles(Resource): args = self.patch_request_parser.parse_args() sonarrSeriesId = args.get('seriesid') sonarrEpisodeId = args.get('episodeid') - episodeInfo = TableEpisodes.select(TableEpisodes.path, - TableEpisodes.scene_name, - TableEpisodes.audio_language, - TableShows.title) \ + episodeInfo = TableEpisodes.select( + TableEpisodes.path, + TableEpisodes.sceneName, + TableEpisodes.audio_language, + TableShows.title) \ .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) \ - .where(TableEpisodes.sonarrEpisodeId == sonarrEpisodeId)\ - .dicts()\ + .where(TableEpisodes.sonarrEpisodeId == sonarrEpisodeId) \ + .dicts() \ .get_or_none() if not episodeInfo: @@ -56,13 +57,13 @@ class EpisodesSubtitles(Resource): title = episodeInfo['title'] episodePath = path_mappings.path_replace(episodeInfo['path']) - sceneName = episodeInfo['scene_name'] or "None" + sceneName = episodeInfo['sceneName'] or "None" language = args.get('language') hi = args.get('hi').capitalize() forced = args.get('forced').capitalize() - audio_language_list = get_audio_profile_languages(episode_id=sonarrEpisodeId) + audio_language_list = get_audio_profile_languages(episodeInfo["audio_language"]) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: @@ -73,23 +74,9 @@ class EpisodesSubtitles(Resource): title, 'series', profile_id=get_profile_id(episode_id=sonarrEpisodeId))) if result: result = result[0] - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] - history_log(1, sonarrSeriesId, sonarrEpisodeId, message, path, language_code, provider, score, subs_id, - subs_path) - send_notifications(sonarrSeriesId, sonarrEpisodeId, message) - store_subtitles(path, episodePath) + history_log(1, sonarrSeriesId, sonarrEpisodeId, result) + send_notifications(sonarrSeriesId, sonarrEpisodeId, result.message) + store_subtitles(result.path, episodePath) else: event_stream(type='episode', payload=sonarrEpisodeId) @@ -117,21 +104,22 @@ class EpisodesSubtitles(Resource): args = self.post_request_parser.parse_args() sonarrSeriesId = args.get('seriesid') sonarrEpisodeId = args.get('episodeid') - episodeInfo = TableEpisodes.select(TableEpisodes.title, - TableEpisodes.path, - TableEpisodes.scene_name, - TableEpisodes.audio_language)\ - .where(TableEpisodes.sonarrEpisodeId == sonarrEpisodeId)\ - .dicts()\ + episodeInfo = TableEpisodes.select(TableEpisodes.path, + TableEpisodes.audio_language) \ + .where(TableEpisodes.sonarrEpisodeId == sonarrEpisodeId) \ + .dicts() \ .get_or_none() if not episodeInfo: return 'Episode not found', 404 - title = episodeInfo['title'] episodePath = path_mappings.path_replace(episodeInfo['path']) - sceneName = episodeInfo['scene_name'] or "None" - audio_language = episodeInfo['audio_language'] + + audio_language = get_audio_profile_languages(episodeInfo['audio_language']) + if len(audio_language) and isinstance(audio_language[0], dict): + audio_language = audio_language[0] + else: + audio_language = {'name': '', 'code2': '', 'code3': ''} language = args.get('language') forced = True if args.get('forced') == 'true' else False @@ -148,8 +136,6 @@ class EpisodesSubtitles(Resource): language=language, forced=forced, hi=hi, - title=title, - scene_name=sceneName, media_type='series', subtitle=subFile, audio_language=audio_language) @@ -157,22 +143,12 @@ class EpisodesSubtitles(Resource): if not result: logging.debug(f"BAZARR unable to process subtitles for this episode: {episodePath}") else: - message = result[0] - path = result[1] - subs_path = result[2] - if hi: - language_code = language + ":hi" - elif forced: - language_code = language + ":forced" - else: - language_code = language provider = "manual" score = 360 - history_log(4, sonarrSeriesId, sonarrEpisodeId, message, path, language_code, provider, score, - subtitles_path=subs_path) + history_log(4, sonarrSeriesId, sonarrEpisodeId, result, fake_provider=provider, fake_score=score) if not settings.general.getboolean('dont_notify_manual_actions'): - send_notifications(sonarrSeriesId, sonarrEpisodeId, message) - store_subtitles(path, episodePath) + send_notifications(sonarrSeriesId, sonarrEpisodeId, result.message) + store_subtitles(result.path, episodePath) except OSError: pass @@ -199,10 +175,10 @@ class EpisodesSubtitles(Resource): sonarrEpisodeId = args.get('episodeid') episodeInfo = TableEpisodes.select(TableEpisodes.title, TableEpisodes.path, - TableEpisodes.scene_name, - TableEpisodes.audio_language)\ - .where(TableEpisodes.sonarrEpisodeId == sonarrEpisodeId)\ - .dicts()\ + TableEpisodes.sceneName, + TableEpisodes.audio_language) \ + .where(TableEpisodes.sonarrEpisodeId == sonarrEpisodeId) \ + .dicts() \ .get_or_none() if not episodeInfo: diff --git a/bazarr/api/episodes/history.py b/bazarr/api/episodes/history.py index a333b433e..128f9eb32 100644 --- a/bazarr/api/episodes/history.py +++ b/bazarr/api/episodes/history.py @@ -1,21 +1,18 @@ # coding=utf-8 -import datetime import os import operator import pretty from flask_restx import Resource, Namespace, reqparse, fields from functools import reduce -from peewee import fn -from datetime import timedelta -from app.database import get_exclusion_clause, TableEpisodes, TableShows, TableHistory, TableBlacklist -from app.config import settings +from app.database import TableEpisodes, TableShows, TableHistory, TableBlacklist +from subtitles.upgrade import get_upgradable_episode_subtitles from utilities.path_mappings import path_mappings from api.swaggerui import subtitles_language_model -from ..utils import authenticate, postprocessEpisode +from ..utils import authenticate, postprocess api_ns_episodes_history = Namespace('Episodes History', description='List episodes history events') @@ -70,42 +67,15 @@ class EpisodesHistory(Resource): length = args.get('length') episodeid = args.get('episodeid') - upgradable_episodes_not_perfect = [] - if settings.general.getboolean('upgrade_subs'): - days_to_upgrade_subs = settings.general.days_to_upgrade_subs - minimum_timestamp = ((datetime.datetime.now() - timedelta(days=int(days_to_upgrade_subs))) - - datetime.datetime(1970, 1, 1)).total_seconds() - - if settings.general.getboolean('upgrade_manual'): - query_actions = [1, 2, 3, 6] - else: - query_actions = [1, 3] - - upgradable_episodes_conditions = [(TableHistory.action.in_(query_actions)), - (TableHistory.timestamp > minimum_timestamp), - (TableHistory.score.is_null(False))] - upgradable_episodes_conditions += get_exclusion_clause('series') - upgradable_episodes = TableHistory.select(TableHistory.video_path, - fn.MAX(TableHistory.timestamp).alias('timestamp'), - TableHistory.score, - TableShows.tags, - TableEpisodes.monitored, - TableShows.seriesType)\ - .join(TableEpisodes, on=(TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId))\ - .join(TableShows, on=(TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId))\ - .where(reduce(operator.and_, upgradable_episodes_conditions))\ - .group_by(TableHistory.video_path)\ - .dicts() - upgradable_episodes = list(upgradable_episodes) - for upgradable_episode in upgradable_episodes: - if upgradable_episode['timestamp'] > minimum_timestamp: - try: - int(upgradable_episode['score']) - except ValueError: - pass - else: - if int(upgradable_episode['score']) < 360: - upgradable_episodes_not_perfect.append(upgradable_episode) + upgradable_episodes_not_perfect = get_upgradable_episode_subtitles() + if len(upgradable_episodes_not_perfect): + upgradable_episodes_not_perfect = [{"video_path": x['video_path'], + "timestamp": x['timestamp'], + "score": x['score'], + "tags": x['tags'], + "monitored": x['monitored'], + "seriesType": x['seriesType']} + for x in upgradable_episodes_not_perfect] query_conditions = [(TableEpisodes.title.is_null(False))] if episodeid: @@ -114,7 +84,8 @@ class EpisodesHistory(Resource): episode_history = TableHistory.select(TableHistory.id, TableShows.title.alias('seriesTitle'), TableEpisodes.monitored, - TableEpisodes.season.concat('x').concat(TableEpisodes.episode).alias('episode_number'), + TableEpisodes.season.concat('x').concat(TableEpisodes.episode).alias( + 'episode_number'), TableEpisodes.title.alias('episodeTitle'), TableHistory.timestamp, TableHistory.subs_id, @@ -129,15 +100,14 @@ class EpisodesHistory(Resource): TableHistory.subtitles_path, TableHistory.sonarrEpisodeId, TableHistory.provider, - TableShows.seriesType)\ - .join(TableShows, on=(TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId))\ - .join(TableEpisodes, on=(TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId))\ - .where(query_condition)\ - .order_by(TableHistory.timestamp.desc())\ - .limit(length)\ - .offset(start)\ - .dicts() - episode_history = list(episode_history) + TableShows.seriesType) \ + .join(TableShows, on=(TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId)) \ + .join(TableEpisodes, on=(TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId)) \ + .where(query_condition) \ + .order_by(TableHistory.timestamp.desc()) + if length > 0: + episode_history = episode_history.limit(length).offset(start) + episode_history = list(episode_history.dicts()) blacklist_db = TableBlacklist.select(TableBlacklist.provider, TableBlacklist.subs_id).dicts() blacklist_db = list(blacklist_db) @@ -145,7 +115,7 @@ class EpisodesHistory(Resource): for item in episode_history: # Mark episode as upgradable or not item.update({"upgradable": False}) - if {"video_path": str(item['path']), "timestamp": float(item['timestamp']), "score": str(item['score']), + if {"video_path": str(item['path']), "timestamp": item['timestamp'], "score": item['score'], "tags": str(item['tags']), "monitored": str(item['monitored']), "seriesType": str(item['seriesType'])} in upgradable_episodes_not_perfect: # noqa: E129 if os.path.exists(path_mappings.path_replace(item['subtitles_path'])) and \ @@ -154,16 +124,16 @@ class EpisodesHistory(Resource): del item['path'] - postprocessEpisode(item) + postprocess(item) if item['score']: item['score'] = str(round((int(item['score']) * 100 / 360), 2)) + "%" # Make timestamp pretty if item['timestamp']: - item["raw_timestamp"] = int(item['timestamp']) - item["parsed_timestamp"] = datetime.datetime.fromtimestamp(int(item['timestamp'])).strftime('%x %X') - item['timestamp'] = pretty.date(item["raw_timestamp"]) + item["raw_timestamp"] = item['timestamp'].timestamp() + item["parsed_timestamp"] = item['timestamp'].strftime('%x %X') + item['timestamp'] = pretty.date(item["timestamp"]) # Check if subtitles is blacklisted item.update({"blacklisted": False}) @@ -174,8 +144,8 @@ class EpisodesHistory(Resource): item.update({"blacklisted": True}) break - count = TableHistory.select()\ - .join(TableEpisodes, on=(TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId))\ + count = TableHistory.select() \ + .join(TableEpisodes, on=(TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId)) \ .where(TableEpisodes.title.is_null(False)).count() return {'data': episode_history, 'total': count} diff --git a/bazarr/api/episodes/wanted.py b/bazarr/api/episodes/wanted.py index c7248ce15..5f0278382 100644 --- a/bazarr/api/episodes/wanted.py +++ b/bazarr/api/episodes/wanted.py @@ -8,7 +8,7 @@ from functools import reduce from app.database import get_exclusion_clause, TableEpisodes, TableShows from api.swaggerui import subtitles_language_model -from ..utils import authenticate, postprocessEpisode +from ..utils import authenticate, postprocess api_ns_episodes_wanted = Namespace('Episodes Wanted', description='List episodes wanted subtitles') @@ -65,7 +65,7 @@ class EpisodesWanted(Resource): TableEpisodes.missing_subtitles, TableEpisodes.sonarrSeriesId, TableEpisodes.sonarrEpisodeId, - TableEpisodes.scene_name.alias('sceneName'), + TableEpisodes.sceneName, TableShows.tags, TableEpisodes.failedAttempts, TableShows.seriesType)\ @@ -82,20 +82,20 @@ class EpisodesWanted(Resource): TableEpisodes.missing_subtitles, TableEpisodes.sonarrSeriesId, TableEpisodes.sonarrEpisodeId, - TableEpisodes.scene_name.alias('sceneName'), + TableEpisodes.sceneName, TableShows.tags, TableEpisodes.failedAttempts, TableShows.seriesType)\ .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId))\ .where(wanted_condition)\ - .order_by(TableEpisodes.rowid.desc())\ - .limit(length)\ - .offset(start)\ - .dicts() + .order_by(TableEpisodes.rowid.desc()) + if length > 0: + data = data.limit(length).offset(start) + data = data.dicts() data = list(data) for item in data: - postprocessEpisode(item) + postprocess(item) count_conditions = [(TableEpisodes.missing_subtitles != '[]')] count_conditions += get_exclusion_clause('series') diff --git a/bazarr/api/history/stats.py b/bazarr/api/history/stats.py index e103b7de3..951777988 100644 --- a/bazarr/api/history/stats.py +++ b/bazarr/api/history/stats.py @@ -1,6 +1,5 @@ # coding=utf-8 -import time import datetime import operator import itertools @@ -63,8 +62,8 @@ class HistoryStats(Resource): elif timeframe == 'week': delay = 6 * 24 * 60 * 60 - now = time.time() - past = now - delay + now = datetime.datetime.now() + past = now - datetime.timedelta(seconds=delay) history_where_clauses = [(TableHistory.timestamp.between(past, now))] history_where_clauses_movie = [(TableHistoryMovie.timestamp.between(past, now))] @@ -92,7 +91,7 @@ class HistoryStats(Resource): .dicts() data_series = [{'date': date[0], 'count': sum(1 for item in date[1])} for date in itertools.groupby(list(data_series), - key=lambda x: datetime.datetime.fromtimestamp(x['timestamp']).strftime( + key=lambda x: x['timestamp'].strftime( '%Y-%m-%d'))] data_movies = TableHistoryMovie.select(TableHistoryMovie.timestamp, TableHistoryMovie.id) \ @@ -100,7 +99,7 @@ class HistoryStats(Resource): .dicts() data_movies = [{'date': date[0], 'count': sum(1 for item in date[1])} for date in itertools.groupby(list(data_movies), - key=lambda x: datetime.datetime.fromtimestamp(x['timestamp']).strftime( + key=lambda x: x['timestamp'].strftime( '%Y-%m-%d'))] for dt in rrule.rrule(rrule.DAILY, diff --git a/bazarr/api/movies/blacklist.py b/bazarr/api/movies/blacklist.py index 8e70006fd..8dba9b555 100644 --- a/bazarr/api/movies/blacklist.py +++ b/bazarr/api/movies/blacklist.py @@ -1,6 +1,5 @@ # coding=utf-8 -import datetime import pretty from flask_restx import Resource, Namespace, reqparse, fields @@ -13,7 +12,7 @@ from subtitles.mass_download import movies_download_subtitles from app.event_handler import event_stream from api.swaggerui import subtitles_language_model -from ..utils import authenticate, postprocessMovie +from ..utils import authenticate, postprocess api_ns_movies_blacklist = Namespace('Movies Blacklist', description='List, add or remove subtitles to or from ' 'movies blacklist') @@ -54,18 +53,17 @@ class MoviesBlacklist(Resource): TableBlacklistMovie.language, TableBlacklistMovie.timestamp)\ .join(TableMovies, on=(TableBlacklistMovie.radarr_id == TableMovies.radarrId))\ - .order_by(TableBlacklistMovie.timestamp.desc())\ - .limit(length)\ - .offset(start)\ - .dicts() - data = list(data) + .order_by(TableBlacklistMovie.timestamp.desc()) + if length > 0: + data = data.limit(length).offset(start) + data = list(data.dicts()) for item in data: - postprocessMovie(item) + postprocess(item) # Make timestamp pretty - item["parsed_timestamp"] = datetime.datetime.fromtimestamp(int(item['timestamp'])).strftime('%x %X') - item.update({'timestamp': pretty.date(datetime.datetime.fromtimestamp(item['timestamp']))}) + item["parsed_timestamp"] = item['timestamp'].strftime('%x %X') + item.update({'timestamp': pretty.date(item['timestamp'])}) return data diff --git a/bazarr/api/movies/history.py b/bazarr/api/movies/history.py index 576bd395e..b9d904bb5 100644 --- a/bazarr/api/movies/history.py +++ b/bazarr/api/movies/history.py @@ -1,21 +1,18 @@ # coding=utf-8 -import datetime import os import operator import pretty from flask_restx import Resource, Namespace, reqparse, fields from functools import reduce -from peewee import fn -from datetime import timedelta -from app.database import get_exclusion_clause, TableMovies, TableHistoryMovie, TableBlacklistMovie -from app.config import settings +from app.database import TableMovies, TableHistoryMovie, TableBlacklistMovie +from subtitles.upgrade import get_upgradable_movies_subtitles from utilities.path_mappings import path_mappings from api.swaggerui import subtitles_language_model -from ..utils import authenticate, postprocessMovie +from api.utils import authenticate, postprocess api_ns_movies_history = Namespace('Movies History', description='List movies history events') @@ -66,42 +63,14 @@ class MoviesHistory(Resource): length = args.get('length') radarrid = args.get('radarrid') - upgradable_movies = [] - upgradable_movies_not_perfect = [] - if settings.general.getboolean('upgrade_subs'): - days_to_upgrade_subs = settings.general.days_to_upgrade_subs - minimum_timestamp = ((datetime.datetime.now() - timedelta(days=int(days_to_upgrade_subs))) - - datetime.datetime(1970, 1, 1)).total_seconds() - - if settings.general.getboolean('upgrade_manual'): - query_actions = [1, 2, 3, 6] - else: - query_actions = [1, 3] - - upgradable_movies_conditions = [(TableHistoryMovie.action.in_(query_actions)), - (TableHistoryMovie.timestamp > minimum_timestamp), - (TableHistoryMovie.score.is_null(False))] - upgradable_movies_conditions += get_exclusion_clause('movie') - upgradable_movies = TableHistoryMovie.select(TableHistoryMovie.video_path, - fn.MAX(TableHistoryMovie.timestamp).alias('timestamp'), - TableHistoryMovie.score, - TableMovies.tags, - TableMovies.monitored)\ - .join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId))\ - .where(reduce(operator.and_, upgradable_movies_conditions))\ - .group_by(TableHistoryMovie.video_path)\ - .dicts() - upgradable_movies = list(upgradable_movies) - - for upgradable_movie in upgradable_movies: - if upgradable_movie['timestamp'] > minimum_timestamp: - try: - int(upgradable_movie['score']) - except ValueError: - pass - else: - if int(upgradable_movie['score']) < 120: - upgradable_movies_not_perfect.append(upgradable_movie) + upgradable_movies_not_perfect = get_upgradable_movies_subtitles() + if len(upgradable_movies_not_perfect): + upgradable_movies_not_perfect = [{"video_path": x['video_path'], + "timestamp": x['timestamp'], + "score": x['score'], + "tags": x['tags'], + "monitored": x['monitored']} + for x in upgradable_movies_not_perfect] query_conditions = [(TableMovies.title.is_null(False))] if radarrid: @@ -122,14 +91,13 @@ class MoviesHistory(Resource): TableHistoryMovie.subs_id, TableHistoryMovie.provider, TableHistoryMovie.subtitles_path, - TableHistoryMovie.video_path)\ - .join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId))\ - .where(query_condition)\ - .order_by(TableHistoryMovie.timestamp.desc())\ - .limit(length)\ - .offset(start)\ - .dicts() - movie_history = list(movie_history) + TableHistoryMovie.video_path) \ + .join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId)) \ + .where(query_condition) \ + .order_by(TableHistoryMovie.timestamp.desc()) + if length > 0: + movie_history = movie_history.limit(length).offset(start) + movie_history = list(movie_history.dicts()) blacklist_db = TableBlacklistMovie.select(TableBlacklistMovie.provider, TableBlacklistMovie.subs_id).dicts() blacklist_db = list(blacklist_db) @@ -137,24 +105,25 @@ class MoviesHistory(Resource): for item in movie_history: # Mark movies as upgradable or not item.update({"upgradable": False}) - if {"video_path": str(item['path']), "timestamp": float(item['timestamp']), "score": str(item['score']), - "tags": str(item['tags']), "monitored": str(item['monitored'])} in upgradable_movies_not_perfect: # noqa: E129 + if {"video_path": str(item['path']), "timestamp": item['timestamp'], "score": item['score'], + "tags": str(item['tags']), + "monitored": str(item['monitored'])} in upgradable_movies_not_perfect: # noqa: E129 if os.path.exists(path_mappings.path_replace_movie(item['subtitles_path'])) and \ os.path.exists(path_mappings.path_replace_movie(item['video_path'])): item.update({"upgradable": True}) del item['path'] - postprocessMovie(item) + postprocess(item) if item['score']: item['score'] = str(round((int(item['score']) * 100 / 120), 2)) + "%" # Make timestamp pretty if item['timestamp']: - item["raw_timestamp"] = int(item['timestamp']) - item["parsed_timestamp"] = datetime.datetime.fromtimestamp(int(item['timestamp'])).strftime('%x %X') - item['timestamp'] = pretty.date(item["raw_timestamp"]) + item["raw_timestamp"] = item['timestamp'].timestamp() + item["parsed_timestamp"] = item['timestamp'].strftime('%x %X') + item['timestamp'] = pretty.date(item["timestamp"]) # Check if subtitles is blacklisted item.update({"blacklisted": False}) @@ -165,9 +134,9 @@ class MoviesHistory(Resource): item.update({"blacklisted": True}) break - count = TableHistoryMovie.select()\ - .join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId))\ - .where(TableMovies.title.is_null(False))\ + count = TableHistoryMovie.select() \ + .join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId)) \ + .where(TableMovies.title.is_null(False)) \ .count() return {'data': movie_history, 'total': count} diff --git a/bazarr/api/movies/movies.py b/bazarr/api/movies/movies.py index 4d8852527..1435978f0 100644 --- a/bazarr/api/movies/movies.py +++ b/bazarr/api/movies/movies.py @@ -9,8 +9,7 @@ from subtitles.wanted import wanted_search_missing_subtitles_movies from subtitles.mass_download import movies_download_subtitles from api.swaggerui import subtitles_model, subtitles_language_model, audio_language_model -from ..utils import authenticate, postprocessMovie, None_Keys - +from api.utils import authenticate, None_Keys, postprocess api_ns_movies = Namespace('Movies', description='List movies metadata, update movie languages profile or run actions ' 'for specific movies.') @@ -82,10 +81,13 @@ class Movies(Resource): .order_by(TableMovies.sortTitle)\ .dicts() else: - result = TableMovies.select().order_by(TableMovies.sortTitle).limit(length).offset(start).dicts() + result = TableMovies.select().order_by(TableMovies.sortTitle) + if length > 0: + result = result.limit(length).offset(start) + result = result.dicts() result = list(result) for item in result: - postprocessMovie(item) + postprocess(item) return {'data': result, 'total': count} diff --git a/bazarr/api/movies/movies_subtitles.py b/bazarr/api/movies/movies_subtitles.py index 78bda7a9c..8e7f2fc20 100644 --- a/bazarr/api/movies/movies_subtitles.py +++ b/bazarr/api/movies/movies_subtitles.py @@ -1,5 +1,6 @@ # coding=utf-8 +import contextlib import os import logging @@ -20,7 +21,6 @@ from app.config import settings from ..utils import authenticate - api_ns_movies_subtitles = Namespace('Movies Subtitles', description='Download, upload or delete movies subtitles') @@ -42,12 +42,13 @@ class MoviesSubtitles(Resource): args = self.patch_request_parser.parse_args() radarrId = args.get('radarrid') - movieInfo = TableMovies.select(TableMovies.title, - TableMovies.path, - TableMovies.sceneName, - TableMovies.audio_language)\ - .where(TableMovies.radarrId == radarrId)\ - .dicts()\ + movieInfo = TableMovies.select( + TableMovies.title, + TableMovies.path, + TableMovies.sceneName, + TableMovies.audio_language) \ + .where(TableMovies.radarrId == radarrId) \ + .dicts() \ .get_or_none() if not movieInfo: @@ -57,44 +58,26 @@ class MoviesSubtitles(Resource): sceneName = movieInfo['sceneName'] or 'None' title = movieInfo['title'] - audio_language = movieInfo['audio_language'] language = args.get('language') hi = args.get('hi').capitalize() forced = args.get('forced').capitalize() - audio_language_list = get_audio_profile_languages(movie_id=radarrId) + audio_language_list = get_audio_profile_languages(movieInfo["audio_language"]) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: audio_language = None - try: + with contextlib.suppress(OSError): result = list(generate_subtitles(moviePath, [(language, hi, forced)], audio_language, sceneName, title, 'movie', profile_id=get_profile_id(movie_id=radarrId))) if result: result = result[0] - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] - history_log_movie(1, radarrId, message, path, language_code, provider, score, subs_id, subs_path) - send_notifications_movie(radarrId, message) - store_subtitles_movie(path, moviePath) + history_log_movie(1, radarrId, result) + store_subtitles_movie(result.path, moviePath) else: event_stream(type='movie', payload=radarrId) - except OSError: - pass - return '', 204 # POST: Upload Subtitles @@ -116,9 +99,7 @@ class MoviesSubtitles(Resource): # TODO: Support Multiply Upload args = self.post_request_parser.parse_args() radarrId = args.get('radarrid') - movieInfo = TableMovies.select(TableMovies.title, - TableMovies.path, - TableMovies.sceneName, + movieInfo = TableMovies.select(TableMovies.path, TableMovies.audio_language) \ .where(TableMovies.radarrId == radarrId) \ .dicts() \ @@ -128,14 +109,16 @@ class MoviesSubtitles(Resource): return 'Movie not found', 404 moviePath = path_mappings.path_replace_movie(movieInfo['path']) - sceneName = movieInfo['sceneName'] or 'None' - title = movieInfo['title'] - audioLanguage = movieInfo['audio_language'] + audio_language = get_audio_profile_languages(movieInfo['audio_language']) + if len(audio_language) and isinstance(audio_language[0], dict): + audio_language = audio_language[0] + else: + audio_language = {'name': '', 'code2': '', 'code3': ''} language = args.get('language') - forced = True if args.get('forced') == 'true' else False - hi = True if args.get('hi') == 'true' else False + forced = args.get('forced') == 'true' + hi = args.get('hi') == 'true' subFile = args.get('file') _, ext = os.path.splitext(subFile.filename) @@ -143,38 +126,24 @@ class MoviesSubtitles(Resource): if not isinstance(ext, str) or ext.lower() not in SUBTITLE_EXTENSIONS: raise ValueError('A subtitle of an invalid format was uploaded.') - try: + with contextlib.suppress(OSError): result = manual_upload_subtitle(path=moviePath, language=language, forced=forced, hi=hi, - title=title, - scene_name=sceneName, media_type='movie', subtitle=subFile, - audio_language=audioLanguage) + audio_language=audio_language) if not result: logging.debug(f"BAZARR unable to process subtitles for this movie: {moviePath}") else: - message = result[0] - path = result[1] - subs_path = result[2] - if hi: - language_code = language + ":hi" - elif forced: - language_code = language + ":forced" - else: - language_code = language provider = "manual" score = 120 - history_log_movie(4, radarrId, message, path, language_code, provider, score, subtitles_path=subs_path) + history_log_movie(4, radarrId, result, fake_provider=provider, fake_score=score) if not settings.general.getboolean('dont_notify_manual_actions'): - send_notifications_movie(radarrId, message) - store_subtitles_movie(path, moviePath) - except OSError: - pass - + send_notifications_movie(radarrId, result.message) + store_subtitles_movie(result.path, moviePath) return '', 204 # DELETE: Delete Subtitles diff --git a/bazarr/api/movies/wanted.py b/bazarr/api/movies/wanted.py index 9a860e9ce..49c0cda2c 100644 --- a/bazarr/api/movies/wanted.py +++ b/bazarr/api/movies/wanted.py @@ -8,7 +8,7 @@ from functools import reduce from app.database import get_exclusion_clause, TableMovies from api.swaggerui import subtitles_language_model -from ..utils import authenticate, postprocessMovie +from api.utils import authenticate, postprocess api_ns_movies_wanted = Namespace('Movies Wanted', description='List movies wanted subtitles') @@ -75,14 +75,14 @@ class MoviesWanted(Resource): TableMovies.tags, TableMovies.monitored)\ .where(wanted_condition)\ - .order_by(TableMovies.rowid.desc())\ - .limit(length)\ - .offset(start)\ - .dicts() + .order_by(TableMovies.rowid.desc()) + if length > 0: + result = result.limit(length).offset(start) + result = result.dicts() result = list(result) for item in result: - postprocessMovie(item) + postprocess(item) count_conditions = [(TableMovies.missing_subtitles != '[]')] count_conditions += get_exclusion_clause('movie') diff --git a/bazarr/api/providers/providers_episodes.py b/bazarr/api/providers/providers_episodes.py index 0e1080cbc..6d1a940f6 100644 --- a/bazarr/api/providers/providers_episodes.py +++ b/bazarr/api/providers/providers_episodes.py @@ -13,7 +13,6 @@ from subtitles.indexer.series import store_subtitles from ..utils import authenticate - api_ns_providers_episodes = Namespace('Providers Episodes', description='List and download episodes subtitles manually') @@ -49,10 +48,10 @@ class ProviderEpisodes(Resource): args = self.get_request_parser.parse_args() sonarrEpisodeId = args.get('episodeid') episodeInfo = TableEpisodes.select(TableEpisodes.path, - TableEpisodes.scene_name, + TableEpisodes.sceneName, TableShows.title, TableShows.profileId) \ - .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId))\ + .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) \ .where(TableEpisodes.sonarrEpisodeId == sonarrEpisodeId) \ .dicts() \ .get_or_none() @@ -62,7 +61,7 @@ class ProviderEpisodes(Resource): title = episodeInfo['title'] episodePath = path_mappings.path_replace(episodeInfo['path']) - sceneName = episodeInfo['scene_name'] or "None" + sceneName = episodeInfo['sceneName'] or "None" profileId = episodeInfo['profileId'] providers_list = get_providers() @@ -92,9 +91,11 @@ class ProviderEpisodes(Resource): args = self.post_request_parser.parse_args() sonarrSeriesId = args.get('seriesid') sonarrEpisodeId = args.get('episodeid') - episodeInfo = TableEpisodes.select(TableEpisodes.path, - TableEpisodes.scene_name, - TableShows.title) \ + episodeInfo = TableEpisodes.select( + TableEpisodes.audio_language, + TableEpisodes.path, + TableEpisodes.sceneName, + TableShows.title) \ .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) \ .where(TableEpisodes.sonarrEpisodeId == sonarrEpisodeId) \ .dicts() \ @@ -105,7 +106,7 @@ class ProviderEpisodes(Resource): title = episodeInfo['title'] episodePath = path_mappings.path_replace(episodeInfo['path']) - sceneName = episodeInfo['scene_name'] or "None" + sceneName = episodeInfo['sceneName'] or "None" hi = args.get('hi').capitalize() forced = args.get('forced').capitalize() @@ -113,7 +114,7 @@ class ProviderEpisodes(Resource): selected_provider = args.get('provider') subtitle = args.get('subtitle') - audio_language_list = get_audio_profile_languages(episode_id=sonarrEpisodeId) + audio_language_list = get_audio_profile_languages(episodeInfo["audio_language"]) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: @@ -123,26 +124,11 @@ class ProviderEpisodes(Resource): result = manual_download_subtitle(episodePath, audio_language, hi, forced, subtitle, selected_provider, sceneName, title, 'series', use_original_format, profile_id=get_profile_id(episode_id=sonarrEpisodeId)) - if result is not None: - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] - history_log(2, sonarrSeriesId, sonarrEpisodeId, message, path, language_code, provider, score, subs_id, - subs_path) + if result: + history_log(2, sonarrSeriesId, sonarrEpisodeId, result) if not settings.general.getboolean('dont_notify_manual_actions'): - send_notifications(sonarrSeriesId, sonarrEpisodeId, message) - store_subtitles(path, episodePath) - return result, 201 + send_notifications(sonarrSeriesId, sonarrEpisodeId, result.message) + store_subtitles(result.path, episodePath) except OSError: pass diff --git a/bazarr/api/providers/providers_movies.py b/bazarr/api/providers/providers_movies.py index 9861b873c..1014933f4 100644 --- a/bazarr/api/providers/providers_movies.py +++ b/bazarr/api/providers/providers_movies.py @@ -110,7 +110,7 @@ class ProviderMovies(Resource): selected_provider = args.get('provider') subtitle = args.get('subtitle') - audio_language_list = get_audio_profile_languages(movie_id=radarrId) + audio_language_list = get_audio_profile_languages(movieInfo["audio_language"]) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: @@ -121,23 +121,10 @@ class ProviderMovies(Resource): sceneName, title, 'movie', use_original_format, profile_id=get_profile_id(movie_id=radarrId)) if result is not None: - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] - history_log_movie(2, radarrId, message, path, language_code, provider, score, subs_id, subs_path) + history_log_movie(2, radarrId, result) if not settings.general.getboolean('dont_notify_manual_actions'): - send_notifications_movie(radarrId, message) - store_subtitles_movie(path, moviePath) + send_notifications_movie(radarrId, result.message) + store_subtitles_movie(result.path, moviePath) except OSError: pass diff --git a/bazarr/api/series/series.py b/bazarr/api/series/series.py index 849824127..b36d3db30 100644 --- a/bazarr/api/series/series.py +++ b/bazarr/api/series/series.py @@ -4,6 +4,7 @@ import operator from flask_restx import Resource, Namespace, reqparse, fields from functools import reduce +from peewee import fn, JOIN from app.database import get_exclusion_clause, TableEpisodes, TableShows from subtitles.indexer.series import list_missing_subtitles, series_scan_subtitles @@ -12,8 +13,7 @@ from subtitles.wanted import wanted_search_missing_subtitles_series from app.event_handler import event_stream from api.swaggerui import subtitles_model, subtitles_language_model, audio_language_model -from ..utils import authenticate, postprocessSeries, None_Keys - +from api.utils import authenticate, None_Keys, postprocess api_ns_series = Namespace('Series', description='List series metadata, update series languages profile or run actions ' 'for specific series.') @@ -34,8 +34,8 @@ class Series(Resource): data_model = api_ns_series.model('series_data_model', { 'alternativeTitles': fields.List(fields.String), 'audio_language': fields.Nested(get_audio_language_model), - 'episodeFileCount': fields.Integer(), - 'episodeMissingCount': fields.Integer(), + 'episodeFileCount': fields.Integer(default=0), + 'episodeMissingCount': fields.Integer(default=0), 'fanart': fields.String(), 'imdbId': fields.String(), 'monitored': fields.Boolean(), @@ -70,40 +70,37 @@ class Series(Resource): seriesId = args.get('seriesid[]') count = TableShows.select().count() + episodeFileCount = TableEpisodes.select(TableShows.sonarrSeriesId, + fn.COUNT(TableEpisodes.sonarrSeriesId).coerce(False).alias('episodeFileCount')) \ + .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) \ + .group_by(TableShows.sonarrSeriesId).alias('episodeFileCount') + + episodes_missing_conditions = [(TableEpisodes.missing_subtitles != '[]')] + episodes_missing_conditions += get_exclusion_clause('series') + + episodeMissingCount = (TableEpisodes.select(TableShows.sonarrSeriesId, + fn.COUNT(TableEpisodes.sonarrSeriesId).coerce(False).alias('episodeMissingCount')) + .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) + .where(reduce(operator.and_, episodes_missing_conditions)).group_by( + TableShows.sonarrSeriesId).alias('episodeMissingCount')) + + result = TableShows.select(TableShows, episodeFileCount.c.episodeFileCount, + episodeMissingCount.c.episodeMissingCount).join(episodeFileCount, + join_type=JOIN.LEFT_OUTER, on=( + TableShows.sonarrSeriesId == + episodeFileCount.c.sonarrSeriesId) + ) \ + .join(episodeMissingCount, join_type=JOIN.LEFT_OUTER, + on=(TableShows.sonarrSeriesId == episodeMissingCount.c.sonarrSeriesId)).order_by(TableShows.sortTitle) if len(seriesId) != 0: - result = TableShows.select() \ - .where(TableShows.sonarrSeriesId.in_(seriesId)) \ - .order_by(TableShows.sortTitle).dicts() - else: - result = TableShows.select().order_by(TableShows.sortTitle).limit(length).offset(start).dicts() - - result = list(result) + result = result.where(TableShows.sonarrSeriesId.in_(seriesId)) + elif length > 0: + result = result.limit(length).offset(start) + result = list(result.dicts()) for item in result: - postprocessSeries(item) - - # Add missing subtitles episode count - episodes_missing_conditions = [(TableEpisodes.sonarrSeriesId == item['sonarrSeriesId']), - (TableEpisodes.missing_subtitles != '[]')] - episodes_missing_conditions += get_exclusion_clause('series') - - episodeMissingCount = TableEpisodes.select(TableShows.tags, - TableEpisodes.monitored, - TableShows.seriesType) \ - .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) \ - .where(reduce(operator.and_, episodes_missing_conditions)) \ - .count() - item.update({"episodeMissingCount": episodeMissingCount}) - - # Add episode count - episodeFileCount = TableEpisodes.select(TableShows.tags, - TableEpisodes.monitored, - TableShows.seriesType) \ - .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) \ - .where(TableEpisodes.sonarrSeriesId == item['sonarrSeriesId']) \ - .count() - item.update({"episodeFileCount": episodeFileCount}) + postprocess(item) return {'data': result, 'total': count} diff --git a/bazarr/api/subtitles/subtitles.py b/bazarr/api/subtitles/subtitles.py index 6ead0c833..01f486204 100644 --- a/bazarr/api/subtitles/subtitles.py +++ b/bazarr/api/subtitles/subtitles.py @@ -7,6 +7,7 @@ import gc from flask_restx import Resource, Namespace, reqparse from app.database import TableEpisodes, TableMovies +from languages.get_languages import alpha3_from_alpha2 from utilities.path_mappings import path_mappings from subtitles.tools.subsyncer import SubSyncer from subtitles.tools.translate import translate_subtitles_file @@ -81,7 +82,7 @@ class Subtitles(Resource): del subsync gc.collect() elif action == 'translate': - from_language = os.path.splitext(subtitles_path)[0].rsplit(".", 1)[1].replace('_', '-') + from_language = subtitles_lang_from_filename(subtitles_path) dest_language = language forced = True if args.get('forced') == 'true' else False hi = True if args.get('hi') == 'true' else False @@ -93,7 +94,8 @@ class Subtitles(Resource): radarr_id=id) else: use_original_format = True if args.get('original_format') == 'true' else False - subtitles_apply_mods(language, subtitles_path, [action], use_original_format) + subtitles_apply_mods(language=language, subtitle_path=subtitles_path, mods=[action], + use_original_format=use_original_format, video_path=video_path) # apply chmod if required chmod = int(settings.general.chmod, 8) if not sys.platform.startswith( @@ -110,3 +112,25 @@ class Subtitles(Resource): event_stream(type='movie', payload=int(id)) return '', 204 + + +def subtitles_lang_from_filename(path): + split_extensionless_path = os.path.splitext(path.lower())[0].rsplit(".", 2) + + if len(split_extensionless_path) < 2: + return None + elif len(split_extensionless_path) == 2: + return_lang = split_extensionless_path[-1] + else: + first_ext = split_extensionless_path[-1] + second_ext = split_extensionless_path[-2] + + if first_ext in ['hi', 'sdh', 'cc']: + if alpha3_from_alpha2(second_ext): + return_lang = second_ext + else: + return first_ext + else: + return_lang = first_ext + + return return_lang.replace('_', '-') diff --git a/bazarr/api/system/__init__.py b/bazarr/api/system/__init__.py index 4da8c633d..a491135a8 100644 --- a/bazarr/api/system/__init__.py +++ b/bazarr/api/system/__init__.py @@ -3,6 +3,7 @@ from .system import api_ns_system from .searches import api_ns_system_searches from .account import api_ns_system_account +from .announcements import api_ns_system_announcements from .backups import api_ns_system_backups from .tasks import api_ns_system_tasks from .logs import api_ns_system_logs @@ -17,6 +18,7 @@ from .notifications import api_ns_system_notifications api_ns_list_system = [ api_ns_system, api_ns_system_account, + api_ns_system_announcements, api_ns_system_backups, api_ns_system_health, api_ns_system_languages, diff --git a/bazarr/api/system/announcements.py b/bazarr/api/system/announcements.py new file mode 100644 index 000000000..27efcb815 --- /dev/null +++ b/bazarr/api/system/announcements.py @@ -0,0 +1,35 @@ +# coding=utf-8 + +from flask_restx import Resource, Namespace, reqparse + +from app.announcements import get_all_announcements, mark_announcement_as_dismissed + +from ..utils import authenticate + +api_ns_system_announcements = Namespace('System Announcements', description='List announcements relative to Bazarr') + + +@api_ns_system_announcements.route('system/announcements') +class SystemAnnouncements(Resource): + @authenticate + @api_ns_system_announcements.doc(parser=None) + @api_ns_system_announcements.response(200, 'Success') + @api_ns_system_announcements.response(401, 'Not Authenticated') + def get(self): + """List announcements relative to Bazarr""" + return {'data': get_all_announcements()} + + post_request_parser = reqparse.RequestParser() + post_request_parser.add_argument('hash', type=str, required=True, help='hash of the announcement to dismiss') + + @authenticate + @api_ns_system_announcements.doc(parser=post_request_parser) + @api_ns_system_announcements.response(204, 'Success') + @api_ns_system_announcements.response(401, 'Not Authenticated') + def post(self): + """Mark announcement as dismissed""" + args = self.post_request_parser.parse_args() + hashed_announcement = args.get('hash') + + mark_announcement_as_dismissed(hashed_announcement=hashed_announcement) + return '', 204 diff --git a/bazarr/api/system/settings.py b/bazarr/api/system/settings.py index 48b69789c..e6be9f444 100644 --- a/bazarr/api/system/settings.py +++ b/bazarr/api/system/settings.py @@ -6,7 +6,7 @@ from flask import request, jsonify from flask_restx import Resource, Namespace from app.database import TableLanguagesProfiles, TableSettingsLanguages, TableShows, TableMovies, \ - TableSettingsNotifier + TableSettingsNotifier, update_profile_id_list from app.event_handler import event_stream from app.config import settings, save_settings, get_settings from app.scheduler import scheduler @@ -92,6 +92,9 @@ class SystemSettings(Resource): # Remove deleted profiles TableLanguagesProfiles.delete().where(TableLanguagesProfiles.profileId == profileId).execute() + # invalidate cache + update_profile_id_list.invalidate() + event_stream("languages") if settings.general.getboolean('use_sonarr'): diff --git a/bazarr/api/utils.py b/bazarr/api/utils.py index 2070285d9..fe48f59d4 100644 --- a/bazarr/api/utils.py +++ b/bazarr/api/utils.py @@ -36,178 +36,61 @@ def authenticate(actual_method): def postprocess(item): # Remove ffprobe_cache - if 'ffprobe_cache' in item: - del (item['ffprobe_cache']) + if item.get('movie_file_id'): + path_replace = path_mappings.path_replace_movie + else: + path_replace = path_mappings.path_replace + if item.get('ffprobe_cache'): + del item['ffprobe_cache'] - # Parse tags - if 'tags' in item: - if item['tags'] is None: - item['tags'] = [] - else: - item['tags'] = ast.literal_eval(item['tags']) - - if 'monitored' in item: - if item['monitored'] is None: - item['monitored'] = False - else: - item['monitored'] = item['monitored'] == 'True' - - if 'hearing_impaired' in item and item['hearing_impaired'] is not None: - if item['hearing_impaired'] is None: - item['hearing_impaired'] = False - else: - item['hearing_impaired'] = item['hearing_impaired'] == 'True' - - if 'language' in item: - if item['language'] == 'None': - item['language'] = None - elif item['language'] is not None: - splitted_language = item['language'].split(':') - item['language'] = {"name": language_from_alpha2(splitted_language[0]), - "code2": splitted_language[0], - "code3": alpha3_from_alpha2(splitted_language[0]), - "forced": True if item['language'].endswith(':forced') else False, - "hi": True if item['language'].endswith(':hi') else False} - - -def postprocessSeries(item): - postprocess(item) # Parse audio language - if 'audio_language' in item and item['audio_language'] is not None: - item['audio_language'] = get_audio_profile_languages(series_id=item['sonarrSeriesId']) - - if 'alternateTitles' in item: - if item['alternateTitles'] is None: - item['alternativeTitles'] = [] - else: - item['alternativeTitles'] = ast.literal_eval(item['alternateTitles']) - del item["alternateTitles"] - - # Parse seriesType - if 'seriesType' in item and item['seriesType'] is not None: - item['seriesType'] = item['seriesType'].capitalize() - - if 'path' in item: - item['path'] = path_mappings.path_replace(item['path']) - - # map poster and fanart to server proxy - if 'poster' in item: - poster = item['poster'] - item['poster'] = f"{base_url}/images/series{poster}" if poster else None - - if 'fanart' in item: - fanart = item['fanart'] - item['fanart'] = f"{base_url}/images/series{fanart}" if fanart else None - - -def postprocessEpisode(item): - postprocess(item) - if 'audio_language' in item and item['audio_language'] is not None: - item['audio_language'] = get_audio_profile_languages(episode_id=item['sonarrEpisodeId']) - - if 'subtitles' in item: - if item['subtitles'] is None: - raw_subtitles = [] - else: - raw_subtitles = ast.literal_eval(item['subtitles']) - subtitles = [] - - for subs in raw_subtitles: - subtitle = subs[0].split(':') - sub = {"name": language_from_alpha2(subtitle[0]), - "code2": subtitle[0], - "code3": alpha3_from_alpha2(subtitle[0]), - "path": path_mappings.path_replace(subs[1]), - "forced": False, - "hi": False} - if len(subtitle) > 1: - sub["forced"] = True if subtitle[1] == 'forced' else False - sub["hi"] = True if subtitle[1] == 'hi' else False - - subtitles.append(sub) + if item.get('audio_language'): + item['audio_language'] = get_audio_profile_languages(item['audio_language']) - item.update({"subtitles": subtitles}) - - # Parse missing subtitles - if 'missing_subtitles' in item: - if item['missing_subtitles'] is None: - item['missing_subtitles'] = [] - else: - item['missing_subtitles'] = ast.literal_eval(item['missing_subtitles']) - for i, subs in enumerate(item['missing_subtitles']): - subtitle = subs.split(':') - item['missing_subtitles'][i] = {"name": language_from_alpha2(subtitle[0]), - "code2": subtitle[0], - "code3": alpha3_from_alpha2(subtitle[0]), - "forced": False, - "hi": False} - if len(subtitle) > 1: - item['missing_subtitles'][i].update({ - "forced": True if subtitle[1] == 'forced' else False, - "hi": True if subtitle[1] == 'hi' else False - }) - - if 'scene_name' in item: - item["sceneName"] = item["scene_name"] - del item["scene_name"] - - if 'path' in item and item['path']: - # Provide mapped path - item['path'] = path_mappings.path_replace(item['path']) - - -# TODO: Move -def postprocessMovie(item): - postprocess(item) - # Parse audio language - if 'audio_language' in item and item['audio_language'] is not None: - item['audio_language'] = get_audio_profile_languages(movie_id=item['radarrId']) + # Make sure profileId is a valid None value + if item.get('profileId') in None_Keys: + item['profileId'] = None # Parse alternate titles - if 'alternativeTitles' in item: - if item['alternativeTitles'] is None: - item['alternativeTitles'] = [] - else: - item['alternativeTitles'] = ast.literal_eval(item['alternativeTitles']) + if item.get('alternativeTitles'): + item['alternativeTitles'] = ast.literal_eval(item['alternativeTitles']) + else: + item['alternativeTitles'] = [] # Parse failed attempts - if 'failedAttempts' in item: - if item['failedAttempts']: - item['failedAttempts'] = ast.literal_eval(item['failedAttempts']) + if item.get('failedAttempts'): + item['failedAttempts'] = ast.literal_eval(item['failedAttempts']) + else: + item['failedAttempts'] = [] # Parse subtitles - if 'subtitles' in item: - if item['subtitles'] is None: - item['subtitles'] = [] - else: - item['subtitles'] = ast.literal_eval(item['subtitles']) + if item.get('subtitles'): + item['subtitles'] = ast.literal_eval(item['subtitles']) for i, subs in enumerate(item['subtitles']): language = subs[0].split(':') - item['subtitles'][i] = {"path": path_mappings.path_replace_movie(subs[1]), + item['subtitles'][i] = {"path": path_replace(subs[1]), "name": language_from_alpha2(language[0]), "code2": language[0], "code3": alpha3_from_alpha2(language[0]), "forced": False, "hi": False} if len(language) > 1: - item['subtitles'][i].update({ - "forced": True if language[1] == 'forced' else False, - "hi": True if language[1] == 'hi' else False - }) - - if settings.general.getboolean('embedded_subs_show_desired'): + item['subtitles'][i].update( + { + "forced": language[1] == 'forced', + "hi": language[1] == 'hi', + } + ) + if settings.general.getboolean('embedded_subs_show_desired') and item.get('profileId'): desired_lang_list = get_desired_languages(item['profileId']) item['subtitles'] = [x for x in item['subtitles'] if x['code2'] in desired_lang_list or x['path']] - - if item['subtitles']: - item['subtitles'] = sorted(item['subtitles'], key=itemgetter('name', 'forced')) + item['subtitles'] = sorted(item['subtitles'], key=itemgetter('name', 'forced')) + else: + item['subtitles'] = [] # Parse missing subtitles - if 'missing_subtitles' in item: - if item['missing_subtitles'] is None: - item['missing_subtitles'] = [] - else: - item['missing_subtitles'] = ast.literal_eval(item['missing_subtitles']) + if item.get('missing_subtitles'): + item['missing_subtitles'] = ast.literal_eval(item['missing_subtitles']) for i, subs in enumerate(item['missing_subtitles']): language = subs.split(':') item['missing_subtitles'][i] = {"name": language_from_alpha2(language[0]), @@ -216,25 +99,58 @@ def postprocessMovie(item): "forced": False, "hi": False} if len(language) > 1: - item['missing_subtitles'][i].update({ - "forced": True if language[1] == 'forced' else False, - "hi": True if language[1] == 'hi' else False - }) + item['missing_subtitles'][i].update( + { + "forced": language[1] == 'forced', + "hi": language[1] == 'hi', + } + ) + else: + item['missing_subtitles'] = [] + + # Parse tags + if item.get('tags') is not None: + item['tags'] = ast.literal_eval(item.get('tags', '[]')) + else: + item['tags'] = [] + if item.get('monitored'): + item['monitored'] = item.get('monitored') == 'True' + else: + item['monitored'] = False + if item.get('hearing_impaired'): + item['hearing_impaired'] = item.get('hearing_impaired') == 'True' + else: + item['hearing_impaired'] = False + + if item.get('language'): + if item['language'] == 'None': + item['language'] = None + if item['language'] is not None: + splitted_language = item['language'].split(':') + item['language'] = { + "name": language_from_alpha2(splitted_language[0]), + "code2": splitted_language[0], + "code3": alpha3_from_alpha2(splitted_language[0]), + "forced": bool(item['language'].endswith(':forced')), + "hi": bool(item['language'].endswith(':hi')), + } + + # Parse seriesType + if item.get('seriesType'): + item['seriesType'] = item['seriesType'].capitalize() - # Provide mapped path - if 'path' in item: - if item['path']: - item['path'] = path_mappings.path_replace_movie(item['path']) + if item.get('path'): + item['path'] = path_replace(item['path']) - if 'subtitles_path' in item: + if item.get('subtitles_path'): # Provide mapped subtitles path - item['subtitles_path'] = path_mappings.path_replace_movie(item['subtitles_path']) + item['subtitles_path'] = path_replace(item['subtitles_path']) # map poster and fanart to server proxy - if 'poster' in item: + if item.get('poster') is not None: poster = item['poster'] - item['poster'] = f"{base_url}/images/movies{poster}" if poster else None + item['poster'] = f"{base_url}/images/{'movies' if item.get('movie_file_id') else 'series'}{poster}" if poster else None - if 'fanart' in item: + if item.get('fanart') is not None: fanart = item['fanart'] - item['fanart'] = f"{base_url}/images/movies{fanart}" if fanart else None + item['fanart'] = f"{base_url}/images/{'movies' if item.get('movie_file_id') else 'series'}{fanart}" if fanart else None diff --git a/bazarr/app/announcements.py b/bazarr/app/announcements.py new file mode 100644 index 000000000..be01d67a7 --- /dev/null +++ b/bazarr/app/announcements.py @@ -0,0 +1,113 @@ +# coding=utf-8 + +import os +import hashlib +import requests +import logging +import json +import pretty + +from datetime import datetime +from operator import itemgetter + +from app.get_providers import get_enabled_providers +from app.database import TableAnnouncements +from .get_args import args + + +# Announcements as receive by browser must be in the form of a list of dicts converted to JSON +# [ +# { +# 'text': 'some text', +# 'link': 'http://to.somewhere.net', +# 'hash': '', +# 'dismissible': True, +# 'timestamp': 1676236978, +# 'enabled': True, +# }, +# ] + + +def parse_announcement_dict(announcement_dict): + announcement_dict['timestamp'] = pretty.date(announcement_dict['timestamp']) + announcement_dict['link'] = announcement_dict.get('link', '') + announcement_dict['dismissible'] = announcement_dict.get('dismissible', True) + announcement_dict['enabled'] = announcement_dict.get('enabled', True) + announcement_dict['hash'] = hashlib.sha256(announcement_dict['text'].encode('UTF8')).hexdigest() + + return announcement_dict + + +def get_announcements_to_file(): + try: + r = requests.get("https://raw.githubusercontent.com/morpheus65535/bazarr-binaries/master/announcements.json") + except requests.exceptions.HTTPError: + logging.exception("Error trying to get announcements from Github. Http error.") + except requests.exceptions.ConnectionError: + logging.exception("Error trying to get announcements from Github. Connection Error.") + except requests.exceptions.Timeout: + logging.exception("Error trying to get announcements from Github. Timeout Error.") + except requests.exceptions.RequestException: + logging.exception("Error trying to get announcements from Github.") + else: + with open(os.path.join(args.config_dir, 'config', 'announcements.json'), 'wb') as f: + f.write(r.content) + + +def get_online_announcements(): + try: + with open(os.path.join(args.config_dir, 'config', 'announcements.json'), 'r') as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return [] + else: + for announcement in data['data']: + if 'enabled' not in announcement: + data['data'][announcement]['enabled'] = True + if 'dismissible' not in announcement: + data['data'][announcement]['dismissible'] = True + + return data['data'] + + +def get_local_announcements(): + announcements = [] + + # opensubtitles.org end-of-life + enabled_providers = get_enabled_providers() + if enabled_providers and 'opensubtitles' in enabled_providers: + announcements.append({ + 'text': 'Opensubtitles.org will be deprecated soon, migrate to Opensubtitles.com ASAP and disable this ' + 'provider to remove this announcement.', + 'link': 'https://wiki.bazarr.media/Troubleshooting/OpenSubtitles-migration/', + 'dismissible': False, + 'timestamp': 1676236978, + }) + + for announcement in announcements: + if 'enabled' not in announcement: + announcement['enabled'] = True + if 'dismissible' not in announcement: + announcement['dismissible'] = True + + return announcements + + +def get_all_announcements(): + # get announcements that haven't been dismissed yet + announcements = [parse_announcement_dict(x) for x in get_online_announcements() + get_local_announcements() if + x['enabled'] and (not x['dismissible'] or not TableAnnouncements.select() + .where(TableAnnouncements.hash == + hashlib.sha256(x['text'].encode('UTF8')).hexdigest()).get_or_none())] + + return sorted(announcements, key=itemgetter('timestamp'), reverse=True) + + +def mark_announcement_as_dismissed(hashed_announcement): + text = [x['text'] for x in get_all_announcements() if x['hash'] == hashed_announcement] + if text: + TableAnnouncements.insert({TableAnnouncements.hash: hashed_announcement, + TableAnnouncements.timestamp: datetime.now(), + TableAnnouncements.text: text[0]})\ + .on_conflict_ignore(ignore=True)\ + .execute() diff --git a/bazarr/app/app.py b/bazarr/app/app.py index 3afd2909d..8445df0e0 100644 --- a/bazarr/app/app.py +++ b/bazarr/app/app.py @@ -5,6 +5,7 @@ from flask import Flask, redirect from flask_cors import CORS from flask_socketio import SocketIO +from .database import database from .get_args import args from .config import settings, base_url @@ -37,6 +38,19 @@ def create_app(): def page_not_found(_): return redirect(base_url, code=302) + # This hook ensures that a connection is opened to handle any queries + # generated by the request. + @app.before_request + def _db_connect(): + database.connect() + + # This hook ensures that the connection is closed when we've finished + # processing the request. + @app.teardown_request + def _db_close(exc): + if not database.is_closed(): + database.close() + return app diff --git a/bazarr/app/config.py b/bazarr/app/config.py index 0ce861856..37da0f744 100644 --- a/bazarr/app/config.py +++ b/bazarr/app/config.py @@ -74,12 +74,15 @@ defaults = { 'days_to_upgrade_subs': '7', 'upgrade_manual': 'True', 'anti_captcha_provider': 'None', - 'wanted_search_frequency': '3', - 'wanted_search_frequency_movie': '3', + 'wanted_search_frequency': '6', + 'wanted_search_frequency_movie': '6', 'subzero_mods': '[]', 'dont_notify_manual_actions': 'False', 'hi_extension': 'hi', - 'embedded_subtitles_parser': 'ffprobe' + 'embedded_subtitles_parser': 'ffprobe', + 'default_und_audio_lang': '', + 'default_und_embedded_subtitles_lang': '', + 'parse_embedded_audio_track': 'False' }, 'auth': { 'type': 'None', @@ -101,6 +104,7 @@ defaults = { 'port': '8989', 'base_url': '/', 'ssl': 'False', + 'http_timeout': '60', 'apikey': '', 'full_update': 'Daily', 'full_update_day': '6', @@ -119,6 +123,7 @@ defaults = { 'port': '7878', 'base_url': '/', 'ssl': 'False', + 'http_timeout': '60', 'apikey': '', 'full_update': 'Daily', 'full_update_day': '6', @@ -161,6 +166,9 @@ defaults = { 'podnapisi': { 'verify_ssl': 'True' }, + 'subf2m': { + 'verify_ssl': 'True' + }, 'legendasdivx': { 'username': '', 'password': '', @@ -259,6 +267,14 @@ defaults = { "streaming_service": 1, "edition": 1, "hearing_impaired": 1, + }, + 'postgresql': { + 'enabled': 'False', + 'host': 'localhost', + 'port': '5432', + 'database': '', + 'username': '', + 'password': '', } } @@ -302,6 +318,12 @@ settings.radarr.base_url = base_url_slash_cleaner(uri=settings.radarr.base_url) if settings.general.page_size not in ['25', '50', '100', '250', '500', '1000']: settings.general.page_size = defaults['general']['page_size'] +# increase delay between searches to reduce impact on providers +if settings.general.wanted_search_frequency == '3': + settings.general.wanted_search_frequency = '6' +if settings.general.wanted_search_frequency_movie == '3': + settings.general.wanted_search_frequency_movie = '6' + # save updated settings to file if os.path.exists(os.path.join(args.config_dir, 'config', 'config.ini')): with open(os.path.join(args.config_dir, 'config', 'config.ini'), 'w+') as handle: @@ -362,6 +384,9 @@ def save_settings(settings_items): sonarr_exclusion_updated = False radarr_exclusion_updated = False use_embedded_subs_changed = False + undefined_audio_track_default_changed = False + undefined_subtitles_track_default_changed = False + audio_tracks_parsing_changed = False # Subzero Mods update_subzero = False @@ -397,6 +422,15 @@ def save_settings(settings_items): 'settings-general-ignore_vobsub_subs', 'settings-general-ignore_ass_subs']: use_embedded_subs_changed = True + if key == 'settings-general-default_und_audio_lang': + undefined_audio_track_default_changed = True + + if key == 'settings-general-parse_embedded_audio_track': + audio_tracks_parsing_changed = True + + if key == 'settings-general-default_und_embedded_subtitles_lang': + undefined_subtitles_track_default_changed = True + if key in ['settings-general-base_url', 'settings-sonarr-base_url', 'settings-radarr-base_url']: value = base_url_slash_cleaner(value) @@ -518,7 +552,7 @@ def save_settings(settings_items): update_subzero = True - if use_embedded_subs_changed: + if use_embedded_subs_changed or undefined_audio_track_default_changed: from .scheduler import scheduler from subtitles.indexer.series import list_missing_subtitles from subtitles.indexer.movies import list_missing_subtitles_movies @@ -527,6 +561,26 @@ def save_settings(settings_items): if settings.general.getboolean('use_radarr'): scheduler.add_job(list_missing_subtitles_movies, kwargs={'send_event': True}) + if undefined_subtitles_track_default_changed: + from .scheduler import scheduler + from subtitles.indexer.series import series_full_scan_subtitles + from subtitles.indexer.movies import movies_full_scan_subtitles + if settings.general.getboolean('use_sonarr'): + scheduler.add_job(series_full_scan_subtitles, kwargs={'use_cache': True}) + if settings.general.getboolean('use_radarr'): + scheduler.add_job(movies_full_scan_subtitles, kwargs={'use_cache': True}) + + if audio_tracks_parsing_changed: + from .scheduler import scheduler + if settings.general.getboolean('use_sonarr'): + from sonarr.sync.episodes import sync_episodes + from sonarr.sync.series import update_series + scheduler.add_job(update_series, kwargs={'send_event': True}, max_instances=1) + scheduler.add_job(sync_episodes, kwargs={'send_event': True}, max_instances=1) + if settings.general.getboolean('use_radarr'): + from radarr.sync.movies import update_movies + scheduler.add_job(update_movies, kwargs={'send_event': True}, max_instances=1) + if update_subzero: settings.set('general', 'subzero_mods', ','.join(subzero_mods)) diff --git a/bazarr/app/database.py b/bazarr/app/database.py index fd87ce061..8f266837b 100644 --- a/bazarr/app/database.py +++ b/bazarr/app/database.py @@ -1,31 +1,64 @@ # -*- coding: utf-8 -*- - -import os +import ast import atexit import json -import ast +import logging +import os import time +from datetime import datetime -from peewee import Model, AutoField, TextField, IntegerField, ForeignKeyField, BlobField, BooleanField -from playhouse.sqliteq import SqliteQueueDatabase +from dogpile.cache import make_region +from peewee import Model, AutoField, TextField, IntegerField, ForeignKeyField, BlobField, BooleanField, BigIntegerField, \ + DateTimeField, OperationalError, PostgresqlDatabase +from playhouse.migrate import PostgresqlMigrator from playhouse.migrate import SqliteMigrator, migrate +from playhouse.shortcuts import ThreadSafeDatabaseMetadata, ReconnectMixin from playhouse.sqlite_ext import RowIDField +from playhouse.sqliteq import SqliteQueueDatabase from utilities.path_mappings import path_mappings - from .config import settings, get_array_from from .get_args import args -database = SqliteQueueDatabase(os.path.join(args.config_dir, 'db', 'bazarr.db'), - use_gevent=False, - autostart=True, - queue_max_size=256) -migrator = SqliteMigrator(database) +logger = logging.getLogger(__name__) + +postgresql = settings.postgresql.getboolean('enabled') + +region = make_region().configure('dogpile.cache.memory') + +if postgresql: + class ReconnectPostgresqlDatabase(ReconnectMixin, PostgresqlDatabase): + reconnect_errors = ( + (OperationalError, 'server closed the connection unexpectedly'), + ) + + + logger.debug( + f"Connecting to PostgreSQL database: {settings.postgresql.host}:{settings.postgresql.port}/{settings.postgresql.database}") + database = ReconnectPostgresqlDatabase(settings.postgresql.database, + user=settings.postgresql.username, + password=settings.postgresql.password, + host=settings.postgresql.host, + port=settings.postgresql.port, + autocommit=True, + autorollback=True, + autoconnect=True, + ) + migrator = PostgresqlMigrator(database) +else: + db_path = os.path.join(args.config_dir, 'db', 'bazarr.db') + logger.debug(f"Connecting to SQLite database: {db_path}") + database = SqliteQueueDatabase(db_path, + use_gevent=False, + autostart=True, + queue_max_size=256) + migrator = SqliteMigrator(database) @atexit.register def _stop_worker_threads(): - database.stop() + if not postgresql: + database.stop() class UnknownField(object): @@ -35,6 +68,7 @@ class UnknownField(object): class BaseModel(Model): class Meta: database = database + model_metadata_class = ThreadSafeDatabaseMetadata class System(BaseModel): @@ -52,7 +86,7 @@ class TableBlacklist(BaseModel): sonarr_episode_id = IntegerField(null=True) sonarr_series_id = IntegerField(null=True) subs_id = TextField(null=True) - timestamp = IntegerField(null=True) + timestamp = DateTimeField(null=True) class Meta: table_name = 'table_blacklist' @@ -64,7 +98,7 @@ class TableBlacklistMovie(BaseModel): provider = TextField(null=True) radarr_id = IntegerField(null=True) subs_id = TextField(null=True) - timestamp = IntegerField(null=True) + timestamp = DateTimeField(null=True) class Meta: table_name = 'table_blacklist_movie' @@ -79,13 +113,13 @@ class TableEpisodes(BaseModel): episode_file_id = IntegerField(null=True) failedAttempts = TextField(null=True) ffprobe_cache = BlobField(null=True) - file_size = IntegerField(default=0, null=True) + file_size = BigIntegerField(default=0, null=True) format = TextField(null=True) missing_subtitles = TextField(null=True) monitored = TextField(null=True) path = TextField() resolution = TextField(null=True) - scene_name = TextField(null=True) + sceneName = TextField(null=True) season = IntegerField() sonarrEpisodeId = IntegerField(unique=True) sonarrSeriesId = IntegerField() @@ -104,12 +138,12 @@ class TableHistory(BaseModel): id = AutoField() language = TextField(null=True) provider = TextField(null=True) - score = TextField(null=True) + score = IntegerField(null=True) sonarrEpisodeId = IntegerField() sonarrSeriesId = IntegerField() subs_id = TextField(null=True) subtitles_path = TextField(null=True) - timestamp = IntegerField() + timestamp = DateTimeField() video_path = TextField(null=True) class Meta: @@ -123,10 +157,10 @@ class TableHistoryMovie(BaseModel): language = TextField(null=True) provider = TextField(null=True) radarrId = IntegerField() - score = TextField(null=True) + score = IntegerField(null=True) subs_id = TextField(null=True) subtitles_path = TextField(null=True) - timestamp = IntegerField() + timestamp = DateTimeField() video_path = TextField(null=True) class Meta: @@ -154,7 +188,7 @@ class TableMovies(BaseModel): failedAttempts = TextField(null=True) fanart = TextField(null=True) ffprobe_cache = BlobField(null=True) - file_size = IntegerField(default=0, null=True) + file_size = BigIntegerField(default=0, null=True) format = TextField(null=True) imdbId = TextField(null=True) missing_subtitles = TextField(null=True) @@ -211,7 +245,7 @@ class TableSettingsNotifier(BaseModel): class TableShows(BaseModel): - alternateTitles = TextField(null=True) + alternativeTitles = TextField(null=True) audio_language = TextField(null=True) fanart = TextField(null=True) imdbId = TextField(default='""', null=True) @@ -264,6 +298,15 @@ class TableCustomScoreProfileConditions(BaseModel): table_name = 'table_custom_score_profile_conditions' +class TableAnnouncements(BaseModel): + timestamp = DateTimeField() + hash = TextField(null=True, unique=True) + text = TextField(null=True) + + class Meta: + table_name = 'table_announcements' + + def init_db(): # Create tables if they don't exists. database.create_tables([System, @@ -280,7 +323,8 @@ def init_db(): TableShows, TableShowsRootfolder, TableCustomScoreProfiles, - TableCustomScoreProfileConditions]) + TableCustomScoreProfileConditions, + TableAnnouncements]) # add the system table single row if it's not existing # we must retry until the tables are created @@ -296,51 +340,185 @@ def init_db(): def migrate_db(): - migrate( - migrator.add_column('table_shows', 'year', TextField(null=True)), - migrator.add_column('table_shows', 'alternateTitles', TextField(null=True)), - migrator.add_column('table_shows', 'tags', TextField(default='[]', null=True)), - migrator.add_column('table_shows', 'seriesType', TextField(default='""', null=True)), - migrator.add_column('table_shows', 'imdbId', TextField(default='""', null=True)), - migrator.add_column('table_shows', 'profileId', IntegerField(null=True)), - migrator.add_column('table_shows', 'monitored', TextField(null=True)), - migrator.add_column('table_episodes', 'format', TextField(null=True)), - migrator.add_column('table_episodes', 'resolution', TextField(null=True)), - migrator.add_column('table_episodes', 'video_codec', TextField(null=True)), - migrator.add_column('table_episodes', 'audio_codec', TextField(null=True)), - migrator.add_column('table_episodes', 'episode_file_id', IntegerField(null=True)), - migrator.add_column('table_episodes', 'audio_language', TextField(null=True)), - migrator.add_column('table_episodes', 'file_size', IntegerField(default=0, null=True)), - migrator.add_column('table_episodes', 'ffprobe_cache', BlobField(null=True)), - migrator.add_column('table_movies', 'sortTitle', TextField(null=True)), - migrator.add_column('table_movies', 'year', TextField(null=True)), - migrator.add_column('table_movies', 'alternativeTitles', TextField(null=True)), - migrator.add_column('table_movies', 'format', TextField(null=True)), - migrator.add_column('table_movies', 'resolution', TextField(null=True)), - migrator.add_column('table_movies', 'video_codec', TextField(null=True)), - migrator.add_column('table_movies', 'audio_codec', TextField(null=True)), - migrator.add_column('table_movies', 'imdbId', TextField(null=True)), - migrator.add_column('table_movies', 'movie_file_id', IntegerField(null=True)), - migrator.add_column('table_movies', 'tags', TextField(default='[]', null=True)), - migrator.add_column('table_movies', 'profileId', IntegerField(null=True)), - migrator.add_column('table_movies', 'file_size', IntegerField(default=0, null=True)), - migrator.add_column('table_movies', 'ffprobe_cache', BlobField(null=True)), - migrator.add_column('table_history', 'video_path', TextField(null=True)), - migrator.add_column('table_history', 'language', TextField(null=True)), - migrator.add_column('table_history', 'provider', TextField(null=True)), - migrator.add_column('table_history', 'score', TextField(null=True)), - migrator.add_column('table_history', 'subs_id', TextField(null=True)), - migrator.add_column('table_history', 'subtitles_path', TextField(null=True)), - migrator.add_column('table_history_movie', 'video_path', TextField(null=True)), - migrator.add_column('table_history_movie', 'language', TextField(null=True)), - migrator.add_column('table_history_movie', 'provider', TextField(null=True)), - migrator.add_column('table_history_movie', 'score', TextField(null=True)), - migrator.add_column('table_history_movie', 'subs_id', TextField(null=True)), - migrator.add_column('table_history_movie', 'subtitles_path', TextField(null=True)), - migrator.add_column('table_languages_profiles', 'mustContain', TextField(null=True)), - migrator.add_column('table_languages_profiles', 'mustNotContain', TextField(null=True)), - migrator.add_column('table_languages_profiles', 'originalFormat', BooleanField(null=True)), - ) + table_shows = [t.name for t in database.get_columns('table_shows')] + table_episodes = [t.name for t in database.get_columns('table_episodes')] + table_movies = [t.name for t in database.get_columns('table_movies')] + table_history = [t.name for t in database.get_columns('table_history')] + table_history_movie = [t.name for t in database.get_columns('table_history_movie')] + table_languages_profiles = [t.name for t in database.get_columns('table_languages_profiles')] + if "year" not in table_shows: + migrate(migrator.add_column('table_shows', 'year', TextField(null=True))) + if "alternativeTitle" not in table_shows: + migrate(migrator.add_column('table_shows', 'alternativeTitle', TextField(null=True))) + if "tags" not in table_shows: + migrate(migrator.add_column('table_shows', 'tags', TextField(default='[]', null=True))) + if "seriesType" not in table_shows: + migrate(migrator.add_column('table_shows', 'seriesType', TextField(default='""', null=True))) + if "imdbId" not in table_shows: + migrate(migrator.add_column('table_shows', 'imdbId', TextField(default='""', null=True))) + if "profileId" not in table_shows: + migrate(migrator.add_column('table_shows', 'profileId', IntegerField(null=True))) + if "profileId" not in table_shows: + migrate(migrator.add_column('table_shows', 'profileId', IntegerField(null=True))) + if "monitored" not in table_shows: + migrate(migrator.add_column('table_shows', 'monitored', TextField(null=True))) + + if "format" not in table_episodes: + migrate(migrator.add_column('table_episodes', 'format', TextField(null=True))) + if "resolution" not in table_episodes: + migrate(migrator.add_column('table_episodes', 'resolution', TextField(null=True))) + if "video_codec" not in table_episodes: + migrate(migrator.add_column('table_episodes', 'video_codec', TextField(null=True))) + if "audio_codec" not in table_episodes: + migrate(migrator.add_column('table_episodes', 'audio_codec', TextField(null=True))) + if "episode_file_id" not in table_episodes: + migrate(migrator.add_column('table_episodes', 'episode_file_id', IntegerField(null=True))) + if "audio_language" not in table_episodes: + migrate(migrator.add_column('table_episodes', 'audio_language', TextField(null=True))) + if "file_size" not in table_episodes: + migrate(migrator.add_column('table_episodes', 'file_size', BigIntegerField(default=0, null=True))) + if "ffprobe_cache" not in table_episodes: + migrate(migrator.add_column('table_episodes', 'ffprobe_cache', BlobField(null=True))) + + if "sortTitle" not in table_movies: + migrate(migrator.add_column('table_movies', 'sortTitle', TextField(null=True))) + if "year" not in table_movies: + migrate(migrator.add_column('table_movies', 'year', TextField(null=True))) + if "alternativeTitles" not in table_movies: + migrate(migrator.add_column('table_movies', 'alternativeTitles', TextField(null=True))) + if "format" not in table_movies: + migrate(migrator.add_column('table_movies', 'format', TextField(null=True))) + if "resolution" not in table_movies: + migrate(migrator.add_column('table_movies', 'resolution', TextField(null=True))) + if "video_codec" not in table_movies: + migrate(migrator.add_column('table_movies', 'video_codec', TextField(null=True))) + if "audio_codec" not in table_movies: + migrate(migrator.add_column('table_movies', 'audio_codec', TextField(null=True))) + if "imdbId" not in table_movies: + migrate(migrator.add_column('table_movies', 'imdbId', TextField(null=True))) + if "movie_file_id" not in table_movies: + migrate(migrator.add_column('table_movies', 'movie_file_id', IntegerField(null=True))) + if "tags" not in table_movies: + migrate(migrator.add_column('table_movies', 'tags', TextField(default='[]', null=True))) + if "profileId" not in table_movies: + migrate(migrator.add_column('table_movies', 'profileId', IntegerField(null=True))) + if "file_size" not in table_movies: + migrate(migrator.add_column('table_movies', 'file_size', BigIntegerField(default=0, null=True))) + if "ffprobe_cache" not in table_movies: + migrate(migrator.add_column('table_movies', 'ffprobe_cache', BlobField(null=True))) + + if "video_path" not in table_history: + migrate(migrator.add_column('table_history', 'video_path', TextField(null=True))) + if "language" not in table_history: + migrate(migrator.add_column('table_history', 'language', TextField(null=True))) + if "provider" not in table_history: + migrate(migrator.add_column('table_history', 'provider', TextField(null=True))) + if "score" not in table_history: + migrate(migrator.add_column('table_history', 'score', TextField(null=True))) + if "subs_id" not in table_history: + migrate(migrator.add_column('table_history', 'subs_id', TextField(null=True))) + if "subtitles_path" not in table_history: + migrate(migrator.add_column('table_history', 'subtitles_path', TextField(null=True))) + + if "video_path" not in table_history_movie: + migrate(migrator.add_column('table_history_movie', 'video_path', TextField(null=True))) + if "language" not in table_history_movie: + migrate(migrator.add_column('table_history_movie', 'language', TextField(null=True))) + if "provider" not in table_history_movie: + migrate(migrator.add_column('table_history_movie', 'provider', TextField(null=True))) + if "score" not in table_history_movie: + migrate(migrator.add_column('table_history_movie', 'score', TextField(null=True))) + if "subs_id" not in table_history_movie: + migrate(migrator.add_column('table_history_movie', 'subs_id', TextField(null=True))) + if "subtitles_path" not in table_history_movie: + migrate(migrator.add_column('table_history_movie', 'subtitles_path', TextField(null=True))) + + if "mustContain" not in table_languages_profiles: + migrate(migrator.add_column('table_languages_profiles', 'mustContain', TextField(null=True))) + if "mustNotContain" not in table_languages_profiles: + migrate(migrator.add_column('table_languages_profiles', 'mustNotContain', TextField(null=True))) + if "originalFormat" not in table_languages_profiles: + migrate(migrator.add_column('table_languages_profiles', 'originalFormat', BooleanField(null=True))) + + if "languages" in table_shows: + migrate(migrator.drop_column('table_shows', 'languages')) + if "hearing_impaired" in table_shows: + migrate(migrator.drop_column('table_shows', 'hearing_impaired')) + + if "languages" in table_movies: + migrate(migrator.drop_column('table_movies', 'languages')) + if "hearing_impaired" in table_movies: + migrate(migrator.drop_column('table_movies', 'hearing_impaired')) + if not any( + x + for x in database.get_columns('table_blacklist') + if x.name == "timestamp" and x.data_type in ["DATETIME", "timestamp without time zone"] + ): + migrate(migrator.alter_column_type('table_blacklist', 'timestamp', DateTimeField(default=datetime.now))) + update = TableBlacklist.select() + for item in update: + item.update({"timestamp": datetime.fromtimestamp(int(item.timestamp))}).execute() + + if not any( + x + for x in database.get_columns('table_blacklist_movie') + if x.name == "timestamp" and x.data_type in ["DATETIME", "timestamp without time zone"] + ): + migrate(migrator.alter_column_type('table_blacklist_movie', 'timestamp', DateTimeField(default=datetime.now))) + update = TableBlacklistMovie.select() + for item in update: + item.update({"timestamp": datetime.fromtimestamp(int(item.timestamp))}).execute() + + if not any( + x for x in database.get_columns('table_history') if x.name == "score" and x.data_type.lower() == "integer"): + migrate(migrator.alter_column_type('table_history', 'score', IntegerField(null=True))) + if not any( + x + for x in database.get_columns('table_history') + if x.name == "timestamp" and x.data_type in ["DATETIME", "timestamp without time zone"] + ): + migrate(migrator.alter_column_type('table_history', 'timestamp', DateTimeField(default=datetime.now))) + update = TableHistory.select() + list_to_update = [] + for i, item in enumerate(update): + item.timestamp = datetime.fromtimestamp(int(item.timestamp)) + list_to_update.append(item) + if i % 100 == 0: + TableHistory.bulk_update(list_to_update, fields=[TableHistory.timestamp]) + list_to_update = [] + if list_to_update: + TableHistory.bulk_update(list_to_update, fields=[TableHistory.timestamp]) + + if not any(x for x in database.get_columns('table_history_movie') if + x.name == "score" and x.data_type.lower() == "integer"): + migrate(migrator.alter_column_type('table_history_movie', 'score', IntegerField(null=True))) + if not any( + x + for x in database.get_columns('table_history_movie') + if x.name == "timestamp" and x.data_type in ["DATETIME", "timestamp without time zone"] + ): + migrate(migrator.alter_column_type('table_history_movie', 'timestamp', DateTimeField(default=datetime.now))) + update = TableHistoryMovie.select() + list_to_update = [] + for i, item in enumerate(update): + item.timestamp = datetime.fromtimestamp(int(item.timestamp)) + list_to_update.append(item) + if i % 100 == 0: + TableHistoryMovie.bulk_update(list_to_update, fields=[TableHistoryMovie.timestamp]) + list_to_update = [] + if list_to_update: + TableHistoryMovie.bulk_update(list_to_update, fields=[TableHistoryMovie.timestamp]) + # if not any(x for x in database.get_columns('table_movies') if x.name == "monitored" and x.data_type == "BOOLEAN"): + # migrate(migrator.alter_column_type('table_movies', 'monitored', BooleanField(null=True))) + + if database.get_columns('table_settings_providers'): + database.execute_sql('drop table if exists table_settings_providers;') + + if "alternateTitles" in table_shows: + migrate(migrator.rename_column('table_shows', 'alternateTitles', "alternativeTitles")) + + if "scene_name" in table_episodes: + migrate(migrator.rename_column('table_episodes', 'scene_name', "sceneName")) class SqliteDictPathMapper: @@ -376,21 +554,21 @@ def get_exclusion_clause(exclusion_type): if exclusion_type == 'series': tagsList = ast.literal_eval(settings.sonarr.excluded_tags) for tag in tagsList: - where_clause.append(~(TableShows.tags.contains("\'"+tag+"\'"))) + where_clause.append(~(TableShows.tags.contains("\'" + tag + "\'"))) else: tagsList = ast.literal_eval(settings.radarr.excluded_tags) for tag in tagsList: - where_clause.append(~(TableMovies.tags.contains("\'"+tag+"\'"))) + where_clause.append(~(TableMovies.tags.contains("\'" + tag + "\'"))) if exclusion_type == 'series': monitoredOnly = settings.sonarr.getboolean('only_monitored') if monitoredOnly: - where_clause.append((TableEpisodes.monitored == 'True')) - where_clause.append((TableShows.monitored == 'True')) + where_clause.append((TableEpisodes.monitored == True)) # noqa E712 + where_clause.append((TableShows.monitored == True)) # noqa E712 else: monitoredOnly = settings.radarr.getboolean('only_monitored') if monitoredOnly: - where_clause.append((TableMovies.monitored == 'True')) + where_clause.append((TableMovies.monitored == True)) # noqa E712 if exclusion_type == 'series': typesList = get_array_from(settings.sonarr.excluded_series_types) @@ -404,6 +582,7 @@ def get_exclusion_clause(exclusion_type): return where_clause +@region.cache_on_arguments() def update_profile_id_list(): profile_id_list = TableLanguagesProfiles.select(TableLanguagesProfiles.profileId, TableLanguagesProfiles.name, @@ -487,52 +666,54 @@ def get_profile_cutoff(profile_id): return cutoff_language -def get_audio_profile_languages(series_id=None, episode_id=None, movie_id=None): - from languages.get_languages import alpha2_from_language, alpha3_from_language +def get_audio_profile_languages(audio_languages_list_str): + from languages.get_languages import alpha2_from_language, alpha3_from_language, language_from_alpha2 audio_languages = [] - if series_id: - audio_languages_list_str = TableShows.get(TableShows.sonarrSeriesId == series_id).audio_language - elif episode_id: - audio_languages_list_str = TableEpisodes.get(TableEpisodes.sonarrEpisodeId == episode_id).audio_language - elif movie_id: - audio_languages_list_str = TableMovies.get(TableMovies.radarrId == movie_id).audio_language - else: - return audio_languages + und_default_language = language_from_alpha2(settings.general.default_und_audio_lang) try: - audio_languages_list = ast.literal_eval(audio_languages_list_str) + audio_languages_list = ast.literal_eval(audio_languages_list_str or '[]') except ValueError: pass else: for language in audio_languages_list: - audio_languages.append( - {"name": language, - "code2": alpha2_from_language(language) or None, - "code3": alpha3_from_language(language) or None} - ) + if language: + audio_languages.append( + {"name": language, + "code2": alpha2_from_language(language) or None, + "code3": alpha3_from_language(language) or None} + ) + else: + if und_default_language: + logging.debug(f"Undefined language audio track treated as {und_default_language}") + audio_languages.append( + {"name": und_default_language, + "code2": alpha2_from_language(und_default_language) or None, + "code3": alpha3_from_language(und_default_language) or None} + ) return audio_languages def get_profile_id(series_id=None, episode_id=None, movie_id=None): if series_id: - data = TableShows.select(TableShows.profileId)\ - .where(TableShows.sonarrSeriesId == series_id)\ + data = TableShows.select(TableShows.profileId) \ + .where(TableShows.sonarrSeriesId == series_id) \ .get_or_none() if data: return data.profileId elif episode_id: - data = TableShows.select(TableShows.profileId)\ - .join(TableEpisodes, on=(TableShows.sonarrSeriesId == TableEpisodes.sonarrSeriesId))\ - .where(TableEpisodes.sonarrEpisodeId == episode_id)\ + data = TableShows.select(TableShows.profileId) \ + .join(TableEpisodes, on=(TableShows.sonarrSeriesId == TableEpisodes.sonarrSeriesId)) \ + .where(TableEpisodes.sonarrEpisodeId == episode_id) \ .get_or_none() if data: return data.profileId elif movie_id: - data = TableMovies.select(TableMovies.profileId)\ - .where(TableMovies.radarrId == movie_id)\ + data = TableMovies.select(TableMovies.profileId) \ + .where(TableMovies.radarrId == movie_id) \ .get_or_none() if data: return data.profileId diff --git a/bazarr/app/get_providers.py b/bazarr/app/get_providers.py index 72842dc36..d73c109b6 100644 --- a/bazarr/app/get_providers.py +++ b/bazarr/app/get_providers.py @@ -1,5 +1,6 @@ # coding=utf-8 +import ast import os import datetime import pytz @@ -143,6 +144,14 @@ def get_providers(): return providers_list +def get_enabled_providers(): + # return enabled provider including those who can be throttled + try: + return ast.literal_eval(settings.general.enabled_providers) + except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError): + return [] + + _FFPROBE_BINARY = get_binary("ffprobe") _FFMPEG_BINARY = get_binary("ffmpeg") @@ -240,6 +249,9 @@ def get_providers_auth(): 'f_username': settings.karagarga.f_username, 'f_password': settings.karagarga.f_password, }, + 'subf2m': { + 'verify_ssl': settings.subf2m.getboolean('verify_ssl') + }, } diff --git a/bazarr/app/scheduler.py b/bazarr/app/scheduler.py index 5394bbdea..bb843619a 100644 --- a/bazarr/app/scheduler.py +++ b/bazarr/app/scheduler.py @@ -12,7 +12,12 @@ from apscheduler.jobstores.base import JobLookupError from datetime import datetime, timedelta from calendar import day_name from random import randrange +from tzlocal import get_localzone +from tzlocal.utils import ZoneInfoNotFoundError +from dateutil import tz +import logging +from app.announcements import get_announcements_to_file from sonarr.sync.series import update_series from sonarr.sync.episodes import sync_episodes, update_all_episodes from radarr.sync.movies import update_movies, update_all_movies @@ -37,7 +42,13 @@ class Scheduler: def __init__(self): self.__running_tasks = [] - self.aps_scheduler = BackgroundScheduler() + try: + self.timezone = get_localzone() + except ZoneInfoNotFoundError as e: + logging.error(f"BAZARR cannot use specified timezone: {e}") + self.timezone = tz.gettz("UTC") + + self.aps_scheduler = BackgroundScheduler({'apscheduler.timezone': self.timezone}) # task listener def task_listener_add(event): @@ -252,16 +263,22 @@ class Scheduler: check_releases, IntervalTrigger(hours=3), max_instances=1, coalesce=True, misfire_grace_time=15, id='update_release', name='Update Release Info', replace_existing=True) + self.aps_scheduler.add_job( + get_announcements_to_file, IntervalTrigger(hours=6), max_instances=1, coalesce=True, misfire_grace_time=15, + id='update_announcements', name='Update Announcements File', replace_existing=True) + def __search_wanted_subtitles_task(self): if settings.general.getboolean('use_sonarr'): self.aps_scheduler.add_job( - wanted_search_missing_subtitles_series, IntervalTrigger(hours=int(settings.general.wanted_search_frequency)), - max_instances=1, coalesce=True, misfire_grace_time=15, id='wanted_search_missing_subtitles_series', - name='Search for wanted Series Subtitles', replace_existing=True) + wanted_search_missing_subtitles_series, + IntervalTrigger(hours=int(settings.general.wanted_search_frequency)), max_instances=1, coalesce=True, + misfire_grace_time=15, id='wanted_search_missing_subtitles_series', replace_existing=True, + name='Search for wanted Series Subtitles') if settings.general.getboolean('use_radarr'): self.aps_scheduler.add_job( - wanted_search_missing_subtitles_movies, IntervalTrigger(hours=int(settings.general.wanted_search_frequency_movie)), - max_instances=1, coalesce=True, misfire_grace_time=15, id='wanted_search_missing_subtitles_movies', + wanted_search_missing_subtitles_movies, + IntervalTrigger(hours=int(settings.general.wanted_search_frequency_movie)), max_instances=1, + coalesce=True, misfire_grace_time=15, id='wanted_search_missing_subtitles_movies', name='Search for wanted Movies Subtitles', replace_existing=True) def __upgrade_subtitles_task(self): @@ -275,7 +292,11 @@ class Scheduler: def __randomize_interval_task(self): for job in self.aps_scheduler.get_jobs(): if isinstance(job.trigger, IntervalTrigger): - self.aps_scheduler.modify_job(job.id, next_run_time=datetime.now() + timedelta(seconds=randrange(job.trigger.interval.total_seconds()*0.75, job.trigger.interval.total_seconds()))) + self.aps_scheduler.modify_job(job.id, + next_run_time=datetime.now(tz=self.timezone) + + timedelta(seconds=randrange( + job.trigger.interval.total_seconds() * 0.75, + job.trigger.interval.total_seconds()))) def __no_task(self): for job in self.aps_scheduler.get_jobs(): diff --git a/bazarr/app/signalr_client.py b/bazarr/app/signalr_client.py index d20d42bb5..5ce8ab401 100644 --- a/bazarr/app/signalr_client.py +++ b/bazarr/app/signalr_client.py @@ -20,7 +20,6 @@ from radarr.sync.movies import update_movies, update_one_movie from sonarr.info import get_sonarr_info, url_sonarr from radarr.info import url_radarr from .database import TableShows -from .event_handler import event_stream from .config import settings from .scheduler import scheduler @@ -285,10 +284,10 @@ def dispatcher(data): if topic == 'series': logging.debug(f'Event received from Sonarr for series: {series_title} ({series_year})') - update_one_series(series_id=media_id, action=action) + update_one_series(series_id=media_id, action=action, send_event=False) if episodesChanged: # this will happen if a season monitored status is changed. - sync_episodes(series_id=media_id, send_event=True) + sync_episodes(series_id=media_id, send_event=False) elif topic == 'episode': logging.debug(f'Event received from Sonarr for episode: {series_title} ({series_year}) - ' f'S{season_number:0>2}E{episode_number:0>2} - {episode_title}') diff --git a/bazarr/app/ui.py b/bazarr/app/ui.py index 8680e90c4..c5bd41736 100644 --- a/bazarr/app/ui.py +++ b/bazarr/app/ui.py @@ -74,12 +74,14 @@ def catch_all(path): updated = '0' inject = dict() - inject["baseUrl"] = base_url - inject["canUpdate"] = not args.no_update - inject["hasUpdate"] = updated != '0' - if auth: - inject["apiKey"] = settings.auth.apikey + if not path.startswith('api/'): + inject["baseUrl"] = base_url + inject["canUpdate"] = not args.no_update + inject["hasUpdate"] = updated != '0' + + if auth: + inject["apiKey"] = settings.auth.apikey template_url = base_url if not template_url.endswith("/"): diff --git a/bazarr/init.py b/bazarr/init.py index ce434acbf..c1b285970 100644 --- a/bazarr/init.py +++ b/bazarr/init.py @@ -65,7 +65,7 @@ import logging # noqa E402 def is_virtualenv(): # return True if Bazarr have been start from within a virtualenv or venv base_prefix = getattr(sys, "base_prefix", None) - # real_prefix will return None if not in a virtualenv enviroment or the default python path + # real_prefix will return None if not in a virtualenv environment or the default python path real_prefix = getattr(sys, "real_prefix", None) or sys.prefix return base_prefix != real_prefix @@ -177,6 +177,11 @@ if not os.path.exists(os.path.join(args.config_dir, 'config', 'releases.txt')): check_releases() logging.debug("BAZARR Created releases file") +if not os.path.exists(os.path.join(args.config_dir, 'config', 'announcements.txt')): + from app.announcements import get_announcements_to_file + get_announcements_to_file() + logging.debug("BAZARR Created announcements file") + config_file = os.path.normpath(os.path.join(args.config_dir, 'config', 'config.ini')) # Move GA visitor from config.ini to dedicated file diff --git a/bazarr/main.py b/bazarr/main.py index 18583e017..7035cc513 100644 --- a/bazarr/main.py +++ b/bazarr/main.py @@ -39,9 +39,12 @@ from app.notifier import update_notifier # noqa E402 from languages.get_languages import load_language_in_db # noqa E402 from app.signalr_client import sonarr_signalr_client, radarr_signalr_client # noqa E402 from app.server import webserver # noqa E402 +from app.announcements import get_announcements_to_file # noqa E402 configure_proxy_func() +get_announcements_to_file() + # Reset the updated once Bazarr have been restarted after an update System.update({System.updated: '0'}).execute() diff --git a/bazarr/radarr/blacklist.py b/bazarr/radarr/blacklist.py index 14ce9717c..0aebb29f9 100644 --- a/bazarr/radarr/blacklist.py +++ b/bazarr/radarr/blacklist.py @@ -1,6 +1,6 @@ # coding=utf-8 -import time +from datetime import datetime from app.database import TableBlacklistMovie from app.event_handler import event_stream @@ -19,7 +19,7 @@ def get_blacklist_movie(): def blacklist_log_movie(radarr_id, provider, subs_id, language): TableBlacklistMovie.insert({ TableBlacklistMovie.radarr_id: radarr_id, - TableBlacklistMovie.timestamp: time.time(), + TableBlacklistMovie.timestamp: datetime.now(), TableBlacklistMovie.provider: provider, TableBlacklistMovie.subs_id: subs_id, TableBlacklistMovie.language: language diff --git a/bazarr/radarr/filesystem.py b/bazarr/radarr/filesystem.py index a88c317fa..d8cb0e2e9 100644 --- a/bazarr/radarr/filesystem.py +++ b/bazarr/radarr/filesystem.py @@ -21,7 +21,7 @@ def browse_radarr_filesystem(path='#'): "&allowFoldersWithoutTrailingSlashes=true&includeFiles=false&apikey=" + \ settings.radarr.apikey try: - r = requests.get(url_radarr_api_filesystem, timeout=60, verify=False, headers=headers) + r = requests.get(url_radarr_api_filesystem, timeout=int(settings.radarr.http_timeout), verify=False, headers=headers) r.raise_for_status() except requests.exceptions.HTTPError: logging.exception("BAZARR Error trying to get series from Radarr. Http error.") diff --git a/bazarr/radarr/history.py b/bazarr/radarr/history.py index b3ddeb819..bc15b3de4 100644 --- a/bazarr/radarr/history.py +++ b/bazarr/radarr/history.py @@ -1,17 +1,24 @@ # coding=utf-8 -import time +from datetime import datetime from app.database import TableHistoryMovie from app.event_handler import event_stream -def history_log_movie(action, radarr_id, description, video_path=None, language=None, provider=None, score=None, - subs_id=None, subtitles_path=None): +def history_log_movie(action, radarr_id, result, fake_provider=None, fake_score=None): + description = result.message + video_path = result.path + language = result.language_code + provider = fake_provider or result.provider + score = fake_score or result.score + subs_id = result.subs_id + subtitles_path = result.subs_path + TableHistoryMovie.insert({ TableHistoryMovie.action: action, TableHistoryMovie.radarrId: radarr_id, - TableHistoryMovie.timestamp: time.time(), + TableHistoryMovie.timestamp: datetime.now(), TableHistoryMovie.description: description, TableHistoryMovie.video_path: video_path, TableHistoryMovie.language: language, diff --git a/bazarr/radarr/info.py b/bazarr/radarr/info.py index 85d31019f..88f7fbf89 100644 --- a/bazarr/radarr/info.py +++ b/bazarr/radarr/info.py @@ -29,7 +29,7 @@ class GetRadarrInfo: if settings.general.getboolean('use_radarr'): try: rv = url_radarr() + "/api/system/status?apikey=" + settings.radarr.apikey - radarr_json = requests.get(rv, timeout=60, verify=False, headers=headers).json() + radarr_json = requests.get(rv, timeout=int(settings.radarr.http_timeout), verify=False, headers=headers).json() if 'version' in radarr_json: radarr_version = radarr_json['version'] else: @@ -37,7 +37,7 @@ class GetRadarrInfo: except json.decoder.JSONDecodeError: try: rv = url_radarr() + "/api/v3/system/status?apikey=" + settings.radarr.apikey - radarr_version = requests.get(rv, timeout=60, verify=False, headers=headers).json()['version'] + radarr_version = requests.get(rv, timeout=int(settings.radarr.http_timeout), verify=False, headers=headers).json()['version'] except json.decoder.JSONDecodeError: logging.debug('BAZARR cannot get Radarr version') radarr_version = 'unknown' diff --git a/bazarr/radarr/notify.py b/bazarr/radarr/notify.py index e59932e5e..d2204b2b3 100644 --- a/bazarr/radarr/notify.py +++ b/bazarr/radarr/notify.py @@ -18,6 +18,6 @@ def notify_radarr(radarr_id): 'name': 'RescanMovie', 'movieId': int(radarr_id) } - requests.post(url, json=data, timeout=60, verify=False, headers=headers) + requests.post(url, json=data, timeout=int(settings.radarr.http_timeout), verify=False, headers=headers) except Exception: logging.exception('BAZARR cannot notify Radarr') diff --git a/bazarr/radarr/rootfolder.py b/bazarr/radarr/rootfolder.py index 9e0f8cec5..bbf3dd63b 100644 --- a/bazarr/radarr/rootfolder.py +++ b/bazarr/radarr/rootfolder.py @@ -22,7 +22,7 @@ def get_radarr_rootfolder(): url_radarr_api_rootfolder = url_radarr() + "/api/v3/rootfolder?apikey=" + apikey_radarr try: - rootfolder = requests.get(url_radarr_api_rootfolder, timeout=60, verify=False, headers=headers) + rootfolder = requests.get(url_radarr_api_rootfolder, timeout=int(settings.radarr.http_timeout), verify=False, headers=headers) except requests.exceptions.ConnectionError: logging.exception("BAZARR Error trying to get rootfolder from Radarr. Connection Error.") return [] diff --git a/bazarr/radarr/sync/movies.py b/bazarr/radarr/sync/movies.py index 56c9ad9f6..59c36993f 100644 --- a/bazarr/radarr/sync/movies.py +++ b/bazarr/radarr/sync/movies.py @@ -147,12 +147,12 @@ def update_movies(send_event=True): # Insert new movies in DB for added_movie in movies_to_add: try: - result = TableMovies.insert(added_movie).on_conflict(action='IGNORE').execute() + result = TableMovies.insert(added_movie).on_conflict_ignore().execute() except IntegrityError as e: logging.error(f"BAZARR cannot insert movie {added_movie['path']} because of {e}") continue else: - if result > 0: + if result and result > 0: altered_movies.append([added_movie['tmdbId'], added_movie['path'], added_movie['radarrId'], diff --git a/bazarr/radarr/sync/parser.py b/bazarr/radarr/sync/parser.py index 017d08a47..60b4c7024 100644 --- a/bazarr/radarr/sync/parser.py +++ b/bazarr/radarr/sync/parser.py @@ -2,8 +2,11 @@ import os -from radarr.info import get_radarr_info +from app.config import settings from languages.get_languages import language_from_alpha2 +from radarr.info import get_radarr_info +from utilities.video_analyzer import embedded_audio_reader +from utilities.path_mappings import path_mappings from .converter import RadarrFormatAudioCodec, RadarrFormatVideoCodec @@ -89,25 +92,31 @@ def movieParser(movie, action, tags_dict, movie_default_profile, audio_profiles) videoCodec = None audioCodec = None - audio_language = [] - if get_radarr_info.is_legacy(): - if 'mediaInfo' in movie['movieFile']: - if 'audioLanguages' in movie['movieFile']['mediaInfo']: - audio_languages_list = movie['movieFile']['mediaInfo']['audioLanguages'].split('/') - if len(audio_languages_list): - for audio_language_list in audio_languages_list: - audio_language.append(audio_language_list.strip()) - if not audio_language: - audio_language = profile_id_to_language(movie['qualityProfileId'], audio_profiles) + if settings.general.getboolean('parse_embedded_audio_track'): + audio_language = embedded_audio_reader(path_mappings.path_replace_movie(movie['movieFile']['path']), + file_size=movie['movieFile']['size'], + movie_file_id=movie['movieFile']['id'], + use_cache=True) else: - if 'languages' in movie['movieFile'] and len(movie['movieFile']['languages']): - for item in movie['movieFile']['languages']: - if isinstance(item, dict): - if 'name' in item: - language = item['name'] - if item['name'] == 'Portuguese (Brazil)': - language = language_from_alpha2('pb') - audio_language.append(language) + audio_language = [] + if get_radarr_info.is_legacy(): + if 'mediaInfo' in movie['movieFile']: + if 'audioLanguages' in movie['movieFile']['mediaInfo']: + audio_languages_list = movie['movieFile']['mediaInfo']['audioLanguages'].split('/') + if len(audio_languages_list): + for audio_language_list in audio_languages_list: + audio_language.append(audio_language_list.strip()) + if not audio_language: + audio_language = profile_id_to_language(movie['qualityProfileId'], audio_profiles) + else: + if 'languages' in movie['movieFile'] and len(movie['movieFile']['languages']): + for item in movie['movieFile']['languages']: + if isinstance(item, dict): + if 'name' in item: + language = item['name'] + if item['name'] == 'Portuguese (Brazil)': + language = language_from_alpha2('pb') + audio_language.append(language) tags = [d['label'] for d in tags_dict if d['id'] in movie['tags']] @@ -160,8 +169,8 @@ def movieParser(movie, action, tags_dict, movie_default_profile, audio_profiles) def profile_id_to_language(id, profiles): + profiles_to_return = [] for profile in profiles: - profiles_to_return = [] if id == profile[0]: profiles_to_return.append(profile[1]) return profiles_to_return diff --git a/bazarr/radarr/sync/utils.py b/bazarr/radarr/sync/utils.py index 81b0dd814..b36bee50b 100644 --- a/bazarr/radarr/sync/utils.py +++ b/bazarr/radarr/sync/utils.py @@ -18,7 +18,7 @@ def get_profile_list(): url_radarr_api_movies = url_radarr() + "/api/v3/qualityprofile?apikey=" + apikey_radarr try: - profiles_json = requests.get(url_radarr_api_movies, timeout=60, verify=False, headers=headers) + profiles_json = requests.get(url_radarr_api_movies, timeout=int(settings.radarr.http_timeout), verify=False, headers=headers) except requests.exceptions.ConnectionError: logging.exception("BAZARR Error trying to get profiles from Radarr. Connection Error.") except requests.exceptions.Timeout: @@ -50,7 +50,7 @@ def get_tags(): url_radarr_api_series = url_radarr() + "/api/v3/tag?apikey=" + apikey_radarr try: - tagsDict = requests.get(url_radarr_api_series, timeout=60, verify=False, headers=headers) + tagsDict = requests.get(url_radarr_api_series, timeout=int(settings.radarr.http_timeout), verify=False, headers=headers) except requests.exceptions.ConnectionError: logging.exception("BAZARR Error trying to get tags from Radarr. Connection Error.") return [] @@ -79,7 +79,7 @@ def get_movies_from_radarr_api(url, apikey_radarr, radarr_id=None): apikey_radarr try: - r = requests.get(url_radarr_api_movies, timeout=60, verify=False, headers=headers) + r = requests.get(url_radarr_api_movies, timeout=int(settings.radarr.http_timeout), verify=False, headers=headers) if r.status_code == 404: return r.raise_for_status() diff --git a/bazarr/sonarr/blacklist.py b/bazarr/sonarr/blacklist.py index 7af21ef97..2f9bbc36b 100644 --- a/bazarr/sonarr/blacklist.py +++ b/bazarr/sonarr/blacklist.py @@ -1,6 +1,6 @@ # coding=utf-8 -import time +from datetime import datetime from app.database import TableBlacklist from app.event_handler import event_stream @@ -20,7 +20,7 @@ def blacklist_log(sonarr_series_id, sonarr_episode_id, provider, subs_id, langua TableBlacklist.insert({ TableBlacklist.sonarr_series_id: sonarr_series_id, TableBlacklist.sonarr_episode_id: sonarr_episode_id, - TableBlacklist.timestamp: time.time(), + TableBlacklist.timestamp: datetime.now(), TableBlacklist.provider: provider, TableBlacklist.subs_id: subs_id, TableBlacklist.language: language diff --git a/bazarr/sonarr/filesystem.py b/bazarr/sonarr/filesystem.py index e6eb5fc17..25bb66f08 100644 --- a/bazarr/sonarr/filesystem.py +++ b/bazarr/sonarr/filesystem.py @@ -20,7 +20,7 @@ def browse_sonarr_filesystem(path='#'): "&allowFoldersWithoutTrailingSlashes=true&includeFiles=false&apikey=" + \ settings.sonarr.apikey try: - r = requests.get(url_sonarr_api_filesystem, timeout=60, verify=False, headers=headers) + r = requests.get(url_sonarr_api_filesystem, timeout=int(settings.sonarr.http_timeout), verify=False, headers=headers) r.raise_for_status() except requests.exceptions.HTTPError: logging.exception("BAZARR Error trying to get series from Sonarr. Http error.") diff --git a/bazarr/sonarr/history.py b/bazarr/sonarr/history.py index 21f8ca5b1..af0a75c96 100644 --- a/bazarr/sonarr/history.py +++ b/bazarr/sonarr/history.py @@ -1,18 +1,25 @@ # coding=utf-8 -import time +from datetime import datetime from app.database import TableHistory from app.event_handler import event_stream -def history_log(action, sonarr_series_id, sonarr_episode_id, description, video_path=None, language=None, provider=None, - score=None, subs_id=None, subtitles_path=None): +def history_log(action, sonarr_series_id, sonarr_episode_id, result, fake_provider=None, fake_score=None): + description = result.message + video_path = result.path + language = result.language_code + provider = fake_provider or result.provider + score = fake_score or result.score + subs_id = result.subs_id + subtitles_path = result.subs_path + TableHistory.insert({ TableHistory.action: action, TableHistory.sonarrSeriesId: sonarr_series_id, TableHistory.sonarrEpisodeId: sonarr_episode_id, - TableHistory.timestamp: time.time(), + TableHistory.timestamp: datetime.now(), TableHistory.description: description, TableHistory.video_path: video_path, TableHistory.language: language, diff --git a/bazarr/sonarr/info.py b/bazarr/sonarr/info.py index becb8de68..287f9c774 100644 --- a/bazarr/sonarr/info.py +++ b/bazarr/sonarr/info.py @@ -29,7 +29,7 @@ class GetSonarrInfo: if settings.general.getboolean('use_sonarr'): try: sv = url_sonarr() + "/api/system/status?apikey=" + settings.sonarr.apikey - sonarr_json = requests.get(sv, timeout=60, verify=False, headers=headers).json() + sonarr_json = requests.get(sv, timeout=int(settings.sonarr.http_timeout), verify=False, headers=headers).json() if 'version' in sonarr_json: sonarr_version = sonarr_json['version'] else: @@ -37,7 +37,7 @@ class GetSonarrInfo: except json.decoder.JSONDecodeError: try: sv = url_sonarr() + "/api/v3/system/status?apikey=" + settings.sonarr.apikey - sonarr_version = requests.get(sv, timeout=60, verify=False, headers=headers).json()['version'] + sonarr_version = requests.get(sv, timeout=int(settings.sonarr.http_timeout), verify=False, headers=headers).json()['version'] except json.decoder.JSONDecodeError: logging.debug('BAZARR cannot get Sonarr version') sonarr_version = 'unknown' diff --git a/bazarr/sonarr/notify.py b/bazarr/sonarr/notify.py index 8b124e108..c6d004091 100644 --- a/bazarr/sonarr/notify.py +++ b/bazarr/sonarr/notify.py @@ -18,6 +18,6 @@ def notify_sonarr(sonarr_series_id): 'name': 'RescanSeries', 'seriesId': int(sonarr_series_id) } - requests.post(url, json=data, timeout=60, verify=False, headers=headers) + requests.post(url, json=data, timeout=int(settings.sonarr.http_timeout), verify=False, headers=headers) except Exception: logging.exception('BAZARR cannot notify Sonarr') diff --git a/bazarr/sonarr/rootfolder.py b/bazarr/sonarr/rootfolder.py index 6e71c3d20..a414e5421 100644 --- a/bazarr/sonarr/rootfolder.py +++ b/bazarr/sonarr/rootfolder.py @@ -22,7 +22,7 @@ def get_sonarr_rootfolder(): url_sonarr_api_rootfolder = url_sonarr() + "/api/v3/rootfolder?apikey=" + apikey_sonarr try: - rootfolder = requests.get(url_sonarr_api_rootfolder, timeout=60, verify=False, headers=headers) + rootfolder = requests.get(url_sonarr_api_rootfolder, timeout=int(settings.sonarr.http_timeout), verify=False, headers=headers) except requests.exceptions.ConnectionError: logging.exception("BAZARR Error trying to get rootfolder from Sonarr. Connection Error.") return [] diff --git a/bazarr/sonarr/sync/episodes.py b/bazarr/sonarr/sync/episodes.py index 0a9e00944..5f5f952a8 100644 --- a/bazarr/sonarr/sync/episodes.py +++ b/bazarr/sonarr/sync/episodes.py @@ -119,7 +119,7 @@ def sync_episodes(series_id=None, send_event=True): TableEpisodes.path, TableEpisodes.season, TableEpisodes.episode, - TableEpisodes.scene_name, + TableEpisodes.sceneName, TableEpisodes.monitored, TableEpisodes.format, TableEpisodes.resolution, @@ -149,12 +149,12 @@ def sync_episodes(series_id=None, send_event=True): # Insert new episodes in DB for added_episode in episodes_to_add: try: - result = TableEpisodes.insert(added_episode).on_conflict(action='IGNORE').execute() + result = TableEpisodes.insert(added_episode).on_conflict_ignore().execute() except IntegrityError as e: logging.error(f"BAZARR cannot insert episode {added_episode['path']} because of {e}") continue else: - if result > 0: + if result and result > 0: altered_episodes.append([added_episode['sonarrEpisodeId'], added_episode['path'], added_episode['monitored']]) diff --git a/bazarr/sonarr/sync/parser.py b/bazarr/sonarr/sync/parser.py index b464062fd..341229d48 100644 --- a/bazarr/sonarr/sync/parser.py +++ b/bazarr/sonarr/sync/parser.py @@ -2,9 +2,11 @@ import os +from app.config import settings from app.database import TableShows -from sonarr.info import get_sonarr_info from utilities.path_mappings import path_mappings +from utilities.video_analyzer import embedded_audio_reader +from sonarr.info import get_sonarr_info from .converter import SonarrFormatVideoCodec, SonarrFormatAudioCodec @@ -25,19 +27,20 @@ def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles) if show['alternateTitles'] is not None: alternate_titles = str([item['title'] for item in show['alternateTitles']]) - audio_language = [] - if get_sonarr_info.is_legacy(): - audio_language = profile_id_to_language(show['qualityProfileId'], audio_profiles) - else: - if 'languageProfileId' in show: - audio_language = profile_id_to_language(show['languageProfileId'], audio_profiles) - else: - audio_language = [] - tags = [d['label'] for d in tags_dict if d['id'] in show['tags']] imdbId = show['imdbId'] if 'imdbId' in show else None + audio_language = [] + if not settings.general.getboolean('parse_embedded_audio_track'): + if get_sonarr_info.is_legacy(): + audio_language = profile_id_to_language(show['qualityProfileId'], audio_profiles) + else: + if 'languageProfileId' in show: + audio_language = profile_id_to_language(show['languageProfileId'], audio_profiles) + else: + audio_language = [] + if action == 'update': return {'title': show["title"], 'path': show["path"], @@ -49,7 +52,7 @@ def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles) 'audio_language': str(audio_language), 'sortTitle': show['sortTitle'], 'year': str(show['year']), - 'alternateTitles': alternate_titles, + 'alternativeTitles': alternate_titles, 'tags': str(tags), 'seriesType': show['seriesType'], 'imdbId': imdbId, @@ -65,7 +68,7 @@ def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles) 'audio_language': str(audio_language), 'sortTitle': show['sortTitle'], 'year': str(show['year']), - 'alternateTitles': alternate_titles, + 'alternativeTitles': alternate_titles, 'tags': str(tags), 'seriesType': show['seriesType'], 'imdbId': imdbId, @@ -95,20 +98,28 @@ def episodeParser(episode): else: sceneName = None - audio_language = [] - if 'language' in episode['episodeFile'] and len(episode['episodeFile']['language']): - item = episode['episodeFile']['language'] - if isinstance(item, dict): - if 'name' in item: - audio_language.append(item['name']) - elif 'languages' in episode['episodeFile'] and len(episode['episodeFile']['languages']): - items = episode['episodeFile']['languages'] - if isinstance(items, list): - for item in items: + if settings.general.getboolean('parse_embedded_audio_track'): + audio_language = embedded_audio_reader(path_mappings.path_replace(episode['episodeFile'] + ['path']), + file_size=episode['episodeFile']['size'], + episode_file_id=episode['episodeFile']['id'], + use_cache=True) + else: + audio_language = [] + if 'language' in episode['episodeFile'] and len(episode['episodeFile']['language']): + item = episode['episodeFile']['language'] + if isinstance(item, dict): if 'name' in item: audio_language.append(item['name']) - else: - audio_language = TableShows.get(TableShows.sonarrSeriesId == episode['seriesId']).audio_language + elif 'languages' in episode['episodeFile'] and len(episode['episodeFile']['languages']): + items = episode['episodeFile']['languages'] + if isinstance(items, list): + for item in items: + if 'name' in item: + audio_language.append(item['name']) + else: + audio_language = TableShows.get( + TableShows.sonarrSeriesId == episode['seriesId']).audio_language if 'mediaInfo' in episode['episodeFile']: if 'videoCodec' in episode['episodeFile']['mediaInfo']: @@ -141,7 +152,7 @@ def episodeParser(episode): 'path': episode['episodeFile']['path'], 'season': episode['seasonNumber'], 'episode': episode['episodeNumber'], - 'scene_name': sceneName, + 'sceneName': sceneName, 'monitored': str(bool(episode['monitored'])), 'format': video_format, 'resolution': video_resolution, diff --git a/bazarr/sonarr/sync/series.py b/bazarr/sonarr/sync/series.py index a61ebe34f..6e975b80c 100644 --- a/bazarr/sonarr/sync/series.py +++ b/bazarr/sonarr/sync/series.py @@ -97,7 +97,7 @@ def update_series(send_event=True): TableShows.audio_language, TableShows.sortTitle, TableShows.year, - TableShows.alternateTitles, + TableShows.alternativeTitles, TableShows.tags, TableShows.seriesType, TableShows.imdbId, @@ -200,7 +200,7 @@ def update_one_series(series_id, action): except IntegrityError as e: logging.error(f"BAZARR cannot update series {series['path']} because of {e}") else: - sync_episodes(series_id=int(series_id), send_event=True) + sync_episodes(series_id=int(series_id), send_event=False) event_stream(type='series', action='update', payload=int(series_id)) logging.debug('BAZARR updated this series into the database:{}'.format(path_mappings.path_replace( series['path']))) diff --git a/bazarr/sonarr/sync/utils.py b/bazarr/sonarr/sync/utils.py index 031a9c9c9..de13d229e 100644 --- a/bazarr/sonarr/sync/utils.py +++ b/bazarr/sonarr/sync/utils.py @@ -22,7 +22,7 @@ def get_profile_list(): url_sonarr_api_series = url_sonarr() + "/api/v3/languageprofile?apikey=" + apikey_sonarr try: - profiles_json = requests.get(url_sonarr_api_series, timeout=60, verify=False, headers=headers) + profiles_json = requests.get(url_sonarr_api_series, timeout=int(settings.sonarr.http_timeout), verify=False, headers=headers) except requests.exceptions.ConnectionError: logging.exception("BAZARR Error trying to get profiles from Sonarr. Connection Error.") return None @@ -55,7 +55,7 @@ def get_tags(): url_sonarr_api_series = url_sonarr() + "/api/v3/tag?apikey=" + apikey_sonarr try: - tagsDict = requests.get(url_sonarr_api_series, timeout=60, verify=False, headers=headers) + tagsDict = requests.get(url_sonarr_api_series, timeout=int(settings.sonarr.http_timeout), verify=False, headers=headers) except requests.exceptions.ConnectionError: logging.exception("BAZARR Error trying to get tags from Sonarr. Connection Error.") return [] @@ -73,7 +73,7 @@ def get_series_from_sonarr_api(url, apikey_sonarr, sonarr_series_id=None): url_sonarr_api_series = url + "/api/{0}series/{1}?apikey={2}".format( '' if get_sonarr_info.is_legacy() else 'v3/', sonarr_series_id if sonarr_series_id else "", apikey_sonarr) try: - r = requests.get(url_sonarr_api_series, timeout=60, verify=False, headers=headers) + r = requests.get(url_sonarr_api_series, timeout=int(settings.sonarr.http_timeout), verify=False, headers=headers) r.raise_for_status() except requests.exceptions.HTTPError as e: if e.response.status_code: @@ -108,7 +108,7 @@ def get_episodes_from_sonarr_api(url, apikey_sonarr, series_id=None, episode_id= return try: - r = requests.get(url_sonarr_api_episode, timeout=60, verify=False, headers=headers) + r = requests.get(url_sonarr_api_episode, timeout=int(settings.sonarr.http_timeout), verify=False, headers=headers) r.raise_for_status() except requests.exceptions.HTTPError: logging.exception("BAZARR Error trying to get episodes from Sonarr. Http error.") @@ -136,7 +136,7 @@ def get_episodesFiles_from_sonarr_api(url, apikey_sonarr, series_id=None, episod return try: - r = requests.get(url_sonarr_api_episodeFiles, timeout=60, verify=False, headers=headers) + r = requests.get(url_sonarr_api_episodeFiles, timeout=int(settings.sonarr.http_timeout), verify=False, headers=headers) r.raise_for_status() except requests.exceptions.HTTPError: logging.exception("BAZARR Error trying to get episodeFiles from Sonarr. Http error.") diff --git a/bazarr/subtitles/indexer/movies.py b/bazarr/subtitles/indexer/movies.py index 05e41506f..97c247760 100644 --- a/bazarr/subtitles/indexer/movies.py +++ b/bazarr/subtitles/indexer/movies.py @@ -8,12 +8,12 @@ import ast from subliminal_patch import core, search_external_subtitles from languages.custom_lang import CustomLanguage -from app.database import get_profiles_list, get_profile_cutoff, TableMovies -from languages.get_languages import alpha2_from_alpha3, language_from_alpha2, get_language_set +from app.database import get_profiles_list, get_profile_cutoff, TableMovies, get_audio_profile_languages +from languages.get_languages import alpha2_from_alpha3, get_language_set from app.config import settings from utilities.helper import get_subtitle_destination_folder from utilities.path_mappings import path_mappings -from subtitles.tools.embedded_subs_reader import embedded_subs_reader +from utilities.video_analyzer import embedded_subs_reader from app.event_handler import event_stream, show_progress, hide_progress from subtitles.indexer.utils import guess_external_subtitles, get_external_subtitles_path @@ -168,8 +168,8 @@ def list_missing_subtitles_movies(no=None, send_event=True): if desired_subtitles_temp: for language in desired_subtitles_temp['items']: if language['audio_exclude'] == "True": - if language_from_alpha2(language['language']) in ast.literal_eval( - movie_subtitles['audio_language']): + if any(x['code2'] == language['language'] for x in get_audio_profile_languages( + movie_subtitles['audio_language'])): continue desired_subtitles_list.append([language['language'], language['forced'], language['hi']]) @@ -202,8 +202,9 @@ def list_missing_subtitles_movies(no=None, send_event=True): if cutoff_temp_list: for cutoff_temp in cutoff_temp_list: cutoff_language = [cutoff_temp['language'], cutoff_temp['forced'], cutoff_temp['hi']] - if cutoff_temp['audio_exclude'] == 'True' and language_from_alpha2(cutoff_temp['language']) in \ - ast.literal_eval(movie_subtitles['audio_language']): + if cutoff_temp['audio_exclude'] == 'True' and \ + any(x['code2'] == cutoff_temp['language'] for x in + get_audio_profile_languages(movie_subtitles['audio_language'])): cutoff_met = True elif cutoff_language in actual_subtitles_list: cutoff_met = True @@ -251,9 +252,7 @@ def list_missing_subtitles_movies(no=None, send_event=True): event_stream(type='badges') -def movies_full_scan_subtitles(): - use_ffprobe_cache = settings.radarr.getboolean('use_ffprobe_cache') - +def movies_full_scan_subtitles(use_cache=settings.radarr.getboolean('use_ffprobe_cache')): movies = TableMovies.select(TableMovies.path).dicts() count_movies = len(movies) @@ -263,8 +262,7 @@ def movies_full_scan_subtitles(): name='Movies subtitles', value=i, count=count_movies) - store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']), - use_cache=use_ffprobe_cache) + store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']), use_cache=use_cache) hide_progress(id='movies_disk_scan') diff --git a/bazarr/subtitles/indexer/series.py b/bazarr/subtitles/indexer/series.py index c93ad8f4e..e7091bf45 100644 --- a/bazarr/subtitles/indexer/series.py +++ b/bazarr/subtitles/indexer/series.py @@ -8,12 +8,12 @@ import ast from subliminal_patch import core, search_external_subtitles from languages.custom_lang import CustomLanguage -from app.database import get_profiles_list, get_profile_cutoff, TableEpisodes, TableShows -from languages.get_languages import alpha2_from_alpha3, language_from_alpha2, get_language_set +from app.database import get_profiles_list, get_profile_cutoff, TableEpisodes, TableShows, get_audio_profile_languages +from languages.get_languages import alpha2_from_alpha3, get_language_set from app.config import settings from utilities.helper import get_subtitle_destination_folder from utilities.path_mappings import path_mappings -from subtitles.tools.embedded_subs_reader import embedded_subs_reader +from utilities.video_analyzer import embedded_subs_reader from app.event_handler import event_stream, show_progress, hide_progress from subtitles.indexer.utils import guess_external_subtitles, get_external_subtitles_path @@ -176,8 +176,8 @@ def list_missing_subtitles(no=None, epno=None, send_event=True): if desired_subtitles_temp: for language in desired_subtitles_temp['items']: if language['audio_exclude'] == "True": - if language_from_alpha2(language['language']) in ast.literal_eval( - episode_subtitles['audio_language']): + if any(x['code2'] == language['language'] for x in get_audio_profile_languages( + episode_subtitles['audio_language'])): continue desired_subtitles_list.append([language['language'], language['forced'], language['hi']]) @@ -210,8 +210,9 @@ def list_missing_subtitles(no=None, epno=None, send_event=True): if cutoff_temp_list: for cutoff_temp in cutoff_temp_list: cutoff_language = [cutoff_temp['language'], cutoff_temp['forced'], cutoff_temp['hi']] - if cutoff_temp['audio_exclude'] == 'True' and language_from_alpha2(cutoff_temp['language']) in \ - ast.literal_eval(episode_subtitles['audio_language']): + if cutoff_temp['audio_exclude'] == 'True' and \ + any(x['code2'] == cutoff_temp['language'] for x in + get_audio_profile_languages(episode_subtitles['audio_language'])): cutoff_met = True elif cutoff_language in actual_subtitles_list: cutoff_met = True @@ -261,9 +262,7 @@ def list_missing_subtitles(no=None, epno=None, send_event=True): event_stream(type='badges') -def series_full_scan_subtitles(): - use_ffprobe_cache = settings.sonarr.getboolean('use_ffprobe_cache') - +def series_full_scan_subtitles(use_cache=settings.sonarr.getboolean('use_ffprobe_cache')): episodes = TableEpisodes.select(TableEpisodes.path).dicts() count_episodes = len(episodes) @@ -273,7 +272,7 @@ def series_full_scan_subtitles(): name='Episodes subtitles', value=i, count=count_episodes) - store_subtitles(episode['path'], path_mappings.path_replace(episode['path']), use_cache=use_ffprobe_cache) + store_subtitles(episode['path'], path_mappings.path_replace(episode['path']), use_cache=use_cache) hide_progress(id='episodes_disk_scan') diff --git a/bazarr/subtitles/manual.py b/bazarr/subtitles/manual.py index a5a9edea6..4dcb574c5 100644 --- a/bazarr/subtitles/manual.py +++ b/bazarr/subtitles/manual.py @@ -14,7 +14,7 @@ from subliminal_patch.core_persistent import list_all_subtitles, download_subtit from subliminal_patch.score import ComputeScore from languages.get_languages import alpha3_from_alpha2 -from app.config import get_scores, settings, get_array_from, get_settings +from app.config import get_scores, settings, get_array_from from utilities.helper import get_target_folder, force_unicode from app.database import get_profiles_list diff --git a/bazarr/subtitles/mass_download/movies.py b/bazarr/subtitles/mass_download/movies.py index c77ab7c59..b04e6df56 100644 --- a/bazarr/subtitles/mass_download/movies.py +++ b/bazarr/subtitles/mass_download/movies.py @@ -42,7 +42,7 @@ def movies_download_subtitles(no): else: count_movie = 0 - audio_language_list = get_audio_profile_languages(movie_id=movie['radarrId']) + audio_language_list = get_audio_profile_languages(movie['audio_language']) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: @@ -77,21 +77,8 @@ def movies_download_subtitles(no): check_if_still_required=True): if result: - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path'])) - history_log_movie(1, no, message, path, language_code, provider, score, subs_id, subs_path) - send_notifications_movie(no, message) + history_log_movie(1, no, result) + send_notifications_movie(no, result.message) hide_progress(id='movie_search_progress_{}'.format(no)) diff --git a/bazarr/subtitles/mass_download/series.py b/bazarr/subtitles/mass_download/series.py index cd67981ad..e126877dd 100644 --- a/bazarr/subtitles/mass_download/series.py +++ b/bazarr/subtitles/mass_download/series.py @@ -26,7 +26,7 @@ def series_download_subtitles(no): TableEpisodes.missing_subtitles, TableEpisodes.monitored, TableEpisodes.sonarrEpisodeId, - TableEpisodes.scene_name, + TableEpisodes.sceneName, TableShows.tags, TableShows.seriesType, TableEpisodes.audio_language, @@ -57,7 +57,7 @@ def series_download_subtitles(no): value=i, count=count_episodes_details) - audio_language_list = get_audio_profile_languages(episode_id=episode['sonarrEpisodeId']) + audio_language_list = get_audio_profile_languages(episode['audio_language']) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: @@ -76,28 +76,14 @@ def series_download_subtitles(no): for result in generate_subtitles(path_mappings.path_replace(episode['path']), languages, audio_language, - str(episode['scene_name']), + str(episode['sceneName']), episode['title'], 'series', check_if_still_required=True): if result: - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] store_subtitles(episode['path'], path_mappings.path_replace(episode['path'])) - history_log(1, no, episode['sonarrEpisodeId'], message, path, language_code, provider, score, - subs_id, subs_path) - send_notifications(no, episode['sonarrEpisodeId'], message) + history_log(1, no, episode['sonarrEpisodeId'], result) + send_notifications(no, episode['sonarrEpisodeId'], result.message) else: logging.info("BAZARR All providers are throttled") break @@ -112,7 +98,7 @@ def episode_download_subtitles(no, send_progress=False): TableEpisodes.missing_subtitles, TableEpisodes.monitored, TableEpisodes.sonarrEpisodeId, - TableEpisodes.scene_name, + TableEpisodes.sceneName, TableShows.tags, TableShows.title, TableShows.sonarrSeriesId, @@ -142,7 +128,7 @@ def episode_download_subtitles(no, send_progress=False): value=0, count=1) - audio_language_list = get_audio_profile_languages(episode_id=episode['sonarrEpisodeId']) + audio_language_list = get_audio_profile_languages(episode['audio_language']) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: @@ -161,28 +147,14 @@ def episode_download_subtitles(no, send_progress=False): for result in generate_subtitles(path_mappings.path_replace(episode['path']), languages, audio_language, - str(episode['scene_name']), + str(episode['sceneName']), episode['title'], 'series', check_if_still_required=True): if result: - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] store_subtitles(episode['path'], path_mappings.path_replace(episode['path'])) - history_log(1, episode['sonarrSeriesId'], episode['sonarrEpisodeId'], message, path, - language_code, provider, score, subs_id, subs_path) - send_notifications(episode['sonarrSeriesId'], episode['sonarrEpisodeId'], message) + history_log(1, episode['sonarrSeriesId'], episode['sonarrEpisodeId'], result) + send_notifications(episode['sonarrSeriesId'], episode['sonarrEpisodeId'], result.message) if send_progress: hide_progress(id='episode_search_progress_{}'.format(no)) diff --git a/bazarr/subtitles/post_processing.py b/bazarr/subtitles/post_processing.py index 75406d417..472c53201 100644 --- a/bazarr/subtitles/post_processing.py +++ b/bazarr/subtitles/post_processing.py @@ -28,11 +28,11 @@ def postprocessing(command, path): except Exception as e: logging.error('BAZARR Post-processing failed for file ' + path + ' : ' + repr(e)) else: - if out == "": - logging.info( - 'BAZARR Post-processing result for file ' + path + ' : Nothing returned from command execution') - elif err: + if err: logging.error( 'BAZARR Post-processing result for file ' + path + ' : ' + err.replace('\n', ' ').replace('\r', ' ')) + elif out == "": + logging.info( + 'BAZARR Post-processing result for file ' + path + ' : Nothing returned from command execution') else: logging.info('BAZARR Post-processing result for file ' + path + ' : ' + out) diff --git a/bazarr/subtitles/processing.py b/bazarr/subtitles/processing.py index 37cfced1f..c0449efb1 100644 --- a/bazarr/subtitles/processing.py +++ b/bazarr/subtitles/processing.py @@ -5,7 +5,7 @@ import logging from app.config import settings from utilities.path_mappings import path_mappings -from utilities.post_processing import pp_replace +from utilities.post_processing import pp_replace, set_chmod from languages.get_languages import alpha2_from_alpha3, alpha2_from_language, alpha3_from_language, language_from_alpha3 from app.database import TableEpisodes, TableMovies from utilities.analytics import track_event @@ -14,10 +14,27 @@ from sonarr.notify import notify_sonarr from app.event_handler import event_stream from .utils import _get_download_code3 -from .sync import sync_subtitles from .post_processing import postprocessing +class ProcessSubtitlesResult: + def __init__(self, message, reversed_path, downloaded_language_code2, downloaded_provider, score, forced, + subtitle_id, reversed_subtitles_path, hearing_impaired): + self.message = message + self.path = reversed_path + self.provider = downloaded_provider + self.score = score + self.subs_id = subtitle_id + self.subs_path = reversed_subtitles_path + + if hearing_impaired: + self.language_code = downloaded_language_code2 + ":hi" + elif forced: + self.language_code = downloaded_language_code2 + ":forced" + else: + self.language_code = downloaded_language_code2 + + def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_upgrade=False, is_manual=False): use_postprocessing = settings.general.getboolean('use_postprocessing') postprocessing_cmd = settings.general.postprocessing_cmd @@ -59,6 +76,8 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u return series_id = episode_metadata['sonarrSeriesId'] episode_id = episode_metadata['sonarrEpisodeId'] + + from .sync import sync_subtitles sync_subtitles(video_path=path, srt_path=downloaded_path, forced=subtitle.language.forced, srt_lang=downloaded_language_code2, media_type=media_type, @@ -74,6 +93,8 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u return series_id = "" episode_id = movie_metadata['radarrId'] + + from .sync import sync_subtitles sync_subtitles(video_path=path, srt_path=downloaded_path, forced=subtitle.language.forced, srt_lang=downloaded_language_code2, media_type=media_type, @@ -95,6 +116,7 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u if not use_pp_threshold or (use_pp_threshold and percent_score < pp_threshold): logging.debug("BAZARR Using post-processing command: {}".format(command)) postprocessing(command, path) + set_chmod(subtitles_path=downloaded_path) else: logging.debug("BAZARR post-processing skipped because subtitles score isn't below this " "threshold value: " + str(pp_threshold) + "%") @@ -115,5 +137,12 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u track_event(category=downloaded_provider, action=action, label=downloaded_language) - return message, reversed_path, downloaded_language_code2, downloaded_provider, subtitle.score, \ - subtitle.language.forced, subtitle.id, reversed_subtitles_path, subtitle.language.hi + return ProcessSubtitlesResult(message=message, + reversed_path=reversed_path, + downloaded_language_code2=downloaded_language_code2, + downloaded_provider=downloaded_provider, + score=subtitle.score, + forced=subtitle.language.forced, + subtitle_id=subtitle.id, + reversed_subtitles_path=reversed_subtitles_path, + hearing_impaired=subtitle.language.hi) diff --git a/bazarr/subtitles/refiners/database.py b/bazarr/subtitles/refiners/database.py index 8f10884b9..5533ef62b 100644 --- a/bazarr/subtitles/refiners/database.py +++ b/bazarr/subtitles/refiners/database.py @@ -23,7 +23,7 @@ def refine_from_db(path, video): TableEpisodes.title.alias('episodeTitle'), TableShows.year, TableShows.tvdbId, - TableShows.alternateTitles, + TableShows.alternativeTitles, TableEpisodes.format, TableEpisodes.resolution, TableEpisodes.video_codec, @@ -43,10 +43,11 @@ def refine_from_db(path, video): # Only refine year as a fallback if not video.year and data['year']: - if int(data['year']) > 0: video.year = int(data['year']) + if int(data['year']) > 0: + video.year = int(data['year']) video.series_tvdb_id = int(data['tvdbId']) - video.alternative_series = ast.literal_eval(data['alternateTitles']) + video.alternative_series = ast.literal_eval(data['alternativeTitles']) if data['imdbId'] and not video.series_imdb_id: video.series_imdb_id = data['imdbId'] if not video.source: @@ -77,7 +78,8 @@ def refine_from_db(path, video): # Only refine year as a fallback if not video.year and data['year']: - if int(data['year']) > 0: video.year = int(data['year']) + if int(data['year']) > 0: + video.year = int(data['year']) if data['imdbId'] and not video.imdb_id: video.imdb_id = data['imdbId'] diff --git a/bazarr/subtitles/refiners/ffprobe.py b/bazarr/subtitles/refiners/ffprobe.py index 9e080ae51..22c21a67c 100644 --- a/bazarr/subtitles/refiners/ffprobe.py +++ b/bazarr/subtitles/refiners/ffprobe.py @@ -7,7 +7,7 @@ from subliminal import Movie from utilities.path_mappings import path_mappings from app.database import TableEpisodes, TableMovies -from subtitles.tools.embedded_subs_reader import parse_video_metadata +from utilities.video_analyzer import parse_video_metadata def refine_from_ffprobe(path, video): @@ -32,7 +32,7 @@ def refine_from_ffprobe(path, video): data = parse_video_metadata(file=path, file_size=file_id['file_size'], episode_file_id=file_id['episode_file_id']) - if 'ffprobe' not in data and 'mediainfo' not in data: + if not data or ('ffprobe' not in data and 'mediainfo' not in data): logging.debug("No cache available for this file: {}".format(path)) return video diff --git a/bazarr/subtitles/tools/delete.py b/bazarr/subtitles/tools/delete.py index c04c8524d..622cf965a 100644 --- a/bazarr/subtitles/tools/delete.py +++ b/bazarr/subtitles/tools/delete.py @@ -10,6 +10,7 @@ from languages.get_languages import language_from_alpha2 from utilities.path_mappings import path_mappings from subtitles.indexer.series import store_subtitles from subtitles.indexer.movies import store_subtitles_movie +from subtitles.processing import ProcessSubtitlesResult from sonarr.history import history_log from radarr.history import history_log_movie from sonarr.notify import notify_sonarr @@ -35,7 +36,15 @@ def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_pat language_log += ':forced' language_string += ' forced' - result = language_string + " subtitles deleted from disk." + result = ProcessSubtitlesResult(message=language_string + " subtitles deleted from disk.", + reversed_path=path_mappings.path_replace_reverse(media_path), + downloaded_language_code2=language_log, + downloaded_provider=None, + score=None, + forced=None, + subtitle_id=None, + reversed_subtitles_path=path_mappings.path_replace_reverse(subtitles_path), + hearing_impaired=None) if media_type == 'series': try: @@ -45,9 +54,7 @@ def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_pat store_subtitles(path_mappings.path_replace_reverse(media_path), media_path) return False else: - history_log(0, sonarr_series_id, sonarr_episode_id, result, language=language_log, - video_path=path_mappings.path_replace_reverse(media_path), - subtitles_path=path_mappings.path_replace_reverse(subtitles_path)) + history_log(0, sonarr_series_id, sonarr_episode_id, result) store_subtitles(path_mappings.path_replace_reverse(media_path), media_path) notify_sonarr(sonarr_series_id) event_stream(type='series', action='update', payload=sonarr_series_id) @@ -61,9 +68,7 @@ def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_pat store_subtitles_movie(path_mappings.path_replace_reverse_movie(media_path), media_path) return False else: - history_log_movie(0, radarr_id, result, language=language_log, - video_path=path_mappings.path_replace_reverse_movie(media_path), - subtitles_path=path_mappings.path_replace_reverse_movie(subtitles_path)) + history_log_movie(0, radarr_id, result) store_subtitles_movie(path_mappings.path_replace_reverse_movie(media_path), media_path) notify_radarr(radarr_id) event_stream(type='movie-wanted', action='update', payload=radarr_id) diff --git a/bazarr/subtitles/tools/mods.py b/bazarr/subtitles/tools/mods.py index a29b715c3..126050b1b 100644 --- a/bazarr/subtitles/tools/mods.py +++ b/bazarr/subtitles/tools/mods.py @@ -4,19 +4,22 @@ import os import logging from subliminal_patch.subtitle import Subtitle +from subliminal_patch.core import get_subtitle_path from subzero.language import Language +from app.config import settings from languages.custom_lang import CustomLanguage from languages.get_languages import alpha3_from_alpha2 -def subtitles_apply_mods(language, subtitle_path, mods, use_original_format): +def subtitles_apply_mods(language, subtitle_path, mods, use_original_format, video_path): language = alpha3_from_alpha2(language) custom = CustomLanguage.from_value(language, "alpha3") if custom is None: lang_obj = Language(language) else: lang_obj = custom.subzero_language() + single = settings.general.getboolean('single_language') sub = Subtitle(lang_obj, mods=mods, original_format=use_original_format) with open(subtitle_path, 'rb') as f: @@ -31,8 +34,17 @@ def subtitles_apply_mods(language, subtitle_path, mods, use_original_format): content = sub.get_modified_content() if content: + if hasattr(sub, 'mods') and isinstance(sub.mods, list) and 'remove_HI' in sub.mods: + modded_subtitles_path = get_subtitle_path(video_path, None if single else sub.language, + forced_tag=sub.language.forced, hi_tag=False, tags=[]) + else: + modded_subtitles_path = subtitle_path + if os.path.exists(subtitle_path): os.remove(subtitle_path) - with open(subtitle_path, 'wb') as f: + if os.path.exists(modded_subtitles_path): + os.remove(modded_subtitles_path) + + with open(modded_subtitles_path, 'wb') as f: f.write(content) diff --git a/bazarr/subtitles/tools/subsyncer.py b/bazarr/subtitles/tools/subsyncer.py index 5200ee105..5667622ac 100644 --- a/bazarr/subtitles/tools/subsyncer.py +++ b/bazarr/subtitles/tools/subsyncer.py @@ -8,6 +8,7 @@ from ffsubsync.ffsubsync import run, make_parser from utilities.binaries import get_binary from radarr.history import history_log_movie from sonarr.history import history_log +from subtitles.processing import ProcessSubtitlesResult from languages.get_languages import language_from_alpha2 from utilities.path_mappings import path_mappings from app.config import settings @@ -83,14 +84,21 @@ class SubSyncer: "scale factor of {2}.".format(language_from_alpha2(srt_lang), offset_seconds, "{:.2f}".format(framerate_scale_factor)) + result = ProcessSubtitlesResult(message=message, + reversed_path=path_mappings.path_replace_reverse(self.reference), + downloaded_language_code2=srt_lang, + downloaded_provider=None, + score=None, + forced=None, + subtitle_id=None, + reversed_subtitles_path=srt_path, + hearing_impaired=None) + if media_type == 'series': history_log(action=5, sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id, - description=message, video_path=path_mappings.path_replace_reverse(self.reference), - language=srt_lang, subtitles_path=srt_path) + result=result) else: - history_log_movie(action=5, radarr_id=radarr_id, description=message, - video_path=path_mappings.path_replace_reverse_movie(self.reference), - language=srt_lang, subtitles_path=srt_path) + history_log_movie(action=5, radarr_id=radarr_id, result=result) else: logging.error('BAZARR unable to sync subtitles: {0}'.format(self.srtin)) diff --git a/bazarr/subtitles/tools/translate.py b/bazarr/subtitles/tools/translate.py index 5a0570f49..b00040dce 100644 --- a/bazarr/subtitles/tools/translate.py +++ b/bazarr/subtitles/tools/translate.py @@ -11,6 +11,7 @@ from languages.custom_lang import CustomLanguage from languages.get_languages import alpha3_from_alpha2, language_from_alpha2, language_from_alpha3 from radarr.history import history_log_movie from sonarr.history import history_log +from subtitles.processing import ProcessSubtitlesResult def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, forced, hi, media_type, sonarr_series_id, @@ -84,11 +85,19 @@ def translate_subtitles_file(video_path, source_srt_file, from_lang, to_lang, fo message = f"{language_from_alpha2(from_lang)} subtitles translated to {language_from_alpha3(to_lang)}." + result = ProcessSubtitlesResult(message=message, + reversed_path=video_path, + downloaded_language_code2=to_lang, + downloaded_provider=None, + score=None, + forced=None, + subtitle_id=None, + reversed_subtitles_path=dest_srt_file, + hearing_impaired=None) + if media_type == 'series': - history_log(action=6, sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id, - description=message, video_path=video_path, language=to_lang, subtitles_path=dest_srt_file) + history_log(action=6, sonarr_series_id=sonarr_series_id, sonarr_episode_id=sonarr_episode_id, result=result) else: - history_log_movie(action=6, radarr_id=radarr_id, description=message, - video_path=video_path, language=to_lang, subtitles_path=dest_srt_file) + history_log_movie(action=6, radarr_id=radarr_id, result=result) return dest_srt_file diff --git a/bazarr/subtitles/upgrade.py b/bazarr/subtitles/upgrade.py index b1716cea6..e292f1621 100644 --- a/bazarr/subtitles/upgrade.py +++ b/bazarr/subtitles/upgrade.py @@ -1,132 +1,34 @@ # coding=utf-8 # fmt: off -import os import logging import operator - -from functools import reduce -from peewee import fn +import os from datetime import datetime, timedelta +from functools import reduce from app.config import settings -from utilities.path_mappings import path_mappings -from subtitles.indexer.series import store_subtitles -from subtitles.indexer.movies import store_subtitles_movie -from radarr.history import history_log_movie -from sonarr.history import history_log -from app.notifier import send_notifications, send_notifications_movie -from app.get_providers import get_providers from app.database import get_exclusion_clause, get_audio_profile_languages, TableShows, TableEpisodes, TableMovies, \ TableHistory, TableHistoryMovie from app.event_handler import show_progress, hide_progress - +from app.get_providers import get_providers +from app.notifier import send_notifications, send_notifications_movie +from radarr.history import history_log_movie +from sonarr.history import history_log +from subtitles.indexer.movies import store_subtitles_movie +from subtitles.indexer.series import store_subtitles +from utilities.path_mappings import path_mappings from .download import generate_subtitles def upgrade_subtitles(): - days_to_upgrade_subs = settings.general.days_to_upgrade_subs - minimum_timestamp = ((datetime.now() - timedelta(days=int(days_to_upgrade_subs))) - - datetime(1970, 1, 1)).total_seconds() - - if settings.general.getboolean('upgrade_manual'): - query_actions = [1, 2, 3, 4, 6] - else: - query_actions = [1, 3] - - if settings.general.getboolean('use_sonarr'): - upgradable_episodes_conditions = [(TableHistory.action << query_actions), - (TableHistory.timestamp > minimum_timestamp), - (TableHistory.score.is_null(False))] - upgradable_episodes_conditions += get_exclusion_clause('series') - upgradable_episodes = TableHistory.select(TableHistory.video_path, - TableHistory.language, - TableHistory.score, - TableShows.tags, - TableShows.profileId, - TableEpisodes.audio_language, - TableEpisodes.scene_name, - TableEpisodes.title, - TableEpisodes.sonarrSeriesId, - TableHistory.action, - TableHistory.subtitles_path, - TableEpisodes.sonarrEpisodeId, - fn.MAX(TableHistory.timestamp).alias('timestamp'), - TableEpisodes.monitored, - TableEpisodes.season, - TableEpisodes.episode, - TableShows.title.alias('seriesTitle'), - TableShows.seriesType)\ - .join(TableShows, on=(TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId))\ - .join(TableEpisodes, on=(TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId))\ - .where(reduce(operator.and_, upgradable_episodes_conditions))\ - .group_by(TableHistory.video_path, TableHistory.language)\ - .dicts() - upgradable_episodes_not_perfect = [] - for upgradable_episode in upgradable_episodes: - if upgradable_episode['timestamp'] > minimum_timestamp: - try: - int(upgradable_episode['score']) - except ValueError: - pass - else: - if int(upgradable_episode['score']) < 360 or (settings.general.getboolean('upgrade_manual') and - upgradable_episode['action'] in [2, 4, 6]): - upgradable_episodes_not_perfect.append(upgradable_episode) - - episodes_to_upgrade = [] - for episode in upgradable_episodes_not_perfect: - if os.path.exists(path_mappings.path_replace(episode['subtitles_path'])) and \ - os.path.exists(path_mappings.path_replace(episode['video_path'])) and \ - int(episode['score']) < 357: - episodes_to_upgrade.append(episode) + use_sonarr = settings.general.getboolean('use_sonarr') + use_radarr = settings.general.getboolean('use_radarr') + if use_sonarr: + episodes_to_upgrade = get_upgradable_episode_subtitles() count_episode_to_upgrade = len(episodes_to_upgrade) - if settings.general.getboolean('use_radarr'): - upgradable_movies_conditions = [(TableHistoryMovie.action << query_actions), - (TableHistoryMovie.timestamp > minimum_timestamp), - (TableHistoryMovie.score.is_null(False))] - upgradable_movies_conditions += get_exclusion_clause('movie') - upgradable_movies = TableHistoryMovie.select(TableHistoryMovie.video_path, - TableHistoryMovie.language, - TableHistoryMovie.score, - TableMovies.profileId, - TableHistoryMovie.action, - TableHistoryMovie.subtitles_path, - TableMovies.audio_language, - TableMovies.sceneName, - fn.MAX(TableHistoryMovie.timestamp).alias('timestamp'), - TableMovies.monitored, - TableMovies.tags, - TableMovies.radarrId, - TableMovies.title)\ - .join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId))\ - .where(reduce(operator.and_, upgradable_movies_conditions))\ - .group_by(TableHistoryMovie.video_path, TableHistoryMovie.language)\ - .dicts() - upgradable_movies_not_perfect = [] - for upgradable_movie in upgradable_movies: - if upgradable_movie['timestamp'] > minimum_timestamp: - try: - int(upgradable_movie['score']) - except ValueError: - pass - else: - if int(upgradable_movie['score']) < 120 or (settings.general.getboolean('upgrade_manual') and - upgradable_movie['action'] in [2, 4, 6]): - upgradable_movies_not_perfect.append(upgradable_movie) - - movies_to_upgrade = [] - for movie in upgradable_movies_not_perfect: - if os.path.exists(path_mappings.path_replace_movie(movie['subtitles_path'])) and \ - os.path.exists(path_mappings.path_replace_movie(movie['video_path'])) and \ - int(movie['score']) < 117: - movies_to_upgrade.append(movie) - - count_movie_to_upgrade = len(movies_to_upgrade) - - if settings.general.getboolean('use_sonarr'): for i, episode in enumerate(episodes_to_upgrade): providers_list = get_providers() @@ -142,20 +44,10 @@ def upgrade_subtitles(): if not providers_list: logging.info("BAZARR All providers are throttled") return - if episode['language'].endswith('forced'): - language = episode['language'].split(':')[0] - is_forced = "True" - is_hi = "False" - elif episode['language'].endswith('hi'): - language = episode['language'].split(':')[0] - is_forced = "False" - is_hi = "True" - else: - language = episode['language'].split(':')[0] - is_forced = "False" - is_hi = "False" - audio_language_list = get_audio_profile_languages(episode_id=episode['sonarrEpisodeId']) + language, is_forced, is_hi = parse_language_string(episode['language']) + + audio_language_list = get_audio_profile_languages(episode['audio_language']) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: @@ -164,7 +56,7 @@ def upgrade_subtitles(): result = list(generate_subtitles(path_mappings.path_replace(episode['video_path']), [(language, is_hi, is_forced)], audio_language, - str(episode['scene_name']), + str(episode['sceneName']), episode['seriesTitle'], 'series', forced_minimum_score=int(episode['score']), @@ -172,27 +64,16 @@ def upgrade_subtitles(): if result: result = result[0] - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] store_subtitles(episode['video_path'], path_mappings.path_replace(episode['video_path'])) - history_log(3, episode['sonarrSeriesId'], episode['sonarrEpisodeId'], message, path, - language_code, provider, score, subs_id, subs_path) - send_notifications(episode['sonarrSeriesId'], episode['sonarrEpisodeId'], message) + history_log(3, episode['sonarrSeriesId'], episode['sonarrEpisodeId'], result) + send_notifications(episode['sonarrSeriesId'], episode['sonarrEpisodeId'], result.message) hide_progress(id='upgrade_episodes_progress') - if settings.general.getboolean('use_radarr'): + if use_radarr: + movies_to_upgrade = get_upgradable_movies_subtitles() + count_movie_to_upgrade = len(movies_to_upgrade) + for i, movie in enumerate(movies_to_upgrade): providers_list = get_providers() @@ -205,20 +86,10 @@ def upgrade_subtitles(): if not providers_list: logging.info("BAZARR All providers are throttled") return - if movie['language'].endswith('forced'): - language = movie['language'].split(':')[0] - is_forced = "True" - is_hi = "False" - elif movie['language'].endswith('hi'): - language = movie['language'].split(':')[0] - is_forced = "False" - is_hi = "True" - else: - language = movie['language'].split(':')[0] - is_forced = "False" - is_hi = "False" - audio_language_list = get_audio_profile_languages(movie_id=movie['radarrId']) + language, is_forced, is_hi = parse_language_string(movie['language']) + + audio_language_list = get_audio_profile_languages(movie['audio_language']) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: @@ -234,24 +105,152 @@ def upgrade_subtitles(): is_upgrade=True)) if result: result = result[0] - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] store_subtitles_movie(movie['video_path'], path_mappings.path_replace_movie(movie['video_path'])) - history_log_movie(3, movie['radarrId'], message, path, language_code, provider, score, subs_id, subs_path) - send_notifications_movie(movie['radarrId'], message) + history_log_movie(3, movie['radarrId'], result) + send_notifications_movie(movie['radarrId'], result.message) hide_progress(id='upgrade_movies_progress') logging.info('BAZARR Finished searching for Subtitles to upgrade. Check History for more information.') + + +def get_queries_condition_parameters(): + days_to_upgrade_subs = settings.general.days_to_upgrade_subs + minimum_timestamp = (datetime.now() - timedelta(days=int(days_to_upgrade_subs))) + + if settings.general.getboolean('upgrade_manual'): + query_actions = [1, 2, 3, 4, 6] + else: + query_actions = [1, 3] + + return [minimum_timestamp, query_actions] + + +def parse_upgradable_list(upgradable_list, perfect_score, media_type): + if media_type == 'series': + path_replace_method = path_mappings.path_replace + else: + path_replace_method = path_mappings.path_replace_movie + + items_to_upgrade = [] + + for item in upgradable_list: + logging.debug(f"Trying to validate eligibility to upgrade for this subtitles: " + f"{item['subtitles_path']}") + if (item['video_path'], item['language']) in \ + [(x['video_path'], x['language']) for x in items_to_upgrade]: + logging.debug("Newer video path and subtitles language combination already in list of subtitles to " + "upgrade, we skip this one.") + continue + + if os.path.exists(path_replace_method(item['subtitles_path'])) and \ + os.path.exists(path_replace_method(item['video_path'])): + logging.debug("Video and subtitles file are still there, we continue the eligibility validation.") + pass + + items_to_upgrade.append(item) + + if not settings.general.getboolean('upgrade_manual'): + logging.debug("Removing history items for manually downloaded or translated subtitles.") + items_to_upgrade = [x for x in items_to_upgrade if x['action'] in [2, 4, 6]] + + logging.debug("Removing history items for already perfectly scored subtitles.") + items_to_upgrade = [x for x in items_to_upgrade if x['score'] < perfect_score] + + logging.debug(f"Bazarr will try to upgrade {len(items_to_upgrade)} subtitles.") + + return items_to_upgrade + + +def parse_language_string(language_string): + if language_string.endswith('forced'): + language = language_string.split(':')[0] + is_forced = "True" + is_hi = "False" + elif language_string.endswith('hi'): + language = language_string.split(':')[0] + is_forced = "False" + is_hi = "True" + else: + language = language_string.split(':')[0] + is_forced = "False" + is_hi = "False" + + return [language, is_forced, is_hi] + + +def get_upgradable_episode_subtitles(): + minimum_timestamp, query_actions = get_queries_condition_parameters() + + upgradable_episodes_conditions = [(TableHistory.action << query_actions), + (TableHistory.timestamp > minimum_timestamp), + (TableHistory.score.is_null(False))] + upgradable_episodes_conditions += get_exclusion_clause('series') + upgradable_episodes = TableHistory.select(TableHistory.video_path, + TableHistory.language, + TableHistory.score, + TableShows.tags, + TableShows.profileId, + TableEpisodes.audio_language, + TableEpisodes.sceneName, + TableEpisodes.title, + TableEpisodes.sonarrSeriesId, + TableHistory.action, + TableHistory.subtitles_path, + TableEpisodes.sonarrEpisodeId, + TableHistory.timestamp.alias('timestamp'), + TableEpisodes.monitored, + TableEpisodes.season, + TableEpisodes.episode, + TableShows.title.alias('seriesTitle'), + TableShows.seriesType) \ + .join(TableShows, on=(TableHistory.sonarrSeriesId == TableShows.sonarrSeriesId)) \ + .join(TableEpisodes, on=(TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId)) \ + .where(reduce(operator.and_, upgradable_episodes_conditions)) \ + .order_by(TableHistory.timestamp.desc()) \ + .dicts() + + if not upgradable_episodes: + return [] + else: + upgradable_episodes = list(upgradable_episodes) + logging.debug(f"{len(upgradable_episodes)} potentially upgradable episode subtitles have been found, let's " + f"filter them...") + + return parse_upgradable_list(upgradable_list=upgradable_episodes, perfect_score=357, media_type='series') + + +def get_upgradable_movies_subtitles(): + minimum_timestamp, query_actions = get_queries_condition_parameters() + + upgradable_movies_conditions = [(TableHistoryMovie.action << query_actions), + (TableHistoryMovie.timestamp > minimum_timestamp), + (TableHistoryMovie.score.is_null(False))] + upgradable_movies_conditions += get_exclusion_clause('movie') + upgradable_movies = TableHistoryMovie.select(TableHistoryMovie.video_path, + TableHistoryMovie.language, + TableHistoryMovie.score, + TableMovies.profileId, + TableHistoryMovie.action, + TableHistoryMovie.subtitles_path, + TableMovies.audio_language, + TableMovies.sceneName, + TableHistoryMovie.timestamp.alias('timestamp'), + TableMovies.monitored, + TableMovies.tags, + TableMovies.radarrId, + TableMovies.title) \ + .join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId)) \ + .where(reduce(operator.and_, upgradable_movies_conditions)) \ + .order_by(TableHistoryMovie.timestamp.desc()) \ + .dicts() + + if not upgradable_movies: + return [] + else: + upgradable_movies = list(upgradable_movies) + logging.debug(f"{len(upgradable_movies)} potentially upgradable movie subtitles have been found, let's filter " + f"them...") + + return parse_upgradable_list(upgradable_list=upgradable_movies, perfect_score=117, media_type='movie') diff --git a/bazarr/subtitles/upload.py b/bazarr/subtitles/upload.py index ec6c01a58..306be2e6b 100644 --- a/bazarr/subtitles/upload.py +++ b/bazarr/subtitles/upload.py @@ -10,24 +10,24 @@ from subliminal_patch.core import save_subtitles from subliminal_patch.subtitle import Subtitle from pysubs2.formats import get_format_identifier -from languages.get_languages import language_from_alpha3, alpha2_from_alpha3, alpha3_from_alpha2, \ - alpha2_from_language, alpha3_from_language +from languages.get_languages import language_from_alpha3, alpha2_from_alpha3, alpha3_from_alpha2 from app.config import settings, get_array_from from utilities.helper import get_target_folder, force_unicode -from utilities.post_processing import pp_replace +from utilities.post_processing import pp_replace, set_chmod from utilities.path_mappings import path_mappings from radarr.notify import notify_radarr from sonarr.notify import notify_sonarr from languages.custom_lang import CustomLanguage from app.database import TableEpisodes, TableMovies, TableShows, get_profiles_list from app.event_handler import event_stream +from subtitles.processing import ProcessSubtitlesResult from .sync import sync_subtitles from .post_processing import postprocessing -def manual_upload_subtitle(path, language, forced, hi, title, scene_name, media_type, subtitle, audio_language): - logging.debug('BAZARR Manually uploading subtitles for this file: ' + path) +def manual_upload_subtitle(path, language, forced, hi, media_type, subtitle, audio_language): + logging.debug(f'BAZARR Manually uploading subtitles for this file: {path}') single = settings.general.getboolean('single_language') @@ -120,7 +120,6 @@ def manual_upload_subtitle(path, language, forced, hi, title, scene_name, media_ modifier_string = " forced" else: modifier_string = "" - message = language_from_alpha3(language) + modifier_string + " Subtitles manually uploaded." if hi: modifier_code = ":hi" @@ -131,8 +130,6 @@ def manual_upload_subtitle(path, language, forced, hi, title, scene_name, media_ uploaded_language_code3 = language + modifier_code uploaded_language = language_from_alpha3(language) + modifier_string uploaded_language_code2 = alpha2_from_alpha3(language) + modifier_code - audio_language_code2 = alpha2_from_language(audio_language) - audio_language_code3 = alpha3_from_language(audio_language) if media_type == 'series': if not episode_metadata: @@ -152,9 +149,10 @@ def manual_upload_subtitle(path, language, forced, hi, title, scene_name, media_ if use_postprocessing: command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language, uploaded_language_code2, - uploaded_language_code3, audio_language, audio_language_code2, audio_language_code3, 100, - "1", "manual", series_id, episode_id) + uploaded_language_code3, audio_language['name'], audio_language['code2'], + audio_language['code3'], 100, "1", "manual", series_id, episode_id) postprocessing(command, path) + set_chmod(subtitles_path=subtitle_path) if media_type == 'series': reversed_path = path_mappings.path_replace_reverse(path) @@ -169,4 +167,15 @@ def manual_upload_subtitle(path, language, forced, hi, title, scene_name, media_ event_stream(type='movie', action='update', payload=movie_metadata['radarrId']) event_stream(type='movie-wanted', action='delete', payload=movie_metadata['radarrId']) - return message, reversed_path, reversed_subtitles_path + result = ProcessSubtitlesResult(message=language_from_alpha3(language) + modifier_string + " Subtitles manually " + "uploaded.", + reversed_path=reversed_path, + downloaded_language_code2=uploaded_language_code2, + downloaded_provider=None, + score=None, + forced=None, + subtitle_id=None, + reversed_subtitles_path=reversed_subtitles_path, + hearing_impaired=None) + + return result diff --git a/bazarr/subtitles/wanted/movies.py b/bazarr/subtitles/wanted/movies.py index d82d4488d..f5ccd61df 100644 --- a/bazarr/subtitles/wanted/movies.py +++ b/bazarr/subtitles/wanted/movies.py @@ -20,7 +20,7 @@ from ..download import generate_subtitles def _wanted_movie(movie): - audio_language_list = get_audio_profile_languages(movie_id=movie['radarrId']) + audio_language_list = get_audio_profile_languages(movie['audio_language']) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: @@ -53,24 +53,10 @@ def _wanted_movie(movie): check_if_still_required=True): if result: - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path'])) - history_log_movie(1, movie['radarrId'], message, path, language_code, provider, score, - subs_id, subs_path) + history_log_movie(1, movie['radarrId'], result) event_stream(type='movie-wanted', action='delete', payload=movie['radarrId']) - send_notifications_movie(movie['radarrId'], message) + send_notifications_movie(movie['radarrId'], result.message) def wanted_download_subtitles_movie(radarr_id): diff --git a/bazarr/subtitles/wanted/series.py b/bazarr/subtitles/wanted/series.py index de6836ca1..6725b128a 100644 --- a/bazarr/subtitles/wanted/series.py +++ b/bazarr/subtitles/wanted/series.py @@ -20,7 +20,7 @@ from ..download import generate_subtitles def _wanted_episode(episode): - audio_language_list = get_audio_profile_languages(episode_id=episode['sonarrEpisodeId']) + audio_language_list = get_audio_profile_languages(episode['audio_language']) if len(audio_language_list) > 0: audio_language = audio_language_list[0]['name'] else: @@ -47,30 +47,16 @@ def _wanted_episode(episode): for result in generate_subtitles(path_mappings.path_replace(episode['path']), languages, audio_language, - str(episode['scene_name']), + str(episode['sceneName']), episode['title'], 'series', check_if_still_required=True): if result: - message = result[0] - path = result[1] - forced = result[5] - if result[8]: - language_code = result[2] + ":hi" - elif forced: - language_code = result[2] + ":forced" - else: - language_code = result[2] - provider = result[3] - score = result[4] - subs_id = result[6] - subs_path = result[7] store_subtitles(episode['path'], path_mappings.path_replace(episode['path'])) - history_log(1, episode['sonarrSeriesId'], episode['sonarrEpisodeId'], message, path, - language_code, provider, score, subs_id, subs_path) + history_log(1, episode['sonarrSeriesId'], episode['sonarrEpisodeId'], result) event_stream(type='series', action='update', payload=episode['sonarrSeriesId']) event_stream(type='episode-wanted', action='delete', payload=episode['sonarrEpisodeId']) - send_notifications(episode['sonarrSeriesId'], episode['sonarrEpisodeId'], message) + send_notifications(episode['sonarrSeriesId'], episode['sonarrEpisodeId'], result.message) def wanted_download_subtitles(sonarr_episode_id): @@ -79,7 +65,7 @@ def wanted_download_subtitles(sonarr_episode_id): TableEpisodes.sonarrEpisodeId, TableEpisodes.sonarrSeriesId, TableEpisodes.audio_language, - TableEpisodes.scene_name, + TableEpisodes.sceneName, TableEpisodes.failedAttempts, TableShows.title)\ .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId))\ diff --git a/bazarr/utilities/backup.py b/bazarr/utilities/backup.py index 4d16a9e02..9697c2073 100644 --- a/bazarr/utilities/backup.py +++ b/bazarr/utilities/backup.py @@ -47,27 +47,29 @@ def get_backup_files(fullpath=True): def backup_to_zip(): now = datetime.now() + database_backup_file = None now_string = now.strftime("%Y.%m.%d_%H.%M.%S") backup_filename = f"bazarr_backup_v{os.environ['BAZARR_VERSION']}_{now_string}.zip" logging.debug(f'Backup filename will be: {backup_filename}') - database_src_file = os.path.join(args.config_dir, 'db', 'bazarr.db') - logging.debug(f'Database file path to backup is: {database_src_file}') + if not settings.postgresql.getboolean('enabled'): + database_src_file = os.path.join(args.config_dir, 'db', 'bazarr.db') + logging.debug(f'Database file path to backup is: {database_src_file}') - try: - database_src_con = sqlite3.connect(database_src_file) + try: + database_src_con = sqlite3.connect(database_src_file) - database_backup_file = os.path.join(get_backup_path(), 'bazarr_temp.db') - database_backup_con = sqlite3.connect(database_backup_file) + database_backup_file = os.path.join(get_backup_path(), 'bazarr_temp.db') + database_backup_con = sqlite3.connect(database_backup_file) - with database_backup_con: - database_src_con.backup(database_backup_con) + with database_backup_con: + database_src_con.backup(database_backup_con) - database_backup_con.close() - database_src_con.close() - except Exception: - database_backup_file = None - logging.exception('Unable to backup database file.') + database_backup_con.close() + database_src_con.close() + except Exception: + database_backup_file = None + logging.exception('Unable to backup database file.') config_file = os.path.join(args.config_dir, 'config', 'config.ini') logging.debug(f'Config file path to backup is: {config_file}') @@ -75,15 +77,14 @@ def backup_to_zip(): with ZipFile(os.path.join(get_backup_path(), backup_filename), 'w') as backupZip: if database_backup_file: backupZip.write(database_backup_file, 'bazarr.db') + try: + os.remove(database_backup_file) + except OSError: + logging.exception(f'Unable to delete temporary database backup file: {database_backup_file}') else: logging.debug('Database file is not included in backup. See previous exception') backupZip.write(config_file, 'config.ini') - try: - os.remove(database_backup_file) - except OSError: - logging.exception(f'Unable to delete temporary database backup file: {database_backup_file}') - def restore_from_backup(): restore_config_path = os.path.join(get_restore_path(), 'config.ini') @@ -97,30 +98,34 @@ def restore_from_backup(): os.remove(restore_config_path) except OSError: logging.exception(f'Unable to restore or delete config.ini to {dest_config_path}') - - try: - shutil.copy(restore_database_path, dest_database_path) - os.remove(restore_database_path) - except OSError: - logging.exception(f'Unable to restore or delete db to {dest_database_path}') - else: + if not settings.postgresql.getboolean('enabled'): + try: + shutil.copy(restore_database_path, dest_database_path) + os.remove(restore_database_path) + except OSError: + logging.exception(f'Unable to restore or delete db to {dest_database_path}') + else: + try: + if os.path.isfile(f'{dest_database_path}-shm'): + os.remove(f'{dest_database_path}-shm') + if os.path.isfile(f'{dest_database_path}-wal'): + os.remove(f'{dest_database_path}-wal') + except OSError: + logging.exception('Unable to delete SHM and WAL file.') try: - if os.path.isfile(dest_database_path + '-shm'): - os.remove(dest_database_path + '-shm') - if os.path.isfile(dest_database_path + '-wal'): - os.remove(dest_database_path + '-wal') + os.remove(restore_database_path) except OSError: - logging.exception('Unable to delete SHM and WAL file.') + logging.exception(f'Unable to delete {dest_database_path}') logging.info('Backup restored successfully. Bazarr will restart.') try: restart_file = io.open(os.path.join(args.config_dir, "bazarr.restart"), "w", encoding='UTF-8') except Exception as e: - logging.error('BAZARR Cannot create restart file: ' + repr(e)) + logging.error(f'BAZARR Cannot create restart file: {repr(e)}') else: logging.info('Bazarr is being restarted...') - restart_file.write(str('')) + restart_file.write('') restart_file.close() os._exit(0) elif os.path.isfile(restore_config_path) or os.path.isfile(restore_database_path): @@ -134,11 +139,6 @@ def restore_from_backup(): except OSError: logging.exception(f'Unable to delete {dest_config_path}') - try: - os.remove(restore_database_path) - except OSError: - logging.exception(f'Unable to delete {dest_database_path}') - def prepare_restore(filename): src_zip_file_path = os.path.join(get_backup_path(), filename) diff --git a/bazarr/utilities/post_processing.py b/bazarr/utilities/post_processing.py index 1017b338d..581071256 100644 --- a/bazarr/utilities/post_processing.py +++ b/bazarr/utilities/post_processing.py @@ -2,6 +2,10 @@ import os import re +import sys +import logging + +from app.config import settings # Wraps the input string within quotes & escapes the string @@ -34,3 +38,12 @@ def pp_replace(pp_command, episode, subtitles, language, language_code2, languag pp_command = re.sub(r'[\'"]?{{series_id}}[\'"]?', _escape(str(series_id)), pp_command) pp_command = re.sub(r'[\'"]?{{episode_id}}[\'"]?', _escape(str(episode_id)), pp_command) return pp_command + + +def set_chmod(subtitles_path): + # apply chmod if required + chmod = int(settings.general.chmod, 8) if not sys.platform.startswith( + 'win') and settings.general.getboolean('chmod_enabled') else None + if chmod: + logging.debug(f"BAZARR setting permission to {chmod} on {subtitles_path} after custom post-processing.") + os.chmod(subtitles_path, chmod) diff --git a/bazarr/subtitles/tools/embedded_subs_reader.py b/bazarr/utilities/video_analyzer.py similarity index 66% rename from bazarr/subtitles/tools/embedded_subs_reader.py rename to bazarr/utilities/video_analyzer.py index b4153ead1..72c3a1b5f 100644 --- a/bazarr/subtitles/tools/embedded_subs_reader.py +++ b/bazarr/utilities/video_analyzer.py @@ -3,9 +3,10 @@ import logging import pickle -from knowit.api import know +from knowit.api import know, KnowitException from languages.custom_lang import CustomLanguage +from languages.get_languages import language_from_alpha3, alpha3_from_alpha2 from app.database import TableEpisodes, TableMovies from utilities.path_mappings import path_mappings from app.config import settings @@ -24,45 +25,73 @@ def _handle_alpha3(detected_language: dict): def embedded_subs_reader(file, file_size, episode_file_id=None, movie_file_id=None, use_cache=True): data = parse_video_metadata(file, file_size, episode_file_id, movie_file_id, use_cache=use_cache) + und_default_language = alpha3_from_alpha2(settings.general.default_und_embedded_subtitles_lang) subtitles_list = [] + + if not data: + return subtitles_list + + cache_provider = None if data["ffprobe"] and "subtitle" in data["ffprobe"]: - for detected_language in data["ffprobe"]["subtitle"]: - if "language" not in detected_language: - continue + cache_provider = 'ffprobe' + elif 'mediainfo' in data and data["mediainfo"] and "subtitle" in data["mediainfo"]: + cache_provider = 'mediainfo' + if cache_provider: + for detected_language in data[cache_provider]["subtitle"]: # Avoid commentary subtitles name = detected_language.get("name", "").lower() if "commentary" in name: - logging.debug("Ignoring commentary subtitle: %s", name) + logging.debug(f"Ignoring commentary subtitle: {name}") continue - language = _handle_alpha3(detected_language) + if "language" not in detected_language: + language = None + else: + language = _handle_alpha3(detected_language) + + if not language and und_default_language: + logging.debug(f"Undefined language embedded subtitles track treated as {language}") + language = und_default_language + + if not language: + continue forced = detected_language.get("forced", False) hearing_impaired = detected_language.get("hearing_impaired", False) codec = detected_language.get("format") # or None subtitles_list.append([language, forced, hearing_impaired, codec]) - elif 'mediainfo' in data and data["mediainfo"] and "subtitle" in data["mediainfo"]: - for detected_language in data["mediainfo"]["subtitle"]: - if "language" not in detected_language: - continue + return subtitles_list - # Avoid commentary subtitles - name = detected_language.get("name", "").lower() - if "commentary" in name: - logging.debug("Ignoring commentary subtitle: %s", name) + +def embedded_audio_reader(file, file_size, episode_file_id=None, movie_file_id=None, use_cache=True): + data = parse_video_metadata(file, file_size, episode_file_id, movie_file_id, use_cache=use_cache) + + audio_list = [] + + if not data: + return audio_list + + cache_provider = None + if data["ffprobe"] and "audio" in data["ffprobe"]: + cache_provider = 'ffprobe' + elif 'mediainfo' in data and data["mediainfo"] and "audio" in data["mediainfo"]: + cache_provider = 'mediainfo' + + if cache_provider: + for detected_language in data[cache_provider]["audio"]: + if "language" not in detected_language: + audio_list.append(None) continue - language = _handle_alpha3(detected_language) + language = language_from_alpha3(detected_language["language"].alpha3) - forced = detected_language.get("forced", False) - hearing_impaired = detected_language.get("hearing_impaired", False) - codec = detected_language.get("format") # or None - subtitles_list.append([language, forced, hearing_impaired, codec]) + if language not in audio_list: + audio_list.append(language) - return subtitles_list + return audio_list def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=None, use_cache=True): @@ -121,10 +150,18 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No # if we have ffprobe available if ffprobe_path: - data["ffprobe"] = know(video_path=file, context={"provider": "ffmpeg", "ffmpeg": ffprobe_path}) + try: + data["ffprobe"] = know(video_path=file, context={"provider": "ffmpeg", "ffmpeg": ffprobe_path}) + except KnowitException as e: + logging.error(f"BAZARR ffprobe cannot analyze this video file {file}. Could it be corrupted? {e}") + return None # or if we have mediainfo available elif mediainfo_path: - data["mediainfo"] = know(video_path=file, context={"provider": "mediainfo", "mediainfo": mediainfo_path}) + try: + data["mediainfo"] = know(video_path=file, context={"provider": "mediainfo", "mediainfo": mediainfo_path}) + except KnowitException as e: + logging.error(f"BAZARR mediainfo cannot analyze this video file {file}. Could it be corrupted? {e}") + return None # else, we warn user of missing binary else: logging.error("BAZARR require ffmpeg/ffprobe or mediainfo, please install it and make sure to choose it in " diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 7292b8a84..65e129bc9 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,6 +1,7 @@ { "rules": { "no-console": "error", + "camelcase": "warn", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-empty-function": "warn", "@typescript-eslint/no-empty-interface": "off", @@ -11,5 +12,15 @@ "plugin:react-hooks/recommended", "eslint:recommended", "plugin:@typescript-eslint/recommended" + ], + "plugins": ["testing-library"], + "overrides": [ + { + "files": [ + "**/__tests__/**/*.[jt]s?(x)", + "**/?(*.)+(spec|test).[jt]s?(x)" + ], + "extends": ["plugin:testing-library/react"] + } ] } diff --git a/frontend/.prettierignore b/frontend/.prettierignore index a8d998e96..2fd8bf549 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,4 +1,4 @@ build dist -converage +coverage public diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 41bceb7ba..b6a813bd9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,7 +30,7 @@ "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/react-fontawesome": "^0.2.0", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^12.1.5", + "@testing-library/react": "^12.1.0", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.4.3", "@types/lodash": "^4.14.0", @@ -39,10 +39,13 @@ "@types/react-dom": "^17.0.0", "@types/react-table": "^7.7.0", "@vitejs/plugin-react": "^2.2.0", + "@vitest/coverage-c8": "^0.25.0", + "@vitest/ui": "^0.25.0", "clsx": "^1.2.0", "eslint": "^8.26.0", "eslint-config-react-app": "^7.0.1", "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-testing-library": "^5.9.0", "husky": "^8.0.2", "jsdom": "^20.0.1", "lodash": "^4.17.0", @@ -55,8 +58,8 @@ "sass": "^1.55.0", "typescript": "^4", "vite": "^3.2.1", - "vite-plugin-checker": "^0.5.1", - "vitest": "^0.24.3" + "vite-plugin-checker": "^0.5.5", + "vitest": "^0.25.0" } }, "node_modules/@adobe/css-tools": { @@ -1939,6 +1942,12 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, "node_modules/@emotion/babel-plugin": { "version": "11.10.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.10.5.tgz", @@ -2295,6 +2304,15 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/expect-utils": { "version": "29.2.2", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.2.2.tgz", @@ -2630,6 +2648,12 @@ "node": ">= 8" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "dev": true + }, "node_modules/@radix-ui/number": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.0.tgz", @@ -3033,9 +3057,9 @@ "dev": true }, "node_modules/@types/chai": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.3.tgz", - "integrity": "sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.4.tgz", + "integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==", "dev": true }, "node_modules/@types/chai-subset": { @@ -3563,6 +3587,28 @@ "vite": "^3.0.0" } }, + "node_modules/@vitest/coverage-c8": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-c8/-/coverage-c8-0.25.8.tgz", + "integrity": "sha512-fWgzQoK2KNzTTNnDcLCyibfO9/pbcpPOMtZ9Yvq/Eggpi2X8lewx/OcKZkO5ba5q9dl6+BBn6d5hTcS1709rZw==", + "dev": true, + "dependencies": { + "c8": "^7.12.0", + "vitest": "0.25.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vitest/ui": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-0.25.8.tgz", + "integrity": "sha512-wfuhghldD5QHLYpS46GK8Ru8P3XcMrWvFjRQD21KNzc9Y/qtJsqoC8KmT6xWVkMNw4oHYixpo3a4ZySRJdserw==", + "dev": true, + "dependencies": { + "sirv": "^2.0.2" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -4034,6 +4080,32 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/c8": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-7.12.0.tgz", + "integrity": "sha512-CtgQrHOkyxr5koX1wEUmN/5cfDa2ckbHRA4Gy5LAL0zaCFtVWJS5++n+w4/sr2GWGerBxgTjpKeDclk/Qk6W/A==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^2.0.0", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-reports": "^3.1.4", + "rimraf": "^3.0.2", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -4071,14 +4143,14 @@ ] }, "node_modules/chai": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz", - "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", + "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", "dev": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.2", - "deep-eql": "^3.0.1", + "deep-eql": "^4.1.2", "get-func-name": "^2.0.0", "loupe": "^2.3.1", "pathval": "^1.1.1", @@ -4169,6 +4241,17 @@ "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==", "dev": true }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, "node_modules/clsx": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", @@ -4446,15 +4529,15 @@ "dev": true }, "node_modules/deep-eql": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", "dev": true, "dependencies": { "type-detect": "^4.0.0" }, "engines": { - "node": ">=0.12" + "node": ">=6" } }, "node_modules/deep-equal": { @@ -5201,9 +5284,9 @@ } }, "node_modules/eslint": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.26.0.tgz", - "integrity": "sha512-kzJkpaw1Bfwheq4VXUezFriD1GxszX6dUekM7Z3aC2o4hju+tsR/XyTC3RcoSD7jmy9VkPU3+N6YjVU2e96Oyg==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.29.0.tgz", + "integrity": "sha512-isQ4EEiyUjZFbEKvEGJKKGBwXtvXX+zJbkVKCgTuB9t/+jUBcy8avhkEwWJecI15BkRkOYmvIM5ynbhRjEkoeg==", "dev": true, "dependencies": { "@eslint/eslintrc": "^1.3.3", @@ -5977,6 +6060,19 @@ "is-callable": "^1.1.3" } }, + "node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -5990,6 +6086,29 @@ "node": ">= 6" } }, + "node_modules/fs-extra": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.0.tgz", + "integrity": "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6049,6 +6168,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-func-name": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", @@ -6270,6 +6398,12 @@ "node": ">=12" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -6523,6 +6657,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6739,6 +6882,63 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", + "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jest-diff": { "version": "29.2.1", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.2.1.tgz", @@ -7220,18 +7420,18 @@ } }, "node_modules/jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-pksjj7Rqoa+wdpkKcLzQRHhJCEE42qQhl/xLMUKHgoSejaKOdaXEAnqs6uDNwMl/fciHTzKeR8Wm8cw7N+g98A==", + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "dev": true, "dependencies": { "abab": "^2.0.6", - "acorn": "^8.8.0", + "acorn": "^8.8.1", "acorn-globals": "^7.0.0", "cssom": "^0.5.0", "cssstyle": "^2.3.0", "data-urls": "^3.0.2", - "decimal.js": "^10.4.1", + "decimal.js": "^10.4.2", "domexception": "^4.0.0", "escodegen": "^2.0.0", "form-data": "^4.0.0", @@ -7244,12 +7444,12 @@ "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^3.0.0", + "w3c-xmlserializer": "^4.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^2.0.0", "whatwg-mimetype": "^3.0.0", "whatwg-url": "^11.0.0", - "ws": "^8.9.0", + "ws": "^8.11.0", "xml-name-validator": "^4.0.0" }, "engines": { @@ -7303,6 +7503,27 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", @@ -7420,9 +7641,9 @@ } }, "node_modules/loupe": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", - "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==", + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", + "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", "dev": true, "dependencies": { "get-func-name": "^2.0.0" @@ -7461,6 +7682,21 @@ "node": ">=12" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/match-sorter": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz", @@ -7578,6 +7814,15 @@ "node": ">=4" } }, + "node_modules/mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -8007,9 +8252,9 @@ } }, "node_modules/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", + "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", "dev": true, "bin": { "prettier": "bin-prettier.js" @@ -8022,16 +8267,20 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.1.1.tgz", - "integrity": "sha512-6bHIQzybqA644h0WGUW3gpWEVbMBvzui5wCMRBi7qA++d5ov2xjjfDk8pxJJ/ardfZrGAwizKMq/fQMFdJ+0Zw==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.1.tgz", + "integrity": "sha512-bty7C2Ecard5EOXirtzeCAqj4FU4epeuWrQt/Z+sh8UVEpBlBZ3m3KNPz2kFu7KgRTQx/C9o4/TdquPD1jOqjQ==", "dev": true, "peerDependencies": { - "@volar/vue-typescript": ">=0.40.2", + "@volar/vue-language-plugin-pug": "^1.0.4", + "@volar/vue-typescript": "^1.0.4", "prettier": ">=2.0", "typescript": ">=2.9" }, "peerDependenciesMeta": { + "@volar/vue-language-plugin-pug": { + "optional": true + }, "@volar/vue-typescript": { "optional": true } @@ -8679,6 +8928,15 @@ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -8878,6 +9136,20 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/sirv": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.2.tgz", + "integrity": "sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8964,6 +9236,26 @@ "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", "dev": true }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, "node_modules/string.prototype.matchall": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz", @@ -9066,12 +9358,12 @@ } }, "node_modules/strip-literal": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-0.4.2.tgz", - "integrity": "sha512-pv48ybn4iE1O9RLgCAN0iU4Xv7RlBTiit6DKmMiErbs9x1wH6vXBs45tWc0H5wUIF6TLTrKweqkmYF/iraQKNw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.0.0.tgz", + "integrity": "sha512-5o4LsH1lzBzO9UFH63AJ2ad2/S2AVx6NtjOcaz+VTT2h1RiRvbipW72z8M/lxEhcPHDBQwpDrnTF7sXy/7OwCQ==", "dev": true, "dependencies": { - "acorn": "^8.8.0" + "acorn": "^8.8.1" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -9111,6 +9403,20 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -9167,6 +9473,15 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.0.tgz", + "integrity": "sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", @@ -9444,6 +9759,20 @@ } } }, + "node_modules/v8-to-istanbul": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", + "integrity": "sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/vite": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.1.tgz", @@ -9490,9 +9819,9 @@ } }, "node_modules/vite-plugin-checker": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.5.1.tgz", - "integrity": "sha512-NFiO1PyK9yGuaeSnJ7Whw9fnxLc1AlELnZoyFURnauBYhbIkx9n+PmIXxSFUuC9iFyACtbJQUAEuQi6yHs2Adg==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.5.5.tgz", + "integrity": "sha512-BLaRlBmiVn3Fg/wR9A0+YNwgXVteFJaH8rCIiIgYQcQ50jc3oVe2m8i0xxG5geq36UttNJsAj7DpDelN7/KjOg==", "dev": true, "dependencies": { "@babel/code-frame": "^7.12.13", @@ -9501,6 +9830,7 @@ "chokidar": "^3.5.1", "commander": "^8.0.0", "fast-glob": "^3.2.7", + "fs-extra": "^11.1.0", "lodash.debounce": "^4.0.8", "lodash.pick": "^4.4.0", "npm-run-path": "^4.0.1", @@ -9516,15 +9846,28 @@ }, "peerDependencies": { "eslint": ">=7", + "meow": "^9.0.0", + "optionator": "^0.9.1", + "stylelint": ">=13", "typescript": "*", - "vite": "^2.0.0 || ^3.0.0-0", + "vite": ">=2.0.0", "vls": "*", - "vti": "*" + "vti": "*", + "vue-tsc": "*" }, "peerDependenciesMeta": { "eslint": { "optional": true }, + "meow": { + "optional": true + }, + "optionator": { + "optional": true + }, + "stylelint": { + "optional": true + }, "typescript": { "optional": true }, @@ -9533,6 +9876,9 @@ }, "vti": { "optional": true + }, + "vue-tsc": { + "optional": true } } }, @@ -9607,22 +9953,25 @@ } }, "node_modules/vitest": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.24.3.tgz", - "integrity": "sha512-aM0auuPPgMSstWvr851hB74g/LKaKBzSxcG3da7ejfZbx08Y21JpZmbmDYrMTCGhVZKqTGwzcnLMwyfz2WzkhQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.25.8.tgz", + "integrity": "sha512-X75TApG2wZTJn299E/TIYevr4E9/nBo1sUtZzn0Ci5oK8qnpZAZyhwg0qCeMSakGIWtc6oRwcQFyFfW14aOFWg==", "dev": true, "dependencies": { - "@types/chai": "^4.3.3", + "@types/chai": "^4.3.4", "@types/chai-subset": "^1.3.3", "@types/node": "*", - "chai": "^4.3.6", + "acorn": "^8.8.1", + "acorn-walk": "^8.2.0", + "chai": "^4.3.7", "debug": "^4.3.4", "local-pkg": "^0.4.2", - "strip-literal": "^0.4.2", - "tinybench": "^2.3.0", + "source-map": "^0.6.1", + "strip-literal": "^1.0.0", + "tinybench": "^2.3.1", "tinypool": "^0.3.0", "tinyspy": "^1.0.2", - "vite": "^3.0.0" + "vite": "^3.0.0 || ^4.0.0" }, "bin": { "vitest": "vitest.mjs" @@ -9658,6 +10007,15 @@ } } }, + "node_modules/vitest/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/vscode-jsonrpc": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-6.0.0.tgz", @@ -9737,15 +10095,15 @@ "dev": true }, "node_modules/w3c-xmlserializer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz", - "integrity": "sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", "dev": true, "dependencies": { "xml-name-validator": "^4.0.0" }, "engines": { - "node": ">=12" + "node": ">=14" } }, "node_modules/webidl-conversions": { @@ -9866,15 +10224,65 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.10.0.tgz", - "integrity": "sha512-+s49uSmZpvtAsd2h37vIPy1RBusaLawVe8of+GyEPsaJTCMpj/2v8NpeK1SHXjBlQ95lQTmQofOJnFiLoaN3yw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", "dev": true, "engines": { "node": ">=10.0.0" @@ -9915,6 +10323,15 @@ "node": ">=0.4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -9929,6 +10346,33 @@ "node": ">= 6" } }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -11228,6 +11672,12 @@ "to-fast-properties": "^2.0.0" } }, + "@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, "@emotion/babel-plugin": { "version": "11.10.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.10.5.tgz", @@ -11491,6 +11941,12 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, "@jest/expect-utils": { "version": "29.2.2", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.2.2.tgz", @@ -11740,6 +12196,12 @@ "fastq": "^1.6.0" } }, + "@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "dev": true + }, "@radix-ui/number": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.0.tgz", @@ -12034,9 +12496,9 @@ "dev": true }, "@types/chai": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.3.tgz", - "integrity": "sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.4.tgz", + "integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==", "dev": true }, "@types/chai-subset": { @@ -12434,6 +12896,25 @@ "react-refresh": "^0.14.0" } }, + "@vitest/coverage-c8": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-c8/-/coverage-c8-0.25.8.tgz", + "integrity": "sha512-fWgzQoK2KNzTTNnDcLCyibfO9/pbcpPOMtZ9Yvq/Eggpi2X8lewx/OcKZkO5ba5q9dl6+BBn6d5hTcS1709rZw==", + "dev": true, + "requires": { + "c8": "^7.12.0", + "vitest": "0.25.8" + } + }, + "@vitest/ui": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-0.25.8.tgz", + "integrity": "sha512-wfuhghldD5QHLYpS46GK8Ru8P3XcMrWvFjRQD21KNzc9Y/qtJsqoC8KmT6xWVkMNw4oHYixpo3a4ZySRJdserw==", + "dev": true, + "requires": { + "sirv": "^2.0.2" + } + }, "abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -12780,6 +13261,26 @@ "update-browserslist-db": "^1.0.9" } }, + "c8": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-7.12.0.tgz", + "integrity": "sha512-CtgQrHOkyxr5koX1wEUmN/5cfDa2ckbHRA4Gy5LAL0zaCFtVWJS5++n+w4/sr2GWGerBxgTjpKeDclk/Qk6W/A==", + "dev": true, + "requires": { + "@bcoe/v8-coverage": "^0.2.3", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^2.0.0", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-reports": "^3.1.4", + "rimraf": "^3.0.2", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9" + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -12801,14 +13302,14 @@ "integrity": "sha512-lfXQ73oB9c8DP5Suxaszm+Ta2sr/4tf8+381GkIm1MLj/YdLf+rEDyDSRCzeltuyTVGm+/s18gdZ0q+Wmp8VsQ==" }, "chai": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz", - "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", + "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", "dev": true, "requires": { "assertion-error": "^1.1.0", "check-error": "^1.0.2", - "deep-eql": "^3.0.1", + "deep-eql": "^4.1.2", "get-func-name": "^2.0.0", "loupe": "^2.3.1", "pathval": "^1.1.1", @@ -12877,6 +13378,17 @@ "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==", "dev": true }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, "clsx": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", @@ -13118,9 +13630,9 @@ "dev": true }, "deep-eql": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", - "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", "dev": true, "requires": { "type-detect": "^4.0.0" @@ -13592,9 +14104,9 @@ } }, "eslint": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.26.0.tgz", - "integrity": "sha512-kzJkpaw1Bfwheq4VXUezFriD1GxszX6dUekM7Z3aC2o4hju+tsR/XyTC3RcoSD7jmy9VkPU3+N6YjVU2e96Oyg==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.29.0.tgz", + "integrity": "sha512-isQ4EEiyUjZFbEKvEGJKKGBwXtvXX+zJbkVKCgTuB9t/+jUBcy8avhkEwWJecI15BkRkOYmvIM5ynbhRjEkoeg==", "dev": true, "requires": { "@eslint/eslintrc": "^1.3.3", @@ -14170,6 +14682,16 @@ "is-callable": "^1.1.3" } }, + "foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + } + }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -14180,6 +14702,25 @@ "mime-types": "^2.1.12" } }, + "fs-extra": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.0.tgz", + "integrity": "sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "dependencies": { + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true + } + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -14220,6 +14761,12 @@ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, "get-func-name": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", @@ -14378,6 +14925,12 @@ "whatwg-encoding": "^2.0.0" } }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -14556,6 +15109,12 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -14703,6 +15262,50 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-reports": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", + "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, "jest-diff": { "version": "29.2.1", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.2.1.tgz", @@ -15069,18 +15672,18 @@ } }, "jsdom": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.1.tgz", - "integrity": "sha512-pksjj7Rqoa+wdpkKcLzQRHhJCEE42qQhl/xLMUKHgoSejaKOdaXEAnqs6uDNwMl/fciHTzKeR8Wm8cw7N+g98A==", + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "dev": true, "requires": { "abab": "^2.0.6", - "acorn": "^8.8.0", + "acorn": "^8.8.1", "acorn-globals": "^7.0.0", "cssom": "^0.5.0", "cssstyle": "^2.3.0", "data-urls": "^3.0.2", - "decimal.js": "^10.4.1", + "decimal.js": "^10.4.2", "domexception": "^4.0.0", "escodegen": "^2.0.0", "form-data": "^4.0.0", @@ -15093,12 +15696,12 @@ "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^3.0.0", + "w3c-xmlserializer": "^4.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^2.0.0", "whatwg-mimetype": "^3.0.0", "whatwg-url": "^11.0.0", - "ws": "^8.9.0", + "ws": "^8.11.0", "xml-name-validator": "^4.0.0" } }, @@ -15129,6 +15732,24 @@ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==" }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + }, + "dependencies": { + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true + } + } + }, "jsx-ast-utils": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", @@ -15222,9 +15843,9 @@ } }, "loupe": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", - "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==", + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", + "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", "dev": true, "requires": { "get-func-name": "^2.0.0" @@ -15254,6 +15875,15 @@ "sourcemap-codec": "^1.4.8" } }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, "match-sorter": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz", @@ -15341,6 +15971,12 @@ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", "dev": true }, + "mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -15643,15 +16279,15 @@ "dev": true }, "prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.1.tgz", + "integrity": "sha512-lqGoSJBQNJidqCHE80vqZJHWHRFoNYsSpP9AjFhlhi9ODCJA541svILes/+/1GM3VaL/abZi7cpFzOpdR9UPKg==", "dev": true }, "prettier-plugin-organize-imports": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.1.1.tgz", - "integrity": "sha512-6bHIQzybqA644h0WGUW3gpWEVbMBvzui5wCMRBi7qA++d5ov2xjjfDk8pxJJ/ardfZrGAwizKMq/fQMFdJ+0Zw==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.1.tgz", + "integrity": "sha512-bty7C2Ecard5EOXirtzeCAqj4FU4epeuWrQt/Z+sh8UVEpBlBZ3m3KNPz2kFu7KgRTQx/C9o4/TdquPD1jOqjQ==", "dev": true, "requires": {} }, @@ -16126,6 +16762,12 @@ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==" }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true + }, "requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -16262,6 +16904,17 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "sirv": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.2.tgz", + "integrity": "sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==", + "dev": true, + "requires": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^3.0.0" + } + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -16329,6 +16982,25 @@ "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", "dev": true }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "dependencies": { + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + } + } + }, "string.prototype.matchall": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz", @@ -16404,12 +17076,12 @@ "dev": true }, "strip-literal": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-0.4.2.tgz", - "integrity": "sha512-pv48ybn4iE1O9RLgCAN0iU4Xv7RlBTiit6DKmMiErbs9x1wH6vXBs45tWc0H5wUIF6TLTrKweqkmYF/iraQKNw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.0.0.tgz", + "integrity": "sha512-5o4LsH1lzBzO9UFH63AJ2ad2/S2AVx6NtjOcaz+VTT2h1RiRvbipW72z8M/lxEhcPHDBQwpDrnTF7sXy/7OwCQ==", "dev": true, "requires": { - "acorn": "^8.8.0" + "acorn": "^8.8.1" } }, "stylis": { @@ -16437,6 +17109,17 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -16481,6 +17164,12 @@ "is-number": "^7.0.0" } }, + "totalist": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.0.tgz", + "integrity": "sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw==", + "dev": true + }, "tough-cookie": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", @@ -16677,6 +17366,17 @@ "use-isomorphic-layout-effect": "^1.1.1" } }, + "v8-to-istanbul": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", + "integrity": "sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0" + } + }, "vite": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.1.tgz", @@ -16691,9 +17391,9 @@ } }, "vite-plugin-checker": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.5.1.tgz", - "integrity": "sha512-NFiO1PyK9yGuaeSnJ7Whw9fnxLc1AlELnZoyFURnauBYhbIkx9n+PmIXxSFUuC9iFyACtbJQUAEuQi6yHs2Adg==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.5.5.tgz", + "integrity": "sha512-BLaRlBmiVn3Fg/wR9A0+YNwgXVteFJaH8rCIiIgYQcQ50jc3oVe2m8i0xxG5geq36UttNJsAj7DpDelN7/KjOg==", "dev": true, "requires": { "@babel/code-frame": "^7.12.13", @@ -16702,6 +17402,7 @@ "chokidar": "^3.5.1", "commander": "^8.0.0", "fast-glob": "^3.2.7", + "fs-extra": "^11.1.0", "lodash.debounce": "^4.0.8", "lodash.pick": "^4.4.0", "npm-run-path": "^4.0.1", @@ -16765,22 +17466,33 @@ } }, "vitest": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.24.3.tgz", - "integrity": "sha512-aM0auuPPgMSstWvr851hB74g/LKaKBzSxcG3da7ejfZbx08Y21JpZmbmDYrMTCGhVZKqTGwzcnLMwyfz2WzkhQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.25.8.tgz", + "integrity": "sha512-X75TApG2wZTJn299E/TIYevr4E9/nBo1sUtZzn0Ci5oK8qnpZAZyhwg0qCeMSakGIWtc6oRwcQFyFfW14aOFWg==", "dev": true, "requires": { - "@types/chai": "^4.3.3", + "@types/chai": "^4.3.4", "@types/chai-subset": "^1.3.3", "@types/node": "*", - "chai": "^4.3.6", + "acorn": "^8.8.1", + "acorn-walk": "^8.2.0", + "chai": "^4.3.7", "debug": "^4.3.4", "local-pkg": "^0.4.2", - "strip-literal": "^0.4.2", - "tinybench": "^2.3.0", + "source-map": "^0.6.1", + "strip-literal": "^1.0.0", + "tinybench": "^2.3.1", "tinypool": "^0.3.0", "tinyspy": "^1.0.2", - "vite": "^3.0.0" + "vite": "^3.0.0 || ^4.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } } }, "vscode-jsonrpc": { @@ -16849,9 +17561,9 @@ "dev": true }, "w3c-xmlserializer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-3.0.0.tgz", - "integrity": "sha512-3WFqGEgSXIyGhOmAFtlicJNMjEps8b1MG31NCA0/vOF9+nKMUW1ckhi9cnNHmf88Rzw5V+dwIwsm2C7X8k9aQg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", "dev": true, "requires": { "xml-name-validator": "^4.0.0" @@ -16942,15 +17654,52 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.10.0.tgz", - "integrity": "sha512-+s49uSmZpvtAsd2h37vIPy1RBusaLawVe8of+GyEPsaJTCMpj/2v8NpeK1SHXjBlQ95lQTmQofOJnFiLoaN3yw==", + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", "dev": true, "requires": {} }, @@ -16971,6 +17720,12 @@ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -16982,6 +17737,27 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9a6e6dc5b..abd29036f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,11 +14,11 @@ "private": true, "dependencies": { "@mantine/core": "^5.6.0", + "@mantine/dropzone": "^5.6.0", "@mantine/form": "^5.6.0", "@mantine/hooks": "^5.6.0", "@mantine/modals": "^5.6.0", "@mantine/notifications": "^5.6.0", - "@mantine/dropzone": "^5.6.0", "axios": "^0.27.2", "react": "^17.0.2", "react-dom": "^17.0.2", @@ -34,7 +34,7 @@ "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/react-fontawesome": "^0.2.0", "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^12.1.5", + "@testing-library/react": "^12.1.0", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.4.3", "@types/lodash": "^4.14.0", @@ -43,10 +43,13 @@ "@types/react-dom": "^17.0.0", "@types/react-table": "^7.7.0", "@vitejs/plugin-react": "^2.2.0", + "@vitest/coverage-c8": "^0.25.0", + "@vitest/ui": "^0.25.0", "clsx": "^1.2.0", "eslint": "^8.26.0", "eslint-config-react-app": "^7.0.1", "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-testing-library": "^5.9.0", "husky": "^8.0.2", "jsdom": "^20.0.1", "lodash": "^4.17.0", @@ -59,8 +62,8 @@ "sass": "^1.55.0", "typescript": "^4", "vite": "^3.2.1", - "vite-plugin-checker": "^0.5.1", - "vitest": "^0.24.3" + "vite-plugin-checker": "^0.5.5", + "vitest": "^0.25.0" }, "scripts": { "start": "vite", @@ -70,6 +73,8 @@ "check:ts": "tsc --noEmit --incremental false", "check:fmt": "prettier -c .", "test": "vitest", + "test:ui": "vitest --ui", + "coverage": "vitest run --coverage", "format": "prettier -w .", "prepare": "cd .. && husky install frontend/.husky" }, diff --git a/frontend/src/App/app.test.tsx b/frontend/src/App/app.test.tsx new file mode 100644 index 000000000..f6236cdc9 --- /dev/null +++ b/frontend/src/App/app.test.tsx @@ -0,0 +1,9 @@ +import { render } from "@/tests"; +import { describe, it } from "vitest"; +import App from "."; + +describe("App", () => { + it("should render without crash", () => { + render(); + }); +}); diff --git a/frontend/src/App/index.tsx b/frontend/src/App/index.tsx index e5c974240..374505be1 100644 --- a/frontend/src/App/index.tsx +++ b/frontend/src/App/index.tsx @@ -4,7 +4,7 @@ import { Layout } from "@/constants"; import NavbarProvider from "@/contexts/Navbar"; import OnlineProvider from "@/contexts/Online"; import { notification } from "@/modules/task"; -import CriticalError from "@/pages/CriticalError"; +import CriticalError from "@/pages/errors/CriticalError"; import { RouterNames } from "@/Router/RouterNames"; import { Environment } from "@/utilities"; import { AppShell } from "@mantine/core"; diff --git a/frontend/src/Router/Redirector.tsx b/frontend/src/Router/Redirector.tsx index c9190c291..064522bbc 100644 --- a/frontend/src/Router/Redirector.tsx +++ b/frontend/src/Router/Redirector.tsx @@ -10,10 +10,10 @@ const Redirector: FunctionComponent = () => { useEffect(() => { if (data) { - const { use_sonarr, use_radarr } = data.general; - if (use_sonarr) { + const { use_sonarr: useSonarr, use_radarr: useRadarr } = data.general; + if (useSonarr) { navigate("/series"); - } else if (use_radarr) { + } else if (useRadarr) { navigate("/movies"); } else { navigate("/settings/general"); diff --git a/frontend/src/Router/index.tsx b/frontend/src/Router/index.tsx index 3c3ab8a4a..d13ea1417 100644 --- a/frontend/src/Router/index.tsx +++ b/frontend/src/Router/index.tsx @@ -6,12 +6,12 @@ import Authentication from "@/pages/Authentication"; import BlacklistMoviesView from "@/pages/Blacklist/Movies"; import BlacklistSeriesView from "@/pages/Blacklist/Series"; import Episodes from "@/pages/Episodes"; +import NotFound from "@/pages/errors/NotFound"; import MoviesHistoryView from "@/pages/History/Movies"; import SeriesHistoryView from "@/pages/History/Series"; import MovieView from "@/pages/Movies"; import MovieDetailView from "@/pages/Movies/Details"; import MovieMassEditor from "@/pages/Movies/Editor"; -import NotFound from "@/pages/NotFound"; import SeriesView from "@/pages/Series"; import SeriesMassEditor from "@/pages/Series/Editor"; import SettingsGeneralView from "@/pages/Settings/General"; @@ -23,6 +23,7 @@ import SettingsSchedulerView from "@/pages/Settings/Scheduler"; import SettingsSonarrView from "@/pages/Settings/Sonarr"; import SettingsSubtitlesView from "@/pages/Settings/Subtitles"; import SettingsUIView from "@/pages/Settings/UI"; +import SystemAnnouncementsView from "@/pages/System/Announcements"; import SystemBackupsView from "@/pages/System/Backups"; import SystemLogsView from "@/pages/System/Logs"; import SystemProvidersView from "@/pages/System/Providers"; @@ -278,6 +279,12 @@ function useRoutes(): CustomRouteObject[] { name: "Releases", element: , }, + { + path: "announcements", + name: "Announcements", + badge: data?.announcements, + element: , + }, ], }, { @@ -299,6 +306,7 @@ function useRoutes(): CustomRouteObject[] { data?.providers, data?.sonarr_signalr, data?.radarr_signalr, + data?.announcements, radarr, sonarr, ] diff --git a/frontend/src/apis/hooks/system.ts b/frontend/src/apis/hooks/system.ts index 24691c4b6..29e379a20 100644 --- a/frontend/src/apis/hooks/system.ts +++ b/frontend/src/apis/hooks/system.ts @@ -6,7 +6,15 @@ import { QueryKeys } from "../queries/keys"; import api from "../raw"; export function useBadges() { - return useQuery([QueryKeys.System, QueryKeys.Badges], () => api.badges.all()); + return useQuery( + [QueryKeys.System, QueryKeys.Badges], + () => api.badges.all(), + { + refetchOnWindowFocus: "always", + refetchInterval: 1000 * 60, + staleTime: 1000 * 10, + } + ); } export function useFileSystem( @@ -49,6 +57,11 @@ export function useSettingsMutation() { { onSuccess: () => { client.invalidateQueries([QueryKeys.System]); + client.invalidateQueries([QueryKeys.Series]); + client.invalidateQueries([QueryKeys.Episodes]); + client.invalidateQueries([QueryKeys.Movies]); + client.invalidateQueries([QueryKeys.Wanted]); + client.invalidateQueries([QueryKeys.Badges]); }, } ); @@ -68,7 +81,7 @@ export function useSystemLogs() { return useQuery([QueryKeys.System, QueryKeys.Logs], () => api.system.logs(), { refetchOnWindowFocus: "always", refetchInterval: 1000 * 60, - staleTime: 1000, + staleTime: 1000 * 10, }); } @@ -85,6 +98,35 @@ export function useDeleteLogs() { ); } +export function useSystemAnnouncements() { + return useQuery( + [QueryKeys.System, QueryKeys.Announcements], + () => api.system.announcements(), + { + refetchOnWindowFocus: "always", + refetchInterval: 1000 * 60, + staleTime: 1000 * 10, + } + ); +} + +export function useSystemAnnouncementsAddDismiss() { + const client = useQueryClient(); + return useMutation( + [QueryKeys.System, QueryKeys.Announcements], + (param: { hash: string }) => { + const { hash } = param; + return api.system.addAnnouncementsDismiss(hash); + }, + { + onSuccess: (_, { hash }) => { + client.invalidateQueries([QueryKeys.System, QueryKeys.Announcements]); + client.invalidateQueries([QueryKeys.System, QueryKeys.Badges]); + }, + } + ); +} + export function useSystemTasks() { return useQuery( [QueryKeys.System, QueryKeys.Tasks], diff --git a/frontend/src/apis/queries/keys.ts b/frontend/src/apis/queries/keys.ts index a3b6e94a7..45f30f12e 100644 --- a/frontend/src/apis/queries/keys.ts +++ b/frontend/src/apis/queries/keys.ts @@ -13,6 +13,7 @@ export enum QueryKeys { Blacklist = "blacklist", Search = "search", Actions = "actions", + Announcements = "announcements", Tasks = "tasks", Backups = "backups", Logs = "logs", diff --git a/frontend/src/apis/raw/system.ts b/frontend/src/apis/raw/system.ts index c2f0382ef..1b64d6b24 100644 --- a/frontend/src/apis/raw/system.ts +++ b/frontend/src/apis/raw/system.ts @@ -87,6 +87,19 @@ class SystemApi extends BaseApi { await this.delete("/logs"); } + async announcements() { + const response = await this.get>( + "/announcements" + ); + return response.data; + } + + async addAnnouncementsDismiss(hash: string) { + await this.post>("/announcements", { + hash, + }); + } + async tasks() { const response = await this.get>("/tasks"); return response.data; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index b7b7494fb..9f6228e34 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import UIError from "@/pages/UIError"; +import UIError from "@/pages/errors/UIError"; import { Component } from "react"; interface State { diff --git a/frontend/src/components/bazarr/Language.test.tsx b/frontend/src/components/bazarr/Language.test.tsx new file mode 100644 index 000000000..8d328753e --- /dev/null +++ b/frontend/src/components/bazarr/Language.test.tsx @@ -0,0 +1,84 @@ +import { rawRender, screen } from "@/tests"; +import { describe, it } from "vitest"; +import { Language } from "."; + +describe("Language text", () => { + const testLanguage: Language.Info = { + code2: "en", + name: "English", + }; + + it("should show short text", () => { + rawRender(); + + expect(screen.getByText(testLanguage.code2)).toBeDefined(); + }); + + it("should show long text", () => { + rawRender(); + + expect(screen.getByText(testLanguage.name)).toBeDefined(); + }); + + const testLanguageWithHi: Language.Info = { ...testLanguage, hi: true }; + + it("should show short text with HI", () => { + rawRender(); + + const expectedText = `${testLanguageWithHi.code2}:HI`; + + expect(screen.getByText(expectedText)).toBeDefined(); + }); + + it("should show long text with HI", () => { + rawRender(); + + const expectedText = `${testLanguageWithHi.name} HI`; + + expect(screen.getByText(expectedText)).toBeDefined(); + }); + + const testLanguageWithForced: Language.Info = { + ...testLanguage, + forced: true, + }; + + it("should show short text with Forced", () => { + rawRender(); + + const expectedText = `${testLanguageWithHi.code2}:Forced`; + + expect(screen.getByText(expectedText)).toBeDefined(); + }); + + it("should show long text with Forced", () => { + rawRender( + + ); + + const expectedText = `${testLanguageWithHi.name} Forced`; + + expect(screen.getByText(expectedText)).toBeDefined(); + }); +}); + +describe("Language list", () => { + const elements: Language.Info[] = [ + { + code2: "en", + name: "English", + }, + { + code2: "zh", + name: "Chinese", + }, + ]; + + it("should show all languages", () => { + rawRender(); + + elements.forEach((value) => { + expect(screen.getByText(value.name)).toBeDefined(); + }); + }); +}); diff --git a/frontend/src/components/forms/ProfileEditForm.tsx b/frontend/src/components/forms/ProfileEditForm.tsx index 50074cbc7..d31a0e338 100644 --- a/frontend/src/components/forms/ProfileEditForm.tsx +++ b/frontend/src/components/forms/ProfileEditForm.tsx @@ -9,6 +9,7 @@ import { Accordion, Button, Checkbox, + Select, Stack, Switch, Text, @@ -26,6 +27,7 @@ const defaultCutoffOptions: SelectorOption[] = [ label: "Any", value: { id: anyCutoff, + // eslint-disable-next-line camelcase audio_exclude: "False", forced: "False", hi: "False", @@ -34,6 +36,21 @@ const defaultCutoffOptions: SelectorOption[] = [ }, ]; +const subtitlesTypeOptions: SelectorOption[] = [ + { + label: "Normal or hearing-impaired", + value: "normal", + }, + { + label: "Hearing-impaired required", + value: "hi", + }, + { + label: "Forced (foreign part only)", + value: "forced", + }, +]; + interface Props { onComplete?: (profile: Language.Profile) => void; languages: readonly Language.Info[]; @@ -112,6 +129,7 @@ const ProfileEditForm: FunctionComponent = ({ const item: Language.ProfileItem = { id, language, + // eslint-disable-next-line camelcase audio_exclude: "False", hi: "False", forced: "False", @@ -157,43 +175,38 @@ const ProfileEditForm: FunctionComponent = ({ }, }, { - Header: "Forced", + Header: "Subtitles Type", accessor: "forced", Cell: ({ row: { original: item, index }, value }) => { + const selectValue = useMemo(() => { + if (item.forced === "True") { + return "forced"; + } else if (item.hi === "True") { + return "hi"; + } else { + return "normal"; + } + }, [item.forced, item.hi]); + return ( - { - action.mutate(index, { - ...item, - forced: checked ? "True" : "False", - hi: checked ? "False" : item.hi, - }); - }} - > - ); - }, - }, - { - Header: "HI", - accessor: "hi", - Cell: ({ row: { original: item, index }, value }) => { - return ( - { - action.mutate(index, { - ...item, - hi: checked ? "True" : "False", - forced: checked ? "False" : item.forced, - }); + ); }, }, { - Header: "Exclude Audio", + Header: "Exclude If Matching Audio", accessor: "audio_exclude", Cell: ({ row: { original: item, index }, value }) => { return ( @@ -202,6 +215,7 @@ const ProfileEditForm: FunctionComponent = ({ onChange={({ currentTarget: { checked } }) => { action.mutate(index, { ...item, + // eslint-disable-next-line camelcase audio_exclude: checked ? "True" : "False", }); }} @@ -317,8 +331,6 @@ export const ProfileEditModal = withModal( "languages-profile-editor", { title: "Edit Languages Profile", - size: "lg", + size: "xl", } ); - -export default ProfileEditForm; diff --git a/frontend/src/components/inputs/Action.test.tsx b/frontend/src/components/inputs/Action.test.tsx new file mode 100644 index 000000000..05086e71c --- /dev/null +++ b/frontend/src/components/inputs/Action.test.tsx @@ -0,0 +1,38 @@ +import { rawRender, screen } from "@/tests"; +import { faStickyNote } from "@fortawesome/free-regular-svg-icons"; +import userEvent from "@testing-library/user-event"; +import { describe, it, vitest } from "vitest"; +import Action from "./Action"; + +const testLabel = "Test Label"; +const testIcon = faStickyNote; + +describe("Action button", () => { + it("should be a button", () => { + rawRender(); + const element = screen.getByRole("button", { name: testLabel }); + + expect(element.getAttribute("type")).toEqual("button"); + expect(element.getAttribute("aria-label")).toEqual(testLabel); + }); + + it("should show icon", () => { + rawRender(); + // TODO: use getBy... + const element = screen.getByRole("img", { hidden: true }); + + expect(element.getAttribute("data-prefix")).toEqual(testIcon.prefix); + expect(element.getAttribute("data-icon")).toEqual(testIcon.iconName); + }); + + it("should call on-click event when clicked", async () => { + const onClickFn = vitest.fn(); + rawRender( + + ); + + await userEvent.click(screen.getByRole("button", { name: testLabel })); + + expect(onClickFn).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/inputs/ChipInput.test.tsx b/frontend/src/components/inputs/ChipInput.test.tsx new file mode 100644 index 000000000..3e30490d2 --- /dev/null +++ b/frontend/src/components/inputs/ChipInput.test.tsx @@ -0,0 +1,48 @@ +import { rawRender, screen } from "@/tests"; +import userEvent from "@testing-library/user-event"; +import { describe, it, vitest } from "vitest"; +import ChipInput from "./ChipInput"; + +describe("ChipInput", () => { + const existedValues = ["value_1", "value_2"]; + + // TODO: Support default value + it.skip("should works with default value", () => { + rawRender(); + + existedValues.forEach((value) => { + expect(screen.getByText(value)).toBeDefined(); + }); + }); + + it("should works with value", () => { + rawRender(); + + existedValues.forEach((value) => { + expect(screen.getByText(value)).toBeDefined(); + }); + }); + + it.skip("should allow user creates new value", async () => { + const typedValue = "value_3"; + const mockedFn = vitest.fn((values: string[]) => { + expect(values).toContain(typedValue); + }); + + rawRender( + + ); + + const element = screen.getByRole("searchbox"); + + await userEvent.type(element, typedValue); + + expect(element).toHaveValue(typedValue); + + const createBtn = screen.getByText(`Add "${typedValue}"`); + + await userEvent.click(createBtn); + + expect(mockedFn).toBeCalledTimes(1); + }); +}); diff --git a/frontend/src/components/inputs/Selector.test.tsx b/frontend/src/components/inputs/Selector.test.tsx new file mode 100644 index 000000000..96382c0b2 --- /dev/null +++ b/frontend/src/components/inputs/Selector.test.tsx @@ -0,0 +1,151 @@ +import { rawRender, screen } from "@/tests"; +import userEvent from "@testing-library/user-event"; +import { describe, it, vitest } from "vitest"; +import { Selector, SelectorOption } from "./Selector"; + +const selectorName = "Test Selections"; +const testOptions: SelectorOption[] = [ + { + label: "Option 1", + value: "option_1", + }, + { + label: "Option 2", + value: "option_2", + }, +]; + +describe("Selector", () => { + describe("options", () => { + it("should work with the SelectorOption", () => { + rawRender( + + ); + + // TODO: selectorName + expect(screen.getByRole("searchbox")).toBeDefined(); + }); + + it("should display when clicked", async () => { + rawRender( + + ); + + const element = screen.getByRole("searchbox"); + + await userEvent.click(element); + + expect(screen.queryAllByRole("option")).toHaveLength(testOptions.length); + + testOptions.forEach((option) => { + expect(screen.getByText(option.label)).toBeDefined(); + }); + }); + + it("shouldn't show default value", async () => { + const option = testOptions[0]; + rawRender( + + ); + + expect(screen.getByDisplayValue(option.label)).toBeDefined(); + }); + + it("shouldn't show value", async () => { + const option = testOptions[0]; + rawRender( + + ); + + expect(screen.getByDisplayValue(option.label)).toBeDefined(); + }); + }); + + describe("event", () => { + it("should fire on-change event when clicking option", async () => { + const clickedOption = testOptions[0]; + const mockedFn = vitest.fn((value: string | null) => { + expect(value).toEqual(clickedOption.value); + }); + rawRender( + + ); + + const element = screen.getByRole("searchbox"); + + await userEvent.click(element); + + await userEvent.click(screen.getByText(clickedOption.label)); + + expect(mockedFn).toBeCalled(); + }); + }); + + describe("with object options", () => { + const objectOptions: SelectorOption<{ name: string }>[] = [ + { + label: "Option 1", + value: { + name: "option_1", + }, + }, + { + label: "Option 2", + value: { + name: "option_2", + }, + }, + ]; + + it("should fire on-change event with payload", async () => { + const clickedOption = objectOptions[0]; + + const mockedFn = vitest.fn((value: { name: string } | null) => { + expect(value).toEqual(clickedOption.value); + }); + rawRender( + v.name} + > + ); + + const element = screen.getByRole("searchbox"); + + await userEvent.click(element); + + await userEvent.click(screen.getByText(clickedOption.label)); + + expect(mockedFn).toBeCalled(); + }); + }); + + describe("placeholder", () => { + it("should show when no selection", () => { + const placeholder = "Empty Selection"; + rawRender( + + ); + + expect(screen.getByPlaceholderText(placeholder)).toBeDefined(); + }); + }); +}); diff --git a/frontend/src/components/modals/HistoryModal.tsx b/frontend/src/components/modals/HistoryModal.tsx index 9976d5688..566a54611 100644 --- a/frontend/src/components/modals/HistoryModal.tsx +++ b/frontend/src/components/modals/HistoryModal.tsx @@ -1,3 +1,4 @@ +/* eslint-disable camelcase */ import { useEpisodeAddBlacklist, useEpisodeHistory, diff --git a/frontend/src/components/modals/ManualSearchModal.tsx b/frontend/src/components/modals/ManualSearchModal.tsx index d6d469cb7..411c93620 100644 --- a/frontend/src/components/modals/ManualSearchModal.tsx +++ b/frontend/src/components/modals/ManualSearchModal.tsx @@ -154,8 +154,8 @@ function ManualSearchView(props: Props) { { accessor: "matches", Cell: (row) => { - const { matches, dont_matches } = row.row.original; - return ; + const { matches, dont_matches: dont } = row.row.original; + return ; }, }, { diff --git a/frontend/src/components/modals/SubtitleToolsModal.tsx b/frontend/src/components/modals/SubtitleToolsModal.tsx index 2f49bccac..c92ff6a18 100644 --- a/frontend/src/components/modals/SubtitleToolsModal.tsx +++ b/frontend/src/components/modals/SubtitleToolsModal.tsx @@ -82,6 +82,7 @@ const SubtitleToolView: FunctionComponent = ({ type, language: v.code2, path: v.path, + // eslint-disable-next-line camelcase raw_language: v, }, ]; diff --git a/frontend/src/dom.tsx b/frontend/src/dom.tsx index 8af806070..f8028af36 100644 --- a/frontend/src/dom.tsx +++ b/frontend/src/dom.tsx @@ -1,10 +1,20 @@ import { StrictMode } from "react"; import ReactDOM from "react-dom"; -import { Main } from "./main"; +import { useRoutes } from "react-router-dom"; +import { AllProviders } from "./providers"; +import { useRouteItems } from "./Router"; + +const RouteApp = () => { + const items = useRouteItems(); + + return useRoutes(items); +}; ReactDOM.render( -
+ + + , document.getElementById("root") ); diff --git a/frontend/src/modules/modals/modal.test.ts b/frontend/src/modules/modals/modal.test.ts deleted file mode 100644 index b4c80f71f..000000000 --- a/frontend/src/modules/modals/modal.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, it } from "vitest"; -import { StaticModals } from "./WithModal"; - -describe("modal tests", () => { - it.skip("no duplicated modals", () => { - const existedKeys = new Set(); - StaticModals.forEach(({ modalKey }) => { - expect(existedKeys.has(modalKey)).toBeFalsy(); - existedKeys.add(modalKey); - }); - }); -}); diff --git a/frontend/src/modules/socketio/index.ts b/frontend/src/modules/socketio/index.ts index 18934b39a..fe9536bbc 100644 --- a/frontend/src/modules/socketio/index.ts +++ b/frontend/src/modules/socketio/index.ts @@ -1,7 +1,7 @@ import { debounce, forIn, remove, uniq } from "lodash"; import { onlineManager } from "react-query"; import { io, Socket } from "socket.io-client"; -import { Environment, isDevEnv } from "../../utilities"; +import { Environment, isDevEnv, isTestEnv } from "../../utilities"; import { ENSURE, GROUP, LOG } from "../../utilities/console"; import { createDefaultReducer } from "./reducer"; @@ -51,6 +51,10 @@ class SocketIOClient { } initialize() { + if (isTestEnv) { + return; + } + LOG("info", "Initializing Socket.IO client..."); this.reducers.push(...createDefaultReducer()); diff --git a/frontend/src/modules/socketio/reducer.ts b/frontend/src/modules/socketio/reducer.ts index 83444f99c..403fc0ce0 100644 --- a/frontend/src/modules/socketio/reducer.ts +++ b/frontend/src/modules/socketio/reducer.ts @@ -2,7 +2,7 @@ import queryClient from "@/apis/queries"; import { QueryKeys } from "@/apis/queries/keys"; import { LOG } from "@/utilities/console"; import { setCriticalError, setOnlineStatus } from "@/utilities/event"; -import { showNotification } from "@mantine/notifications"; +import { cleanNotifications, showNotification } from "@mantine/notifications"; import { notification, task } from "../task"; export function createDefaultReducer(): SocketIO.Reducer[] { @@ -15,6 +15,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] { key: "connect_error", any: () => { setCriticalError("Cannot connect to backend"); + cleanNotifications(); }, }, { diff --git a/frontend/src/modules/task/index.ts b/frontend/src/modules/task/index.ts index a1439148c..eb39e9e87 100644 --- a/frontend/src/modules/task/index.ts +++ b/frontend/src/modules/task/index.ts @@ -10,10 +10,12 @@ import { notification } from "./notification"; class TaskDispatcher { private running: boolean; private tasks: Record = {}; + private progress: Record = {}; constructor() { this.running = false; this.tasks = {}; + this.progress = {}; window.addEventListener("beforeunload", this.onBeforeUnload.bind(this)); } @@ -108,9 +110,10 @@ class TaskDispatcher { // TODO: FIX ME! item.value += 1; - if (item.value >= item.count) { + if (item.value >= item.count && this.progress[item.id]) { updateNotification(notification.progress.end(item.id, item.header)); - } else if (item.value > 1) { + delete this.progress[item.id]; + } else if (item.value > 1 && this.progress[item.id]) { updateNotification( notification.progress.update( item.id, @@ -120,8 +123,10 @@ class TaskDispatcher { item.count ) ); - } else { + } else if (item.value > 1 && this.progress[item.id] === undefined) { showNotification(notification.progress.pending(item.id, item.header)); + this.progress[item.id] = true; + setTimeout(() => this.updateProgress([item]), 1000); } }); } diff --git a/frontend/src/modules/task/notification.ts b/frontend/src/modules/task/notification.ts index a9d7246c5..1bd1b3de5 100644 --- a/frontend/src/modules/task/notification.ts +++ b/frontend/src/modules/task/notification.ts @@ -54,7 +54,7 @@ export const notification = { title: header, message: `[${current}/${total}] ${body}`, loading: true, - autoClose: 2 * 60 * 1000, + autoClose: false, }; }, end: (id: string, header: string): NotificationProps & { id: string } => { diff --git a/frontend/src/pages/Authentication.test.tsx b/frontend/src/pages/Authentication.test.tsx new file mode 100644 index 000000000..95bfe3f47 --- /dev/null +++ b/frontend/src/pages/Authentication.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from "@/tests"; +import { describe, it } from "vitest"; +import Authentication from "./Authentication"; + +describe("Authentication", () => { + it("should render without crash", () => { + render(); + + expect(screen.getByPlaceholderText("Username")).toBeDefined(); + expect(screen.getByPlaceholderText("Password")).toBeDefined(); + expect(screen.getByRole("button", { name: "Login" })).toBeDefined(); + }); +}); diff --git a/frontend/src/pages/Authentication.tsx b/frontend/src/pages/Authentication.tsx index d4266111c..baf21f6cd 100644 --- a/frontend/src/pages/Authentication.tsx +++ b/frontend/src/pages/Authentication.tsx @@ -40,11 +40,13 @@ const Authentication: FunctionComponent = () => { > = ({ blacklist }) => { all: false, form: { provider: row.original.provider, + // eslint-disable-next-line camelcase subs_id: value, }, })} diff --git a/frontend/src/pages/Blacklist/Series/table.tsx b/frontend/src/pages/Blacklist/Series/table.tsx index 7777b55f1..7a751f32e 100644 --- a/frontend/src/pages/Blacklist/Series/table.tsx +++ b/frontend/src/pages/Blacklist/Series/table.tsx @@ -82,6 +82,7 @@ const Table: FunctionComponent = ({ blacklist }) => { all: false, form: { provider: row.original.provider, + // eslint-disable-next-line camelcase subs_id: value, }, })} diff --git a/frontend/src/pages/Blacklist/blacklist.test.tsx b/frontend/src/pages/Blacklist/blacklist.test.tsx new file mode 100644 index 000000000..4360c473c --- /dev/null +++ b/frontend/src/pages/Blacklist/blacklist.test.tsx @@ -0,0 +1,16 @@ +import { renderTest, RenderTestCase } from "@/tests/render"; +import BlacklistMoviesView from "./Movies"; +import BlacklistSeriesView from "./Series"; + +const cases: RenderTestCase[] = [ + { + name: "movie page", + ui: BlacklistMoviesView, + }, + { + name: "series page", + ui: BlacklistSeriesView, + }, +]; + +renderTest("Blacklist", cases); diff --git a/frontend/src/pages/Episodes/table.tsx b/frontend/src/pages/Episodes/table.tsx index 477693b7a..3fa0a89ba 100644 --- a/frontend/src/pages/Episodes/table.tsx +++ b/frontend/src/pages/Episodes/table.tsx @@ -47,7 +47,7 @@ const Table: FunctionComponent = ({ episodes, profile, disabled }) => { forced, provider, subtitle, - original_format, + original_format: originalFormat, } = result; const { sonarrSeriesId: seriesId, sonarrEpisodeId: episodeId } = item; @@ -60,7 +60,8 @@ const Table: FunctionComponent = ({ episodes, profile, disabled }) => { forced, provider, subtitle, - original_format, + // eslint-disable-next-line camelcase + original_format: originalFormat, }, }); }, @@ -129,12 +130,12 @@ const Table: FunctionComponent = ({ episodes, profile, disabled }) => { > )); - let raw_subtitles = episode.subtitles; + let rawSubtitles = episode.subtitles; if (onlyDesired) { - raw_subtitles = filterSubtitleBy(raw_subtitles, profileItems); + rawSubtitles = filterSubtitleBy(rawSubtitles, profileItems); } - const subtitles = raw_subtitles.map((val, idx) => ( + const subtitles = rawSubtitles.map((val, idx) => ( { forced, provider, subtitle, - original_format, + original_format: originalFormat, } = result; const { radarrId } = item; @@ -73,7 +73,8 @@ const MovieDetailView: FunctionComponent = () => { forced, provider, subtitle, - original_format, + // eslint-disable-next-line camelcase + original_format: originalFormat, }, }); }, diff --git a/frontend/src/pages/Movies/Details/table.tsx b/frontend/src/pages/Movies/Details/table.tsx index 389cb32c8..9f4e5c948 100644 --- a/frontend/src/pages/Movies/Details/table.tsx +++ b/frontend/src/pages/Movies/Details/table.tsx @@ -180,12 +180,12 @@ const Table: FunctionComponent = ({ movie, profile, disabled }) => { path: missingText, })) ?? []; - let raw_subtitles = movie?.subtitles ?? []; + let rawSubtitles = movie?.subtitles ?? []; if (onlyDesired) { - raw_subtitles = filterSubtitleBy(raw_subtitles, profileItems); + rawSubtitles = filterSubtitleBy(rawSubtitles, profileItems); } - return [...raw_subtitles, ...missing]; + return [...rawSubtitles, ...missing]; }, [movie, onlyDesired, profileItems]); return ( diff --git a/frontend/src/pages/Movies/movies.test.tsx b/frontend/src/pages/Movies/movies.test.tsx new file mode 100644 index 000000000..fe5691a15 --- /dev/null +++ b/frontend/src/pages/Movies/movies.test.tsx @@ -0,0 +1,16 @@ +import { render } from "@/tests"; +import { describe } from "vitest"; +import MovieView from "."; +import MovieMassEditor from "./Editor"; + +describe("Movies page", () => { + it("should render", () => { + render(); + }); +}); + +describe("Movies editor page", () => { + it("should render", () => { + render(); + }); +}); diff --git a/frontend/src/pages/Series/series.test.tsx b/frontend/src/pages/Series/series.test.tsx new file mode 100644 index 000000000..6813c6e19 --- /dev/null +++ b/frontend/src/pages/Series/series.test.tsx @@ -0,0 +1,16 @@ +import { render } from "@/tests"; +import { describe } from "vitest"; +import SeriesView from "."; +import SeriesMassEditor from "./Editor"; + +describe("Series page", () => { + it("should render", () => { + render(); + }); +}); + +describe("Series editor page", () => { + it("should render", () => { + render(); + }); +}); diff --git a/frontend/src/pages/Settings/Languages/index.tsx b/frontend/src/pages/Settings/Languages/index.tsx index 726992916..993820478 100644 --- a/frontend/src/pages/Settings/Languages/index.tsx +++ b/frontend/src/pages/Settings/Languages/index.tsx @@ -1,8 +1,20 @@ import { useLanguageProfiles, useLanguages } from "@/apis/hooks"; import { useEnabledLanguages } from "@/utilities/languages"; import { FunctionComponent } from "react"; -import { Check, CollapseBox, Layout, Message, Section } from "../components"; -import { enabledLanguageKey, languageProfileKey } from "../keys"; +import { + Check, + CollapseBox, + Layout, + Message, + Section, + Selector, +} from "../components"; +import { + defaultUndAudioLang, + defaultUndEmbeddedSubtitlesLang, + enabledLanguageKey, + languageProfileKey, +} from "../keys"; import { useSettingValue } from "../utilities/hooks"; import { LanguageSelector, ProfileSelector } from "./components"; import Table from "./table"; @@ -31,6 +43,8 @@ export function useLatestProfiles() { const SettingsLanguagesView: FunctionComponent = () => { const { data: languages } = useLanguages(); + const { data: undAudioLanguages } = useEnabledLanguages(); + const { data: undEmbeddedSubtitlesLanguages } = useEnabledLanguages(); return (
@@ -54,6 +68,43 @@ const SettingsLanguagesView: FunctionComponent = () => { options={languages ?? []} >
+ +
+ + + { + return { label: v.name, value: v.code2 }; + })} + settingOptions={{ + onSubmit: (v) => (v === null ? "" : v), + }} + > + + + { + return { label: v.name, value: v.code2 }; + })} + settingOptions={{ + onSubmit: (v) => (v === null ? "" : v), + }} + > +
diff --git a/frontend/src/pages/Settings/Providers/list.ts b/frontend/src/pages/Settings/Providers/list.ts index 93c7e8014..3ecc6097d 100644 --- a/frontend/src/pages/Settings/Providers/list.ts +++ b/frontend/src/pages/Settings/Providers/list.ts @@ -286,7 +286,7 @@ export const ProviderList: Readonly = [ { key: "regielive", name: "RegieLive", - description: "Romanian Subtitles Provider. Broken, will not works.", + description: "Romanian Subtitles Provider.", }, { key: "soustitreseu", @@ -303,6 +303,14 @@ export const ProviderList: Readonly = [ key: "subf2m", name: "subf2m.co", description: "Subscene Alternative Provider", + inputs: [ + { + type: "switch", + key: "verify_ssl", + name: "Verify SSL", + defaultValue: true, + }, + ], }, { key: "subs4free", diff --git a/frontend/src/pages/Settings/Radarr/index.tsx b/frontend/src/pages/Settings/Radarr/index.tsx index 497ef7873..8cd038ab8 100644 --- a/frontend/src/pages/Settings/Radarr/index.tsx +++ b/frontend/src/pages/Settings/Radarr/index.tsx @@ -9,11 +9,13 @@ import { Number, PathMappingTable, Section, + Selector, Slider, Text, URLTestButton, } from "../components"; import { moviesEnabledKey } from "../keys"; +import { timeoutOptions } from "./options"; const SettingsRadarrView: FunctionComponent = () => { return ( @@ -35,6 +37,11 @@ const SettingsRadarrView: FunctionComponent = () => { onSubmit: (v) => "/" + v, }} > + diff --git a/frontend/src/pages/Settings/Radarr/options.ts b/frontend/src/pages/Settings/Radarr/options.ts new file mode 100644 index 000000000..706e3a4d4 --- /dev/null +++ b/frontend/src/pages/Settings/Radarr/options.ts @@ -0,0 +1,10 @@ +import { SelectorOption } from "@/components"; + +export const timeoutOptions: SelectorOption[] = [ + { label: "60", value: 60 }, + { label: "120", value: 120 }, + { label: "180", value: 180 }, + { label: "240", value: 240 }, + { label: "300", value: 300 }, + { label: "600", value: 600 }, +]; diff --git a/frontend/src/pages/Settings/Scheduler/options.ts b/frontend/src/pages/Settings/Scheduler/options.ts index 78ba35378..ab193774b 100644 --- a/frontend/src/pages/Settings/Scheduler/options.ts +++ b/frontend/src/pages/Settings/Scheduler/options.ts @@ -32,7 +32,6 @@ export const dayOptions: SelectorOption[] = [ ]; export const upgradeOptions: SelectorOption[] = [ - { label: "3 Hours", value: 3 }, { label: "6 Hours", value: 6 }, { label: "12 Hours", value: 12 }, { label: "24 Hours", value: 24 }, diff --git a/frontend/src/pages/Settings/Sonarr/index.tsx b/frontend/src/pages/Settings/Sonarr/index.tsx index ef2464fd3..1d2125568 100644 --- a/frontend/src/pages/Settings/Sonarr/index.tsx +++ b/frontend/src/pages/Settings/Sonarr/index.tsx @@ -10,12 +10,14 @@ import { Number, PathMappingTable, Section, + Selector, Slider, Text, URLTestButton, } from "../components"; import { seriesEnabledKey } from "../keys"; import { seriesTypeOptions } from "../options"; +import { timeoutOptions } from "./options"; const SettingsSonarrView: FunctionComponent = () => { return ( @@ -37,6 +39,11 @@ const SettingsSonarrView: FunctionComponent = () => { onSubmit: (v) => "/" + v, }} > + diff --git a/frontend/src/pages/Settings/Sonarr/options.ts b/frontend/src/pages/Settings/Sonarr/options.ts new file mode 100644 index 000000000..706e3a4d4 --- /dev/null +++ b/frontend/src/pages/Settings/Sonarr/options.ts @@ -0,0 +1,10 @@ +import { SelectorOption } from "@/components"; + +export const timeoutOptions: SelectorOption[] = [ + { label: "60", value: 60 }, + { label: "120", value: 120 }, + { label: "180", value: 180 }, + { label: "240", value: 240 }, + { label: "300", value: 300 }, + { label: "600", value: 600 }, +]; diff --git a/frontend/src/pages/Settings/components/Layout.test.tsx b/frontend/src/pages/Settings/components/Layout.test.tsx new file mode 100644 index 000000000..0cec7c9cd --- /dev/null +++ b/frontend/src/pages/Settings/components/Layout.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@/tests"; +import { Text } from "@mantine/core"; +import { describe, it } from "vitest"; +import Layout from "./Layout"; + +describe("Settings layout", () => { + it.concurrent("should be able to render without issues", () => { + render( + + Value + + ); + }); + + it.concurrent("save button should be disabled by default", () => { + render( + + Value + + ); + + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); +}); diff --git a/frontend/src/pages/Settings/components/Section.test.tsx b/frontend/src/pages/Settings/components/Section.test.tsx new file mode 100644 index 000000000..716b95e40 --- /dev/null +++ b/frontend/src/pages/Settings/components/Section.test.tsx @@ -0,0 +1,38 @@ +import { rawRender, screen } from "@/tests"; +import { Text } from "@mantine/core"; +import { describe, it } from "vitest"; +import { Section } from "./Section"; + +describe("Settings section", () => { + const header = "Section Header"; + it("should show header", () => { + rawRender(
); + + expect(screen.getByText(header)).toBeDefined(); + expect(screen.getByRole("separator")).toBeDefined(); + }); + + it("should show children", () => { + const text = "Section Child"; + rawRender( +
+ {text} +
+ ); + + expect(screen.getByText(header)).toBeDefined(); + expect(screen.getByText(text)).toBeDefined(); + }); + + it("should work with hidden", () => { + const text = "Section Child"; + rawRender( + + ); + + expect(screen.getByText(header)).not.toBeVisible(); + expect(screen.getByText(text)).not.toBeVisible(); + }); +}); diff --git a/frontend/src/pages/Settings/components/forms.test.tsx b/frontend/src/pages/Settings/components/forms.test.tsx new file mode 100644 index 000000000..711c764a9 --- /dev/null +++ b/frontend/src/pages/Settings/components/forms.test.tsx @@ -0,0 +1,39 @@ +import { rawRender, RenderOptions, screen } from "@/tests"; +import { useForm } from "@mantine/form"; +import { FunctionComponent, ReactElement } from "react"; +import { describe, it } from "vitest"; +import { FormContext, FormValues } from "../utilities/FormValues"; +import { Number, Text } from "./forms"; + +const FormSupport: FunctionComponent = ({ children }) => { + const form = useForm({ + initialValues: { + settings: {}, + hooks: {}, + }, + }); + return {children}; +}; + +const formRender = ( + ui: ReactElement, + options?: Omit +) => rawRender(ui, { wrapper: FormSupport, ...options }); + +describe("Settings form", () => { + describe("number component", () => { + it("should be able to render", () => { + formRender(); + + expect(screen.getByRole("textbox")).toBeDefined(); + }); + }); + + describe("text component", () => { + it("should be able to render", () => { + formRender(); + + expect(screen.getByRole("textbox")).toBeDefined(); + }); + }); +}); diff --git a/frontend/src/pages/Settings/keys.ts b/frontend/src/pages/Settings/keys.ts index a8ab17a5b..40b6a252d 100644 --- a/frontend/src/pages/Settings/keys.ts +++ b/frontend/src/pages/Settings/keys.ts @@ -1,4 +1,7 @@ export const enabledLanguageKey = "languages-enabled"; +export const defaultUndAudioLang = "settings-general-default_und_audio_lang"; +export const defaultUndEmbeddedSubtitlesLang = + "settings-general-default_und_embedded_subtitles_lang"; export const languageProfileKey = "languages-profiles"; export const notificationsKey = "notifications-providers"; diff --git a/frontend/src/pages/Settings/settings.test.tsx b/frontend/src/pages/Settings/settings.test.tsx new file mode 100644 index 000000000..71aa74158 --- /dev/null +++ b/frontend/src/pages/Settings/settings.test.tsx @@ -0,0 +1,51 @@ +import { renderTest, RenderTestCase } from "@/tests/render"; +import SettingsGeneralView from "./General"; +import SettingsLanguagesView from "./Languages"; +import SettingsNotificationsView from "./Notifications"; +import SettingsProvidersView from "./Providers"; +import SettingsRadarrView from "./Radarr"; +import SettingsSchedulerView from "./Scheduler"; +import SettingsSonarrView from "./Sonarr"; +import SettingsSubtitlesView from "./Subtitles"; +import SettingsUIView from "./UI"; + +const cases: RenderTestCase[] = [ + { + name: "general page", + ui: SettingsGeneralView, + }, + { + name: "languages page", + ui: SettingsLanguagesView, + }, + { + name: "notifications page", + ui: SettingsNotificationsView, + }, + { + name: "providers page", + ui: SettingsProvidersView, + }, + { + name: "radarr page", + ui: SettingsRadarrView, + }, + { + name: "scheduler page", + ui: SettingsSchedulerView, + }, + { + name: "sonarr page", + ui: SettingsSonarrView, + }, + { + name: "subtitles page", + ui: SettingsSubtitlesView, + }, + { + name: "ui page", + ui: SettingsUIView, + }, +]; + +renderTest("Settings", cases); diff --git a/frontend/src/pages/System/Announcements/index.tsx b/frontend/src/pages/System/Announcements/index.tsx new file mode 100644 index 000000000..4e204431e --- /dev/null +++ b/frontend/src/pages/System/Announcements/index.tsx @@ -0,0 +1,24 @@ +import { useSystemAnnouncements } from "@/apis/hooks"; +import { QueryOverlay } from "@/components/async"; +import { Container } from "@mantine/core"; +import { useDocumentTitle } from "@mantine/hooks"; +import { FunctionComponent } from "react"; +import Table from "./table"; + +const SystemAnnouncementsView: FunctionComponent = () => { + const announcements = useSystemAnnouncements(); + + const { data } = announcements; + + useDocumentTitle("Announcements - Bazarr (System)"); + + return ( + + +
+
+
+ ); +}; + +export default SystemAnnouncementsView; diff --git a/frontend/src/pages/System/Announcements/table.tsx b/frontend/src/pages/System/Announcements/table.tsx new file mode 100644 index 000000000..97f8cbe3e --- /dev/null +++ b/frontend/src/pages/System/Announcements/table.tsx @@ -0,0 +1,91 @@ +import { useSystemAnnouncementsAddDismiss } from "@/apis/hooks"; +import { SimpleTable } from "@/components"; +import { MutateAction } from "@/components/async"; +import { useTableStyles } from "@/styles"; +import { faWindowClose } from "@fortawesome/free-solid-svg-icons"; +import { Anchor, Text } from "@mantine/core"; +import { FunctionComponent, useMemo } from "react"; +import { Column } from "react-table"; + +interface Props { + announcements: readonly System.Announcements[]; +} + +const Table: FunctionComponent = ({ announcements }) => { + const columns: Column[] = useMemo< + Column[] + >( + () => [ + { + Header: "Since", + accessor: "timestamp", + Cell: ({ value }) => { + const { classes } = useTableStyles(); + return {value}; + }, + }, + { + Header: "Announcement", + accessor: "text", + Cell: ({ value }) => { + const { classes } = useTableStyles(); + return {value}; + }, + }, + { + Header: "More info", + accessor: "link", + Cell: ({ value }) => { + if (value) { + return ; + } else { + return n/a; + } + }, + }, + { + Header: "Dismiss", + accessor: "hash", + Cell: ({ row, value }) => { + const add = useSystemAnnouncementsAddDismiss(); + return ( + ({ + hash: value, + })} + > + ); + }, + }, + ], + [] + ); + + return ( + + ); +}; + +export default Table; + +interface LabelProps { + link: string; + children: string; +} + +function Label(props: LabelProps): JSX.Element { + const { link, children } = props; + return ( + + {children} + + ); +} diff --git a/frontend/src/pages/System/Tasks/table.tsx b/frontend/src/pages/System/Tasks/table.tsx index c52b1978f..ac87c7c54 100644 --- a/frontend/src/pages/System/Tasks/table.tsx +++ b/frontend/src/pages/System/Tasks/table.tsx @@ -37,7 +37,7 @@ const Table: FunctionComponent = ({ tasks }) => { { accessor: "job_running", Cell: ({ row, value }) => { - const { job_id } = row.original; + const { job_id: jobId } = row.original; const runTask = useRunTask(); return ( @@ -46,7 +46,7 @@ const Table: FunctionComponent = ({ tasks }) => { icon={faPlay} iconProps={{ spin: value }} mutation={runTask} - args={() => job_id} + args={() => jobId} > ); }, diff --git a/frontend/src/pages/System/system.test.tsx b/frontend/src/pages/System/system.test.tsx new file mode 100644 index 000000000..813654a7b --- /dev/null +++ b/frontend/src/pages/System/system.test.tsx @@ -0,0 +1,41 @@ +import SystemAnnouncementsView from "@/pages/System/Announcements"; +import { renderTest, RenderTestCase } from "@/tests/render"; +import SystemBackupsView from "./Backups"; +import SystemLogsView from "./Logs"; +import SystemProvidersView from "./Providers"; +import SystemReleasesView from "./Releases"; +import SystemStatusView from "./Status"; +import SystemTasksView from "./Tasks"; + +const cases: RenderTestCase[] = [ + { + name: "backups page", + ui: SystemBackupsView, + }, + { + name: "logs page", + ui: SystemLogsView, + }, + { + name: "providers page", + ui: SystemProvidersView, + }, + { + name: "releases page", + ui: SystemReleasesView, + }, + { + name: "status page", + ui: SystemStatusView, + }, + { + name: "tasks page", + ui: SystemTasksView, + }, + { + name: "announcements page", + ui: SystemAnnouncementsView, + }, +]; + +renderTest("System", cases); diff --git a/frontend/src/pages/Wanted/wanted.test.tsx b/frontend/src/pages/Wanted/wanted.test.tsx new file mode 100644 index 000000000..36e72c4bb --- /dev/null +++ b/frontend/src/pages/Wanted/wanted.test.tsx @@ -0,0 +1,16 @@ +import { renderTest, RenderTestCase } from "@/tests/render"; +import WantedMoviesView from "./Movies"; +import WantedSeriesView from "./Series"; + +const cases: RenderTestCase[] = [ + { + name: "movie page", + ui: WantedMoviesView, + }, + { + name: "series page", + ui: WantedSeriesView, + }, +]; + +renderTest("Wanted", cases); diff --git a/frontend/src/pages/CriticalError.tsx b/frontend/src/pages/errors/CriticalError.tsx similarity index 100% rename from frontend/src/pages/CriticalError.tsx rename to frontend/src/pages/errors/CriticalError.tsx diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/errors/NotFound.tsx similarity index 100% rename from frontend/src/pages/NotFound.tsx rename to frontend/src/pages/errors/NotFound.tsx diff --git a/frontend/src/pages/UIError.tsx b/frontend/src/pages/errors/UIError.tsx similarity index 100% rename from frontend/src/pages/UIError.tsx rename to frontend/src/pages/errors/UIError.tsx diff --git a/frontend/src/pages/errors/errors.test.tsx b/frontend/src/pages/errors/errors.test.tsx new file mode 100644 index 000000000..eeacfd631 --- /dev/null +++ b/frontend/src/pages/errors/errors.test.tsx @@ -0,0 +1,22 @@ +import { render } from "@/tests"; +import CriticalError from "./CriticalError"; +import NotFound from "./NotFound"; +import UIError from "./UIError"; + +describe("Not found page", () => { + it("should display message", () => { + render(); + }); +}); + +describe("Critical error page", () => { + it("should disable error", () => { + render(); + }); +}); + +describe("UI error page", () => { + it("should disable error", () => { + render(); + }); +}); diff --git a/frontend/src/pages/views/MassEditor.tsx b/frontend/src/pages/views/MassEditor.tsx index 3e6fa1035..02668302d 100644 --- a/frontend/src/pages/views/MassEditor.tsx +++ b/frontend/src/pages/views/MassEditor.tsx @@ -1,10 +1,10 @@ import { useIsAnyMutationRunning, useLanguageProfiles } from "@/apis/hooks"; import { SimpleTable, Toolbox } from "@/components"; -import { Selector } from "@/components/inputs"; +import { Selector, SelectorOption } from "@/components/inputs"; import { useCustomSelection } from "@/components/tables/plugins"; import { GetItemId, useSelectorOptions } from "@/utilities"; import { faCheck, faUndo } from "@fortawesome/free-solid-svg-icons"; -import { Container } from "@mantine/core"; +import { Box, Container } from "@mantine/core"; import { uniqBy } from "lodash"; import { useCallback, useMemo, useState } from "react"; import { UseMutationResult } from "react-query"; @@ -36,6 +36,24 @@ function MassEditor(props: MassEditorProps) { const profileOptions = useSelectorOptions(profiles ?? [], (v) => v.name); + const profileOptionsWithAction = useMemo< + SelectorOption[] + >( + () => [ + { label: "Clear", value: null, group: "Action" }, + ...profileOptions.options, + ], + [profileOptions.options] + ); + + const getKey = useCallback((value: Language.Profile | null) => { + if (value) { + return value.name; + } + + return "Clear"; + }, []); + const { mutateAsync } = mutation; const save = useCallback(() => { @@ -67,15 +85,17 @@ function MassEditor(props: MassEditorProps) { return ( -
+ -
-
+ + Cancel @@ -87,7 +107,7 @@ function MassEditor(props: MassEditorProps) { > Save -
+
{ - const items = useRouteItems(); - - return useRoutes(items); -}; - -export const Main = () => { +export const AllProviders: FunctionComponent = ({ children }) => { return ( + {/* c8 ignore next 3 */} {Environment.queryDev && ( )} - + {children} diff --git a/frontend/src/tests/index.tsx b/frontend/src/tests/index.tsx new file mode 100644 index 000000000..9053be909 --- /dev/null +++ b/frontend/src/tests/index.tsx @@ -0,0 +1,22 @@ +import { AllProviders } from "@/providers"; +import { render, RenderOptions } from "@testing-library/react"; +import { FunctionComponent, ReactElement, StrictMode } from "react"; + +const AllProvidersWithStrictMode: FunctionComponent = ({ children }) => { + return ( + + {children} + + ); +}; + +const customRender = ( + ui: ReactElement, + options?: Omit +) => render(ui, { wrapper: AllProvidersWithStrictMode, ...options }); + +// re-export everything +export * from "@testing-library/react"; +// override render method +export { customRender as render }; +export { render as rawRender }; diff --git a/frontend/src/tests/render.tsx b/frontend/src/tests/render.tsx new file mode 100644 index 000000000..e0031e903 --- /dev/null +++ b/frontend/src/tests/render.tsx @@ -0,0 +1,17 @@ +import { FunctionComponent } from "react"; +import { render } from "."; + +export interface RenderTestCase { + name: string; + ui: FunctionComponent; +} + +export function renderTest(name: string, cases: RenderTestCase[]) { + describe(name, () => { + cases.forEach((element) => { + it(`${element.name.toLowerCase()} should render`, () => { + render(); + }); + }); + }); +} diff --git a/frontend/src/tests/setup.ts b/frontend/src/tests/setup.ts new file mode 100644 index 000000000..2b3bf3672 --- /dev/null +++ b/frontend/src/tests/setup.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ + +import "@testing-library/jest-dom"; +import { vitest } from "vitest"; + +// From https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vitest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vitest.fn(), // Deprecated + removeListener: vitest.fn(), // Deprecated + addEventListener: vitest.fn(), + removeEventListener: vitest.fn(), + dispatchEvent: vitest.fn(), + })), +}); + +// From https://github.com/mantinedev/mantine/blob/master/configuration/jest/jsdom.mocks.js +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +window.ResizeObserver = ResizeObserver; + +window.scrollTo = () => {}; diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index ffd931d43..b19c682c0 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -5,6 +5,7 @@ interface Badge { status: number; sonarr_signalr: string; radarr_signalr: string; + announcements: number; } declare namespace Language { diff --git a/frontend/src/types/form.d.ts b/frontend/src/types/form.d.ts index 99b16da88..6019a3fa0 100644 --- a/frontend/src/types/form.d.ts +++ b/frontend/src/types/form.d.ts @@ -74,4 +74,8 @@ declare namespace FormType { subtitle: unknown; original_format: PythonBoolean; } + + interface AddAnnouncementsDismiss { + hash: number; + } } diff --git a/frontend/src/types/react-table.d.ts b/frontend/src/types/react-table.d.ts index 4e4405711..e1acd4230 100644 --- a/frontend/src/types/react-table.d.ts +++ b/frontend/src/types/react-table.d.ts @@ -45,9 +45,8 @@ declare module "react-table" { interface CustomTableProps> extends useSelectionProps {} - export interface TableOptions< - D extends Record - > extends UseExpandedOptions, + export interface TableOptions> + extends UseExpandedOptions, // UseFiltersOptions, // UseGlobalFiltersOptions, UseGroupByOptions, diff --git a/frontend/src/types/system.d.ts b/frontend/src/types/system.d.ts index dc4e33799..544d969ae 100644 --- a/frontend/src/types/system.d.ts +++ b/frontend/src/types/system.d.ts @@ -1,4 +1,12 @@ declare namespace System { + interface Announcements { + text: string; + link: string; + hash: string; + dismissible: boolean; + timestamp: string; + } + interface Task { interval: string; job_id: string; diff --git a/frontend/src/utilities/routers.ts b/frontend/src/utilities/routers.ts index b3c91c541..6dd988be7 100644 --- a/frontend/src/utilities/routers.ts +++ b/frontend/src/utilities/routers.ts @@ -3,6 +3,7 @@ import type { Blocker, History, Transition } from "history"; import { useContext, useEffect } from "react"; +// eslint-disable-next-line camelcase import { UNSAFE_NavigationContext } from "react-router-dom"; export function useBlocker(blocker: Blocker, when = true) { diff --git a/frontend/test/render.test.tsx b/frontend/test/render.test.tsx deleted file mode 100644 index c3f4ea968..000000000 --- a/frontend/test/render.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { render } from "@testing-library/react"; -import { StrictMode } from "react"; -import { describe, it, vitest } from "vitest"; -import { Main } from "../src/main"; - -describe("render test", () => { - beforeAll(() => { - // From https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function - Object.defineProperty(window, "matchMedia", { - writable: true, - value: vitest.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: vitest.fn(), // Deprecated - removeListener: vitest.fn(), // Deprecated - addEventListener: vitest.fn(), - removeEventListener: vitest.fn(), - dispatchEvent: vitest.fn(), - })), - }); - }); - - it("render without crashing", () => { - render( - -
- - ); - }); -}); diff --git a/frontend/test/setup.ts b/frontend/test/setup.ts deleted file mode 100644 index d0de870dc..000000000 --- a/frontend/test/setup.ts +++ /dev/null @@ -1 +0,0 @@ -import "@testing-library/jest-dom"; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 600cc8f37..41fbaa7a4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -50,7 +50,7 @@ export default defineConfig(async ({ mode, command }) => { test: { globals: true, environment: "jsdom", - setupFiles: "./test/setup.ts", + setupFiles: "./src/tests/setup.ts", }, server: { proxy: { diff --git a/libs/fese/__init__.py b/libs/fese/__init__.py index 243201ef2..8a94470ce 100755 --- a/libs/fese/__init__.py +++ b/libs/fese/__init__.py @@ -4,4 +4,4 @@ from .container import FFprobeVideoContainer from .stream import FFprobeSubtitleStream -__version__ = "0.2.6" +__version__ = "0.2.7" diff --git a/libs/fese/tags.py b/libs/fese/tags.py index eee5c336e..b846fffea 100644 --- a/libs/fese/tags.py +++ b/libs/fese/tags.py @@ -15,11 +15,14 @@ class FFprobeGenericSubtitleTags: _DETECTABLE_TAGS = None def __init__(self, data: dict): + self._language_fallback = False + try: self.language = _get_language(data) except LanguageNotFound: if LANGUAGE_FALLBACK is not None: self.language = Language.fromietf(LANGUAGE_FALLBACK) + self._language_fallback = True else: raise @@ -35,6 +38,10 @@ class FFprobeGenericSubtitleTags: logger.debug("Unable to detect tags class. Using generic") return FFprobeGenericSubtitleTags(data) + @property + def language_fallback(self): + return self._language_fallback + @property def suffix(self): lang = self.language.alpha2 diff --git a/libs/subliminal_patch/core.py b/libs/subliminal_patch/core.py index 9c8f6159d..2950467a9 100644 --- a/libs/subliminal_patch/core.py +++ b/libs/subliminal_patch/core.py @@ -1118,6 +1118,9 @@ def save_subtitles(file_path, subtitles, single=False, directory=None, chmod=Non saved_subtitles = [] for subtitle in subtitles: + # check if HI mods will be used to get the proper name for the subtitles file + must_remove_hi = 'remove_HI' in subtitle.mods + # check content if subtitle.content is None: logger.error('Skipping subtitle %r: no content', subtitle) @@ -1130,7 +1133,8 @@ def save_subtitles(file_path, subtitles, single=False, directory=None, chmod=Non # create subtitle path subtitle_path = get_subtitle_path(file_path, None if single else subtitle.language, - forced_tag=subtitle.language.forced, hi_tag=subtitle.language.hi, tags=tags) + forced_tag=subtitle.language.forced, + hi_tag=False if must_remove_hi else subtitle.language.hi, tags=tags) if directory is not None: subtitle_path = os.path.join(directory, os.path.split(subtitle_path)[1]) diff --git a/libs/subliminal_patch/providers/embeddedsubtitles.py b/libs/subliminal_patch/providers/embeddedsubtitles.py index cc322ba43..83a6f10d8 100644 --- a/libs/subliminal_patch/providers/embeddedsubtitles.py +++ b/libs/subliminal_patch/providers/embeddedsubtitles.py @@ -6,12 +6,13 @@ import os import re import shutil import tempfile +from typing import List from babelfish import language_converters -from fese import tags from fese import container from fese import FFprobeSubtitleStream from fese import FFprobeVideoContainer +from fese import tags from fese.exceptions import InvalidSource from subliminal.subtitle import fix_line_ending from subliminal_patch.core import Episode @@ -119,13 +120,13 @@ class EmbeddedSubtitlesProvider(Provider): video = _get_memoized_video_container(path) try: - streams = filter(_check_allowed_codecs, video.get_subtitles()) + streams = list(_filter_subtitles(video.get_subtitles())) except InvalidSource as error: logger.error("Error trying to get subtitles for %s: %s", video, error) self._blacklist.add(path) streams = [] - streams = _discard_possible_incomplete_subtitles(list(streams)) + streams = _discard_possible_incomplete_subtitles(streams) if not streams: logger.debug("No subtitles found for container: %s", video) @@ -207,9 +208,10 @@ class EmbeddedSubtitlesProvider(Provider): if container.path not in self._cached_paths: # Extract all subittle streams to avoid reading the entire # container over and over - streams = filter(_check_allowed_codecs, container.get_subtitles()) + subs = list(_filter_subtitles(container.get_subtitles())) + extracted = container.copy_subtitles( - list(streams), + subs, self._cache_dir, timeout=self._timeout, fallback_to_convert=True, @@ -245,12 +247,20 @@ def _get_memoized_video_container(path: str): return _MemoizedFFprobeVideoContainer(path) -def _check_allowed_codecs(subtitle: FFprobeSubtitleStream): - if subtitle.codec_name not in _ALLOWED_CODECS: - logger.debug("Unallowed codec: %s", subtitle) - return False +def _filter_subtitles(subtitles: List[FFprobeSubtitleStream]): + for subtitle in subtitles: + if subtitle.codec_name not in _ALLOWED_CODECS: + logger.debug("Unallowed codec: %s", subtitle) + continue + + if subtitle.tags.language_fallback is True and any( + (subtitle.language == sub.language) and (subtitle.index != sub.index) + for sub in subtitles + ): + logger.debug("Not using language fallback. Language already found") + continue - return True + yield subtitle def _check_hi_fallback(streams, languages): diff --git a/libs/subliminal_patch/providers/gestdown.py b/libs/subliminal_patch/providers/gestdown.py index c5adbadb9..f8add61ac 100644 --- a/libs/subliminal_patch/providers/gestdown.py +++ b/libs/subliminal_patch/providers/gestdown.py @@ -26,7 +26,7 @@ class GestdownSubtitle(Subtitle): self.page_link = _BASE_URL + data["downloadUri"] self._id = data["subtitleId"] self.release_info = data["version"] - self._matches = {"title", "series", "season", "episode"} + self._matches = {"title", "series", "season", "episode", "tvdb_id"} def get_matches(self, video): update_matches(self._matches, video, self.release_info) @@ -106,9 +106,9 @@ class GestdownProvider(Provider): def _search_show(self, video): try: - response = self._session.get(f"{_BASE_URL}/shows/search/{video.series}") + response = self._session.get(f"{_BASE_URL}/shows/external/tvdb/{video.series_tvdb_id}") response.raise_for_status() - return response.json()["shows"][0] + return response.json()["shows"] except HTTPError as error: if error.response.status_code == 404: return None @@ -118,14 +118,18 @@ class GestdownProvider(Provider): @_retry_on_423 def list_subtitles(self, video, languages): subtitles = [] - show = self._search_show(video) - if show is None: + shows = self._search_show(video) + if shows is None: logger.debug("Couldn't find the show") return subtitles for language in languages: try: - subtitles += self._subtitles_search(video, language, show["id"]) + for show in shows: + subs = list(self._subtitles_search(video, language, show["id"])) + if len(subs) > 0: + subtitles += subs + continue except HTTPError as error: if error.response.status_code == 404: logger.debug("Couldn't find the show or its season/episode") diff --git a/libs/subliminal_patch/providers/opensubtitlescom.py b/libs/subliminal_patch/providers/opensubtitlescom.py index 532998c0d..a1603d822 100644 --- a/libs/subliminal_patch/providers/opensubtitlescom.py +++ b/libs/subliminal_patch/providers/opensubtitlescom.py @@ -274,15 +274,6 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): return [] lang_strings = [str(lang.basename) for lang in languages] - only_foreign = all([lang.forced for lang in languages]) - also_foreign = any([lang.forced for lang in languages]) - if only_foreign: - forced = 'only' - elif also_foreign: - forced = 'include' - else: - forced = 'exclude' - langs = ','.join(lang_strings) logging.debug(f'Searching for this languages: {lang_strings}') @@ -292,7 +283,6 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): lambda: checked( lambda: self.session.get(self.server_url + 'subtitles', params=(('episode_number', self.video.episode), - ('foreign_parts_only', forced), ('imdb_id', imdb_id if not title_id else None), ('languages', langs.lower()), ('moviehash', file_hash), @@ -308,8 +298,7 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): res = self.retry( lambda: checked( lambda: self.session.get(self.server_url + 'subtitles', - params=(('foreign_parts_only', forced), - ('id', title_id if title_id else None), + params=(('id', title_id if title_id else None), ('imdb_id', imdb_id if not title_id else None), ('languages', langs.lower()), ('moviehash', file_hash)), @@ -324,6 +313,14 @@ class OpenSubtitlesComProvider(ProviderRetryMixin, Provider): result = res.json() + # filter out forced subtitles or not depending on the required languages + if all([lang.forced for lang in languages]): # only forced + result['data'] = [x for x in result['data'] if x['attributes']['foreign_parts_only']] + elif any([lang.forced for lang in languages]): # also forced + pass + else: # not forced + result['data'] = [x for x in result['data'] if not x['attributes']['foreign_parts_only']] + logging.debug(f"Query returned {len(result['data'])} subtitles") if len(result['data']): diff --git a/libs/subliminal_patch/providers/podnapisi.py b/libs/subliminal_patch/providers/podnapisi.py index 5aafaa8fe..f567f25ac 100644 --- a/libs/subliminal_patch/providers/podnapisi.py +++ b/libs/subliminal_patch/providers/podnapisi.py @@ -112,7 +112,7 @@ class PodnapisiSubtitle(_PodnapisiSubtitle): class PodnapisiAdapter(HTTPAdapter): def init_poolmanager(self, connections, maxsize, block=False): ctx = ssl.create_default_context() - ctx.set_ciphers('DEFAULT@SECLEVEL=1') + ctx.set_ciphers('DEFAULT@SECLEVEL=0') ctx.check_hostname = False self.poolmanager = poolmanager.PoolManager( num_pools=connections, diff --git a/libs/subliminal_patch/providers/regielive.py b/libs/subliminal_patch/providers/regielive.py index 94fceef88..d20972f03 100644 --- a/libs/subliminal_patch/providers/regielive.py +++ b/libs/subliminal_patch/providers/regielive.py @@ -11,7 +11,7 @@ from subliminal_patch.subtitle import Subtitle, guess_matches from subliminal.subtitle import SUBTITLE_EXTENSIONS, fix_line_ending from subliminal.video import Episode, Movie from subzero.language import Language - +import urllib import zipfile logger = logging.getLogger(__name__) @@ -38,7 +38,7 @@ class RegieLiveSubtitle(Subtitle): def get_matches(self, video): type_ = "movie" if isinstance(video, Movie) else "episode" matches = set() - subtitle_filename = self.filename + subtitle_filename = self.filename.lower() # episode if type_ == "episode": @@ -49,9 +49,8 @@ class RegieLiveSubtitle(Subtitle): # already matched in search query matches.update(['title', 'year']) - # release_group if video.release_group and video.release_group.lower() in subtitle_filename: - matches.add('release_group') + matches.update(['release_group', 'hash']) matches |= guess_matches(video, guessit(self.filename, {"type": type_})) @@ -64,16 +63,15 @@ class RegieLiveProvider(Provider): language = list(languages)[0] video_types = (Episode, Movie) SEARCH_THROTTLE = 8 + hash_verifiable = False def __init__(self): self.initialize() def initialize(self): self.session = Session() - #self.url = 'http://api.regielive.ro/kodi/cauta.php' - # this is a proxy API/scraper for subtitrari.regielive.ro used for subtitles search only - self.url = 'http://subtitles.24-7.ro/index.php' - self.api = 'API-KODI-KINGUL' + self.url = 'https://api.regielive.ro/bazarr/search.php' + self.api = 'API-BAZARR-YTZ-SL' self.headers = {'RL-API': self.api} def terminate(self): @@ -81,39 +79,42 @@ class RegieLiveProvider(Provider): def query(self, video, language): payload = {} - if isinstance (video, Episode): + if isinstance(video, Episode): payload['nume'] = video.series payload['sezon'] = video.season payload['episod'] = video.episode elif isinstance(video, Movie): payload['nume'] = video.title payload['an'] = video.year - response = self.session.post(self.url, data=payload, headers=self.headers) - logger.info(response.json()) + + response = self.session.get( + self.url + "?" + urllib.parse.urlencode(payload), + 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']: - logger.debug(sub) subtitles.append( - RegieLiveSubtitle(sub['titlu'], video, sub['url'], sub['rating'], language) - ) - - # {'titlu': 'Chernobyl.S01E04.The.Happiness.of.All.Mankind.720p.AMZN.WEB-DL.DDP5.1.H.264-NTb', 'url': 'https://subtitrari.regielive.ro/descarca-33336-418567.zip', 'rating': {'nota': 4.89, 'voturi': 48}} - # subtitle def __init__(self, language, filename, subtype, video, link): + 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): return self.query(video, self.language) def download_subtitle(self, subtitle): - session = Session() + session = self.session _addheaders = { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:72.0) Gecko/20100101 Firefox/72.0', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Origin': 'https://subtitrari.regielive.ro', - 'Accept-Language' : 'en-US,en;q=0.5', + 'Accept-Language': 'en-US,en;q=0.5', 'Referer': 'https://subtitrari.regielive.ro', 'Pragma': 'no-cache', 'Cache-Control': 'no-cache' diff --git a/libs/subliminal_patch/providers/subf2m.py b/libs/subliminal_patch/providers/subf2m.py index 5ab637c6f..2ee78899b 100644 --- a/libs/subliminal_patch/providers/subf2m.py +++ b/libs/subliminal_patch/providers/subf2m.py @@ -135,8 +135,14 @@ class Subf2mProvider(Provider): video_types = (Episode, Movie) subtitle_class = Subf2mSubtitle + def __init__(self, verify_ssl=True): + super().__init__() + self._verify_ssl = verify_ssl + def initialize(self): self._session = Session() + self._session.verify = self._verify_ssl + self._session.headers.update({"user-agent": "Bazarr"}) def terminate(self): diff --git a/libs/subliminal_patch/providers/subscene_cloudscraper.py b/libs/subliminal_patch/providers/subscene_cloudscraper.py new file mode 100644 index 000000000..f9eead046 --- /dev/null +++ b/libs/subliminal_patch/providers/subscene_cloudscraper.py @@ -0,0 +1,410 @@ +# -*- coding: utf-8 -*- + +from difflib import SequenceMatcher +import functools +import logging +import re +import time +import urllib.parse + +from bs4 import BeautifulSoup as bso +import cloudscraper +from guessit import guessit +from requests import Session +from requests.exceptions import HTTPError +from subliminal.exceptions import ProviderError +from subliminal_patch.core import Episode +from subliminal_patch.core import Movie +from subliminal_patch.exceptions import APIThrottled +from subliminal_patch.providers import Provider +from subliminal_patch.providers.utils import get_archive_from_bytes +from subliminal_patch.providers.utils import get_subtitle_from_archive +from subliminal_patch.providers.utils import update_matches +from subliminal_patch.subtitle import Subtitle +from subzero.language import Language + +logger = logging.getLogger(__name__) + + +class SubsceneSubtitle(Subtitle): + provider_name = "subscene_cloudscraper" + hash_verifiable = False + + def __init__(self, language, page_link, release_info, episode_number=None): + super().__init__(language, page_link=page_link) + + self.release_info = release_info + self.episode_number = episode_number + self.episode_title = None + + self._matches = set( + ("title", "year") + if episode_number is None + else ("title", "series", "year", "season", "episode") + ) + + def get_matches(self, video): + update_matches(self._matches, video, self.release_info) + + return self._matches + + @property + def id(self): + return self.page_link + + +_BASE_URL = "https://subscene.com" + +# TODO: add more seasons and languages + +_SEASONS = ( + "First", + "Second", + "Third", + "Fourth", + "Fifth", + "Sixth", + "Seventh", + "Eighth", + "Ninth", + "Tenth", + "Eleventh", + "Twelfth", + "Thirdteenth", + "Fourthteenth", + "Fifteenth", + "Sixteenth", + "Seventeenth", + "Eightheenth", + "Nineteenth", + "Tweentieth", +) + +_LANGUAGE_MAP = { + "english": "eng", + "farsi_persian": "per", + "arabic": "ara", + "spanish": "spa", + "portuguese": "por", + "italian": "ita", + "dutch": "dut", + "hebrew": "heb", + "indonesian": "ind", + "danish": "dan", + "norwegian": "nor", + "bengali": "ben", + "bulgarian": "bul", + "croatian": "hrv", + "swedish": "swe", + "vietnamese": "vie", + "czech": "cze", + "finnish": "fin", + "french": "fre", + "german": "ger", + "greek": "gre", + "hungarian": "hun", + "icelandic": "ice", + "japanese": "jpn", + "macedonian": "mac", + "malay": "may", + "polish": "pol", + "romanian": "rum", + "russian": "rus", + "serbian": "srp", + "thai": "tha", + "turkish": "tur", +} + + +class SubsceneProvider(Provider): + provider_name = "subscene_cloudscraper" + + _movie_title_regex = re.compile(r"^(.+?)( \((\d{4})\))?$") + _tv_show_title_regex = re.compile( + r"^(.+?) [-\(]\s?(.*?) (season|series)\)?( \((\d{4})\))?$" + ) + _supported_languages = {} + _supported_languages["brazillian-portuguese"] = Language("por", "BR") + + for key, val in _LANGUAGE_MAP.items(): + _supported_languages[key] = Language.fromalpha3b(val) + + _supported_languages_reversed = { + val: key for key, val in _supported_languages.items() + } + + languages = set(_supported_languages.values()) + + video_types = (Episode, Movie) + subtitle_class = SubsceneSubtitle + + def initialize(self): + pass + + def terminate(self): + pass + + def _scraper_call(self, url, retry=7, method="GET", sleep=5, **kwargs): + last_exc = None + + for n in range(retry): + # Creating an instance for every try in order to avoid dropped connections. + + # This could probably be improved! + scraper = cloudscraper.create_scraper() + if method == "GET": + req = scraper.get(url, **kwargs) + elif method == "POST": + req = scraper.post(url, **kwargs) + else: + raise NotImplementedError(f"{method} not allowed") + + try: + req.raise_for_status() + except HTTPError as error: + logger.debug( + "'%s' returned. Trying again [%d] in %s", error, n + 1, sleep + ) + last_exc = error + time.sleep(sleep) + else: + return req + + raise ProviderError("403 Retry count exceeded") from last_exc + + def _gen_results(self, query): + url = ( + f"{_BASE_URL}/subtitles/searchbytitle?query={urllib.parse.quote(query)}&l=" + ) + + result = self._scraper_call(url, method="POST") + soup = bso(result.content, "html.parser") + + for title in soup.select("li div[class='title'] a"): + yield title + + def _search_movie(self, title, year): + title = title.lower() + year = str(year) + + found_movie = None + + results = [] + for result in self._gen_results(title): + text = result.text.lower() + match = self._movie_title_regex.match(text) + if not match: + continue + match_title = match.group(1) + match_year = match.group(3) + if year == match_year: + results.append( + { + "href": result.get("href"), + "similarity": SequenceMatcher(None, title, match_title).ratio(), + } + ) + + if results: + results.sort(key=lambda x: x["similarity"], reverse=True) + found_movie = results[0]["href"] + logger.debug("Movie found: %s", results[0]) + return found_movie + + def _search_tv_show_season(self, title, season, year=None): + try: + season_str = _SEASONS[season - 1].lower() + except IndexError: + logger.debug("Season number not supported: %s", season) + return None + + found_tv_show_season = None + + results = [] + for result in self._gen_results(title): + text = result.text.lower() + + match = self._tv_show_title_regex.match(text) + if not match: + logger.debug("Series title not matched: %s", text) + continue + else: + logger.debug("Series title matched: %s", text) + + match_title = match.group(1) + match_season = match.group(2) + + # Match "complete series" titles as they usually contain season packs + if season_str == match_season or "complete" in match_season: + plus = 0.1 if year and str(year) in text else 0 + results.append( + { + "href": result.get("href"), + "similarity": SequenceMatcher(None, title, match_title).ratio() + + plus, + } + ) + + if results: + results.sort(key=lambda x: x["similarity"], reverse=True) + found_tv_show_season = results[0]["href"] + logger.debug("TV Show season found: %s", results[0]) + + return found_tv_show_season + + def _find_movie_subtitles(self, path, language): + soup = self._get_subtitle_page_soup(path, language) + + subtitles = [] + for item in soup.select("tr"): + subtitle = _get_subtitle_from_item(item, language) + if subtitle is None: + continue + + logger.debug("Found subtitle: %s", subtitle) + subtitles.append(subtitle) + + return subtitles + + def _find_episode_subtitles( + self, path, season, episode, language, episode_title=None + ): + soup = self._get_subtitle_page_soup(path, language) + + subtitles = [] + + for item in soup.select("tr"): + valid_item = None + clean_text = " ".join(item.text.split()) + + if not clean_text: + continue + + # It will return list values + guess = _memoized_episode_guess(clean_text) + + if "season" not in guess: + if "complete series" in clean_text.lower(): + logger.debug("Complete series pack found: %s", clean_text) + guess["season"] = [season] + else: + logger.debug("Nothing guessed from release: %s", clean_text) + continue + + if season in guess["season"] and episode in guess.get("episode", []): + logger.debug("Episode match found: %s - %s", guess, clean_text) + valid_item = item + + elif season in guess["season"] and not "episode" in guess: + logger.debug("Season pack found: %s", clean_text) + valid_item = item + + if valid_item is None: + continue + + subtitle = _get_subtitle_from_item(item, language, episode) + + if subtitle is None: + continue + + subtitle.episode_title = episode_title + + logger.debug("Found subtitle: %s", subtitle) + subtitles.append(subtitle) + + return subtitles + + def _get_subtitle_page_soup(self, path, language): + language_path = self._supported_languages_reversed[language] + result = self._scraper_call(f"{_BASE_URL}{path}/{language_path}") + return bso(result.content, "html.parser") + + def list_subtitles(self, video, languages): + is_episode = isinstance(video, Episode) + + if is_episode: + result = self._search_tv_show_season(video.series, video.season, video.year) + else: + result = self._search_movie(video.title, video.year) + + if result is None: + logger.debug("No results") + return [] + + subtitles = [] + + for language in languages: + if is_episode: + subtitles.extend( + self._find_episode_subtitles( + result, video.season, video.episode, language, video.title + ) + ) + else: + subtitles.extend(self._find_movie_subtitles(result, language)) + + return subtitles + + def download_subtitle(self, subtitle): + # TODO: add MustGetBlacklisted support + + result = self._scraper_call(subtitle.page_link) + soup = bso(result.content, "html.parser") + try: + download_url = _BASE_URL + str( + soup.select_one("a[id='downloadButton']")["href"] # type: ignore + ) + except (AttributeError, KeyError, TypeError): + raise APIThrottled(f"Couldn't get download url from {subtitle.page_link}") + + downloaded = self._scraper_call(download_url) + archive = get_archive_from_bytes(downloaded.content) + + if archive is None: + raise APIThrottled(f"Invalid archive: {subtitle.page_link}") + + subtitle.content = get_subtitle_from_archive( + archive, + episode=subtitle.episode_number, + episode_title=subtitle.episode_title, + ) + + +@functools.lru_cache(2048) +def _memoized_episode_guess(content): + # Use include to save time from unnecessary checks + return guessit( + content, + { + "type": "episode", + # Add codec keys to avoid matching x264, 5.1, etc as episode info + "includes": ["season", "episode", "video_codec", "audio_codec"], + "enforce_list": True, + }, + ) + + +def _get_subtitle_from_item(item, language, episode_number=None): + release_infos = [] + + try: + release_infos.append(item.find("td", {"class": "a6"}).text.strip()) + except (AttributeError, KeyError): + pass + + try: + release_infos.append( + item.find("td", {"class": "a1"}).find_all("span")[-1].text.strip() + ) + except (AttributeError, KeyError): + pass + + release_info = "".join(r_info for r_info in release_infos if r_info) + + try: + path = item.find("td", {"class": "a1"}).find("a")["href"] + except (AttributeError, KeyError): + logger.debug("Couldn't get path: %s", item) + return None + + return SubsceneSubtitle(language, _BASE_URL + path, release_info, episode_number) diff --git a/libs/subliminal_patch/providers/supersubtitles.py b/libs/subliminal_patch/providers/supersubtitles.py index cfc6bff00..7f779fedb 100644 --- a/libs/subliminal_patch/providers/supersubtitles.py +++ b/libs/subliminal_patch/providers/supersubtitles.py @@ -1,32 +1,34 @@ # coding=utf-8 -import io import logging +from random import randint import re import time +import urllib.parse from babelfish import language_converters -from subzero.language import Language +from bs4.element import NavigableString +from bs4.element import Tag +from guessit import guessit from requests import Session from requests.exceptions import JSONDecodeError -import urllib.parse -from random import randint - -from subliminal.subtitle import fix_line_ending -from subliminal_patch.providers import Provider -from subliminal_patch.providers.mixins import ProviderSubtitleArchiveMixin from subliminal.providers import ParserBeautifulSoup -from bs4.element import Tag, NavigableString from subliminal.score import get_equivalent_release_groups -from subliminal_patch.subtitle import Subtitle, guess_matches +from subliminal.utils import sanitize +from subliminal.utils import sanitize_release_group +from subliminal.video import Episode +from subliminal.video import Movie from subliminal_patch.exceptions import APIThrottled -from subliminal.utils import sanitize, sanitize_release_group -from subliminal.video import Episode, Movie -from zipfile import ZipFile, is_zipfile -from rarfile import RarFile, is_rarfile -from subliminal_patch.utils import sanitize, fix_inconsistent_naming -from guessit import guessit -from .utils import FIRST_THOUSAND_OR_SO_USER_AGENTS as AGENT_LIST +from subliminal_patch.providers import Provider +from subliminal_patch.providers.mixins import ProviderSubtitleArchiveMixin +from subliminal_patch.subtitle import Subtitle +from subliminal_patch.utils import fix_inconsistent_naming +from subliminal_patch.utils import sanitize +from subzero.language import Language +from .utils import FIRST_THOUSAND_OR_SO_USER_AGENTS as AGENT_LIST +from .utils import get_archive_from_bytes +from .utils import get_subtitle_from_archive +from .utils import update_matches logger = logging.getLogger(__name__) @@ -78,7 +80,7 @@ class SuperSubtitlesSubtitle(Subtitle): self.season = season self.episode = episode self.version = version - self.releases = releases + self.releases = releases or [] self.year = year self.uploader = uploader if year: @@ -91,7 +93,7 @@ class SuperSubtitlesSubtitle(Subtitle): self.asked_for_episode = asked_for_episode self.imdb_id = imdb_id self.is_pack = True - self.matches = None + self.matches = set() def numeric_id(self): return self.subtitle_id @@ -109,8 +111,8 @@ class SuperSubtitlesSubtitle(Subtitle): return str(self.subtitle_id) def get_matches(self, video): - type_ = "movie" if isinstance(video, Movie) else "episode" - matches = guess_matches(video, guessit(self.release_info, {"type": type_})) + matches = set() + update_matches(matches, video, self.releases) # episode if isinstance(video, Episode): @@ -543,21 +545,12 @@ class SuperSubtitlesProvider(Provider, ProviderSubtitleArchiveMixin): return subtitles def download_subtitle(self, subtitle): - - # download as a zip - logger.info('Downloading subtitle %r', subtitle.subtitle_id) r = self.session.get(subtitle.page_link, timeout=10) r.raise_for_status() - archive_stream = io.BytesIO(r.content) - archive = None + archive = get_archive_from_bytes(r.content) - if is_rarfile(archive_stream): - archive = RarFile(archive_stream) - elif is_zipfile(archive_stream): - archive = ZipFile(archive_stream) - else: - subtitle.content = fix_line_ending(r.content) + if archive is None: + raise APIThrottled(f"Invalid archive from {subtitle.page_link}") - if archive is not None: - subtitle.content = self.get_subtitle_from_archive(subtitle, archive) + subtitle.content = get_subtitle_from_archive(archive, episode=subtitle.episode or None) diff --git a/libs/subliminal_patch/providers/utils.py b/libs/subliminal_patch/providers/utils.py index bf3aa856b..ea8e411e6 100644 --- a/libs/subliminal_patch/providers/utils.py +++ b/libs/subliminal_patch/providers/utils.py @@ -4,9 +4,12 @@ import io import logging import os import re +import tempfile +from typing import Iterable, Union import zipfile from guessit import guessit +import pysubs2 import rarfile from subliminal.subtitle import fix_line_ending from subliminal_patch.core import Episode @@ -119,10 +122,10 @@ def is_episode(content): def get_archive_from_bytes(content: bytes): - """Get RarFile/ZipFile object from bytes. Return None is something else - is found.""" - # open the archive + """Get RarFile/ZipFile object from bytes. A ZipFile instance will be returned + if a subtitle-like stream is found. Return None if something else is found.""" archive_stream = io.BytesIO(content) + if rarfile.is_rarfile(archive_stream): logger.debug("Identified rar archive") return rarfile.RarFile(archive_stream) @@ -130,18 +133,50 @@ def get_archive_from_bytes(content: bytes): logger.debug("Identified zip archive") return zipfile.ZipFile(archive_stream) - logger.debug("Unknown compression format") + logger.debug("No compression format found. Trying with subtitle-like files") + + # If the file is a subtitle-like file + with tempfile.NamedTemporaryFile(prefix="spsub", suffix=".srt") as tmp_f: + try: + tmp_f.write(content) + sub = pysubs2.load(tmp_f.name) + except Exception as error: + logger.debug("Couldn't load file: '%s'", error) + else: + if sub is not None: + logger.debug("Identified subtitle file: %s", sub) + zip_obj = zipfile.ZipFile(io.BytesIO(), mode="x") + zip_obj.write(tmp_f.name, os.path.basename(tmp_f.name)) + return zip_obj + + logger.debug("Nothing found") return None -def update_matches(matches, video, release_info: str, **guessit_options): - "Update matches set from release info string. New lines are iterated." +def update_matches( + matches, + video, + release_info: Union[str, Iterable[str]], + split="\n", + **guessit_options +): + """Update matches set from release info string or Iterable. + + Use the split parameter to iterate over the set delimiter; set None to avoid split.""" + guessit_options["type"] = "episode" if isinstance(video, Episode) else "movie" + logger.debug("Guessit options to update matches: %s", guessit_options) - for release in release_info.split("\n"): - logger.debug("Updating matches from release info: %s", release) - matches |= guess_matches(video, guessit(release.strip(), guessit_options)) - logger.debug("New matches: %s", matches) + if isinstance(release_info, str): + release_info = release_info.split(split) + + for release in release_info: + for release_split in release.split(split): + logger.debug("Updating matches from release info: %s", release) + matches |= guess_matches( + video, guessit(release_split.strip(), guessit_options) + ) + logger.debug("New matches: %s", matches) return matches diff --git a/libs/subliminal_patch/providers/zimuku.py b/libs/subliminal_patch/providers/zimuku.py index 469160315..27251e657 100644 --- a/libs/subliminal_patch/providers/zimuku.py +++ b/libs/subliminal_patch/providers/zimuku.py @@ -88,7 +88,7 @@ class ZimukuProvider(Provider): logger.info(str(supported_languages)) server_url = "http://zimuku.org" - search_url = "/search?q={}&vertoken={}" + search_url = "/search?q={}&security_verify_data={}" download_url = "http://zimuku.org/" subtitle_class = ZimukuSubtitle @@ -115,18 +115,12 @@ class ZimukuProvider(Provider): self.session.cookies.set("srcurl", self.stringToHex(r.url)) if(tr): verify_resp = self.session.get( - self.server_url+tr[0]+self.stringToHex("1080,1920"), allow_redirects=False) + self.server_url+tr[0]+self.stringToHex("1920,1080"), allow_redirects=False) if(verify_resp.status_code == 302 and self.session.cookies.get("security_session_verify") != None): pass continue if len(self.location_re.findall(r.text)) == 0: - if(r.headers.get("Content-Type") == "text/html; charset=utf-8"): - v = ParserBeautifulSoup( - r.content.decode("utf-8", "ignore"), ["html.parser"] - ).find( - "input", attrs={'name': 'vertoken'}) - if(v): - self.vertoken = v.get("value") + self.vertoken = self.stringToHex("1920,1080") return r def initialize(self): diff --git a/libs/subzero/modification/main.py b/libs/subzero/modification/main.py index 13bf22483..b0faddd4e 100644 --- a/libs/subzero/modification/main.py +++ b/libs/subzero/modification/main.py @@ -184,22 +184,22 @@ class SubtitleModifications(object): entries_used = 0 for entry in self.f: entry_used = False - for sub in entry.text.strip().split(r"\N"): - # skip HI bracket entries, those might actually be lowercase - sub = sub.strip() - for processor in registry.mods["remove_HI"].processors[:4]: - sub = processor.process(sub) - - if sub.strip(): - # only consider alphabetic characters to determine if uppercase - alpha_sub = ''.join([i for i in sub if i.isalpha()]) - if alpha_sub and not alpha_sub.isupper(): - return False - - entry_used = True - else: - # skip full entry - break + sub = entry.text + # skip HI bracket entries, those might actually be lowercase + sub = sub.strip() + for processor in registry.mods["remove_HI"].processors[:4]: + sub = processor.process(sub) + + if sub.strip(): + # only consider alphabetic characters to determine if uppercase + alpha_sub = ''.join([i for i in sub if i.isalpha()]) + if alpha_sub and not alpha_sub.isupper(): + return False + + entry_used = True + else: + # skip full entry + break if entry_used: entries_used += 1 diff --git a/postgres-requirements.txt b/postgres-requirements.txt new file mode 100644 index 000000000..2b39b96f4 --- /dev/null +++ b/postgres-requirements.txt @@ -0,0 +1 @@ +psycopg2-binary>=2.9.5 diff --git a/tests/subliminal_patch/conftest.py b/tests/subliminal_patch/conftest.py index 07c79e9d2..6dd0dd559 100644 --- a/tests/subliminal_patch/conftest.py +++ b/tests/subliminal_patch/conftest.py @@ -123,6 +123,7 @@ def episodes(): 1, 1, source="Blu-Ray", + series_tvdb_id=81189, series_imdb_id="tt0903747", release_group="REWARD", resolution="720p", @@ -133,6 +134,7 @@ def episodes(): "Better Call Saul", 6, 4, + series_tvdb_id=273181, source="Web", resolution="720p", video_codec="H.264", diff --git a/tests/subliminal_patch/test_embeddedsubtitles.py b/tests/subliminal_patch/test_embeddedsubtitles.py index ab0dc1c41..2c04aa377 100644 --- a/tests/subliminal_patch/test_embeddedsubtitles.py +++ b/tests/subliminal_patch/test_embeddedsubtitles.py @@ -126,8 +126,8 @@ def fake_streams(): @pytest.mark.parametrize("tags_", [{}, {"language": "und", "title": "Unknown"}]) -def test_list_subtitles_unknown_as_english(mocker, tags_): - with EmbeddedSubtitlesProvider(unknown_as_english=True): +def test_list_subtitles_unknown_as_english(mocker, tags_, video_single_language): + with EmbeddedSubtitlesProvider(unknown_as_english=True) as provider: fake = FFprobeSubtitleStream( {"index": 3, "codec_name": "subrip", "tags": tags_} ) @@ -135,9 +135,32 @@ def test_list_subtitles_unknown_as_english(mocker, tags_): "subliminal_patch.providers.embeddedsubtitles._MemoizedFFprobeVideoContainer.get_subtitles", return_value=[fake], ) - streams = _MemoizedFFprobeVideoContainer.get_subtitles("") - assert len(streams) == 1 - assert streams[0].language == Language.fromietf("en") + result = provider.list_subtitles( + video_single_language, {Language.fromalpha2("en")} + ) + assert len(result) == 1 + + +def test_list_subtitles_unknown_as_english_w_real_english_subtitles( + video_single_language, mocker +): + with EmbeddedSubtitlesProvider(unknown_as_english=True) as provider: + fakes = [ + FFprobeSubtitleStream( + {"index": 3, "codec_name": "subrip", "tags": {"language": "und"}} + ), + FFprobeSubtitleStream( + {"index": 2, "codec_name": "subrip", "tags": {"language": "eng"}} + ), + ] + mocker.patch( + "subliminal_patch.providers.embeddedsubtitles._MemoizedFFprobeVideoContainer.get_subtitles", + return_value=fakes, + ) + result = provider.list_subtitles( + video_single_language, {Language.fromalpha2("en")} + ) + assert len(result) == 1 @pytest.mark.parametrize("tags_", [{}, {"language": "und", "title": "Unknown"}]) diff --git a/tests/subliminal_patch/test_gestdown.py b/tests/subliminal_patch/test_gestdown.py index 117430a1d..35749d7e7 100644 --- a/tests/subliminal_patch/test_gestdown.py +++ b/tests/subliminal_patch/test_gestdown.py @@ -79,7 +79,7 @@ def test_subtitle_download(subtitle): def test_list_subtitles_423(episodes, requests_mock, mocker): mocker.patch("time.sleep") requests_mock.get( - "https://api.gestdown.info/shows/search/Breaking%20Bad", + "https://api.gestdown.info/shows/external/tvdb/81189", status_code=200, text='{"shows":[{"id":"cd880e2e-ef44-47cd-9f3d-a03b343ba2d0","name":"Breaking Bad","nbSeasons":5,"seasons":[1,2,3,4,5]}]}' ) diff --git a/tests/subliminal_patch/test_subscene.py b/tests/subliminal_patch/test_subscene.py new file mode 100644 index 000000000..72063aae3 --- /dev/null +++ b/tests/subliminal_patch/test_subscene.py @@ -0,0 +1,50 @@ +from subliminal_patch.providers import subscene_cloudscraper as subscene + + +def test_provider_scraper_call(): + with subscene.SubsceneProvider() as provider: + result = provider._scraper_call( + "https://subscene.com/subtitles/breaking-bad-fifth-season" + ) + assert result.status_code == 200 + + +def test_provider_gen_results(): + with subscene.SubsceneProvider() as provider: + assert list(provider._gen_results("Breaking Bad")) + + +def test_provider_search_movie(): + with subscene.SubsceneProvider() as provider: + result = provider._search_movie("Taxi Driver", 1976) + assert result == "/subtitles/taxi-driver" + + +def test_provider_find_movie_subtitles(languages): + with subscene.SubsceneProvider() as provider: + result = provider._find_movie_subtitles( + "/subtitles/taxi-driver", languages["en"] + ) + assert result + + +def test_provider_search_tv_show_season(): + with subscene.SubsceneProvider() as provider: + result = provider._search_tv_show_season("The Wire", 1) + assert result == "/subtitles/the-wire--first-season" + + +def test_provider_find_episode_subtitles(languages): + with subscene.SubsceneProvider() as provider: + result = provider._find_episode_subtitles( + "/subtitles/the-wire--first-season", 1, 1, languages["en"] + ) + assert result + + +def test_provider_download_subtitle(languages): + path = "https://subscene.com/subtitles/the-wire--first-season/english/115904" + subtitle = subscene.SubsceneSubtitle(languages["en"], path, "", 1) + with subscene.SubsceneProvider() as provider: + provider.download_subtitle(subtitle) + assert subtitle.is_valid() diff --git a/tests/subliminal_patch/test_supersubtitles.py b/tests/subliminal_patch/test_supersubtitles.py index 6111cabc0..3794a04ca 100644 --- a/tests/subliminal_patch/test_supersubtitles.py +++ b/tests/subliminal_patch/test_supersubtitles.py @@ -44,7 +44,7 @@ def test_list_episode_subtitles(episode): def test_download_episode_subtitle(episode): subtitle = SuperSubtitlesSubtitle( Language.fromalpha2("en"), - "https://www.feliratok.info/index.php?action=letolt&felirat=1643361676", + "https://www.feliratok.eu/index.php?action=letolt&felirat=1643361676", 1643361676, "All of us are dead", 1, @@ -82,7 +82,7 @@ def test_download_movie_subtitle(movies): subtitle = SuperSubtitlesSubtitle( Language.fromalpha2("en"), - "https://www.feliratok.info/index.php?action=letolt&felirat=1634579718", + "https://www.feliratok.eu/index.php?action=letolt&felirat=1634579718", 1634579718, "Dune", 0, diff --git a/tests/subliminal_patch/test_utils.py b/tests/subliminal_patch/test_utils.py index eb8dc2421..c90cf972a 100644 --- a/tests/subliminal_patch/test_utils.py +++ b/tests/subliminal_patch/test_utils.py @@ -122,6 +122,14 @@ def test_update_matches(movies): assert "source" in matches +def test_update_matches_iterable(movies): + matches = set() + utils.update_matches( + matches, movies["dune"], ["Subs for dune 2021 bluray x264", "Dune webrip x264"] + ) + assert "source" in matches + + @pytest.mark.parametrize( "content,expected", [("the.wire.s01e01", True), ("taxi driver 1976", False)] )