Merge development into master

This commit is contained in:
github-actions[bot] 2021-08-31 16:54:28 +00:00 committed by GitHub
commit c66d5662b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
183 changed files with 12547 additions and 11302 deletions

View File

@ -47,6 +47,10 @@ jobs:
run: npm run build run: npm run build
working-directory: ${{ env.UI_DIRECTORY }} working-directory: ${{ env.UI_DIRECTORY }}
- name: Unit Test
run: npm test
working-directory: ${{ env.UI_DIRECTORY }}
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v2
with: with:
name: ${{ env.UI_ARTIFACT_NAME }} name: ${{ env.UI_ARTIFACT_NAME }}

5
.gitignore vendored
View File

@ -21,8 +21,3 @@ VERSION
# Allow # Allow
!*.dll !*.dll
# Frontend
node_modules
/frontend/dist
/frontend/*.local
/frontend/build

View File

@ -856,7 +856,8 @@ class EpisodesSubtitles(Resource):
if sceneName is None: sceneName = "None" if sceneName is None: sceneName = "None"
language = request.form.get('language') language = request.form.get('language')
forced = True if request.form.get('forced') == 'on' else False forced = True if request.form.get('forced') == 'true' else False
hi = True if request.form.get('hi') == 'true' else False
subFile = request.files.get('file') subFile = request.files.get('file')
_, ext = os.path.splitext(subFile.filename) _, ext = os.path.splitext(subFile.filename)
@ -868,6 +869,7 @@ class EpisodesSubtitles(Resource):
result = manual_upload_subtitle(path=episodePath, result = manual_upload_subtitle(path=episodePath,
language=language, language=language,
forced=forced, forced=forced,
hi=hi,
title=title, title=title,
scene_name=sceneName, scene_name=sceneName,
media_type='series', media_type='series',
@ -878,7 +880,9 @@ class EpisodesSubtitles(Resource):
message = result[0] message = result[0]
path = result[1] path = result[1]
subs_path = result[2] subs_path = result[2]
if forced: if hi:
language_code = language + ":hi"
elif forced:
language_code = language + ":forced" language_code = language + ":forced"
else: else:
language_code = language language_code = language
@ -1086,6 +1090,7 @@ class MoviesSubtitles(Resource):
language = request.form.get('language') language = request.form.get('language')
forced = True if request.form.get('forced') == 'true' else False forced = True if request.form.get('forced') == 'true' else False
hi = True if request.form.get('hi') == 'true' else False
subFile = request.files.get('file') subFile = request.files.get('file')
_, ext = os.path.splitext(subFile.filename) _, ext = os.path.splitext(subFile.filename)
@ -1097,6 +1102,7 @@ class MoviesSubtitles(Resource):
result = manual_upload_subtitle(path=moviePath, result = manual_upload_subtitle(path=moviePath,
language=language, language=language,
forced=forced, forced=forced,
hi=hi,
title=title, title=title,
scene_name=sceneName, scene_name=sceneName,
media_type='movie', media_type='movie',
@ -1107,7 +1113,9 @@ class MoviesSubtitles(Resource):
message = result[0] message = result[0]
path = result[1] path = result[1]
subs_path = result[2] subs_path = result[2]
if forced: if hi:
language_code = language + ":hi"
elif forced:
language_code = language + ":forced" language_code = language + ":forced"
else: else:
language_code = language language_code = language
@ -1419,7 +1427,8 @@ class EpisodesHistory(Resource):
if episodeid: if episodeid:
query_conditions.append((TableEpisodes.sonarrEpisodeId == episodeid)) query_conditions.append((TableEpisodes.sonarrEpisodeId == episodeid))
query_condition = reduce(operator.and_, query_conditions) query_condition = reduce(operator.and_, query_conditions)
episode_history = TableHistory.select(TableShows.title.alias('seriesTitle'), episode_history = TableHistory.select(TableHistory.id,
TableShows.title.alias('seriesTitle'),
TableEpisodes.monitored, 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'), TableEpisodes.title.alias('episodeTitle'),
@ -1535,7 +1544,8 @@ class MoviesHistory(Resource):
query_conditions.append((TableMovies.radarrId == radarrid)) query_conditions.append((TableMovies.radarrId == radarrid))
query_condition = reduce(operator.and_, query_conditions) query_condition = reduce(operator.and_, query_conditions)
movie_history = TableHistoryMovie.select(TableHistoryMovie.action, movie_history = TableHistoryMovie.select(TableHistoryMovie.id,
TableHistoryMovie.action,
TableMovies.title, TableMovies.title,
TableHistoryMovie.timestamp, TableHistoryMovie.timestamp,
TableHistoryMovie.description, TableHistoryMovie.description,

View File

@ -91,7 +91,8 @@ defaults = {
'series_sync': '60', 'series_sync': '60',
'episodes_sync': '60', 'episodes_sync': '60',
'excluded_tags': '[]', 'excluded_tags': '[]',
'excluded_series_types': '[]' 'excluded_series_types': '[]',
'use_ffprobe_cache': 'True'
}, },
'radarr': { 'radarr': {
'ip': '127.0.0.1', 'ip': '127.0.0.1',
@ -104,7 +105,8 @@ defaults = {
'full_update_hour': '5', 'full_update_hour': '5',
'only_monitored': 'False', 'only_monitored': 'False',
'movies_sync': '60', 'movies_sync': '60',
'excluded_tags': '[]' 'excluded_tags': '[]',
'use_ffprobe_cache': 'True'
}, },
'proxy': { 'proxy': {
'type': 'None', 'type': 'None',
@ -303,6 +305,8 @@ def save_settings(settings_items):
update_path_map = False update_path_map = False
configure_proxy = False configure_proxy = False
exclusion_updated = False exclusion_updated = False
sonarr_exclusion_updated = False
radarr_exclusion_updated = False
# Subzero Mods # Subzero Mods
update_subzero = False update_subzero = False
@ -374,6 +378,13 @@ def save_settings(settings_items):
'settings-radarr-only_monitored']: 'settings-radarr-only_monitored']:
exclusion_updated = True exclusion_updated = True
if key in ['settings-sonarr-excluded_tags', 'settings-sonarr-only_monitored',
'settings-sonarr-excluded_series_types']:
sonarr_exclusion_updated = True
if key in ['settings.radarr.excluded_tags', 'settings-radarr-only_monitored']:
radarr_exclusion_updated = True
if key == 'settings-addic7ed-username': if key == 'settings-addic7ed-username':
if key != settings.addic7ed.username: if key != settings.addic7ed.username:
region.delete('addic7ed_data') region.delete('addic7ed_data')
@ -465,6 +476,10 @@ def save_settings(settings_items):
if exclusion_updated: if exclusion_updated:
from event_handler import event_stream from event_handler import event_stream
event_stream(type='badges') event_stream(type='badges')
if sonarr_exclusion_updated:
event_stream(type='reset-episode-wanted')
if radarr_exclusion_updated:
event_stream(type='reset-movie-wanted')
def url_sonarr(): def url_sonarr():

View File

@ -9,6 +9,7 @@ from enzyme.exceptions import MalformedMKVError
from enzyme.exceptions import MalformedMKVError from enzyme.exceptions import MalformedMKVError
from custom_lang import CustomLanguage from custom_lang import CustomLanguage
from database import TableEpisodes, TableMovies from database import TableEpisodes, TableMovies
from helper import path_mappings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -77,14 +78,12 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No
# Get the actual cache value form database # Get the actual cache value form database
if episode_file_id: if episode_file_id:
cache_key = TableEpisodes.select(TableEpisodes.ffprobe_cache)\ cache_key = TableEpisodes.select(TableEpisodes.ffprobe_cache)\
.where((TableEpisodes.episode_file_id == episode_file_id) and .where(TableEpisodes.path == path_mappings.path_replace_reverse(file))\
(TableEpisodes.file_size == file_size))\
.dicts()\ .dicts()\
.get() .get()
elif movie_file_id: elif movie_file_id:
cache_key = TableMovies.select(TableMovies.ffprobe_cache)\ cache_key = TableMovies.select(TableMovies.ffprobe_cache)\
.where(TableMovies.movie_file_id == movie_file_id and .where(TableMovies.path == path_mappings.path_replace_reverse_movie(file))\
TableMovies.file_size == file_size)\
.dicts()\ .dicts()\
.get() .get()
else: else:
@ -110,7 +109,7 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No
if ffprobe_path: if ffprobe_path:
api.initialize({"provider": "ffmpeg", "ffmpeg": ffprobe_path}) api.initialize({"provider": "ffmpeg", "ffmpeg": ffprobe_path})
data["ffprobe"] = api.know(file) data["ffprobe"] = api.know(file)
# if nto, we use enzyme for mkv files # if not, we use enzyme for mkv files
else: else:
if os.path.splitext(file)[1] == ".mkv": if os.path.splitext(file)[1] == ".mkv":
with open(file, "rb") as f: with open(file, "rb") as f:
@ -127,10 +126,10 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No
# we write to db the result and return the newly cached ffprobe dict # we write to db the result and return the newly cached ffprobe dict
if episode_file_id: if episode_file_id:
TableEpisodes.update({TableEpisodes.ffprobe_cache: pickle.dumps(data, pickle.HIGHEST_PROTOCOL)})\ TableEpisodes.update({TableEpisodes.ffprobe_cache: pickle.dumps(data, pickle.HIGHEST_PROTOCOL)})\
.where(TableEpisodes.episode_file_id == episode_file_id)\ .where(TableEpisodes.path == path_mappings.path_replace_reverse(file))\
.execute() .execute()
elif movie_file_id: elif movie_file_id:
TableMovies.update({TableEpisodes.ffprobe_cache: pickle.dumps(data, pickle.HIGHEST_PROTOCOL)})\ TableMovies.update({TableEpisodes.ffprobe_cache: pickle.dumps(data, pickle.HIGHEST_PROTOCOL)})\
.where(TableMovies.movie_file_id == movie_file_id)\ .where(TableMovies.path == path_mappings.path_replace_reverse_movie(file))\
.execute() .execute()
return data return data

View File

@ -44,7 +44,7 @@ def sync_episodes(series_id=None, send_event=True):
seriesIdList = get_series_from_sonarr_api(series_id=series_id, url=url_sonarr(), apikey_sonarr=apikey_sonarr,) seriesIdList = get_series_from_sonarr_api(series_id=series_id, url=url_sonarr(), apikey_sonarr=apikey_sonarr,)
series_count = len(seriesIdList) series_count = len(seriesIdList)
for i, seriesId in enumerate(seriesIdList, 1): for i, seriesId in enumerate(seriesIdList):
sleep() sleep()
if send_event: if send_event:
show_progress(id='episodes_progress', show_progress(id='episodes_progress',
@ -85,12 +85,6 @@ def sync_episodes(series_id=None, send_event=True):
episodes_to_add.append(episodeParser(episode)) episodes_to_add.append(episodeParser(episode))
if send_event: if send_event:
show_progress(id='episodes_progress',
header='Syncing episodes...',
name='Completed successfully',
value=series_count,
count=series_count)
hide_progress(id='episodes_progress') hide_progress(id='episodes_progress')
# Remove old episodes from DB # Remove old episodes from DB
@ -183,12 +177,10 @@ def sync_one_episode(episode_id):
else: else:
# For Sonarr v3, we need to update episodes to integrate the episodeFile API endpoint results # For Sonarr v3, we need to update episodes to integrate the episodeFile API endpoint results
if not get_sonarr_info.is_legacy(): if not get_sonarr_info.is_legacy() and existing_episode and episode_data['hasFile']:
episodeFile = get_episodesFiles_from_sonarr_api(url=url, apikey_sonarr=apikey_sonarr, episode_data['episodeFile'] = \
episode_file_id=existing_episode['episode_file_id']) get_episodesFiles_from_sonarr_api(url=url, apikey_sonarr=apikey_sonarr,
if episode_data['hasFile']: episode_file_id=existing_episode['episode_file_id'])
episode_data['episodeFile'] = episodeFile
episode = episodeParser(episode_data) episode = episodeParser(episode_data)
except Exception: except Exception:
logging.debug('BAZARR cannot get episode returned by SignalR feed from Sonarr API.') logging.debug('BAZARR cannot get episode returned by SignalR feed from Sonarr API.')

View File

@ -63,7 +63,7 @@ def update_movies(send_event=True):
# Build new and updated movies # Build new and updated movies
movies_count = len(movies) movies_count = len(movies)
for i, movie in enumerate(movies, 1): for i, movie in enumerate(movies):
sleep() sleep()
if send_event: if send_event:
show_progress(id='movies_progress', show_progress(id='movies_progress',
@ -88,14 +88,8 @@ def update_movies(send_event=True):
tags_dict=tagsDict, tags_dict=tagsDict,
movie_default_profile=movie_default_profile, movie_default_profile=movie_default_profile,
audio_profiles=audio_profiles)) audio_profiles=audio_profiles))
if send_event: if send_event:
show_progress(id='movies_progress',
header='Syncing movies...',
name='Completed successfully',
value=movies_count,
count=movies_count)
hide_progress(id='movies_progress') hide_progress(id='movies_progress')
# Remove old movies from DB # Remove old movies from DB

View File

@ -281,7 +281,7 @@ def update_throttled_provider():
del tp[provider] del tp[provider]
set_throttled_providers(str(tp)) set_throttled_providers(str(tp))
event_stream(type='badges') event_stream(type='badges')
def list_throttled_providers(): def list_throttled_providers():

View File

@ -50,7 +50,7 @@ def update_series(send_event=True):
series_to_add = [] series_to_add = []
series_count = len(series) series_count = len(series)
for i, show in enumerate(series, 1): for i, show in enumerate(series):
sleep() sleep()
if send_event: if send_event:
show_progress(id='series_progress', show_progress(id='series_progress',
@ -72,12 +72,6 @@ def update_series(send_event=True):
audio_profiles=audio_profiles)) audio_profiles=audio_profiles))
if send_event: if send_event:
show_progress(id='series_progress',
header='Syncing series...',
name='Completed successfully',
value=series_count,
count=series_count)
hide_progress(id='series_progress') hide_progress(id='series_progress')
# Remove old series from DB # Remove old series from DB
@ -170,8 +164,7 @@ def update_one_series(series_id, action):
series = None series = None
series_data = get_series_from_sonarr_api(url=url_sonarr(), apikey_sonarr=settings.sonarr.apikey, series_data = get_series_from_sonarr_api(url=url_sonarr(), apikey_sonarr=settings.sonarr.apikey,
sonarr_series_id=int(series_id), sonarr_series_id=int(series_id))
sonarr_version=get_sonarr_info.version())
if not series_data: if not series_data:
return return

View File

@ -249,7 +249,8 @@ def download_subtitle(path, language, audio_language, hi, forced, providers, pro
command = pp_replace(postprocessing_cmd, path, downloaded_path, downloaded_language, command = pp_replace(postprocessing_cmd, path, downloaded_path, downloaded_language,
downloaded_language_code2, downloaded_language_code3, audio_language, downloaded_language_code2, downloaded_language_code3, audio_language,
audio_language_code2, audio_language_code3, subtitle.language.forced, audio_language_code2, audio_language_code3, subtitle.language.forced,
percent_score, subtitle_id, downloaded_provider, series_id, episode_id) percent_score, subtitle_id, downloaded_provider, series_id, episode_id,
subtitle.language.hi)
if media_type == 'series': if media_type == 'series':
use_pp_threshold = settings.general.getboolean('use_postprocessing_threshold') use_pp_threshold = settings.general.getboolean('use_postprocessing_threshold')
@ -569,7 +570,8 @@ def manual_download_subtitle(path, language, audio_language, hi, forced, subtitl
command = pp_replace(postprocessing_cmd, path, downloaded_path, downloaded_language, command = pp_replace(postprocessing_cmd, path, downloaded_path, downloaded_language,
downloaded_language_code2, downloaded_language_code3, audio_language, downloaded_language_code2, downloaded_language_code3, audio_language,
audio_language_code2, audio_language_code3, subtitle.language.forced, audio_language_code2, audio_language_code3, subtitle.language.forced,
percent_score, subtitle_id, downloaded_provider, series_id, episode_id) percent_score, subtitle_id, downloaded_provider, series_id, episode_id,
subtitle.language.hi)
if media_type == 'series': if media_type == 'series':
use_pp_threshold = settings.general.getboolean('use_postprocessing_threshold') use_pp_threshold = settings.general.getboolean('use_postprocessing_threshold')
@ -610,7 +612,7 @@ def manual_download_subtitle(path, language, audio_language, hi, forced, subtitl
logging.debug('BAZARR Ended manually downloading Subtitles for file: ' + path) logging.debug('BAZARR Ended manually downloading Subtitles for file: ' + path)
def manual_upload_subtitle(path, language, forced, title, scene_name, media_type, subtitle, audio_language): 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) logging.debug('BAZARR Manually uploading subtitles for this file: ' + path)
single = settings.general.getboolean('single_language') single = settings.general.getboolean('single_language')
@ -663,11 +665,24 @@ def manual_upload_subtitle(path, language, forced, title, scene_name, media_type
return return
subtitle_path = saved_subtitles[0].storage_path subtitle_path = saved_subtitles[0].storage_path
message = language_from_alpha3(language) + (" forced" if forced else "") + " Subtitles manually uploaded."
uploaded_language_code3 = language if hi:
uploaded_language = language_from_alpha3(uploaded_language_code3) modifier_string = " HI"
uploaded_language_code2 = alpha2_from_alpha3(uploaded_language_code3) elif forced:
modifier_string = " forced"
else:
modifier_string = ""
message = language_from_alpha3(language) + modifier_string + " Subtitles manually uploaded."
if hi:
modifier_code = ":hi"
elif forced:
modifier_code = ":forced"
else:
modifier_code = ""
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_code2 = alpha2_from_language(audio_language)
audio_language_code3 = alpha3_from_language(audio_language) audio_language_code3 = alpha3_from_language(audio_language)
@ -694,7 +709,8 @@ def manual_upload_subtitle(path, language, forced, title, scene_name, media_type
if use_postprocessing : if use_postprocessing :
command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language, command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language,
uploaded_language_code2, uploaded_language_code3, audio_language, uploaded_language_code2, uploaded_language_code3, audio_language,
audio_language_code2, audio_language_code3, forced, 100, "1", "manual", series_id, episode_id) audio_language_code2, audio_language_code3, forced, 100, "1", "manual", series_id,
episode_id, hi=hi)
postprocessing(command, path) postprocessing(command, path)
if media_type == 'series': if media_type == 'series':
@ -729,7 +745,8 @@ def series_download_subtitles(no):
.where(reduce(operator.and_, conditions))\ .where(reduce(operator.and_, conditions))\
.dicts() .dicts()
if not episodes_details: if not episodes_details:
logging.debug("BAZARR no episode for that sonarrSeriesId can be found in database:", str(no)) logging.debug("BAZARR no episode for that sonarrSeriesId have been found in database or they have all been "
"ignored because of monitored status, series type or series tags: {}".format(no))
return return
providers_list = get_providers() providers_list = get_providers()
@ -737,7 +754,7 @@ def series_download_subtitles(no):
count_episodes_details = len(episodes_details) count_episodes_details = len(episodes_details)
for i, episode in enumerate(episodes_details, 1): for i, episode in enumerate(episodes_details):
if providers_list: if providers_list:
show_progress(id='series_search_progress_{}'.format(no), show_progress(id='series_search_progress_{}'.format(no),
header='Searching missing subtitles...', header='Searching missing subtitles...',
@ -796,13 +813,6 @@ def series_download_subtitles(no):
logging.info("BAZARR All providers are throttled") logging.info("BAZARR All providers are throttled")
break break
if count_episodes_details:
show_progress(id='series_search_progress_{}'.format(no),
header='Searching missing subtitles...',
name='Completed successfully',
value=count_episodes_details,
count=count_episodes_details)
hide_progress(id='series_search_progress_{}'.format(no)) hide_progress(id='series_search_progress_{}'.format(no))
@ -841,7 +851,7 @@ def episode_download_subtitles(no, send_progress=False):
episode['season'], episode['season'],
episode['episode'], episode['episode'],
episode['episodeTitle']), episode['episodeTitle']),
value=1, value=0,
count=1) count=1)
for language in ast.literal_eval(episode['missing_subtitles']): for language in ast.literal_eval(episode['missing_subtitles']):
# confirm if language is still missing or if cutoff have been reached # confirm if language is still missing or if cutoff have been reached
@ -921,7 +931,7 @@ def movies_download_subtitles(no):
else: else:
count_movie = 0 count_movie = 0
for i, language in enumerate(ast.literal_eval(movie['missing_subtitles']), 1): for i, language in enumerate(ast.literal_eval(movie['missing_subtitles'])):
# confirm if language is still missing or if cutoff have been reached # confirm if language is still missing or if cutoff have been reached
confirmed_missing_subs = TableMovies.select(TableMovies.missing_subtitles)\ confirmed_missing_subs = TableMovies.select(TableMovies.missing_subtitles)\
.where(TableMovies.radarrId == movie['radarrId'])\ .where(TableMovies.radarrId == movie['radarrId'])\
@ -975,17 +985,10 @@ def movies_download_subtitles(no):
logging.info("BAZARR All providers are throttled") logging.info("BAZARR All providers are throttled")
break break
if count_movie:
show_progress(id='movie_search_progress_{}'.format(no),
header='Searching missing subtitles...',
name='Completed successfully',
value=count_movie,
count=count_movie)
hide_progress(id='movie_search_progress_{}'.format(no)) hide_progress(id='movie_search_progress_{}'.format(no))
def wanted_download_subtitles(sonarr_series_id): def wanted_download_subtitles(sonarr_episode_id):
episodes_details = TableEpisodes.select(TableEpisodes.path, episodes_details = TableEpisodes.select(TableEpisodes.path,
TableEpisodes.missing_subtitles, TableEpisodes.missing_subtitles,
TableEpisodes.sonarrEpisodeId, TableEpisodes.sonarrEpisodeId,
@ -995,7 +998,7 @@ def wanted_download_subtitles(sonarr_series_id):
TableEpisodes.failedAttempts, TableEpisodes.failedAttempts,
TableShows.title)\ TableShows.title)\
.join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId))\ .join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId))\
.where((TableEpisodes.sonarrSeriesId == sonarr_series_id))\ .where((TableEpisodes.sonarrEpisodeId == sonarr_episode_id))\
.dicts() .dicts()
episodes_details = list(episodes_details) episodes_details = list(episodes_details)
@ -1159,6 +1162,7 @@ def wanted_search_missing_subtitles_series():
conditions = [(TableEpisodes.missing_subtitles != '[]')] conditions = [(TableEpisodes.missing_subtitles != '[]')]
conditions += get_exclusion_clause('series') conditions += get_exclusion_clause('series')
episodes = TableEpisodes.select(TableEpisodes.sonarrSeriesId, episodes = TableEpisodes.select(TableEpisodes.sonarrSeriesId,
TableEpisodes.sonarrEpisodeId,
TableShows.tags, TableShows.tags,
TableEpisodes.monitored, TableEpisodes.monitored,
TableShows.title, TableShows.title,
@ -1172,7 +1176,7 @@ def wanted_search_missing_subtitles_series():
episodes = list(episodes) episodes = list(episodes)
count_episodes = len(episodes) count_episodes = len(episodes)
for i, episode in enumerate(episodes, 1): for i, episode in enumerate(episodes):
show_progress(id='wanted_episodes_progress', show_progress(id='wanted_episodes_progress',
header='Searching subtitles...', header='Searching subtitles...',
name='{0} - S{1:02d}E{2:02d} - {3}'.format(episode['title'], name='{0} - S{1:02d}E{2:02d} - {3}'.format(episode['title'],
@ -1184,17 +1188,11 @@ def wanted_search_missing_subtitles_series():
providers = get_providers() providers = get_providers()
if providers: if providers:
wanted_download_subtitles(episode['sonarrSeriesId']) wanted_download_subtitles(episode['sonarrEpisodeId'])
else: else:
logging.info("BAZARR All providers are throttled") logging.info("BAZARR All providers are throttled")
return return
show_progress(id='wanted_episodes_progress',
header='Searching subtitles...',
name='Completed successfully',
value=count_episodes,
count=count_episodes)
hide_progress(id='wanted_episodes_progress') hide_progress(id='wanted_episodes_progress')
logging.info('BAZARR Finished searching for missing Series Subtitles. Check History for more information.') logging.info('BAZARR Finished searching for missing Series Subtitles. Check History for more information.')
@ -1212,7 +1210,7 @@ def wanted_search_missing_subtitles_movies():
movies = list(movies) movies = list(movies)
count_movies = len(movies) count_movies = len(movies)
for i, movie in enumerate(movies, 1): for i, movie in enumerate(movies):
show_progress(id='wanted_movies_progress', show_progress(id='wanted_movies_progress',
header='Searching subtitles...', header='Searching subtitles...',
name=movie['title'], name=movie['title'],
@ -1226,12 +1224,6 @@ def wanted_search_missing_subtitles_movies():
logging.info("BAZARR All providers are throttled") logging.info("BAZARR All providers are throttled")
return return
show_progress(id='wanted_movies_progress',
header='Searching subtitles...',
name='Completed successfully',
value=count_movies,
count=count_movies)
hide_progress(id='wanted_movies_progress') hide_progress(id='wanted_movies_progress')
logging.info('BAZARR Finished searching for missing Movies Subtitles. Check History for more information.') logging.info('BAZARR Finished searching for missing Movies Subtitles. Check History for more information.')
@ -1495,7 +1487,7 @@ def upgrade_subtitles():
providers_auth = get_providers_auth() providers_auth = get_providers_auth()
if settings.general.getboolean('use_sonarr'): if settings.general.getboolean('use_sonarr'):
for i, episode in enumerate(episodes_to_upgrade, 1): for i, episode in enumerate(episodes_to_upgrade):
show_progress(id='upgrade_episodes_progress', show_progress(id='upgrade_episodes_progress',
header='Upgrading episodes subtitles...', header='Upgrading episodes subtitles...',
name='{0} - S{1:02d}E{2:02d} - {3}'.format(episode['seriesTitle'], name='{0} - S{1:02d}E{2:02d} - {3}'.format(episode['seriesTitle'],
@ -1559,16 +1551,10 @@ def upgrade_subtitles():
language_code, provider, score, subs_id, subs_path) language_code, provider, score, subs_id, subs_path)
send_notifications(episode['sonarrSeriesId'], episode['sonarrEpisodeId'], message) send_notifications(episode['sonarrSeriesId'], episode['sonarrEpisodeId'], message)
show_progress(id='upgrade_episodes_progress',
header='Upgrading episodes subtitles...',
name='Completed successfully',
value=count_episode_to_upgrade,
count=count_episode_to_upgrade)
hide_progress(id='upgrade_episodes_progress') hide_progress(id='upgrade_episodes_progress')
if settings.general.getboolean('use_radarr'): if settings.general.getboolean('use_radarr'):
for i, movie in enumerate(movies_to_upgrade, 1): for i, movie in enumerate(movies_to_upgrade):
show_progress(id='upgrade_movies_progress', show_progress(id='upgrade_movies_progress',
header='Upgrading movies subtitles...', header='Upgrading movies subtitles...',
name=movie['title'], name=movie['title'],
@ -1632,12 +1618,6 @@ def upgrade_subtitles():
history_log_movie(3, movie['radarrId'], message, path, language_code, provider, score, subs_id, subs_path) history_log_movie(3, movie['radarrId'], message, path, language_code, provider, score, subs_id, subs_path)
send_notifications_movie(movie['radarrId'], message) send_notifications_movie(movie['radarrId'], message)
show_progress(id='upgrade_movies_progress',
header='Upgrading movies subtitles...',
name='Completed successfully',
value=count_movie_to_upgrade,
count=count_movie_to_upgrade)
hide_progress(id='upgrade_movies_progress') hide_progress(id='upgrade_movies_progress')
logging.info('BAZARR Finished searching for Subtitles to upgrade. Check History for more information.') logging.info('BAZARR Finished searching for Subtitles to upgrade. Check History for more information.')

View File

@ -96,16 +96,33 @@ class PathMappings:
path_mappings = PathMappings() path_mappings = PathMappings()
def pp_replace(pp_command, episode, subtitles, language, language_code2, language_code3, episode_language, episode_language_code2, episode_language_code3, forced, score, subtitle_id, provider, series_id, episode_id): def pp_replace(pp_command, episode, subtitles, language, language_code2, language_code3, episode_language, episode_language_code2, episode_language_code3, forced, score, subtitle_id, provider, series_id, episode_id, hi):
is_forced = ":forced" if forced else "" if hi:
is_forced_string = " forced" if forced else "" modifier_string = " HI"
elif forced:
modifier_string = " forced"
else:
modifier_string = ""
if hi:
modifier_code = ":hi"
modifier_code_dot = ".hi"
elif forced:
modifier_code = ":forced"
modifier_code_dot = ".forced"
else:
modifier_code = ""
modifier_code_dot = ""
pp_command = pp_command.replace('{{directory}}', os.path.dirname(episode)) pp_command = pp_command.replace('{{directory}}', os.path.dirname(episode))
pp_command = pp_command.replace('{{episode}}', episode) pp_command = pp_command.replace('{{episode}}', episode)
pp_command = pp_command.replace('{{episode_name}}', os.path.splitext(os.path.basename(episode))[0]) pp_command = pp_command.replace('{{episode_name}}', os.path.splitext(os.path.basename(episode))[0])
pp_command = pp_command.replace('{{subtitles}}', str(subtitles)) pp_command = pp_command.replace('{{subtitles}}', str(subtitles))
pp_command = pp_command.replace('{{subtitles_language}}', str(language) + is_forced_string) pp_command = pp_command.replace('{{subtitles_language}}', str(language))
pp_command = pp_command.replace('{{subtitles_language_code2}}', str(language_code2) + is_forced) pp_command = pp_command.replace('{{subtitles_language_code2}}', str(language_code2))
pp_command = pp_command.replace('{{subtitles_language_code3}}', str(language_code3) + is_forced) pp_command = pp_command.replace('{{subtitles_language_code3}}', str(language_code3))
pp_command = pp_command.replace('{{subtitles_language_code2_dot}}', str(language_code2).replace(':', '.'))
pp_command = pp_command.replace('{{subtitles_language_code3_dot}}', str(language_code3).replace(':', '.'))
pp_command = pp_command.replace('{{episode_language}}', str(episode_language)) pp_command = pp_command.replace('{{episode_language}}', str(episode_language))
pp_command = pp_command.replace('{{episode_language_code2}}', str(episode_language_code2)) pp_command = pp_command.replace('{{episode_language_code2}}', str(episode_language_code2))
pp_command = pp_command.replace('{{episode_language_code3}}', str(episode_language_code3)) pp_command = pp_command.replace('{{episode_language_code3}}', str(episode_language_code3))

View File

@ -446,23 +446,19 @@ def list_missing_subtitles_movies(no=None, send_event=True):
def series_full_scan_subtitles(): def series_full_scan_subtitles():
use_ffprobe_cache = settings.sonarr.getboolean('use_ffprobe_cache')
episodes = TableEpisodes.select(TableEpisodes.path).dicts() episodes = TableEpisodes.select(TableEpisodes.path).dicts()
count_episodes = len(episodes) count_episodes = len(episodes)
for i, episode in enumerate(episodes, 1): for i, episode in enumerate(episodes):
sleep() sleep()
show_progress(id='episodes_disk_scan', show_progress(id='episodes_disk_scan',
header='Full disk scan...', header='Full disk scan...',
name='Episodes subtitles', name='Episodes subtitles',
value=i, value=i,
count=count_episodes) count=count_episodes)
store_subtitles(episode['path'], path_mappings.path_replace(episode['path'])) store_subtitles(episode['path'], path_mappings.path_replace(episode['path']), use_cache=use_ffprobe_cache)
show_progress(id='episodes_disk_scan',
header='Full disk scan...',
name='Completed successfully',
value=count_episodes,
count=count_episodes)
hide_progress(id='episodes_disk_scan') hide_progress(id='episodes_disk_scan')
@ -470,23 +466,20 @@ def series_full_scan_subtitles():
def movies_full_scan_subtitles(): def movies_full_scan_subtitles():
use_ffprobe_cache = settings.radarr.getboolean('use_ffprobe_cache')
movies = TableMovies.select(TableMovies.path).dicts() movies = TableMovies.select(TableMovies.path).dicts()
count_movies = len(movies) count_movies = len(movies)
for i, movie in enumerate(movies, 1): for i, movie in enumerate(movies):
sleep() sleep()
show_progress(id='movies_disk_scan', show_progress(id='movies_disk_scan',
header='Full disk scan...', header='Full disk scan...',
name='Movies subtitles', name='Movies subtitles',
value=i, value=i,
count=count_movies) count=count_movies)
store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path'])) store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']),
use_cache=use_ffprobe_cache)
show_progress(id='movies_disk_scan',
header='Full disk scan...',
name='Completed successfully',
value=count_movies,
count=count_movies)
hide_progress(id='movies_disk_scan') hide_progress(id='movies_disk_scan')

View File

@ -207,7 +207,6 @@ if settings.general.getboolean('use_sonarr'):
greenlets.append(Greenlet.spawn(sonarr_signalr_client.start)) greenlets.append(Greenlet.spawn(sonarr_signalr_client.start))
if settings.general.getboolean('use_radarr'): if settings.general.getboolean('use_radarr'):
greenlets.append(Greenlet.spawn(radarr_signalr_client.start)) greenlets.append(Greenlet.spawn(radarr_signalr_client.start))
joinall(greenlets)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations
import logging import logging
import re import re
@ -10,6 +12,72 @@ from database import TableCustomScoreProfiles as profiles_table
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Condition:
"""Base class for score conditions. Every condition can take the amount
of attributes needed from a subtitle object in order to find a match."""
type = None
against = ()
# {type: provider, value: subdivx, required: False, negate: False}
def __init__(self, value: str, required=False, negate=False, **kwargs):
self._value = str(value)
self._negate = negate
self.required = required
@classmethod
def from_dict(cls, item: dict) -> Condition:
"""A factory method to create a condition object from a database
dictionary."""
try:
new = _registered_conditions[item["type"]]
except IndexError:
raise NotImplementedError(f"{item} condition doesn't have a class.")
return new(**item)
def check(self, subtitle) -> bool:
"""Check if the condition is met against a Subtitle object. **May be implemented
in a subclass**."""
to_match = [str(getattr(subtitle, name, None)) for name in self.against]
met = any(item == self._value for item in to_match)
if met and not self._negate:
return True
return not met and self._negate
def __repr__(self) -> str:
return f"<Condition {self.type}={self._value} (r:{self.required} n:{self._negate})>"
class ProviderCondition(Condition):
type = "provider"
against = ("provider_name",)
class UploaderCondition(Condition):
type = "uploader"
against = ("uploader",)
class LanguageCondition(Condition):
type = "language"
against = ("language",)
class RegexCondition(Condition):
type = "regex"
against = ("release_info", "filename")
def check(self, subtitle):
to_match = [str(getattr(subtitle, name, None)) for name in self.against]
met = re.search(rf"{self._value}", "".join(to_match)) is not None
if met and not self._negate:
return True
return not met and self._negate
class CustomScoreProfile: class CustomScoreProfile:
table = profiles_table table = profiles_table
conditions_table = conditions_table conditions_table = conditions_table
@ -24,11 +92,12 @@ class CustomScoreProfile:
def load_conditions(self): def load_conditions(self):
try: try:
self._conditions = list( self._conditions = [
self.conditions_table.select() Condition.from_dict(item)
for item in self.conditions_table.select()
.where(self.conditions_table.profile_id == self.id) .where(self.conditions_table.profile_id == self.id)
.dicts() .dicts()
) ]
except self.conditions_table.DoesNotExist: except self.conditions_table.DoesNotExist:
logger.debug("Conditions not found for %s", self) logger.debug("Conditions not found for %s", self)
self._conditions = [] self._conditions = []
@ -42,50 +111,28 @@ class CustomScoreProfile:
# Always return False if no conditions are set # Always return False if no conditions are set
if not self._conditions: if not self._conditions:
logger.debug("No conditions found in %s profile", self) logger.debug("No conditions found in db for %s", self)
return False return False
logger.debug("Checking conditions for %s profile", self) return self._check_conditions(subtitle)
met = self._check_conditions(subtitle)
logger.debug("Profile conditions met? %s", met)
return met
def _check_conditions(self, subtitle): def _check_conditions(self, subtitle):
checkers = { logger.debug("Checking conditions for %s profile", self)
"provider": subtitle.provider_name,
"uploader": subtitle.uploader,
"language": subtitle.language,
"regex": subtitle.release_info,
}
matches = [] matches = []
for condition in self._conditions: for condition in self._conditions:
# Condition dict example: matched = condition.check(subtitle)
# {type: provider, value: subdivx, required: False, negate: False}
key = condition.get("type")
sub_value = checkers.get(key)
if sub_value is None:
continue
cond_value = condition.get("value", "") if matched is True:
negate = condition.get("negate", False) logger.debug("%s Condition met", condition)
matches.append(True)
logger.debug("Checking %s: %s (condition: %s)", key, sub_value, condition) elif condition.required and not matched:
logger.debug("%s not met, discarding profile", condition)
if key == "regex" and re.findall(rf"{cond_value}", sub_value):
logger.debug("Regex matched: %s -> %s", cond_value, sub_value)
matches.append(not negate and True)
elif cond_value == sub_value:
logger.debug("%s condition met: %s -> %s", key, cond_value, sub_value)
matches.append(not negate and True)
# Return False if any required condition is not met
elif condition.get("required"):
logger.debug("%s required condition not met, discarding profile", key)
return False return False
return True in matches met = True in matches
logger.debug("Profile conditions met? %s", met)
return met
def __repr__(self): def __repr__(self):
return f"<ScoreProfile {self.name} (score: {self.score})>" return f"<ScoreProfile {self.name} (score: {self.score})>"
@ -217,5 +264,12 @@ class MovieScore(Score):
self.data.update(kwargs["movie_scores"]) self.data.update(kwargs["movie_scores"])
_registered_conditions = {
"provider": ProviderCondition,
"uploader": UploaderCondition,
"language": LanguageCondition,
"regex": RegexCondition,
}
series_score = SeriesScore.from_config(**get_settings()) series_score = SeriesScore.from_config(**get_settings())
movie_score = MovieScore.from_config(**get_settings()) movie_score = MovieScore.from_config(**get_settings())

View File

@ -429,6 +429,10 @@ def subtitles_apply_mods(language, subtitle_path, mods):
def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi): def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
language_code_convert_dict = {
'he': 'iw',
}
to_lang = alpha3_from_alpha2(to_lang) to_lang = alpha3_from_alpha2(to_lang)
lang_obj = Language(to_lang) lang_obj = Language(to_lang)
if forced: if forced:
@ -464,7 +468,9 @@ def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
for block_str in lines_block_list: for block_str in lines_block_list:
try: try:
translated_partial_srt_text = GoogleTranslator(source='auto', translated_partial_srt_text = GoogleTranslator(source='auto',
target=lang_obj.basename).translate(text=block_str) target=language_code_convert_dict.get(lang_obj.basename,
lang_obj.basename)
).translate(text=block_str)
except: except:
return False return False
else: else:

View File

@ -1,8 +0,0 @@
# Please override by creating a .env.local file at the same directory
# Required
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
# Optional
REACT_APP_CAN_UPDATE=true
REACT_APP_HAS_UPDATE=false
REACT_APP_LOG_REDUX_EVENT=false

24
frontend/.env.development Normal file
View File

@ -0,0 +1,24 @@
# Override by duplicating me and rename to .env.local
# The following environment variables will only be used during development
# Required
# API key of your backend
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
# Address of your backend
REACT_APP_PROXY_URL=http://localhost:6767
# Optional
# Allow Unsecured connection to your backend
REACT_APP_PROXY_SECURE=true
# Allow websocket connection in Socket.IO
REACT_APP_ALLOW_WEBSOCKET=true
# Display update section in settings
REACT_APP_CAN_UPDATE=true
# Display update notification in notification center
REACT_APP_HAS_UPDATE=false

3
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "react-app"
}

4
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
*.local
build

7
frontend/.husky/pre-commit Executable file
View File

@ -0,0 +1,7 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
[ -n "$CI" ] && exit 0
cd frontend
npx pretty-quick --staged

View File

@ -1,10 +1,12 @@
# Bazarr Frontend # Bazarr Frontend
## Dependencies ## Dependencies
* [Node.js](https://nodejs.org/)
* npm (included in Node.js) - [Node.js](https://nodejs.org/)
- npm (included in Node.js)
## Getting Started ## Getting Started
1. Clone or download this repository 1. Clone or download this repository
``` ```
@ -17,18 +19,36 @@
``` ```
$ npm install $ npm install
``` ```
4. Duplicate `.env` file and rename to `.env.local`
3. Duplicate `.env.development` file and rename to `.env.local`
``` ```
$ cp .env .env.local $ cp .env .env.local
``` ```
6. Fill any variable that defined in `.env.local`
7. Run Bazarr backend (Backend must listening on `http://localhost:6767`) 4. Update your backend server's API key in `.env.local`
```
# API key of your backend
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
```
5. Change the address of your backend server (Optional)
> http://localhost:6767 will be used by default
```
# Address of your backend
REACT_APP_PROXY_URL=http://localhost:6767
```
6. Run Bazarr backend
``` ```
$ python3 ../bazarr.py $ python3 ../bazarr.py
``` ```
9. Run the web client for local development
7. Run the web development tool
``` ```
$ npm start $ npm start
@ -46,6 +66,12 @@ Open `http://localhost:3000` to view it in the browser.
The page will reload if you make edits. The page will reload if you make edits.
You will also see any lint errors in the console. You will also see any lint errors in the console.
### `npm test`
Run the Unit Test to validate app state.
Please ensure all tests are passed before uploading the code
### `npm run build` ### `npm run build`
Builds the app for production to the `build` folder. Builds the app for production to the `build` folder.

13687
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@
"bugs": { "bugs": {
"url": "https://github.com/morpheus65535/bazarr/issues" "url": "https://github.com/morpheus65535/bazarr/issues"
}, },
"private": true,
"homepage": "./", "homepage": "./",
"dependencies": { "dependencies": {
"@fontsource/roboto": "^4.2.2", "@fontsource/roboto": "^4.2.2",
@ -19,21 +20,9 @@
"@fortawesome/free-regular-svg-icons": "^5.15", "@fortawesome/free-regular-svg-icons": "^5.15",
"@fortawesome/free-solid-svg-icons": "^5.15", "@fortawesome/free-solid-svg-icons": "^5.15",
"@fortawesome/react-fontawesome": "^0.1.11", "@fortawesome/react-fontawesome": "^0.1.11",
"@types/bootstrap": "^5", "@reduxjs/toolkit": "^1.6",
"@types/lodash": "^4",
"@types/node": "^15",
"@types/react": "^16",
"@types/react-dom": "^17",
"@types/react-helmet": "^6.1",
"@types/react-router-dom": "^5",
"@types/react-select": "^4.0.3",
"@types/react-table": "^7",
"@types/redux-actions": "^2",
"@types/redux-logger": "^3",
"@types/redux-promise": "^0.5",
"axios": "^0.21", "axios": "^0.21",
"bootstrap": "^4", "bootstrap": "^4",
"http-proxy-middleware": "^0.19",
"lodash": "^4", "lodash": "^4",
"rc-slider": "^9.7", "rc-slider": "^9.7",
"react": "^17", "react": "^17",
@ -46,17 +35,24 @@
"react-select": "^4", "react-select": "^4",
"react-table": "^7", "react-table": "^7",
"recharts": "^2.0.8", "recharts": "^2.0.8",
"redux-actions": "^2",
"redux-logger": "^3",
"redux-promise": "^0.6",
"redux-thunk": "^2.3",
"rooks": "^5", "rooks": "^5",
"sass": "^1", "sass": "^1",
"socket.io-client": "^4", "socket.io-client": "^4",
"typescript": "^4" "typescript": "^4"
}, },
"devDependencies": { "devDependencies": {
"husky": "^4", "@types/bootstrap": "^5",
"@types/jest": "~26.0.24",
"@types/lodash": "^4",
"@types/node": "^15",
"@types/react": "^17",
"@types/react-dom": "^17",
"@types/react-helmet": "^6.1",
"@types/react-router-dom": "^5",
"@types/react-select": "^4.0.3",
"@types/react-table": "^7",
"http-proxy-middleware": "^2",
"husky": "^7",
"prettier": "^2", "prettier": "^2",
"prettier-plugin-organize-imports": "^2", "prettier-plugin-organize-imports": "^2",
"pretty-quick": "^3.1" "pretty-quick": "^3.1"
@ -65,10 +61,8 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"lint": "prettier --write --ignore-unknown ." "lint": "prettier --write --ignore-unknown .",
}, "prepare": "cd .. && husky install frontend/.husky"
"eslintConfig": {
"extends": "react-app"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
@ -81,10 +75,5 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"husky": {
"hooks": {
"pre-commit": "pretty-quick --staged"
}
} }
} }

View File

@ -0,0 +1,13 @@
import { useEffect } from "react";
import Socketio from ".";
import { log } from "../../utilities/logger";
export function useSocketIOReducer(reducer: SocketIO.Reducer) {
useEffect(() => {
Socketio.addReducer(reducer);
log("info", "listening to SocketIO event", reducer.key);
return () => {
Socketio.removeReducer(reducer);
};
}, [reducer]);
}

View File

@ -1,7 +1,7 @@
import { debounce, forIn, remove, uniq } from "lodash"; import { debounce, forIn, remove, uniq } from "lodash";
import { io, Socket } from "socket.io-client"; import { io, Socket } from "socket.io-client";
import { getBaseUrl } from "../utilites"; import { Environment } from "../../utilities";
import { conditionalLog, log } from "../utilites/logger"; import { conditionalLog, log } from "../../utilities/logger";
import { createDefaultReducer } from "./reducer"; import { createDefaultReducer } from "./reducer";
class SocketIOClient { class SocketIOClient {
@ -12,9 +12,8 @@ class SocketIOClient {
private reducers: SocketIO.Reducer[]; private reducers: SocketIO.Reducer[];
constructor() { constructor() {
const baseUrl = getBaseUrl();
this.socket = io({ this.socket = io({
path: `${baseUrl}/api/socket.io`, path: `${Environment.baseUrl}/api/socket.io`,
transports: ["polling", "websocket"], transports: ["polling", "websocket"],
upgrade: true, upgrade: true,
rememberUpgrade: true, rememberUpgrade: true,
@ -65,12 +64,13 @@ class SocketIOClient {
if (!(e.type in records)) { if (!(e.type in records)) {
records[e.type] = {}; records[e.type] = {};
} }
const record = records[e.type]!; const record = records[e.type]!;
if (!(e.action in record)) { if (!(e.action in record)) {
record[e.action] = []; record[e.action] = [];
} }
if (e.payload) { if (e.payload) {
record[e.action]?.push(e.payload); record[e.action]!.push(e.payload);
} }
}); });
@ -91,11 +91,11 @@ class SocketIOClient {
forIn(element, (ids, key) => { forIn(element, (ids, key) => {
ids = uniq(ids); ids = uniq(ids);
const action = handler[key]; const action = handler[key as SocketIO.ActionType];
if (typeof action == "function") { if (action) {
action(ids); action(ids);
} else if (anyAction === undefined) { } else if (anyAction === undefined) {
log("warning", "Unhandle action of SocketIO event", key, type); log("warning", "Unhandled SocketIO event", key, type);
} }
}); });
}); });

View File

@ -0,0 +1,158 @@
import { ActionCreator } from "@reduxjs/toolkit";
import {
episodesMarkBlacklistDirty,
episodesMarkDirtyById,
episodesRemoveById,
episodesResetHistory,
movieMarkBlacklistDirty,
movieMarkDirtyById,
movieMarkWantedDirtyById,
movieRemoveById,
movieRemoveWantedById,
movieResetHistory,
movieResetWanted,
seriesMarkDirtyById,
seriesMarkWantedDirtyById,
seriesRemoveById,
seriesRemoveWantedById,
seriesResetWanted,
siteAddNotifications,
siteAddProgress,
siteBootstrap,
siteRemoveProgress,
siteUpdateBadges,
siteUpdateInitialization,
siteUpdateOffline,
systemMarkTasksDirty,
systemUpdateAllSettings,
systemUpdateLanguages,
} from "../../@redux/actions";
import reduxStore from "../../@redux/store";
function bindReduxAction<T extends ActionCreator<any>>(action: T) {
return (...args: Parameters<T>) => {
reduxStore.dispatch(action(...args));
};
}
function bindReduxActionWithParam<T extends ActionCreator<any>>(
action: T,
...param: Parameters<T>
) {
return () => {
reduxStore.dispatch(action(...param));
};
}
export function createDefaultReducer(): SocketIO.Reducer[] {
return [
{
key: "connect",
any: bindReduxActionWithParam(siteUpdateOffline, false),
},
{
key: "connect",
any: bindReduxAction(siteBootstrap),
},
{
key: "connect_error",
any: () => {
const initialized = reduxStore.getState().site.initialized;
if (initialized === true) {
reduxStore.dispatch(siteUpdateOffline(true));
} else {
reduxStore.dispatch(siteUpdateInitialization("Socket.IO Error"));
}
},
},
{
key: "disconnect",
any: bindReduxActionWithParam(siteUpdateOffline, true),
},
{
key: "message",
update: (msg) => {
if (msg) {
const notifications = msg.map<Server.Notification>((message) => ({
message,
type: "info",
id: "backend-message",
timeout: 5 * 1000,
}));
reduxStore.dispatch(siteAddNotifications(notifications));
}
},
},
{
key: "progress",
update: bindReduxAction(siteAddProgress),
delete: bindReduxAction(siteRemoveProgress),
},
{
key: "series",
update: bindReduxAction(seriesMarkDirtyById),
delete: bindReduxAction(seriesRemoveById),
},
{
key: "movie",
update: bindReduxAction(movieMarkDirtyById),
delete: bindReduxAction(movieRemoveById),
},
{
key: "episode",
update: bindReduxAction(episodesMarkDirtyById),
delete: bindReduxAction(episodesRemoveById),
},
{
key: "episode-wanted",
update: bindReduxAction(seriesMarkWantedDirtyById),
delete: bindReduxAction(seriesRemoveWantedById),
},
{
key: "movie-wanted",
update: bindReduxAction(movieMarkWantedDirtyById),
delete: bindReduxAction(movieRemoveWantedById),
},
{
key: "settings",
any: bindReduxAction(systemUpdateAllSettings),
},
{
key: "languages",
any: bindReduxAction(systemUpdateLanguages),
},
{
key: "badges",
any: bindReduxAction(siteUpdateBadges),
},
{
key: "movie-history",
any: bindReduxAction(movieResetHistory),
},
{
key: "movie-blacklist",
any: bindReduxAction(movieMarkBlacklistDirty),
},
{
key: "episode-history",
any: bindReduxAction(episodesResetHistory),
},
{
key: "episode-blacklist",
any: bindReduxAction(episodesMarkBlacklistDirty),
},
{
key: "reset-episode-wanted",
any: bindReduxAction(seriesResetWanted),
},
{
key: "reset-movie-wanted",
any: bindReduxAction(movieResetWanted),
},
{
key: "task",
any: bindReduxAction(systemMarkTasksDirty),
},
];
}

View File

@ -1,4 +1,4 @@
namespace SocketIO { declare namespace SocketIO {
type EventType = NumEventType | NullEventType | SpecialEventType; type EventType = NumEventType | NullEventType | SpecialEventType;
type NumEventType = type NumEventType =
@ -16,6 +16,8 @@ namespace SocketIO {
| "episode-history" | "episode-history"
| "movie-blacklist" | "movie-blacklist"
| "movie-history" | "movie-history"
| "reset-episode-wanted"
| "reset-movie-wanted"
| "badges" | "badges"
| "task" | "task"
| "settings" | "settings"
@ -23,25 +25,26 @@ namespace SocketIO {
type SpecialEventType = "message" | "progress"; type SpecialEventType = "message" | "progress";
type ReducerCreator<E extends EventType, U, D = never> = ValueOf< type ActionType = "update" | "delete";
type ReducerCreator<E extends EventType, U, D = U> = ValueOf<
{ {
[P in E]: { [P in E]: {
key: P; key: P;
any?: () => void; any?: ActionHandler<null>;
update?: ActionFn<T>; update?: ActionHandler<U>;
delete?: ActionFn<D extends never ? T : D>; delete?: ActionHandler<D>;
} & LooseObject; };
// TODO: Typing
} }
>; >;
type Event = { type Event = {
type: EventType; type: EventType;
action: string; action: ActionType;
payload: any; payload: any;
}; };
type ActionFn<T> = (payload?: T[]) => void; type ActionHandler<T> = T extends null ? () => void : (payload: T[]) => void;
type Reducer = type Reducer =
| ReducerCreator<NumEventType, number> | ReducerCreator<NumEventType, number>
@ -49,9 +52,13 @@ namespace SocketIO {
| ReducerCreator<"message", string> | ReducerCreator<"message", string>
| ReducerCreator<"progress", CustomEvent.Progress, string>; | ReducerCreator<"progress", CustomEvent.Progress, string>;
type ActionRecord = OptionalRecord<EventType, StrictObject<any[]>>; type ActionRecord = {
[P in EventType]?: {
[R in ActionType]?: any[];
};
};
namespace CustomEvent { namespace CustomEvent {
type Progress = ReduxStore.Progress; type Progress = Site.Progress;
} }
} }

View File

@ -0,0 +1,17 @@
import BGT from "./";
export function useIsAnyTaskRunning() {
return BGT.isRunning();
}
export function useIsAnyTaskRunningWithId(ids: number[]) {
return BGT.hasId(ids);
}
export function useIsGroupTaskRunning(groupName: string) {
return BGT.has(groupName);
}
export function useIsGroupTaskRunningWithId(groupName: string, id: number) {
return BGT.find(groupName, id);
}

View File

@ -0,0 +1,109 @@
import { keys } from "lodash";
import {
siteAddProgress,
siteRemoveProgress,
siteUpdateNotifier,
siteUpdateProgressCount,
} from "../../@redux/actions";
import store from "../../@redux/store";
// A background task manager, use for dispatching task one by one
class BackgroundTask {
private groups: Task.Group;
constructor() {
this.groups = {};
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
}
private onBeforeUnload(e: BeforeUnloadEvent) {
const message = "Background tasks are still running";
if (Object.keys(this.groups).length !== 0) {
e.preventDefault();
e.returnValue = message;
return;
}
delete e["returnValue"];
}
dispatch<T extends Task.Callable>(groupName: string, tasks: Task.Task<T>[]) {
if (groupName in this.groups) {
this.groups[groupName].push(...tasks);
store.dispatch(
siteUpdateProgressCount({
id: groupName,
count: this.groups[groupName].length,
})
);
return;
}
this.groups[groupName] = tasks;
setTimeout(async () => {
for (let index = 0; index < tasks.length; index++) {
const task = tasks[index];
store.dispatch(
siteAddProgress([
{
id: groupName,
header: groupName,
name: task.name,
value: index,
count: tasks.length,
},
])
);
try {
await task.callable(...task.parameters);
} catch (error) {
// TODO
}
}
delete this.groups[groupName];
store.dispatch(siteRemoveProgress([groupName]));
});
}
find(groupName: string, id: number) {
if (groupName in this.groups) {
return this.groups[groupName].find((v) => v.id === id) !== undefined;
}
return false;
}
has(groupName: string) {
return groupName in this.groups;
}
hasId(ids: number[]) {
for (const id of ids) {
for (const key in this.groups) {
const tasks = this.groups[key];
if (tasks.find((v) => v.id === id) !== undefined) {
return true;
}
}
}
return false;
}
isRunning() {
return keys(this.groups).length > 0;
}
}
const BGT = new BackgroundTask();
export default BGT;
export function dispatchTask<T extends Task.Callable>(
groupName: string,
tasks: Task.Task<T>[],
comment?: string
) {
BGT.dispatch(groupName, tasks);
if (comment) {
store.dispatch(siteUpdateNotifier(comment));
}
}

14
frontend/src/@modules/task/task.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
declare namespace Task {
type Callable = (...args: any[]) => Promise<void>;
interface Task<FN extends Callable> {
name: string;
id?: number;
callable: FN;
parameters: Parameters<FN>;
}
type Group = {
[category: string]: Task.Task<Callable>[];
};
}

View File

@ -0,0 +1,13 @@
export function createTask<T extends Task.Callable>(
name: string,
id: number | undefined,
callable: T,
...parameters: Parameters<T>
): Task.Task<T> {
return {
name,
id,
callable,
parameters,
};
}

View File

@ -0,0 +1,406 @@
import {
configureStore,
createAction,
createAsyncThunk,
createReducer,
} from "@reduxjs/toolkit";
import {} from "jest";
import { differenceWith, intersectionWith, isString, uniq } from "lodash";
import { defaultList, defaultState, TestType } from "../tests/helper";
import { createAsyncEntityReducer } from "../utils/factory";
const newItem: TestType = {
id: 123,
name: "extended",
};
const longerList: TestType[] = [...defaultList, newItem];
const shorterList: TestType[] = defaultList.slice(0, defaultList.length - 1);
const allResolved = createAsyncThunk("all/resolved", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({ total: defaultList.length, data: defaultList });
});
});
const allResolvedLonger = createAsyncThunk("all/longer/resolved", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({ total: longerList.length, data: longerList });
});
});
const allResolvedShorter = createAsyncThunk("all/shorter/resolved", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({ total: shorterList.length, data: shorterList });
});
});
const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: defaultList.length,
data: intersectionWith(defaultList, param, (l, r) => l.id === r),
});
});
});
const idsResolvedLonger = createAsyncThunk(
"ids/longer/resolved",
(param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: longerList.length,
data: intersectionWith(longerList, param, (l, r) => l.id === r),
});
});
}
);
const idsResolvedShorter = createAsyncThunk(
"ids/shorter/resolved",
(param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: shorterList.length,
data: intersectionWith(shorterList, param, (l, r) => l.id === r),
});
});
}
);
const rangeResolved = createAsyncThunk(
"range/resolved",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: defaultList.length,
data: defaultList.slice(param.start, param.start + param.length),
});
});
}
);
const rangeResolvedLonger = createAsyncThunk(
"range/longer/resolved",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: longerList.length,
data: longerList.slice(param.start, param.start + param.length),
});
});
}
);
const rangeResolvedShorter = createAsyncThunk(
"range/shorter/resolved",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: shorterList.length,
data: shorterList.slice(param.start, param.start + param.length),
});
});
}
);
const allRejected = createAsyncThunk("all/rejected", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
rejected("Error");
});
});
const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
rejected("Error");
});
});
const rangeRejected = createAsyncThunk(
"range/rejected",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
rejected("Error");
});
}
);
const removeIds = createAction<number[]>("remove/id");
const dirty = createAction<number[]>("dirty/id");
const reset = createAction("reset");
const reducer = createReducer(defaultState, (builder) => {
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allResolved,
range: rangeResolved,
ids: idsResolved,
dirty,
removeIds,
reset,
});
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allRejected,
range: rangeRejected,
ids: idsRejected,
});
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allResolvedLonger,
range: rangeResolvedLonger,
ids: idsResolvedLonger,
});
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allResolvedShorter,
range: rangeResolvedShorter,
ids: idsResolvedShorter,
});
});
function createStore() {
const store = configureStore({
reducer,
});
expect(store.getState()).toEqual(defaultState);
return store;
}
let store = createStore();
function use(callback: (entities: Async.Entity<TestType>) => void) {
const entities = store.getState().entities;
callback(entities);
}
beforeEach(() => {
store = createStore();
});
it("entity update all resolved", async () => {
await store.dispatch(allResolved());
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
defaultList.forEach((v, index) => {
const id = v.id.toString();
expect(entities.content.ids[index]).toEqual(id);
expect(entities.content.entities[id]).toEqual(v);
expect(entities.didLoaded).toContain(id);
});
});
});
it("entity update all rejected", async () => {
await store.dispatch(allRejected());
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.error).not.toBeNull();
expect(entities.state).toBe("failed");
expect(entities.content.ids).toHaveLength(0);
expect(entities.content.entities).toEqual({});
});
});
it("entity reset", async () => {
await store.dispatch(allResolved());
store.dispatch(reset());
use((entities) => {
expect(entities).toEqual(defaultState.entities);
});
});
it("entity mark dirty", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty([1, 2, 3]));
use((entities) => {
expect(entities.error).toBeNull();
expect(entities.state).toBe("dirty");
defaultList.forEach((v, index) => {
const id = v.id.toString();
expect(entities.content.ids[index]).toEqual(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
});
it("delete entity item", async () => {
await store.dispatch(allResolved());
const idsToRemove = [0, 1, 3, 5];
const expectResults = differenceWith(
defaultList,
idsToRemove,
(l, r) => l.id === r
);
store.dispatch(removeIds(idsToRemove));
use((entities) => {
expect(entities.state).toBe("succeeded");
idsToRemove.map(String).forEach((v) => {
expect(entities.didLoaded).not.toContain(v);
});
expectResults.forEach((v, index) => {
const id = v.id.toString();
expect(entities.content.ids[index]).toEqual(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
});
it("entity update by range", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
await store.dispatch(rangeResolved({ start: 4, length: 2 }));
use((entities) => {
expect(entities.content.ids).toHaveLength(defaultList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(4);
[0, 1, 4, 5].forEach((v) => {
const id = v.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id].id).toEqual(v);
expect(entities.didLoaded).toContain(id);
});
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
});
});
it("entity update by duplicative range", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
await store.dispatch(rangeResolved({ start: 1, length: 2 }));
use((entities) => {
expect(entities.content.ids).toHaveLength(defaultList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(3);
defaultList.slice(0, 3).forEach((v) => {
const id = v.id.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id]).toEqual(v);
expect(entities.didLoaded.filter((v) => v === id)).toHaveLength(1);
});
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
});
});
it("entity update by range and ids", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
await store.dispatch(idsResolved([3]));
await store.dispatch(rangeResolved({ start: 2, length: 2 }));
use((entries) => {
const ids = entries.content.ids.filter(isString);
const dedupIds = uniq(ids);
expect(ids.length).toBe(dedupIds.length);
});
});
it("entity resolved by dirty", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
store.dispatch(dirty([1, 2, 3]));
await store.dispatch(rangeResolved({ start: 1, length: 2 }));
use((entities) => {
expect(entities.dirtyEntities).not.toContain("1");
expect(entities.dirtyEntities).not.toContain("2");
expect(entities.dirtyEntities).toContain("3");
expect(entities.state).toBe("dirty");
});
await store.dispatch(rangeResolved({ start: 1, length: 3 }));
use((entities) => {
expect(entities.dirtyEntities).not.toContain("1");
expect(entities.dirtyEntities).not.toContain("2");
expect(entities.dirtyEntities).not.toContain("3");
expect(entities.state).toBe("succeeded");
});
});
it("entity update by ids", async () => {
await store.dispatch(idsResolved([999]));
use((entities) => {
expect(entities.content.ids).toHaveLength(defaultList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(0);
expect(entities.content.entities).not.toHaveProperty("999");
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
});
});
it("entity resolved dirty by ids", async () => {
await store.dispatch(idsResolved([0, 1, 2, 3, 4]));
store.dispatch(dirty([0, 1, 2, 3]));
await store.dispatch(idsResolved([0, 1]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(2);
expect(entities.content.ids.filter(isString)).toHaveLength(5);
expect(entities.error).toBeNull();
expect(entities.state).toBe("dirty");
});
});
it("entity resolved non-exist by ids", async () => {
await store.dispatch(idsResolved([0, 1]));
store.dispatch(dirty([999]));
await store.dispatch(idsResolved([999]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
});
});
it("entity update by variant range", async () => {
await store.dispatch(allResolved());
await store.dispatch(rangeResolvedLonger({ start: 0, length: 2 }));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(longerList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(2);
longerList.slice(0, 2).forEach((v) => {
const id = v.id.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
await store.dispatch(allResolved());
await store.dispatch(rangeResolvedShorter({ start: 0, length: 2 }));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(shorterList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(2);
shorterList.slice(0, 2).forEach((v) => {
const id = v.id.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
});
it("entity update by variant ids", async () => {
await store.dispatch(allResolved());
await store.dispatch(idsResolvedLonger([2, 3, 4]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(longerList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(3);
Array(3)
.fill(undefined)
.forEach((v) => {
expect(entities.content.ids[v]).not.toBeNull();
});
});
await store.dispatch(allResolved());
await store.dispatch(idsResolvedShorter([2, 3, 4]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(shorterList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(3);
Array(3)
.fill(undefined)
.forEach((v) => {
expect(entities.content.ids[v]).not.toBeNull();
});
});
});

View File

@ -0,0 +1,155 @@
import {
configureStore,
createAction,
createAsyncThunk,
createReducer,
} from "@reduxjs/toolkit";
import {} from "jest";
import { defaultState, TestType } from "../tests/helper";
import { createAsyncItemReducer } from "../utils/factory";
// Item
const defaultItem: TestType = { id: 0, name: "test" };
const allResolved = createAsyncThunk("all/resolved", () => {
return new Promise<TestType>((resolve) => {
resolve(defaultItem);
});
});
const allRejected = createAsyncThunk("all/rejected", () => {
return new Promise<TestType>((resolve, rejected) => {
rejected("Error");
});
});
const dirty = createAction("dirty/ids");
const reducer = createReducer(defaultState, (builder) => {
createAsyncItemReducer(builder, (s) => s.item, { all: allResolved, dirty });
createAsyncItemReducer(builder, (s) => s.item, { all: allRejected });
});
function createStore() {
const store = configureStore({
reducer,
});
expect(store.getState()).toEqual(defaultState);
return store;
}
let store = createStore();
function use(callback: (entities: Async.Item<TestType>) => void) {
const item = store.getState().item;
callback(item);
}
// Begin Test Section
beforeEach(() => {
store = createStore();
});
it("item loading", async () => {
return new Promise<void>((done) => {
store.dispatch(allResolved()).finally(() => {
use((item) => {
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
done();
});
use((item) => {
expect(item.state).toBe("loading");
expect(item.error).toBeNull();
expect(item.content).toBeNull();
});
});
});
it("item uninitialized -> succeeded", async () => {
await store.dispatch(allResolved());
use((item) => {
expect(item.state).toBe("succeeded");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item uninitialized -> failed", async () => {
await store.dispatch(allRejected());
use((item) => {
expect(item.state).toBe("failed");
expect(item.error).not.toBeNull();
expect(item.content).toBeNull();
});
});
it("item uninitialized -> dirty", () => {
store.dispatch(dirty());
use((item) => {
expect(item.state).toBe("uninitialized");
expect(item.error).toBeNull();
expect(item.content).toBeNull();
});
});
it("item succeeded -> failed", async () => {
await store.dispatch(allResolved());
await store.dispatch(allRejected());
use((item) => {
expect(item.state).toBe("failed");
expect(item.error).not.toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item failed -> succeeded", async () => {
await store.dispatch(allRejected());
await store.dispatch(allResolved());
use((item) => {
expect(item.state).toBe("succeeded");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item succeeded -> dirty", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty());
use((item) => {
expect(item.state).toBe("dirty");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item failed -> dirty", async () => {
await store.dispatch(allRejected());
store.dispatch(dirty());
use((item) => {
expect(item.state).toBe("dirty");
expect(item.error).not.toBeNull();
expect(item.content).toBeNull();
});
});
it("item dirty -> failed", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty());
await store.dispatch(allRejected());
use((item) => {
expect(item.state).toBe("failed");
expect(item.error).not.toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item dirty -> succeeded", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty());
await store.dispatch(allResolved());
use((item) => {
expect(item.state).toBe("succeeded");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});

View File

@ -0,0 +1,252 @@
import {
configureStore,
createAction,
createAsyncThunk,
createReducer,
} from "@reduxjs/toolkit";
import {} from "jest";
import { intersectionWith } from "lodash";
import { defaultList, defaultState, TestType } from "../tests/helper";
import { createAsyncListReducer } from "../utils/factory";
const allResolved = createAsyncThunk("all/resolved", () => {
return new Promise<TestType[]>((resolve) => {
resolve(defaultList);
});
});
const allRejected = createAsyncThunk("all/rejected", () => {
return new Promise<TestType[]>((resolve, rejected) => {
rejected("Error");
});
});
const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => {
return new Promise<TestType[]>((resolve) => {
resolve(intersectionWith(defaultList, param, (l, r) => l.id === r));
});
});
const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => {
return new Promise<TestType[]>((resolve, rejected) => {
rejected("Error");
});
});
const removeIds = createAction<number[]>("remove/id");
const dirty = createAction<number[]>("dirty/id");
const reducer = createReducer(defaultState, (builder) => {
createAsyncListReducer(builder, (s) => s.list, {
all: allResolved,
ids: idsResolved,
removeIds,
dirty,
});
createAsyncListReducer(builder, (s) => s.list, {
all: allRejected,
ids: idsRejected,
});
});
function createStore() {
const store = configureStore({
reducer,
});
expect(store.getState()).toEqual(defaultState);
return store;
}
let store = createStore();
function use(callback: (list: Async.List<TestType>) => void) {
const list = store.getState().list;
callback(list);
}
beforeEach(() => {
store = createStore();
});
it("list all uninitialized -> succeeded", async () => {
await store.dispatch(allResolved());
use((list) => {
expect(list.content).toEqual(defaultList);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.didLoaded).toHaveLength(defaultList.length);
expect(list.error).toBeNull();
expect(list.state).toEqual("succeeded");
});
});
it("list all uninitialized -> failed", async () => {
await store.dispatch(allRejected());
use((list) => {
expect(list.content).toHaveLength(0);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).not.toBeNull();
expect(list.state).toEqual("failed");
});
});
it("list uninitialized -> dirty", () => {
store.dispatch(dirty([0, 1]));
use((list) => {
expect(list.content).toHaveLength(0);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).toBeNull();
expect(list.state).toEqual("uninitialized");
});
});
it("list succeeded -> dirty", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty([1, 2, 3]));
use((list) => {
expect(list.content).toEqual(defaultList);
expect(list.dirtyEntities).toHaveLength(3);
expect(list.error).toBeNull();
expect(list.state).toEqual("dirty");
});
});
it("list ids uninitialized -> succeeded", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.didLoaded).toHaveLength(3);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).toBeNull();
expect(list.state).toEqual("succeeded");
});
});
it("list ids succeeded -> dirty", async () => {
await store.dispatch(idsResolved([0, 1]));
store.dispatch(dirty([2, 3]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(2);
expect(list.state).toEqual("dirty");
});
});
it("list ids succeeded -> dirty", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
store.dispatch(dirty([2, 3]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(2);
expect(list.state).toEqual("dirty");
});
});
it("list ids update data", async () => {
await store.dispatch(idsResolved([0, 1]));
await store.dispatch(idsResolved([3, 4]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.state).toEqual("succeeded");
});
});
it("list ids update duplicative data", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
await store.dispatch(idsResolved([2, 3]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.didLoaded).toHaveLength(4);
expect(list.state).toEqual("succeeded");
});
});
it("list ids update new data", async () => {
await store.dispatch(idsResolved([0, 1]));
await store.dispatch(idsResolved([2, 3]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.didLoaded).toHaveLength(4);
expect(list.content[1].id).toBe(2);
expect(list.content[0].id).toBe(3);
expect(list.state).toEqual("succeeded");
});
});
it("list ids empty data", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
await store.dispatch(idsResolved([999]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.state).toEqual("succeeded");
});
});
it("list ids duplicative dirty", async () => {
await store.dispatch(idsResolved([0]));
store.dispatch(dirty([2, 2]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(1);
expect(list.dirtyEntities).toContain("2");
expect(list.state).toEqual("dirty");
});
});
it("list ids resolved dirty", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
store.dispatch(dirty([2, 3]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.dirtyEntities).toContain("2");
expect(list.dirtyEntities).toContain("3");
expect(list.state).toBe("dirty");
});
});
it("list ids resolved dirty", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
store.dispatch(dirty([1, 2, 3, 999]));
await store.dispatch(idsResolved([1, 2]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.dirtyEntities).not.toContain("1");
expect(list.dirtyEntities).not.toContain("2");
expect(list.state).toBe("dirty");
});
await store.dispatch(idsResolved([3]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.dirtyEntities).not.toContain("3");
expect(list.state).toBe("dirty");
});
await store.dispatch(idsResolved([999]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.dirtyEntities).not.toContain("999");
expect(list.state).toBe("succeeded");
});
});
it("list remove ids", async () => {
await store.dispatch(allResolved());
const totalSize = store.getState().list.content.length;
store.dispatch(removeIds([1, 2]));
use((list) => {
expect(list.content).toHaveLength(totalSize - 2);
expect(list.content.map((v) => v.id)).not.toContain(1);
expect(list.content.map((v) => v.id)).not.toContain(2);
expect(list.state).toEqual("succeeded");
});
});
it("list remove dirty ids", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty([1, 2, 3]));
store.dispatch(removeIds([1, 2]));
use((list) => {
expect(list.dirtyEntities).not.toContain("1");
expect(list.dirtyEntities).not.toContain("2");
expect(list.state).toEqual("dirty");
});
store.dispatch(removeIds([3]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(0);
expect(list.state).toEqual("succeeded");
});
});

View File

@ -1,123 +0,0 @@
import {
ActionCallback,
ActionDispatcher,
AsyncActionCreator,
AsyncActionDispatcher,
AvailableCreator,
AvailableType,
PromiseCreator,
} from "../types";
function asyncActionFactory<T extends PromiseCreator>(
type: string,
promise: T,
args: Parameters<T>
): AsyncActionDispatcher<PromiseType<ReturnType<T>>> {
return (dispatch) => {
dispatch({
type,
payload: {
loading: true,
parameters: args,
},
});
return new Promise((resolve, reject) => {
promise(...args)
.then((val) => {
dispatch({
type,
payload: {
loading: false,
item: val,
parameters: args,
},
});
resolve();
})
.catch((err) => {
dispatch({
type,
error: true,
payload: {
loading: false,
item: err,
parameters: args,
},
});
reject(err);
});
});
};
}
export function createAsyncAction<T extends PromiseCreator>(
type: string,
promise: T
) {
return (...args: Parameters<T>) => asyncActionFactory(type, promise, args);
}
// Create a action which combine multiple ActionDispatcher and execute them at once
function combineActionFactory(
dispatchers: AvailableType<any>[]
): ActionDispatcher {
return (dispatch) => {
dispatchers.forEach((fn) => {
if (typeof fn === "function") {
fn(dispatch);
} else {
dispatch(fn);
}
});
};
}
export function createCombineAction<T extends AvailableCreator>(fn: T) {
return (...args: Parameters<T>) => combineActionFactory(fn(...args));
}
function combineAsyncActionFactory(
dispatchers: AsyncActionDispatcher<any>[]
): AsyncActionDispatcher<any> {
return (dispatch) => {
const promises = dispatchers.map((v) => v(dispatch));
return Promise.all(promises) as Promise<any>;
};
}
export function createAsyncCombineAction<T extends AsyncActionCreator>(fn: T) {
return (...args: Parameters<T>) => combineAsyncActionFactory(fn(...args));
}
export function callbackActionFactory(
dispatchers: AsyncActionDispatcher<any>[],
success: ActionCallback,
error?: ActionCallback
): ActionDispatcher<any> {
return (dispatch) => {
const promises = dispatchers.map((v) => v(dispatch));
Promise.all(promises)
.then(() => {
const action = success();
if (action !== undefined) {
dispatch(action);
}
})
.catch(() => {
const action = error && error();
if (action !== undefined) {
dispatch(action);
}
});
};
}
export function createCallbackAction<T extends AsyncActionCreator>(
fn: T,
success: ActionCallback,
error?: ActionCallback
) {
return (...args: Parameters<T>) =>
callbackActionFactory(fn(args), success, error);
}

View File

@ -1,47 +1,84 @@
import { createDeleteAction } from "../../@socketio/reducer"; import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { MoviesApi } from "../../apis"; import { MoviesApi } from "../../apis";
import {
MOVIES_DELETE_ITEMS,
MOVIES_DELETE_WANTED_ITEMS,
MOVIES_UPDATE_BLACKLIST,
MOVIES_UPDATE_HISTORY_LIST,
MOVIES_UPDATE_LIST,
MOVIES_UPDATE_WANTED_LIST,
} from "../constants";
import { createAsyncAction } from "./factory";
export const movieUpdateList = createAsyncAction( export const movieUpdateByRange = createAsyncThunk(
MOVIES_UPDATE_LIST, "movies/update/range",
(id?: number[]) => MoviesApi.movies(id) async (params: Parameter.Range) => {
const response = await MoviesApi.moviesBy(params);
return response;
}
); );
export const movieDeleteItems = createDeleteAction(MOVIES_DELETE_ITEMS); export const movieUpdateById = createAsyncThunk(
"movies/update/id",
export const movieUpdateWantedList = createAsyncAction( async (ids: number[]) => {
MOVIES_UPDATE_WANTED_LIST, const response = await MoviesApi.movies(ids);
(radarrid: number[]) => MoviesApi.wantedBy(radarrid) return response;
}
); );
export const movieDeleteWantedItems = createDeleteAction( export const movieUpdateAll = createAsyncThunk(
MOVIES_DELETE_WANTED_ITEMS "movies/update/all",
async () => {
const response = await MoviesApi.movies();
return response;
}
); );
export const movieUpdateWantedByRange = createAsyncAction( export const movieRemoveById = createAction<number[]>("movies/remove");
MOVIES_UPDATE_WANTED_LIST,
(start: number, length: number) => MoviesApi.wanted(start, length) export const movieMarkDirtyById = createAction<number[]>(
"movies/mark_dirty/id"
); );
export const movieUpdateHistoryList = createAsyncAction( export const movieUpdateWantedById = createAsyncThunk(
MOVIES_UPDATE_HISTORY_LIST, "movies/wanted/update/id",
() => MoviesApi.history() async (ids: number[]) => {
const response = await MoviesApi.wantedBy(ids);
return response;
}
); );
export const movieUpdateByRange = createAsyncAction( export const movieRemoveWantedById = createAction<number[]>(
MOVIES_UPDATE_LIST, "movies/wanted/remove/id"
(start: number, length: number) => MoviesApi.moviesBy(start, length)
); );
export const movieUpdateBlacklist = createAsyncAction( export const movieResetWanted = createAction("movies/wanted/reset");
MOVIES_UPDATE_BLACKLIST,
() => MoviesApi.blacklist() export const movieMarkWantedDirtyById = createAction<number[]>(
"movies/wanted/mark_dirty/id"
);
export const movieUpdateWantedByRange = createAsyncThunk(
"movies/wanted/update/range",
async (params: Parameter.Range) => {
const response = await MoviesApi.wanted(params);
return response;
}
);
export const movieUpdateHistoryByRange = createAsyncThunk(
"movies/history/update/range",
async (params: Parameter.Range) => {
const response = await MoviesApi.history(params);
return response;
}
);
export const movieMarkHistoryDirty = createAction<number[]>(
"movies/history/mark_dirty"
);
export const movieResetHistory = createAction("movie/history/reset");
export const movieUpdateBlacklist = createAsyncThunk(
"movies/blacklist/update",
async () => {
const response = await MoviesApi.blacklist();
return response;
}
);
export const movieMarkBlacklistDirty = createAction(
"movies/blacklist/mark_dirty"
); );

View File

@ -1,61 +1,106 @@
import { createDeleteAction } from "../../@socketio/reducer"; import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { EpisodesApi, SeriesApi } from "../../apis"; import { EpisodesApi, SeriesApi } from "../../apis";
import {
SERIES_DELETE_EPISODES,
SERIES_DELETE_ITEMS,
SERIES_DELETE_WANTED_ITEMS,
SERIES_UPDATE_BLACKLIST,
SERIES_UPDATE_EPISODE_LIST,
SERIES_UPDATE_HISTORY_LIST,
SERIES_UPDATE_LIST,
SERIES_UPDATE_WANTED_LIST,
} from "../constants";
import { createAsyncAction } from "./factory";
export const seriesUpdateWantedList = createAsyncAction( export const seriesUpdateWantedById = createAsyncThunk(
SERIES_UPDATE_WANTED_LIST, "series/wanted/update/id",
(episodeid: number[]) => EpisodesApi.wantedBy(episodeid) async (episodeid: number[]) => {
const response = await EpisodesApi.wantedBy(episodeid);
return response;
}
); );
export const seriesDeleteWantedItems = createDeleteAction( export const seriesUpdateWantedByRange = createAsyncThunk(
SERIES_DELETE_WANTED_ITEMS "series/wanted/update/range",
async (params: Parameter.Range) => {
const response = await EpisodesApi.wanted(params);
return response;
}
); );
export const seriesUpdateWantedByRange = createAsyncAction( export const seriesRemoveWantedById = createAction<number[]>(
SERIES_UPDATE_WANTED_LIST, "series/wanted/remove/id"
(start: number, length: number) => EpisodesApi.wanted(start, length)
); );
export const seriesUpdateList = createAsyncAction( export const seriesResetWanted = createAction("series/wanted/reset");
SERIES_UPDATE_LIST,
(id?: number[]) => SeriesApi.series(id) export const seriesMarkWantedDirtyById = createAction<number[]>(
"series/wanted/mark_dirty/episode_id"
); );
export const seriesDeleteItems = createDeleteAction(SERIES_DELETE_ITEMS); export const seriesRemoveById = createAction<number[]>("series/remove");
export const episodeUpdateBy = createAsyncAction( export const seriesMarkDirtyById = createAction<number[]>(
SERIES_UPDATE_EPISODE_LIST, "series/mark_dirty/id"
(seriesid: number[]) => EpisodesApi.bySeriesId(seriesid)
); );
export const episodeDeleteItems = createDeleteAction(SERIES_DELETE_EPISODES); export const seriesUpdateById = createAsyncThunk(
"series/update/id",
export const episodeUpdateById = createAsyncAction( async (ids: number[]) => {
SERIES_UPDATE_EPISODE_LIST, const response = await SeriesApi.series(ids);
(episodeid: number[]) => EpisodesApi.byEpisodeId(episodeid) return response;
}
); );
export const seriesUpdateByRange = createAsyncAction( export const seriesUpdateAll = createAsyncThunk(
SERIES_UPDATE_LIST, "series/update/all",
(start: number, length: number) => SeriesApi.seriesBy(start, length) async () => {
const response = await SeriesApi.series();
return response;
}
); );
export const seriesUpdateHistoryList = createAsyncAction( export const seriesUpdateByRange = createAsyncThunk(
SERIES_UPDATE_HISTORY_LIST, "series/update/range",
() => EpisodesApi.history() async (params: Parameter.Range) => {
const response = await SeriesApi.seriesBy(params);
return response;
}
); );
export const seriesUpdateBlacklist = createAsyncAction( export const episodesRemoveById = createAction<number[]>("episodes/remove");
SERIES_UPDATE_BLACKLIST,
() => EpisodesApi.blacklist() export const episodesMarkDirtyById = createAction<number[]>(
"episodes/mark_dirty/id"
);
export const episodeUpdateBySeriesId = createAsyncThunk(
"episodes/update/series_id",
async (seriesid: number[]) => {
const response = await EpisodesApi.bySeriesId(seriesid);
return response;
}
);
export const episodeUpdateById = createAsyncThunk(
"episodes/update/episodes_id",
async (episodeid: number[]) => {
const response = await EpisodesApi.byEpisodeId(episodeid);
return response;
}
);
export const episodesUpdateHistoryByRange = createAsyncThunk(
"episodes/history/update/range",
async (param: Parameter.Range) => {
const response = await EpisodesApi.history(param);
return response;
}
);
export const episodesMarkHistoryDirty = createAction<number[]>(
"episodes/history/update"
);
export const episodesResetHistory = createAction("episodes/history/reset");
export const episodesUpdateBlacklist = createAsyncThunk(
"episodes/blacklist/update",
async () => {
const response = await EpisodesApi.blacklist();
return response;
}
);
export const episodesMarkBlacklistDirty = createAction(
"episodes/blacklist/update"
); );

View File

@ -1,63 +1,60 @@
import { createAction } from "redux-actions"; import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { BadgesApi } from "../../apis"; import { BadgesApi } from "../../apis";
import { import { waitFor } from "../../utilities";
SITE_BADGE_UPDATE, import { systemUpdateAllSettings } from "./system";
SITE_INITIALIZED,
SITE_INITIALIZE_FAILED,
SITE_NEED_AUTH,
SITE_NOTIFICATIONS_ADD,
SITE_NOTIFICATIONS_REMOVE,
SITE_OFFLINE_UPDATE,
SITE_PROGRESS_ADD,
SITE_PROGRESS_REMOVE,
SITE_SIDEBAR_UPDATE,
} from "../constants";
import { createAsyncAction, createCallbackAction } from "./factory";
import { systemUpdateLanguagesAll, systemUpdateSettings } from "./system";
export const bootstrap = createCallbackAction( export const siteBootstrap = createAsyncThunk(
() => [systemUpdateLanguagesAll(), systemUpdateSettings(), badgeUpdateAll()], "site/bootstrap",
() => siteInitialized(), (_: undefined, { dispatch }) => {
() => siteInitializationFailed() return Promise.all([
dispatch(systemUpdateAllSettings()),
dispatch(siteUpdateBadges()),
]);
}
); );
// TODO: Override error messages export const siteUpdateInitialization = createAction<string | true>(
export const siteInitializationFailed = createAction(SITE_INITIALIZE_FAILED); "site/initialization/update"
const siteInitialized = createAction(SITE_INITIALIZED);
export const siteRedirectToAuth = createAction(SITE_NEED_AUTH);
export const badgeUpdateAll = createAsyncAction(SITE_BADGE_UPDATE, () =>
BadgesApi.all()
); );
export const siteAddNotifications = createAction( export const siteRedirectToAuth = createAction("site/redirect_auth");
SITE_NOTIFICATIONS_ADD,
(notification: ReduxStore.Notification[]) => notification export const siteAddNotifications = createAction<Server.Notification[]>(
"site/notifications/add"
); );
export const siteRemoveNotifications = createAction( export const siteRemoveNotifications = createAction<string>(
SITE_NOTIFICATIONS_REMOVE, "site/notifications/remove"
(id: string) => id
); );
export const siteAddProgress = createAction( export const siteAddProgress =
SITE_PROGRESS_ADD, createAction<Site.Progress[]>("site/progress/add");
(progress: ReduxStore.Progress[]) => progress
export const siteUpdateProgressCount = createAction<{
id: string;
count: number;
}>("site/progress/update_count");
export const siteRemoveProgress = createAsyncThunk(
"site/progress/remove",
async (ids: string[]) => {
await waitFor(3 * 1000);
return ids;
}
); );
export const siteRemoveProgress = createAction( export const siteUpdateNotifier = createAction<string>(
SITE_PROGRESS_REMOVE, "site/progress/update_notifier"
(id: string) => id
); );
export const siteChangeSidebar = createAction( export const siteChangeSidebar = createAction<string>("site/sidebar/update");
SITE_SIDEBAR_UPDATE,
(id: string) => id
);
export const siteUpdateOffline = createAction( export const siteUpdateOffline = createAction<boolean>("site/offline/update");
SITE_OFFLINE_UPDATE,
(state: boolean) => state export const siteUpdateBadges = createAsyncThunk(
"site/badges/update",
async () => {
const response = await BadgesApi.all();
return response;
}
); );

View File

@ -1,64 +1,87 @@
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { ProvidersApi, SystemApi } from "../../apis"; import { ProvidersApi, SystemApi } from "../../apis";
import {
SYSTEM_UPDATE_HEALTH,
SYSTEM_UPDATE_LANGUAGES_LIST,
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
SYSTEM_UPDATE_LOGS,
SYSTEM_UPDATE_PROVIDERS,
SYSTEM_UPDATE_RELEASES,
SYSTEM_UPDATE_SETTINGS,
SYSTEM_UPDATE_STATUS,
SYSTEM_UPDATE_TASKS,
} from "../constants";
import { createAsyncAction, createAsyncCombineAction } from "./factory";
export const systemUpdateLanguagesAll = createAsyncCombineAction(() => [ export const systemUpdateAllSettings = createAsyncThunk(
systemUpdateLanguages(), "system/update",
systemUpdateLanguagesProfiles(), async (_: undefined, { dispatch }) => {
]); await Promise.all([
dispatch(systemUpdateSettings()),
export const systemUpdateLanguages = createAsyncAction( dispatch(systemUpdateLanguages()),
SYSTEM_UPDATE_LANGUAGES_LIST, dispatch(systemUpdateLanguagesProfiles()),
() => SystemApi.languages() ]);
}
); );
export const systemUpdateLanguagesProfiles = createAsyncAction( export const systemUpdateLanguages = createAsyncThunk(
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST, "system/languages/update",
() => SystemApi.languagesProfileList() async () => {
const response = await SystemApi.languages();
return response;
}
); );
export const systemUpdateStatus = createAsyncAction(SYSTEM_UPDATE_STATUS, () => export const systemUpdateLanguagesProfiles = createAsyncThunk(
SystemApi.status() "system/languages/profile/update",
async () => {
const response = await SystemApi.languagesProfileList();
return response;
}
); );
export const systemUpdateHealth = createAsyncAction(SYSTEM_UPDATE_HEALTH, () => export const systemUpdateStatus = createAsyncThunk(
SystemApi.health() "system/status/update",
async () => {
const response = await SystemApi.status();
return response;
}
); );
export const systemUpdateTasks = createAsyncAction(SYSTEM_UPDATE_TASKS, () => export const systemUpdateHealth = createAsyncThunk(
SystemApi.getTasks() "system/health/update",
async () => {
const response = await SystemApi.health();
return response;
}
); );
export const systemUpdateLogs = createAsyncAction(SYSTEM_UPDATE_LOGS, () => export const systemMarkTasksDirty = createAction("system/tasks/mark_dirty");
SystemApi.logs()
export const systemUpdateTasks = createAsyncThunk(
"system/tasks/update",
async () => {
const response = await SystemApi.tasks();
return response;
}
); );
export const systemUpdateReleases = createAsyncAction( export const systemUpdateLogs = createAsyncThunk(
SYSTEM_UPDATE_RELEASES, "system/logs/update",
() => SystemApi.releases() async () => {
const response = await SystemApi.logs();
return response;
}
); );
export const systemUpdateSettings = createAsyncAction( export const systemUpdateReleases = createAsyncThunk(
SYSTEM_UPDATE_SETTINGS, "system/releases/update",
() => SystemApi.settings() async () => {
const response = await SystemApi.releases();
return response;
}
); );
export const providerUpdateList = createAsyncAction( export const systemUpdateSettings = createAsyncThunk(
SYSTEM_UPDATE_PROVIDERS, "system/settings/update",
() => ProvidersApi.providers() async () => {
const response = await SystemApi.settings();
return response;
}
); );
export const systemUpdateSettingsAll = createAsyncCombineAction(() => [ export const providerUpdateList = createAsyncThunk(
systemUpdateSettings(), "providers/update",
systemUpdateLanguagesAll(), async () => {
]); const response = await ProvidersApi.providers();
return response;
}
);

View File

@ -1,43 +0,0 @@
// Provider action
// System action
export const SYSTEM_UPDATE_LANGUAGES_LIST = "UPDATE_ALL_LANGUAGES_LIST";
export const SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST =
"UPDATE_LANGUAGES_PROFILE_LIST";
export const SYSTEM_UPDATE_STATUS = "UPDATE_SYSTEM_STATUS";
export const SYSTEM_UPDATE_HEALTH = "UPDATE_SYSTEM_HEALTH";
export const SYSTEM_UPDATE_TASKS = "UPDATE_SYSTEM_TASKS";
export const SYSTEM_UPDATE_LOGS = "UPDATE_SYSTEM_LOGS";
export const SYSTEM_UPDATE_RELEASES = "SYSTEM_UPDATE_RELEASES";
export const SYSTEM_UPDATE_SETTINGS = "UPDATE_SYSTEM_SETTINGS";
export const SYSTEM_UPDATE_PROVIDERS = "SYSTEM_UPDATE_PROVIDERS";
// Series action
export const SERIES_UPDATE_WANTED_LIST = "UPDATE_SERIES_WANTED_LIST";
export const SERIES_DELETE_WANTED_ITEMS = "SERIES_DELETE_WANTED_ITEMS";
export const SERIES_UPDATE_EPISODE_LIST = "UPDATE_SERIES_EPISODE_LIST";
export const SERIES_DELETE_EPISODES = "SERIES_DELETE_EPISODES";
export const SERIES_UPDATE_HISTORY_LIST = "UPDATE_SERIES_HISTORY_LIST";
export const SERIES_UPDATE_LIST = "UPDATE_SEIRES_LIST";
export const SERIES_DELETE_ITEMS = "SERIES_DELETE_ITEMS";
export const SERIES_UPDATE_BLACKLIST = "UPDATE_SERIES_BLACKLIST";
// Movie action
export const MOVIES_UPDATE_LIST = "UPDATE_MOVIE_LIST";
export const MOVIES_DELETE_ITEMS = "MOVIES_DELETE_ITEMS";
export const MOVIES_UPDATE_WANTED_LIST = "UPDATE_MOVIE_WANTED_LIST";
export const MOVIES_DELETE_WANTED_ITEMS = "MOVIES_DELETE_WANTED_ITEMS";
export const MOVIES_UPDATE_HISTORY_LIST = "UPDATE_MOVIE_HISTORY_LIST";
export const MOVIES_UPDATE_BLACKLIST = "UPDATE_MOVIES_BLACKLIST";
// Site Action
export const SITE_NEED_AUTH = "SITE_NEED_AUTH";
export const SITE_INITIALIZED = "SITE_SYSTEM_INITIALIZED";
export const SITE_INITIALIZE_FAILED = "SITE_INITIALIZE_FAILED";
export const SITE_NOTIFICATIONS_ADD = "SITE_NOTIFICATIONS_ADD";
export const SITE_NOTIFICATIONS_REMOVE = "SITE_NOTIFICATIONS_REMOVE";
export const SITE_PROGRESS_ADD = "SITE_PROGRESS_ADD";
export const SITE_PROGRESS_REMOVE = "SITE_PROGRESS_REMOVE";
export const SITE_SIDEBAR_UPDATE = "SITE_SIDEBAR_UPDATE";
export const SITE_BADGE_UPDATE = "SITE_BADGE_UPDATE";
export const SITE_OFFLINE_UPDATE = "SITE_OFFLINE_UPDATE";

View File

@ -0,0 +1,29 @@
import { AsyncThunk } from "@reduxjs/toolkit";
import { useEffect } from "react";
import { log } from "../../utilities/logger";
import { useReduxAction } from "./base";
export function useAutoUpdate(item: Async.Item<any>, update: () => void) {
useEffect(() => {
if (item.state === "uninitialized" || item.state === "dirty") {
update();
}
}, [item.state, update]);
}
export function useAutoDirtyUpdate(
item: Async.List<any> | Async.Entity<any>,
updateAction: AsyncThunk<any, number[], {}>
) {
const { state, dirtyEntities } = item;
const hasDirty = dirtyEntities.length > 0 && state === "dirty";
const update = useReduxAction(updateAction);
useEffect(() => {
if (hasDirty) {
log("info", "updating dirty entities...");
update(dirtyEntities.map(Number));
}
}, [hasDirty, dirtyEntities, update]);
}

View File

@ -1,36 +1,24 @@
import { ActionCreator } from "@reduxjs/toolkit";
import { useCallback } from "react"; import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { createCallbackAction } from "../actions/factory"; import { AppDispatch, RootState } from "../store";
import { ActionCallback, AsyncActionDispatcher } from "../types";
// function use // function use
export function useReduxStore<T extends (store: ReduxStore) => any>( export function useReduxStore<T extends (store: RootState) => any>(
selector: T selector: T
) { ) {
return useSelector<ReduxStore, ReturnType<T>>(selector); return useSelector<RootState, ReturnType<T>>(selector);
} }
export function useReduxAction<T extends (...args: any[]) => void>(action: T) { export function useAppDispatch() {
const dispatch = useDispatch(); return useDispatch<AppDispatch>();
return useCallback((...args: Parameters<T>) => dispatch(action(...args)), [
action,
dispatch,
]);
} }
export function useReduxActionWith< // TODO: Fix type
T extends (...args: any[]) => AsyncActionDispatcher<any> export function useReduxAction<T extends ActionCreator<any>>(action: T) {
>(action: T, success: ActionCallback) { const dispatch = useAppDispatch();
const dispatch = useDispatch();
return useCallback( return useCallback(
(...args: Parameters<T>) => { (...args: Parameters<T>) => dispatch(action(...args)),
const callbackAction = createCallbackAction( [action, dispatch]
() => [action(...args)],
success
);
dispatch(callbackAction());
},
[dispatch, action, success]
); );
} }

View File

@ -1,393 +1,4 @@
import { useCallback, useEffect, useMemo } from "react"; export * from "./movies";
import { useSocketIOReducer, useWrapToOptionalId } from "../../@socketio/hooks"; export * from "./series";
import { buildOrderList } from "../../utilites"; export * from "./site";
import { export * from "./system";
episodeDeleteItems,
episodeUpdateBy,
episodeUpdateById,
movieUpdateBlacklist,
movieUpdateHistoryList,
movieUpdateList,
movieUpdateWantedList,
providerUpdateList,
seriesUpdateBlacklist,
seriesUpdateHistoryList,
seriesUpdateList,
seriesUpdateWantedList,
systemUpdateHealth,
systemUpdateLanguages,
systemUpdateLanguagesProfiles,
systemUpdateLogs,
systemUpdateReleases,
systemUpdateSettingsAll,
systemUpdateStatus,
systemUpdateTasks,
} from "../actions";
import { useReduxAction, useReduxStore } from "./base";
function stateBuilder<T, D extends (...args: any[]) => any>(
t: T,
d: D
): [Readonly<T>, D] {
return [t, d];
}
export function useSystemSettings() {
const update = useReduxAction(systemUpdateSettingsAll);
const items = useReduxStore((s) => s.system.settings);
return stateBuilder(items, update);
}
export function useSystemLogs() {
const items = useReduxStore(({ system }) => system.logs);
const update = useReduxAction(systemUpdateLogs);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSystemTasks() {
const items = useReduxStore((s) => s.system.tasks);
const update = useReduxAction(systemUpdateTasks);
const reducer = useMemo<SocketIO.Reducer>(() => ({ key: "task", update }), [
update,
]);
useSocketIOReducer(reducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSystemStatus() {
const items = useReduxStore((s) => s.system.status.data);
const update = useReduxAction(systemUpdateStatus);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSystemHealth() {
const update = useReduxAction(systemUpdateHealth);
const items = useReduxStore((s) => s.system.health);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSystemProviders() {
const update = useReduxAction(providerUpdateList);
const items = useReduxStore((d) => d.system.providers);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSystemReleases() {
const items = useReduxStore(({ system }) => system.releases);
const update = useReduxAction(systemUpdateReleases);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useLanguageProfiles() {
const action = useReduxAction(systemUpdateLanguagesProfiles);
const items = useReduxStore((s) => s.system.languagesProfiles.data);
return stateBuilder(items, action);
}
export function useProfileBy(id: number | null | undefined) {
const [profiles] = useLanguageProfiles();
return useMemo(() => profiles.find((v) => v.profileId === id), [
id,
profiles,
]);
}
export function useLanguages(enabled: boolean = false) {
const action = useReduxAction(systemUpdateLanguages);
const items = useReduxStore((s) =>
enabled ? s.system.enabledLanguage.data : s.system.languages.data
);
return stateBuilder(items, action);
}
function useLanguageGetter(enabled: boolean = false) {
const [languages] = useLanguages(enabled);
return useCallback(
(code?: string) => {
if (code === undefined) {
return undefined;
} else {
return languages.find((v) => v.code2 === code);
}
},
[languages]
);
}
export function useLanguageBy(code?: string) {
const getter = useLanguageGetter();
return useMemo(() => getter(code), [code, getter]);
}
// Convert languageprofile items to language
export function useProfileItems(profile?: Profile.Languages) {
const getter = useLanguageGetter(true);
return useMemo(
() =>
profile?.items.map<Language>(({ language, hi, forced }) => {
const name = getter(language)?.name ?? "";
return {
hi: hi === "True",
forced: forced === "True",
code2: language,
name,
};
}) ?? [],
[getter, profile?.items]
);
}
export function useRawSeries() {
const update = useReduxAction(seriesUpdateList);
const items = useReduxStore((d) => d.series.seriesList);
return stateBuilder(items, update);
}
export function useSeries(order = true) {
const [rawSeries, action] = useRawSeries();
const series = useMemo<AsyncState<Item.Series[]>>(() => {
const state = rawSeries.data;
if (order) {
return {
...rawSeries,
data: buildOrderList(state),
};
} else {
return {
...rawSeries,
data: Object.values(state.items),
};
}
}, [rawSeries, order]);
return stateBuilder(series, action);
}
export function useSerieBy(id?: number) {
const [series, updateSerie] = useRawSeries();
const serie = useMemo<AsyncState<Item.Series | null>>(() => {
const items = series.data.items;
let item: Item.Series | null = null;
if (id && !isNaN(id) && id in items) {
item = items[id];
}
return {
...series,
data: item,
};
}, [id, series]);
const update = useCallback(() => {
if (id && !isNaN(id)) {
updateSerie([id]);
}
}, [id, updateSerie]);
useEffect(() => {
if (serie.data === null) {
update();
}
}, [serie.data, update]);
return stateBuilder(serie, update);
}
export function useEpisodesBy(seriesId?: number) {
const action = useReduxAction(episodeUpdateBy);
const update = useCallback(() => {
if (seriesId !== undefined && !isNaN(seriesId)) {
action([seriesId]);
}
}, [action, seriesId]);
const list = useReduxStore((d) => d.series.episodeList);
const items = useMemo(() => {
if (seriesId !== undefined && !isNaN(seriesId)) {
return list.data.filter((v) => v.sonarrSeriesId === seriesId);
} else {
return [];
}
}, [seriesId, list.data]);
const state: AsyncState<Item.Episode[]> = useMemo(
() => ({
...list,
data: items,
}),
[list, items]
);
const actionById = useReduxAction(episodeUpdateById);
const wrapActionById = useWrapToOptionalId(actionById);
const deleteAction = useReduxAction(episodeDeleteItems);
const episodeReducer = useMemo<SocketIO.Reducer>(
() => ({ key: "episode", update: wrapActionById, delete: deleteAction }),
[wrapActionById, deleteAction]
);
useSocketIOReducer(episodeReducer);
const wrapAction = useWrapToOptionalId(action);
const seriesReducer = useMemo<SocketIO.Reducer>(
() => ({ key: "series", update: wrapAction }),
[wrapAction]
);
useSocketIOReducer(seriesReducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(state, update);
}
export function useRawMovies() {
const update = useReduxAction(movieUpdateList);
const items = useReduxStore((d) => d.movie.movieList);
return stateBuilder(items, update);
}
export function useMovies(order = true) {
const [rawMovies, action] = useRawMovies();
const movies = useMemo<AsyncState<Item.Movie[]>>(() => {
const state = rawMovies.data;
if (order) {
return {
...rawMovies,
data: buildOrderList(state),
};
} else {
return {
...rawMovies,
data: Object.values(state.items),
};
}
}, [rawMovies, order]);
return stateBuilder(movies, action);
}
export function useMovieBy(id?: number) {
const [movies, updateMovies] = useRawMovies();
const movie = useMemo<AsyncState<Item.Movie | null>>(() => {
const items = movies.data.items;
let item: Item.Movie | null = null;
if (id && !isNaN(id) && id in items) {
item = items[id];
}
return {
...movies,
data: item,
};
}, [id, movies]);
const update = useCallback(() => {
if (id && !isNaN(id)) {
updateMovies([id]);
}
}, [id, updateMovies]);
useEffect(() => {
if (movie.data === null) {
update();
}
}, [movie.data, update]);
return stateBuilder(movie, update);
}
export function useWantedSeries() {
const update = useReduxAction(seriesUpdateWantedList);
const items = useReduxStore((d) => d.series.wantedEpisodesList);
return stateBuilder(items, update);
}
export function useWantedMovies() {
const update = useReduxAction(movieUpdateWantedList);
const items = useReduxStore((d) => d.movie.wantedMovieList);
return stateBuilder(items, update);
}
export function useBlacklistMovies() {
const update = useReduxAction(movieUpdateBlacklist);
const items = useReduxStore((d) => d.movie.blacklist);
const reducer = useMemo<SocketIO.Reducer>(
() => ({ key: "movie-blacklist", any: update }),
[update]
);
useSocketIOReducer(reducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useBlacklistSeries() {
const update = useReduxAction(seriesUpdateBlacklist);
const items = useReduxStore((d) => d.series.blacklist);
const reducer = useMemo<SocketIO.Reducer>(
() => ({ key: "episode-blacklist", any: update }),
[update]
);
useSocketIOReducer(reducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useMoviesHistory() {
const update = useReduxAction(movieUpdateHistoryList);
const items = useReduxStore((s) => s.movie.historyList);
const reducer = useMemo<SocketIO.Reducer>(
() => ({ key: "movie-history", update }),
[update]
);
useSocketIOReducer(reducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSeriesHistory() {
const update = useReduxAction(seriesUpdateHistoryList);
const items = useReduxStore((s) => s.series.historyList);
const reducer = useMemo<SocketIO.Reducer>(
() => ({ key: "episode-history", update }),
[update]
);
useSocketIOReducer(reducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}

View File

@ -0,0 +1,67 @@
import { useCallback, useMemo } from "react";
import { useEntityItemById, useEntityToList } from "../../utilities";
import {
movieUpdateBlacklist,
movieUpdateById,
movieUpdateWantedById,
} from "../actions";
import { useAutoDirtyUpdate, useAutoUpdate } from "./async";
import { useReduxAction, useReduxStore } from "./base";
export function useMovieEntities() {
const entities = useReduxStore((d) => d.movies.movieList);
useAutoDirtyUpdate(entities, movieUpdateById);
return entities;
}
export function useMovies() {
const rawMovies = useMovieEntities();
const content = useEntityToList(rawMovies.content);
const movies = useMemo<Async.List<Item.Movie>>(() => {
return {
...rawMovies,
keyName: rawMovies.content.keyName,
content,
};
}, [rawMovies, content]);
return movies;
}
export function useMovieBy(id: number) {
const movies = useMovieEntities();
const action = useReduxAction(movieUpdateById);
const update = useCallback(() => {
if (!isNaN(id)) {
action([id]);
}
}, [id, action]);
const movie = useEntityItemById(movies, id.toString());
useAutoUpdate(movie, update);
return movie;
}
export function useWantedMovies() {
const items = useReduxStore((d) => d.movies.wantedMovieList);
useAutoDirtyUpdate(items, movieUpdateWantedById);
return items;
}
export function useBlacklistMovies() {
const update = useReduxAction(movieUpdateBlacklist);
const items = useReduxStore((d) => d.movies.blacklist);
useAutoUpdate(items, update);
return items;
}
export function useMoviesHistory() {
const items = useReduxStore((s) => s.movies.historyList);
return items;
}

View File

@ -0,0 +1,99 @@
import { useCallback, useEffect, useMemo } from "react";
import { useEntityItemById, useEntityToList } from "../../utilities";
import {
episodesUpdateBlacklist,
episodeUpdateById,
episodeUpdateBySeriesId,
seriesUpdateById,
seriesUpdateWantedById,
} from "../actions";
import { useAutoDirtyUpdate, useAutoUpdate } from "./async";
import { useReduxAction, useReduxStore } from "./base";
export function useSerieEntities() {
const items = useReduxStore((d) => d.series.seriesList);
useAutoDirtyUpdate(items, seriesUpdateById);
return items;
}
export function useSeries() {
const rawSeries = useSerieEntities();
const content = useEntityToList(rawSeries.content);
const series = useMemo<Async.List<Item.Series>>(() => {
return {
...rawSeries,
keyName: rawSeries.content.keyName,
content,
};
}, [rawSeries, content]);
return series;
}
export function useSerieBy(id: number) {
const series = useSerieEntities();
const action = useReduxAction(seriesUpdateById);
const serie = useEntityItemById(series, String(id));
const update = useCallback(() => {
if (!isNaN(id)) {
action([id]);
}
}, [id, action]);
useAutoUpdate(serie, update);
return serie;
}
export function useEpisodesBy(seriesId: number) {
const action = useReduxAction(episodeUpdateBySeriesId);
const update = useCallback(() => {
if (!isNaN(seriesId)) {
action([seriesId]);
}
}, [action, seriesId]);
const episodes = useReduxStore((d) => d.series.episodeList);
const newContent = useMemo(() => {
return episodes.content.filter((v) => v.sonarrSeriesId === seriesId);
}, [seriesId, episodes.content]);
const newList: Async.List<Item.Episode> = useMemo(
() => ({
...episodes,
content: newContent,
}),
[episodes, newContent]
);
// FIXME
useEffect(() => {
update();
}, [update]);
useAutoDirtyUpdate(episodes, episodeUpdateById);
return newList;
}
export function useWantedSeries() {
const items = useReduxStore((d) => d.series.wantedEpisodesList);
useAutoDirtyUpdate(items, seriesUpdateWantedById);
return items;
}
export function useBlacklistSeries() {
const update = useReduxAction(episodesUpdateBlacklist);
const items = useReduxStore((d) => d.series.blacklist);
useAutoUpdate(items, update);
return items;
}
export function useSeriesHistory() {
const items = useReduxStore((s) => s.series.historyList);
return items;
}

View File

@ -7,8 +7,8 @@ export function useNotification(id: string, timeout: number = 5000) {
const add = useReduxAction(siteAddNotifications); const add = useReduxAction(siteAddNotifications);
return useCallback( return useCallback(
(msg: Omit<ReduxStore.Notification, "id" | "timeout">) => { (msg: Omit<Server.Notification, "id" | "timeout">) => {
const notification: ReduxStore.Notification = { const notification: Server.Notification = {
...msg, ...msg,
id, id,
timeout, timeout,
@ -24,18 +24,18 @@ export function useIsOffline() {
} }
export function useIsSonarrEnabled() { export function useIsSonarrEnabled() {
const [settings] = useSystemSettings(); const settings = useSystemSettings();
return settings.data?.general.use_sonarr ?? true; return settings.content?.general.use_sonarr ?? true;
} }
export function useIsRadarrEnabled() { export function useIsRadarrEnabled() {
const [settings] = useSystemSettings(); const settings = useSystemSettings();
return settings.data?.general.use_radarr ?? true; return settings.content?.general.use_radarr ?? true;
} }
export function useShowOnlyDesired() { export function useShowOnlyDesired() {
const [settings] = useSystemSettings(); const settings = useSystemSettings();
return settings.data?.general.embedded_subs_show_desired ?? false; return settings.content?.general.embedded_subs_show_desired ?? false;
} }
export function useSetSidebar(key: string) { export function useSetSidebar(key: string) {

View File

@ -0,0 +1,131 @@
import { useMemo } from "react";
import {
providerUpdateList,
systemUpdateHealth,
systemUpdateLogs,
systemUpdateReleases,
systemUpdateStatus,
systemUpdateTasks,
} from "../actions";
import { useAutoUpdate } from "./async";
import { useReduxAction, useReduxStore } from "./base";
export function useSystemSettings() {
const items = useReduxStore((s) => s.system.settings);
return items;
}
export function useSystemLogs() {
const items = useReduxStore(({ system }) => system.logs);
const update = useReduxAction(systemUpdateLogs);
useAutoUpdate(items, update);
return items;
}
export function useSystemTasks() {
const items = useReduxStore((s) => s.system.tasks);
const update = useReduxAction(systemUpdateTasks);
useAutoUpdate(items, update);
return items;
}
export function useSystemStatus() {
const items = useReduxStore((s) => s.system.status);
const update = useReduxAction(systemUpdateStatus);
useAutoUpdate(items, update);
return items.content;
}
export function useSystemHealth() {
const items = useReduxStore((s) => s.system.health);
const update = useReduxAction(systemUpdateHealth);
useAutoUpdate(items, update);
return items;
}
export function useSystemProviders() {
const update = useReduxAction(providerUpdateList);
const items = useReduxStore((d) => d.system.providers);
useAutoUpdate(items, update);
return items;
}
export function useSystemReleases() {
const items = useReduxStore(({ system }) => system.releases);
const update = useReduxAction(systemUpdateReleases);
useAutoUpdate(items, update);
return items;
}
export function useLanguageProfiles() {
const items = useReduxStore((s) => s.system.languagesProfiles);
return items.content;
}
export function useProfileBy(id: number | null | undefined) {
const profiles = useLanguageProfiles();
return useMemo(
() => profiles?.find((v) => v.profileId === id),
[id, profiles]
);
}
export function useLanguages() {
const data = useReduxStore((s) => s.system.languages);
const languages = useMemo<Language.Info[]>(
() => data.content?.map((v) => ({ code2: v.code2, name: v.name })) ?? [],
[data.content]
);
return languages;
}
export function useEnabledLanguages() {
const data = useReduxStore((s) => s.system.languages);
const enabled = useMemo<Language.Info[]>(
() =>
data.content
?.filter((v) => v.enabled)
.map((v) => ({ code2: v.code2, name: v.name })) ?? [],
[data.content]
);
return enabled;
}
export function useLanguageBy(code?: string) {
const languages = useLanguages();
return useMemo(
() => languages.find((v) => v.code2 === code),
[languages, code]
);
}
// Convert languageprofile items to language
export function useProfileItemsToLanguages(profile?: Language.Profile) {
const languages = useLanguages();
return useMemo(
() =>
profile?.items.map<Language.Info>(({ language: code, hi, forced }) => {
const name = languages.find((v) => v.code2 === code)?.name ?? "";
return {
hi: hi === "True",
forced: forced === "True",
code2: code,
name,
};
}) ?? [],
[languages, profile?.items]
);
}

View File

@ -1,12 +1,13 @@
import { combineReducers } from "redux"; import movies from "./movie";
import movie from "./movie";
import series from "./series"; import series from "./series";
import site from "./site"; import site from "./site";
import system from "./system"; import system from "./system";
export default combineReducers({ const AllReducers = {
system, movies,
series, series,
movie,
site, site,
}); system,
};
export default AllReducers;

View File

@ -1,81 +1,68 @@
import { Action, handleActions } from "redux-actions"; import { createReducer } from "@reduxjs/toolkit";
import { import {
MOVIES_DELETE_ITEMS, movieMarkBlacklistDirty,
MOVIES_DELETE_WANTED_ITEMS, movieMarkDirtyById,
MOVIES_UPDATE_BLACKLIST, movieMarkHistoryDirty,
MOVIES_UPDATE_HISTORY_LIST, movieMarkWantedDirtyById,
MOVIES_UPDATE_LIST, movieRemoveById,
MOVIES_UPDATE_WANTED_LIST, movieRemoveWantedById,
} from "../constants"; movieResetHistory,
import { AsyncAction } from "../types"; movieResetWanted,
import { defaultAOS } from "../utils"; movieUpdateAll,
movieUpdateBlacklist,
movieUpdateById,
movieUpdateByRange,
movieUpdateHistoryByRange,
movieUpdateWantedById,
movieUpdateWantedByRange,
} from "../actions";
import { AsyncUtility } from "../utils";
import { import {
deleteOrderListItemBy, createAsyncEntityReducer,
updateAsyncState, createAsyncItemReducer,
updateOrderIdState, } from "../utils/factory";
} from "../utils/mapper";
const reducer = handleActions<ReduxStore.Movie, any>( interface Movie {
{ movieList: Async.Entity<Item.Movie>;
[MOVIES_UPDATE_WANTED_LIST]: ( wantedMovieList: Async.Entity<Wanted.Movie>;
state, historyList: Async.Entity<History.Movie>;
action: AsyncAction<AsyncDataWrapper<Wanted.Movie>> blacklist: Async.Item<Blacklist.Movie[]>;
) => { }
return {
...state, const defaultMovie: Movie = {
wantedMovieList: updateOrderIdState( movieList: AsyncUtility.getDefaultEntity("radarrId"),
action, wantedMovieList: AsyncUtility.getDefaultEntity("radarrId"),
state.wantedMovieList, historyList: AsyncUtility.getDefaultEntity("id"),
"radarrId" blacklist: AsyncUtility.getDefaultItem(),
), };
};
}, const reducer = createReducer(defaultMovie, (builder) => {
[MOVIES_DELETE_WANTED_ITEMS]: (state, action: Action<number[]>) => { createAsyncEntityReducer(builder, (s) => s.movieList, {
return { range: movieUpdateByRange,
...state, ids: movieUpdateById,
wantedMovieList: deleteOrderListItemBy(action, state.wantedMovieList), removeIds: movieRemoveById,
}; all: movieUpdateAll,
}, dirty: movieMarkDirtyById,
[MOVIES_UPDATE_HISTORY_LIST]: ( });
state,
action: AsyncAction<History.Movie[]> createAsyncEntityReducer(builder, (s) => s.wantedMovieList, {
) => { range: movieUpdateWantedByRange,
return { ids: movieUpdateWantedById,
...state, removeIds: movieRemoveWantedById,
historyList: updateAsyncState(action, state.historyList.data), dirty: movieMarkWantedDirtyById,
}; reset: movieResetWanted,
}, });
[MOVIES_UPDATE_LIST]: (
state, createAsyncEntityReducer(builder, (s) => s.historyList, {
action: AsyncAction<AsyncDataWrapper<Item.Movie>> range: movieUpdateHistoryByRange,
) => { dirty: movieMarkHistoryDirty,
return { reset: movieResetHistory,
...state, });
movieList: updateOrderIdState(action, state.movieList, "radarrId"),
}; createAsyncItemReducer(builder, (s) => s.blacklist, {
}, all: movieUpdateBlacklist,
[MOVIES_DELETE_ITEMS]: (state, action: Action<number[]>) => { dirty: movieMarkBlacklistDirty,
return { });
...state, });
movieList: deleteOrderListItemBy(action, state.movieList),
};
},
[MOVIES_UPDATE_BLACKLIST]: (
state,
action: AsyncAction<Blacklist.Movie[]>
) => {
return {
...state,
blacklist: updateAsyncState(action, state.blacklist.data),
};
},
},
{
movieList: defaultAOS(),
wantedMovieList: defaultAOS(),
historyList: { updating: true, data: [] },
blacklist: { updating: true, data: [] },
}
);
export default reducer; export default reducer;

View File

@ -1,116 +1,100 @@
import { Action, handleActions } from "redux-actions"; import { createReducer } from "@reduxjs/toolkit";
import { import {
SERIES_DELETE_EPISODES, episodesMarkBlacklistDirty,
SERIES_DELETE_ITEMS, episodesMarkDirtyById,
SERIES_DELETE_WANTED_ITEMS, episodesMarkHistoryDirty,
SERIES_UPDATE_BLACKLIST, episodesRemoveById,
SERIES_UPDATE_EPISODE_LIST, episodesResetHistory,
SERIES_UPDATE_HISTORY_LIST, episodesUpdateBlacklist,
SERIES_UPDATE_LIST, episodesUpdateHistoryByRange,
SERIES_UPDATE_WANTED_LIST, episodeUpdateById,
} from "../constants"; episodeUpdateBySeriesId,
import { AsyncAction } from "../types"; seriesMarkDirtyById,
import { defaultAOS } from "../utils"; seriesMarkWantedDirtyById,
seriesRemoveById,
seriesRemoveWantedById,
seriesResetWanted,
seriesUpdateAll,
seriesUpdateById,
seriesUpdateByRange,
seriesUpdateWantedById,
seriesUpdateWantedByRange,
} from "../actions";
import { AsyncUtility, ReducerUtility } from "../utils";
import { import {
deleteAsyncListItemBy, createAsyncEntityReducer,
deleteOrderListItemBy, createAsyncItemReducer,
updateAsyncList, createAsyncListReducer,
updateAsyncState, } from "../utils/factory";
updateOrderIdState,
} from "../utils/mapper";
const reducer = handleActions<ReduxStore.Series, any>( interface Series {
{ seriesList: Async.Entity<Item.Series>;
[SERIES_UPDATE_WANTED_LIST]: ( wantedEpisodesList: Async.Entity<Wanted.Episode>;
state, episodeList: Async.List<Item.Episode>;
action: AsyncAction<AsyncDataWrapper<Wanted.Episode>> historyList: Async.Entity<History.Episode>;
) => { blacklist: Async.Item<Blacklist.Episode[]>;
return { }
...state,
wantedEpisodesList: updateOrderIdState( const defaultSeries: Series = {
action, seriesList: AsyncUtility.getDefaultEntity("sonarrSeriesId"),
state.wantedEpisodesList, wantedEpisodesList: AsyncUtility.getDefaultEntity("sonarrEpisodeId"),
"sonarrEpisodeId" episodeList: AsyncUtility.getDefaultList("sonarrEpisodeId"),
), historyList: AsyncUtility.getDefaultEntity("id"),
}; blacklist: AsyncUtility.getDefaultItem(),
}, };
[SERIES_DELETE_WANTED_ITEMS]: (state, action: Action<number[]>) => {
return { const reducer = createReducer(defaultSeries, (builder) => {
...state, createAsyncEntityReducer(builder, (s) => s.seriesList, {
wantedEpisodesList: deleteOrderListItemBy( range: seriesUpdateByRange,
action, ids: seriesUpdateById,
state.wantedEpisodesList removeIds: seriesRemoveById,
), all: seriesUpdateAll,
}; });
},
[SERIES_UPDATE_EPISODE_LIST]: ( builder.addCase(seriesMarkDirtyById, (state, action) => {
state, const series = state.seriesList;
action: AsyncAction<Item.Episode[]> const dirtyIds = action.payload.map(String);
) => {
return { ReducerUtility.markDirty(series, dirtyIds);
...state,
episodeList: updateAsyncList( // Update episode list
action, const episodes = state.episodeList;
state.episodeList, const dirtyIdsSet = new Set(dirtyIds);
"sonarrEpisodeId" const dirtyEpisodeIds = episodes.content
), .filter((v) => dirtyIdsSet.has(v.sonarrSeriesId.toString()))
}; .map((v) => String(v.sonarrEpisodeId));
},
[SERIES_DELETE_EPISODES]: (state, action: Action<number[]>) => { ReducerUtility.markDirty(episodes, dirtyEpisodeIds);
return { });
...state,
episodeList: deleteAsyncListItemBy( createAsyncEntityReducer(builder, (s) => s.wantedEpisodesList, {
action, range: seriesUpdateWantedByRange,
state.episodeList, ids: seriesUpdateWantedById,
"sonarrEpisodeId" removeIds: seriesRemoveWantedById,
), dirty: seriesMarkWantedDirtyById,
}; reset: seriesResetWanted,
}, });
[SERIES_UPDATE_HISTORY_LIST]: (
state, createAsyncEntityReducer(builder, (s) => s.historyList, {
action: AsyncAction<History.Episode[]> range: episodesUpdateHistoryByRange,
) => { dirty: episodesMarkHistoryDirty,
return { reset: episodesResetHistory,
...state, });
historyList: updateAsyncState(action, state.historyList.data),
}; createAsyncItemReducer(builder, (s) => s.blacklist, {
}, all: episodesUpdateBlacklist,
[SERIES_UPDATE_LIST]: ( dirty: episodesMarkBlacklistDirty,
state, });
action: AsyncAction<AsyncDataWrapper<Item.Series>>
) => { createAsyncListReducer(builder, (s) => s.episodeList, {
return { ids: episodeUpdateBySeriesId,
...state, });
seriesList: updateOrderIdState(
action, createAsyncListReducer(builder, (s) => s.episodeList, {
state.seriesList, ids: episodeUpdateById,
"sonarrSeriesId" removeIds: episodesRemoveById,
), dirty: episodesMarkDirtyById,
}; });
}, });
[SERIES_DELETE_ITEMS]: (state, action: Action<number[]>) => {
return {
...state,
seriesList: deleteOrderListItemBy(action, state.seriesList),
};
},
[SERIES_UPDATE_BLACKLIST]: (
state,
action: AsyncAction<Blacklist.Episode[]>
) => {
return {
...state,
blacklist: updateAsyncState(action, state.blacklist.data),
};
},
},
{
seriesList: defaultAOS(),
wantedEpisodesList: defaultAOS(),
episodeList: { updating: true, data: [] },
historyList: { updating: true, data: [] },
blacklist: { updating: true, data: [] },
}
);
export default reducer; export default reducer;

View File

@ -1,100 +1,130 @@
import { remove, uniqBy } from "lodash"; import { createReducer } from "@reduxjs/toolkit";
import { Action, handleActions } from "redux-actions"; import { intersectionWith, pullAllWith, remove, sortBy, uniqBy } from "lodash";
import apis from "../../apis"; import apis from "../../apis";
import { isProdEnv } from "../../utilities";
import { import {
SITE_BADGE_UPDATE, siteAddNotifications,
SITE_INITIALIZED, siteAddProgress,
SITE_INITIALIZE_FAILED, siteBootstrap,
SITE_NEED_AUTH, siteChangeSidebar,
SITE_NOTIFICATIONS_ADD, siteRedirectToAuth,
SITE_NOTIFICATIONS_REMOVE, siteRemoveNotifications,
SITE_OFFLINE_UPDATE, siteRemoveProgress,
SITE_PROGRESS_ADD, siteUpdateBadges,
SITE_PROGRESS_REMOVE, siteUpdateInitialization,
SITE_SIDEBAR_UPDATE, siteUpdateNotifier,
} from "../constants"; siteUpdateOffline,
import { AsyncAction } from "../types"; siteUpdateProgressCount,
} from "../actions/site";
const reducer = handleActions<ReduxStore.Site, any>( interface Site {
{ // Initialization state or error message
[SITE_NEED_AUTH]: (state) => { initialized: boolean | string;
if (process.env.NODE_ENV !== "development") { offline: boolean;
apis.danger_resetApi("NEED_AUTH"); auth: boolean;
} progress: Site.Progress[];
return { notifier: {
...state, content: string | null;
auth: false, timestamp: string;
}; };
}, notifications: Server.Notification[];
[SITE_INITIALIZED]: (state) => ({ sidebar: string;
...state, badges: Badge;
initialized: true, }
}),
[SITE_INITIALIZE_FAILED]: (state) => ({ const defaultSite: Site = {
...state, initialized: false,
initialized: "An Error Occurred When Initializing Bazarr UI", auth: true,
}), progress: [],
[SITE_NOTIFICATIONS_ADD]: ( notifier: {
state, content: null,
action: Action<ReduxStore.Notification[]> timestamp: String(Date.now()),
) => {
const notifications = uniqBy(
[...action.payload.reverse(), ...state.notifications],
(n) => n.id
);
return { ...state, notifications };
},
[SITE_NOTIFICATIONS_REMOVE]: (state, action: Action<string>) => {
const notifications = [...state.notifications];
remove(notifications, (n) => n.id === action.payload);
return { ...state, notifications };
},
[SITE_PROGRESS_ADD]: (state, action: Action<ReduxStore.Progress[]>) => {
const progress = uniqBy(
[...action.payload.reverse(), ...state.progress],
(n) => n.id
);
return { ...state, progress };
},
[SITE_PROGRESS_REMOVE]: (state, action: Action<string>) => {
const progress = [...state.progress];
remove(progress, (n) => n.id === action.payload);
return { ...state, progress };
},
[SITE_SIDEBAR_UPDATE]: (state, action: Action<string>) => {
return {
...state,
sidebar: action.payload,
};
},
[SITE_BADGE_UPDATE]: {
next: (state, action: AsyncAction<Badge>) => {
const badges = action.payload.item;
if (badges && action.error !== true) {
return { ...state, badges: badges as Badge };
}
return state;
},
throw: (state) => state,
},
[SITE_OFFLINE_UPDATE]: (state, action: Action<boolean>) => {
return { ...state, offline: action.payload };
},
}, },
{ notifications: [],
initialized: false, sidebar: "",
auth: true, badges: {
progress: [], movies: 0,
notifications: [], episodes: 0,
sidebar: "", providers: 0,
badges: { status: 0,
movies: 0, },
episodes: 0, offline: false,
providers: 0, };
status: 0,
}, const reducer = createReducer(defaultSite, (builder) => {
offline: false, builder
} .addCase(siteBootstrap.fulfilled, (state) => {
); state.initialized = true;
})
.addCase(siteBootstrap.rejected, (state) => {
state.initialized = "An Error Occurred When Initializing Bazarr UI";
})
.addCase(siteRedirectToAuth, (state) => {
if (!isProdEnv) {
apis._resetApi("NEED_AUTH");
}
state.auth = false;
})
.addCase(siteUpdateInitialization, (state, action) => {
state.initialized = action.payload;
});
builder
.addCase(siteAddNotifications, (state, action) => {
state.notifications = uniqBy(
[...action.payload, ...state.notifications],
(v) => v.id
);
state.notifications = sortBy(state.notifications, (v) => v.id);
})
.addCase(siteRemoveNotifications, (state, action) => {
remove(state.notifications, (n) => n.id === action.payload);
});
builder
.addCase(siteAddProgress, (state, action) => {
state.progress = uniqBy(
[...action.payload, ...state.progress],
(n) => n.id
);
state.progress = sortBy(state.progress, (v) => v.id);
})
.addCase(siteRemoveProgress.pending, (state, action) => {
// Mark completed
intersectionWith(
state.progress,
action.meta.arg,
(l, r) => l.id === r
).forEach((v) => {
v.value = v.count + 1;
});
})
.addCase(siteRemoveProgress.fulfilled, (state, action) => {
pullAllWith(state.progress, action.payload, (l, r) => l.id === r);
})
.addCase(siteUpdateProgressCount, (state, action) => {
const { id, count } = action.payload;
const progress = state.progress.find((v) => v.id === id);
if (progress) {
progress.count = count;
}
});
builder.addCase(siteUpdateNotifier, (state, action) => {
state.notifier.content = action.payload;
state.notifier.timestamp = String(Date.now());
});
builder
.addCase(siteChangeSidebar, (state, action) => {
state.sidebar = action.payload;
})
.addCase(siteUpdateOffline, (state, action) => {
state.offline = action.payload;
})
.addCase(siteUpdateBadges.fulfilled, (state, action) => {
state.badges = action.payload;
});
});
export default reducer; export default reducer;

View File

@ -1,121 +1,74 @@
import { handleActions } from "redux-actions"; import { createReducer } from "@reduxjs/toolkit";
import { import {
SYSTEM_UPDATE_HEALTH, providerUpdateList,
SYSTEM_UPDATE_LANGUAGES_LIST, systemMarkTasksDirty,
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST, systemUpdateHealth,
SYSTEM_UPDATE_LOGS, systemUpdateLanguages,
SYSTEM_UPDATE_PROVIDERS, systemUpdateLanguagesProfiles,
SYSTEM_UPDATE_RELEASES, systemUpdateLogs,
SYSTEM_UPDATE_SETTINGS, systemUpdateReleases,
SYSTEM_UPDATE_STATUS, systemUpdateSettings,
SYSTEM_UPDATE_TASKS, systemUpdateStatus,
} from "../constants"; systemUpdateTasks,
import { updateAsyncState } from "../utils/mapper"; } from "../actions";
import { AsyncUtility } from "../utils";
import { createAsyncItemReducer } from "../utils/factory";
const reducer = handleActions<ReduxStore.System, any>( interface System {
{ languages: Async.Item<Language.Server[]>;
[SYSTEM_UPDATE_LANGUAGES_LIST]: (state, action) => { languagesProfiles: Async.Item<Language.Profile[]>;
const languages = updateAsyncState<Array<ApiLanguage>>(action, []); status: Async.Item<System.Status>;
const enabledLanguage: AsyncState<ApiLanguage[]> = { health: Async.Item<System.Health[]>;
...languages, tasks: Async.Item<System.Task[]>;
data: languages.data.filter((v) => v.enabled), providers: Async.Item<System.Provider[]>;
}; logs: Async.Item<System.Log[]>;
const newState = { releases: Async.Item<ReleaseInfo[]>;
...state, settings: Async.Item<Settings>;
languages, }
enabledLanguage,
}; const defaultSystem: System = {
return newState; languages: AsyncUtility.getDefaultItem(),
}, languagesProfiles: AsyncUtility.getDefaultItem(),
[SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST]: (state, action) => { status: AsyncUtility.getDefaultItem(),
const newState = { health: AsyncUtility.getDefaultItem(),
...state, tasks: AsyncUtility.getDefaultItem(),
languagesProfiles: updateAsyncState<Array<Profile.Languages>>( providers: AsyncUtility.getDefaultItem(),
action, logs: AsyncUtility.getDefaultItem(),
[] releases: AsyncUtility.getDefaultItem(),
), settings: AsyncUtility.getDefaultItem(),
}; };
return newState;
}, const reducer = createReducer(defaultSystem, (builder) => {
[SYSTEM_UPDATE_STATUS]: (state, action) => { createAsyncItemReducer(builder, (s) => s.languages, {
return { all: systemUpdateLanguages,
...state, });
status: updateAsyncState<System.Status | undefined>(
action, createAsyncItemReducer(builder, (s) => s.languagesProfiles, {
state.status.data all: systemUpdateLanguagesProfiles,
), });
}; createAsyncItemReducer(builder, (s) => s.status, { all: systemUpdateStatus });
}, createAsyncItemReducer(builder, (s) => s.settings, {
[SYSTEM_UPDATE_HEALTH]: (state, action) => { all: systemUpdateSettings,
return { });
...state, createAsyncItemReducer(builder, (s) => s.releases, {
health: updateAsyncState(action, state.health.data), all: systemUpdateReleases,
}; });
}, createAsyncItemReducer(builder, (s) => s.logs, {
[SYSTEM_UPDATE_TASKS]: (state, action) => { all: systemUpdateLogs,
return { });
...state,
tasks: updateAsyncState<Array<System.Task>>(action, state.tasks.data), createAsyncItemReducer(builder, (s) => s.health, {
}; all: systemUpdateHealth,
}, });
[SYSTEM_UPDATE_PROVIDERS]: (state, action) => {
return { createAsyncItemReducer(builder, (s) => s.tasks, {
...state, all: systemUpdateTasks,
providers: updateAsyncState(action, state.providers.data), dirty: systemMarkTasksDirty,
}; });
},
[SYSTEM_UPDATE_LOGS]: (state, action) => { createAsyncItemReducer(builder, (s) => s.providers, {
return { all: providerUpdateList,
...state, });
logs: updateAsyncState(action, state.logs.data), });
};
},
[SYSTEM_UPDATE_RELEASES]: (state, action) => {
return {
...state,
releases: updateAsyncState(action, state.releases.data),
};
},
[SYSTEM_UPDATE_SETTINGS]: (state, action) => {
return {
...state,
settings: updateAsyncState(action, state.settings.data),
};
},
},
{
languages: { updating: true, data: [] },
enabledLanguage: { updating: true, data: [] },
languagesProfiles: { updating: true, data: [] },
status: {
updating: true,
data: undefined,
},
health: {
updating: true,
data: [],
},
tasks: {
updating: true,
data: [],
},
providers: {
updating: true,
data: [],
},
logs: {
updating: true,
data: [],
},
releases: {
updating: true,
data: [],
},
settings: {
updating: true,
data: undefined,
},
}
);
export default reducer; export default reducer;

View File

@ -1,62 +0,0 @@
interface ReduxStore {
system: ReduxStore.System;
series: ReduxStore.Series;
movie: ReduxStore.Movie;
site: ReduxStore.Site;
}
namespace ReduxStore {
interface Notification {
type: "error" | "warning" | "info";
id: string;
message: string;
timeout: number;
}
interface Progress {
id: string;
header: string;
name: string;
value: number;
count: number;
}
interface Site {
// Initialization state or error message
initialized: boolean | string;
auth: boolean;
progress: Progress[];
notifications: Notification[];
sidebar: string;
badges: Badge;
offline: boolean;
}
interface System {
languages: AsyncState<Array<Language>>;
enabledLanguage: AsyncState<Array<Language>>;
languagesProfiles: AsyncState<Array<Profile.Languages>>;
status: AsyncState<System.Status | undefined>;
health: AsyncState<Array<System.Health>>;
tasks: AsyncState<Array<System.Task>>;
providers: AsyncState<Array<System.Provider>>;
logs: AsyncState<Array<System.Log>>;
releases: AsyncState<Array<ReleaseInfo>>;
settings: AsyncState<Settings | undefined>;
}
interface Series {
seriesList: AsyncOrderState<Item.Series>;
wantedEpisodesList: AsyncOrderState<Wanted.Episode>;
episodeList: AsyncState<Item.Episode[]>;
historyList: AsyncState<Array<History.Episode>>;
blacklist: AsyncState<Array<Blacklist.Episode>>;
}
interface Movie {
movieList: AsyncOrderState<Item.Movie>;
wantedMovieList: AsyncOrderState<Wanted.Movie>;
historyList: AsyncState<Array<History.Movie>>;
blacklist: AsyncState<Array<Blacklist.Movie>>;
}
}

View File

@ -1,17 +1,15 @@
import { applyMiddleware, createStore } from "redux"; import { configureStore } from "@reduxjs/toolkit";
import logger from "redux-logger"; import apis from "../../apis";
import promise from "redux-promise"; import reducer from "../reducers";
import trunk from "redux-thunk";
import rootReducer from "../reducers";
const plugins = [promise, trunk]; const store = configureStore({
reducer,
});
if ( // FIXME
process.env.NODE_ENV === "development" && apis.dispatch = store.dispatch;
process.env["REACT_APP_LOG_REDUX_EVENT"] !== "false"
) { export type AppDispatch = typeof store.dispatch;
plugins.push(logger); export type RootState = ReturnType<typeof store.getState>;
}
const store = createStore(rootReducer, applyMiddleware(...plugins));
export default store; export default store;

View File

@ -0,0 +1,31 @@
import { AsyncUtility } from "../utils";
export interface TestType {
id: number;
name: string;
}
export interface Reducer {
item: Async.Item<TestType>;
list: Async.List<TestType>;
entities: Async.Entity<TestType>;
}
export const defaultState: Reducer = {
item: AsyncUtility.getDefaultItem(),
list: AsyncUtility.getDefaultList("id"),
entities: AsyncUtility.getDefaultEntity("id"),
};
export const defaultItem: TestType = { id: 0, name: "test" };
export const defaultList: TestType[] = [
{ id: 0, name: "test" },
{ id: 1, name: "test_1" },
{ id: 2, name: "test_2" },
{ id: 3, name: "test_3" },
{ id: 4, name: "test_4" },
{ id: 5, name: "test_5" },
{ id: 6, name: "test_6" },
{ id: 7, name: "test_6" },
];

View File

@ -1,22 +0,0 @@
import { Dispatch } from "redux";
import { Action } from "redux-actions";
interface AsyncPayload<Payload> {
loading: boolean;
item?: Payload | Error;
parameters: any[];
}
type AvailableType<T> = Action<T> | ActionDispatcher<T>;
type AsyncAction<Payload> = Action<AsyncPayload<Payload>>;
type ActionDispatcher<T = any> = (dispatch: Dispatch<Action<T>>) => void;
type AsyncActionDispatcher<T> = (
dispatch: Dispatch<AsyncAction<T>>
) => Promise<void>;
type PromiseCreator = (...args: any[]) => Promise<any>;
type AvailableCreator = (...args: any[]) => AvailableType<any>[];
type AsyncActionCreator = (...args: any[]) => AsyncActionDispatcher<any>[];
type ActionCallback = () => Action<any> | void;

View File

@ -0,0 +1,32 @@
import {} from "jest";
import { AsyncUtility } from "..";
interface AsyncTest {
id: string;
name: string;
}
it("Item Init", () => {
const item = AsyncUtility.getDefaultItem<AsyncTest>();
expect(item.state).toEqual("uninitialized");
expect(item.error).toBeNull();
expect(item.content).toBeNull();
});
it("List Init", () => {
const list = AsyncUtility.getDefaultList<AsyncTest>("id");
expect(list.state).toEqual("uninitialized");
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).toBeNull();
expect(list.content).toHaveLength(0);
});
it("Entity Init", () => {
const entity = AsyncUtility.getDefaultEntity<AsyncTest>("id");
expect(entity.state).toEqual("uninitialized");
expect(entity.dirtyEntities).toHaveLength(0);
expect(entity.error).toBeNull();
expect(entity.content.ids).toHaveLength(0);
expect(entity.content.keyName).toBe("id");
expect(entity.content.entities).toMatchObject({});
});

View File

@ -0,0 +1,339 @@
import {
ActionCreatorWithoutPayload,
ActionCreatorWithPayload,
ActionReducerMapBuilder,
AsyncThunk,
Draft,
} from "@reduxjs/toolkit";
import {
difference,
findIndex,
isNull,
isString,
omit,
pullAll,
pullAllWith,
} from "lodash";
import { ReducerUtility } from ".";
import { conditionalLog } from "../../utilities/logger";
interface ActionParam<T, ID = null> {
range?: AsyncThunk<T, Parameter.Range, {}>;
all?: AsyncThunk<T, void, {}>;
ids?: AsyncThunk<T, ID[], {}>;
removeIds?: ActionCreatorWithPayload<ID[]>;
reset?: ActionCreatorWithoutPayload;
dirty?: ID extends null
? ActionCreatorWithoutPayload
: ActionCreatorWithPayload<ID[]>;
}
export function createAsyncItemReducer<S, T>(
builder: ActionReducerMapBuilder<S>,
getItem: (state: Draft<S>) => Draft<Async.Item<T>>,
actions: Pick<ActionParam<T>, "all" | "dirty">
) {
const { all, dirty } = actions;
all &&
builder
.addCase(all.pending, (state) => {
const item = getItem(state);
item.state = "loading";
item.error = null;
})
.addCase(all.fulfilled, (state, action) => {
const item = getItem(state);
item.state = "succeeded";
item.content = action.payload as Draft<T>;
})
.addCase(all.rejected, (state, action) => {
const item = getItem(state);
item.state = "failed";
item.error = action.error.message ?? null;
});
dirty &&
builder.addCase(dirty, (state) => {
const item = getItem(state);
if (item.state !== "uninitialized") {
item.state = "dirty";
}
});
}
export function createAsyncListReducer<S, T, ID extends Async.IdType>(
builder: ActionReducerMapBuilder<S>,
getList: (state: Draft<S>) => Draft<Async.List<T>>,
actions: ActionParam<T[], ID>
) {
const { ids, removeIds, all, dirty } = actions;
ids &&
builder
.addCase(ids.pending, (state) => {
const list = getList(state);
list.state = "loading";
list.error = null;
})
.addCase(ids.fulfilled, (state, action) => {
const list = getList(state);
const {
meta: { arg },
} = action;
const strIds = arg.map(String);
const keyName = list.keyName as keyof T;
action.payload.forEach((v) => {
const idx = findIndex(list.content, [keyName, v[keyName]]);
if (idx !== -1) {
list.content.splice(idx, 1, v as Draft<T>);
} else {
list.content.unshift(v as Draft<T>);
}
});
ReducerUtility.updateDirty(list, strIds);
ReducerUtility.updateDidLoaded(list, strIds);
})
.addCase(ids.rejected, (state, action) => {
const list = getList(state);
list.state = "failed";
list.error = action.error.message ?? null;
});
removeIds &&
builder.addCase(removeIds, (state, action) => {
const list = getList(state);
const keyName = list.keyName as keyof T;
const removeIds = action.payload.map(String);
pullAllWith(list.content, removeIds, (lhs, rhs) => {
return String((lhs as T)[keyName]) === rhs;
});
ReducerUtility.removeDirty(list, removeIds);
ReducerUtility.removeDidLoaded(list, removeIds);
});
all &&
builder
.addCase(all.pending, (state) => {
const list = getList(state);
list.state = "loading";
list.error = null;
})
.addCase(all.fulfilled, (state, action) => {
const list = getList(state);
list.state = "succeeded";
list.content = action.payload as Draft<T[]>;
list.dirtyEntities = [];
const ids = action.payload.map((v) =>
String(v[list.keyName as keyof T])
);
ReducerUtility.updateDidLoaded(list, ids);
})
.addCase(all.rejected, (state, action) => {
const list = getList(state);
list.state = "failed";
list.error = action.error.message ?? null;
});
dirty &&
builder.addCase(dirty, (state, action) => {
const list = getList(state);
ReducerUtility.markDirty(list, action.payload.map(String));
});
}
export function createAsyncEntityReducer<S, T, ID extends Async.IdType>(
builder: ActionReducerMapBuilder<S>,
getEntity: (state: Draft<S>) => Draft<Async.Entity<T>>,
actions: ActionParam<AsyncDataWrapper<T>, ID>
) {
const { all, removeIds, ids, range, dirty, reset } = actions;
const checkSizeUpdate = (entity: Draft<Async.Entity<T>>, newSize: number) => {
if (entity.content.ids.length !== newSize) {
// Reset Entity State
entity.dirtyEntities = [];
entity.content.ids = Array(newSize).fill(null);
entity.content.entities = {};
}
};
range &&
builder
.addCase(range.pending, (state) => {
const entity = getEntity(state);
entity.state = "loading";
entity.error = null;
})
.addCase(range.fulfilled, (state, action) => {
const entity = getEntity(state);
const {
meta: {
arg: { start, length },
},
payload: { data, total },
} = action;
const keyName = entity.content.keyName as keyof T;
checkSizeUpdate(entity, total);
data.forEach((v) => {
const key = String(v[keyName]);
entity.content.entities[key] = v as Draft<T>;
});
const idsToUpdate = data.map((v) => String(v[keyName]));
// Remove duplicated ids
const pulledSize =
total - pullAll(entity.content.ids, idsToUpdate).length;
entity.content.ids.push(...Array(pulledSize).fill(null));
entity.content.ids.splice(start, length, ...idsToUpdate);
ReducerUtility.updateDirty(entity, idsToUpdate);
ReducerUtility.updateDidLoaded(entity, idsToUpdate);
})
.addCase(range.rejected, (state, action) => {
const entity = getEntity(state);
entity.state = "failed";
entity.error = action.error.message ?? null;
});
ids &&
builder
.addCase(ids.pending, (state) => {
const entity = getEntity(state);
entity.state = "loading";
entity.error = null;
})
.addCase(ids.fulfilled, (state, action) => {
const entity = getEntity(state);
const {
meta: { arg },
payload: { data, total },
} = action;
const keyName = entity.content.keyName as keyof T;
checkSizeUpdate(entity, total);
const idsToAdd = data.map((v) => String(v[keyName]));
// For new ids, remove null from list and add them
const newIds = difference(
idsToAdd,
entity.content.ids.filter(isString)
);
const newSize = entity.content.ids.unshift(...newIds);
Array(newSize - total)
.fill(undefined)
.forEach(() => {
const idx = entity.content.ids.findIndex(isNull);
conditionalLog(idx === -1, "Error when deleting ids from entity");
entity.content.ids.splice(idx, 1);
});
data.forEach((v) => {
const key = String(v[keyName]);
entity.content.entities[key] = v as Draft<T>;
});
const allIds = arg.map(String);
ReducerUtility.updateDirty(entity, allIds);
ReducerUtility.updateDidLoaded(entity, allIds);
})
.addCase(ids.rejected, (state, action) => {
const entity = getEntity(state);
entity.state = "failed";
entity.error = action.error.message ?? null;
});
removeIds &&
builder.addCase(removeIds, (state, action) => {
const entity = getEntity(state);
conditionalLog(
entity.state === "loading",
"Try to delete async entity when it's now loading"
);
const idsToDelete = action.payload.map(String);
pullAll(entity.content.ids, idsToDelete);
ReducerUtility.removeDirty(entity, idsToDelete);
ReducerUtility.removeDidLoaded(entity, idsToDelete);
omit(entity.content.entities, idsToDelete);
});
all &&
builder
.addCase(all.pending, (state) => {
const entity = getEntity(state);
entity.state = "loading";
entity.error = null;
})
.addCase(all.fulfilled, (state, action) => {
const entity = getEntity(state);
const {
payload: { data, total },
} = action;
conditionalLog(
data.length !== total,
"Length of data is mismatch with total length"
);
const keyName = entity.content.keyName as keyof T;
entity.state = "succeeded";
entity.dirtyEntities = [];
entity.content.ids = data.map((v) => String(v[keyName]));
entity.content.entities = data.reduce<
Draft<{
[id: string]: T;
}>
>((prev, curr) => {
const id = String(curr[keyName]);
prev[id] = curr as Draft<T>;
return prev;
}, {});
const allIds = entity.content.ids.filter(isString);
ReducerUtility.updateDidLoaded(entity, allIds);
})
.addCase(all.rejected, (state, action) => {
const entity = getEntity(state);
entity.state = "failed";
entity.error = action.error.message ?? null;
});
dirty &&
builder.addCase(dirty, (state, action) => {
const entity = getEntity(state);
ReducerUtility.markDirty(entity, action.payload.map(String));
});
reset &&
builder.addCase(reset, (state) => {
const entity = getEntity(state);
entity.content.entities = {};
entity.content.ids = [];
entity.didLoaded = [];
entity.dirtyEntities = [];
entity.error = null;
entity.state = "uninitialized";
});
}

View File

@ -1,10 +1,88 @@
export function defaultAOS(): AsyncOrderState<any> { import { Draft } from "@reduxjs/toolkit";
return { import { difference, pullAll, uniq } from "lodash";
updating: true,
data: { export namespace AsyncUtility {
items: [], export function getDefaultItem<T>(): Async.Item<T> {
order: [], return {
dirty: false, state: "uninitialized",
}, content: null,
}; error: null,
};
}
export function getDefaultList<T>(key: keyof T): Async.List<T> {
return {
state: "uninitialized",
keyName: key,
dirtyEntities: [],
didLoaded: [],
content: [],
error: null,
};
}
export function getDefaultEntity<T>(key: keyof T): Async.Entity<T> {
return {
state: "uninitialized",
dirtyEntities: [],
didLoaded: [],
content: {
keyName: key,
ids: [],
entities: {},
},
error: null,
};
}
}
export namespace ReducerUtility {
type DirtyType = Draft<Async.Entity<any>> | Draft<Async.List<any>>;
export function markDirty<T extends DirtyType>(
entity: T,
dirtyIds: string[]
) {
if (entity.state !== "uninitialized" && entity.state !== "loading") {
entity.state = "dirty";
entity.dirtyEntities.push(...dirtyIds);
entity.dirtyEntities = uniq(entity.dirtyEntities);
}
}
export function updateDirty<T extends DirtyType>(
entity: T,
updatedIds: string[]
) {
entity.dirtyEntities = difference(entity.dirtyEntities, updatedIds);
if (entity.dirtyEntities.length > 0) {
entity.state = "dirty";
} else {
entity.state = "succeeded";
}
}
export function removeDirty<T extends DirtyType>(
entity: T,
removedIds: string[]
) {
pullAll(entity.dirtyEntities, removedIds);
if (entity.dirtyEntities.length === 0 && entity.state === "dirty") {
entity.state = "succeeded";
}
}
export function updateDidLoaded<T extends DirtyType>(
entity: T,
loadedIds: string[]
) {
entity.didLoaded.push(...loadedIds);
entity.didLoaded = uniq(entity.didLoaded);
}
export function removeDidLoaded<T extends DirtyType>(
entity: T,
removedIds: string[]
) {
pullAll(entity.didLoaded, removedIds);
}
} }

View File

@ -1,181 +0,0 @@
import { difference, has, isArray, isNull, isNumber, uniqBy } from "lodash";
import { Action } from "redux-actions";
import { conditionalLog } from "../../utilites/logger";
import { AsyncAction } from "../types";
export function updateAsyncState<Payload>(
action: AsyncAction<Payload>,
defVal: Readonly<Payload>
): AsyncState<Payload> {
if (action.payload.loading) {
return {
updating: true,
data: defVal,
};
} else if (action.error !== undefined) {
return {
updating: false,
error: action.payload.item as Error,
data: defVal,
};
} else {
return {
updating: false,
error: undefined,
data: action.payload.item as Payload,
};
}
}
export function updateOrderIdState<T extends LooseObject>(
action: AsyncAction<AsyncDataWrapper<T>>,
state: AsyncOrderState<T>,
id: ItemIdType<T>
): AsyncOrderState<T> {
if (action.payload.loading) {
return {
data: {
...state.data,
dirty: true,
},
updating: true,
};
} else if (action.error !== undefined) {
return {
data: {
...state.data,
dirty: true,
},
updating: false,
error: action.payload.item as Error,
};
} else {
const { data, total } = action.payload.item as AsyncDataWrapper<T>;
const { parameters } = action.payload;
const [start, length] = parameters;
// Convert item list to object
const newItems = data.reduce<IdState<T>>(
(prev, curr) => {
const tid = curr[id];
prev[tid] = curr;
return prev;
},
{ ...state.data.items }
);
let newOrder = [...state.data.order];
const countDist = total - newOrder.length;
if (countDist > 0) {
newOrder = Array(countDist).fill(null).concat(newOrder);
} else if (countDist < 0) {
// Completely drop old data if list has shrinked
newOrder = Array(total).fill(null);
}
const idList = newOrder.filter(isNumber);
const dataOrder: number[] = data.map((v) => v[id]);
if (typeof start === "number" && typeof length === "number") {
newOrder.splice(start, length, ...dataOrder);
} else if (isArray(start)) {
// Find the null values and delete them, insert new values to the front of array
const addition = difference(dataOrder, idList);
let addCount = addition.length;
newOrder.unshift(...addition);
newOrder = newOrder.flatMap((v) => {
if (isNull(v) && addCount > 0) {
--addCount;
return [];
} else {
return [v];
}
}, []);
conditionalLog(
addCount !== 0,
"Error when replacing item in OrderIdState"
);
} else if (parameters.length === 0) {
// TODO: Delete me -> Full Update
newOrder = dataOrder;
}
return {
updating: false,
data: {
dirty: true,
items: newItems,
order: newOrder,
},
};
}
}
export function deleteOrderListItemBy<T extends LooseObject>(
action: Action<number[]>,
state: AsyncOrderState<T>
): AsyncOrderState<T> {
const ids = action.payload;
const { items, order } = state.data;
const newItems = { ...items };
ids.forEach((v) => {
if (has(newItems, v)) {
delete newItems[v];
}
});
const newOrder = difference(order, ids);
return {
...state,
data: {
dirty: true,
items: newItems,
order: newOrder,
},
};
}
export function deleteAsyncListItemBy<T extends LooseObject>(
action: Action<number[]>,
state: AsyncState<T[]>,
match: ItemIdType<T>
): AsyncState<T[]> {
const ids = new Set(action.payload);
const data = [...state.data].filter((v) => !ids.has(v[match]));
return {
...state,
data,
};
}
export function updateAsyncList<T, ID extends keyof T>(
action: AsyncAction<T[]>,
state: AsyncState<T[]>,
match: ID
): AsyncState<T[]> {
if (action.payload.loading) {
return {
...state,
updating: true,
};
} else if (action.error !== undefined) {
return {
...state,
updating: false,
error: action.payload.item as Error,
};
} else {
const olds = state.data as T[];
const news = action.payload.item as T[];
const result = uniqBy([...news, ...olds], match);
return {
updating: false,
data: result,
};
}
}

View File

@ -16,7 +16,7 @@ td {
.dropdown-hidden { .dropdown-hidden {
&::after { &::after {
display: none; display: none !important;
} }
} }

View File

@ -1,26 +0,0 @@
import { useCallback, useEffect } from "react";
import Socketio from ".";
import { log } from "../utilites/logger";
export function useSocketIOReducer(reducer: SocketIO.Reducer) {
useEffect(() => {
Socketio.addReducer(reducer);
log("info", "listening to SocketIO event", reducer.key);
return () => {
Socketio.removeReducer(reducer);
};
}, [reducer]);
}
export function useWrapToOptionalId(
fn: (id: number[]) => void
): SocketIO.ActionFn<number> {
return useCallback(
(id?: number[]) => {
if (id) {
fn(id);
}
},
[fn]
);
}

View File

@ -1,129 +0,0 @@
import { createAction } from "redux-actions";
import {
badgeUpdateAll,
bootstrap,
movieDeleteItems,
movieDeleteWantedItems,
movieUpdateList,
movieUpdateWantedList,
seriesDeleteItems,
seriesDeleteWantedItems,
seriesUpdateList,
seriesUpdateWantedList,
siteAddNotifications,
siteAddProgress,
siteInitializationFailed,
siteRemoveProgress,
siteUpdateOffline,
systemUpdateLanguagesAll,
systemUpdateSettings,
} from "../@redux/actions";
import reduxStore from "../@redux/store";
function bindToReduxStore(
fn: (ids?: number[]) => any
): SocketIO.ActionFn<number> {
return (ids?: number[]) => reduxStore.dispatch(fn(ids));
}
export function createDeleteAction(type: string): SocketIO.ActionFn<number> {
return createAction(type, (id?: number[]) => id ?? []);
}
export function createDefaultReducer(): SocketIO.Reducer[] {
return [
{
key: "connect",
any: () => reduxStore.dispatch(siteUpdateOffline(false)),
},
{
key: "connect",
any: () => reduxStore.dispatch<any>(bootstrap()),
},
{
key: "connect_error",
any: () => {
const initialized = reduxStore.getState().site.initialized;
if (initialized === true) {
reduxStore.dispatch(siteUpdateOffline(true));
} else {
reduxStore.dispatch(siteInitializationFailed());
}
},
},
{
key: "disconnect",
any: () => reduxStore.dispatch(siteUpdateOffline(true)),
},
{
key: "message",
update: (msg) => {
if (msg) {
const notifications = msg.map<ReduxStore.Notification>((message) => ({
message,
type: "info",
id: "backend-message",
timeout: 5 * 1000,
}));
reduxStore.dispatch(siteAddNotifications(notifications));
}
},
},
{
key: "progress",
update: (progress) => {
if (progress) {
reduxStore.dispatch(siteAddProgress(progress));
}
},
delete: (ids) => {
setTimeout(() => {
ids?.forEach((id) => {
reduxStore.dispatch(siteRemoveProgress(id));
});
}, 3 * 1000);
},
},
{
key: "series",
update: bindToReduxStore(seriesUpdateList),
delete: bindToReduxStore(seriesDeleteItems),
},
{
key: "movie",
update: bindToReduxStore(movieUpdateList),
delete: bindToReduxStore(movieDeleteItems),
},
{
key: "episode-wanted",
update: (ids: number[] | undefined) => {
if (ids) {
reduxStore.dispatch(seriesUpdateWantedList(ids) as any);
}
},
delete: bindToReduxStore(seriesDeleteWantedItems),
},
{
key: "movie-wanted",
update: (ids: number[] | undefined) => {
if (ids) {
reduxStore.dispatch(movieUpdateWantedList(ids) as any);
}
},
delete: bindToReduxStore(movieDeleteWantedItems),
},
{
key: "settings",
any: bindToReduxStore(systemUpdateSettings),
},
{
key: "languages",
any: bindToReduxStore(systemUpdateLanguagesAll),
},
{
key: "badges",
any: bindToReduxStore(badgeUpdateAll),
},
];
}

View File

@ -1,5 +1,3 @@
type LanguageCodeType = string;
interface Badge { interface Badge {
episodes: number; episodes: number;
movies: number; movies: number;
@ -7,35 +5,40 @@ interface Badge {
status: number; status: number;
} }
interface ApiLanguage { declare namespace Language {
code2: LanguageCodeType; type CodeType = string;
name: string; interface Server {
enabled: boolean; code2: CodeType;
} name: string;
enabled: boolean;
}
type Language = Omit<ApiLanguage, "enabled"> & { interface Info {
// TODO: Make things unify code2: CodeType;
hi?: boolean; name: string;
forced?: boolean; hi?: boolean;
}; forced?: boolean;
}
namespace Profile { interface ProfileItem {
interface Item {
id: number; id: number;
audio_exclude: PythonBoolean; audio_exclude: PythonBoolean;
forced: PythonBoolean; forced: PythonBoolean;
hi: PythonBoolean; hi: PythonBoolean;
language: LanguageCodeType; language: CodeType;
} }
interface Languages {
interface Profile {
name: string; name: string;
profileId: number; profileId: number;
cutoff: number | null; cutoff: number | null;
items: Item[]; items: ProfileItem[];
} }
} }
interface Subtitle extends Language { interface Subtitle {
code2: Language.CodeType;
name: string;
forced: boolean; forced: boolean;
hi: boolean; hi: boolean;
path: string | null; path: string | null;
@ -91,15 +94,15 @@ interface TitleType {
} }
interface AudioLanguageType { interface AudioLanguageType {
audio_language: Language[]; audio_language: Language.Info[];
} }
interface ItemHistoryType { interface ItemHistoryType {
language: Language; language: Language.Info;
provider: string; provider: string;
} }
namespace Item { declare namespace Item {
type Base = PathType & type Base = PathType &
TitleType & TitleType &
TagType & TagType &
@ -152,7 +155,7 @@ namespace Item {
}; };
} }
namespace Wanted { declare namespace Wanted {
type Base = MonitoredType & type Base = MonitoredType &
TagType & TagType &
SceneNameType & { SceneNameType & {
@ -171,7 +174,7 @@ namespace Wanted {
type Movie = Base & MovieIdType & TitleType; type Movie = Base & MovieIdType & TitleType;
} }
namespace Blacklist { declare namespace Blacklist {
type Base = ItemHistoryType & { type Base = ItemHistoryType & {
parsed_timestamp: string; parsed_timestamp: string;
timestamp: string; timestamp: string;
@ -187,16 +190,17 @@ namespace Blacklist {
}; };
} }
namespace History { declare namespace History {
type Base = SubtitlePathType & type Base = SubtitlePathType &
TagType & TagType &
MonitoredType & MonitoredType &
Partial<ItemHistoryType> & { Partial<ItemHistoryType> & {
id: number;
action: number; action: number;
blacklisted: boolean; blacklisted: boolean;
score?: string; score?: string;
subs_id?: string; subs_id?: string;
raw_timestamp: int; raw_timestamp: number;
parsed_timestamp: string; parsed_timestamp: string;
timestamp: string; timestamp: string;
description: string; description: string;
@ -225,6 +229,13 @@ namespace History {
type ActionOptions = 1 | 2 | 3; type ActionOptions = 1 | 2 | 3;
} }
declare namespace Parameter {
interface Range {
start: number;
length: number;
}
}
interface SearchResultType { interface SearchResultType {
matches: string[]; matches: string[];
dont_matches: string[]; dont_matches: string[];

24
frontend/src/@types/async.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
declare namespace Async {
type State = "loading" | "succeeded" | "failed" | "dirty" | "uninitialized";
type IdType = number | string;
type Base<T> = {
state: State;
content: T;
error: string | null;
};
type List<T> = Base<T[]> & {
keyName: keyof T;
dirtyEntities: string[];
didLoaded: string[];
};
type Item<T> = Base<T | null>;
type Entity<T> = Base<EntityStruct<T>> & {
dirtyEntities: string[];
didLoaded: string[];
};
}

View File

@ -11,22 +11,6 @@ type FileTree = {
type StorageType = string | null; type StorageType = string | null;
interface OrderIdState<T> {
items: IdState<T>;
order: (number | null)[];
dirty: boolean;
}
interface AsyncState<T> {
updating: boolean;
error?: Error;
data: Readonly<T>;
}
type AsyncOrderState<T> = AsyncState<OrderIdState<T>>;
type AsyncPayload<T> = T extends AsyncState<infer D> ? D : never;
type SelectorOption<PAYLOAD> = { type SelectorOption<PAYLOAD> = {
label: string; label: string;
value: PAYLOAD; value: PAYLOAD;

View File

@ -1,4 +1,4 @@
namespace FormType { declare namespace FormType {
interface ModifyItem { interface ModifyItem {
id: number[]; id: number[];
profileid: (number | null)[]; profileid: (number | null)[];
@ -57,7 +57,7 @@ namespace FormType {
interface AddBlacklist { interface AddBlacklist {
provider: string; provider: string;
subs_id: string; subs_id: string;
language: LanguageCodeType; language: Language.CodeType;
subtitles_path: string; subtitles_path: string;
} }

View File

@ -1,3 +1,6 @@
// @ts-nocheck
// TODO: Fine a better solution for this
import { import {
UseColumnOrderInstanceProps, UseColumnOrderInstanceProps,
UseColumnOrderState, UseColumnOrderState,
@ -48,8 +51,7 @@ declare module "react-table" {
interface CustomTableProps<D extends Record<string, unknown>> interface CustomTableProps<D extends Record<string, unknown>>
extends useSelectionProps<D> { extends useSelectionProps<D> {
externalUpdate?: TableUpdater<D>; update?: TableUpdater<D>;
loose?: any[];
} }
interface CustomTableState<D extends Record<string, unknown>> interface CustomTableState<D extends Record<string, unknown>>

View File

@ -20,11 +20,11 @@ interface Settings {
napisy24: Settings.Napisy24; napisy24: Settings.Napisy24;
subscene: Settings.Subscene; subscene: Settings.Subscene;
betaseries: Settings.Betaseries; betaseries: Settings.Betaseries;
titlovi: Settings.titlovi; titlovi: Settings.Titlovi;
notifications: Settings.Notifications; notifications: Settings.Notifications;
} }
namespace Settings { declare namespace Settings {
interface General { interface General {
adaptive_searching: boolean; adaptive_searching: boolean;
anti_captcha_provider?: string; anti_captcha_provider?: string;

18
frontend/src/@types/site.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
declare namespace Server {
interface Notification {
type: "error" | "warning" | "info";
id: string;
message: string;
timeout: number;
}
}
declare namespace Site {
interface Progress {
id: string;
header: string;
name: string;
value: number;
count: number;
}
}

View File

@ -1,4 +1,4 @@
namespace System { declare namespace System {
interface Task { interface Task {
interval: string; interval: string;
job_id: string; job_id: string;

View File

@ -17,6 +17,14 @@ type Pair<T = string> = {
value: T; value: T;
}; };
type EntityStruct<T> = {
keyName: keyof T;
ids: (string | null)[];
entities: {
[id: string]: T;
};
};
interface DataWrapper<T> { interface DataWrapper<T> {
data: T; data: T;
} }
@ -32,13 +40,7 @@ type Override<T, U> = T & Omit<U, keyof T>;
type Comparer<T> = (lhs: T, rhs: T) => boolean; type Comparer<T> = (lhs: T, rhs: T) => boolean;
type KeysOfType<D, T> = NonNullable< type OptionalRecord<T extends string | number, D> = { [P in T]?: D };
ValueOf<{ [P in keyof D]: D[P] extends T ? P : never }>
>;
type ItemIdType<T> = KeysOfType<T, number>;
type OptionalRecord<T, D> = { [P in T]?: D };
interface IdState<T> { interface IdState<T> {
[key: number]: Readonly<T>; [key: number]: Readonly<T>;

View File

@ -15,6 +15,7 @@ import {
Navbar, Navbar,
Row, Row,
} from "react-bootstrap"; } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { SidebarToggleContext } from "."; import { SidebarToggleContext } from ".";
import { siteRedirectToAuth } from "../@redux/actions"; import { siteRedirectToAuth } from "../@redux/actions";
import { useSystemSettings } from "../@redux/hooks"; import { useSystemSettings } from "../@redux/hooks";
@ -23,8 +24,9 @@ import { useIsOffline } from "../@redux/hooks/site";
import logo from "../@static/logo64.png"; import logo from "../@static/logo64.png";
import { SystemApi } from "../apis"; import { SystemApi } from "../apis";
import { ActionButton, SearchBar, SearchResult } from "../components"; import { ActionButton, SearchBar, SearchResult } from "../components";
import { useGotoHomepage } from "../utilites"; import { useGotoHomepage, useIsMobile } from "../utilities";
import "./header.scss"; import "./header.scss";
import NotificationCenter from "./Notification";
async function SearchItem(text: string) { async function SearchItem(text: string) {
const results = await SystemApi.search(text); const results = await SystemApi.search(text);
@ -50,15 +52,17 @@ interface Props {}
const Header: FunctionComponent<Props> = () => { const Header: FunctionComponent<Props> = () => {
const setNeedAuth = useReduxAction(siteRedirectToAuth); const setNeedAuth = useReduxAction(siteRedirectToAuth);
const [settings] = useSystemSettings(); const settings = useSystemSettings();
const canLogout = (settings.data?.auth.type ?? "none") === "form"; const canLogout = (settings.content?.auth.type ?? "none") === "form";
const toggleSidebar = useContext(SidebarToggleContext); const toggleSidebar = useContext(SidebarToggleContext);
const offline = useIsOffline(); const offline = useIsOffline();
const dropdown = useMemo( const isMobile = useIsMobile();
const serverActions = useMemo(
() => ( () => (
<Dropdown alignRight> <Dropdown alignRight>
<Dropdown.Toggle className="dropdown-hidden" as={Button}> <Dropdown.Toggle className="dropdown-hidden" as={Button}>
@ -98,6 +102,9 @@ const Header: FunctionComponent<Props> = () => {
return ( return (
<Navbar bg="primary" className="flex-grow-1 px-0"> <Navbar bg="primary" className="flex-grow-1 px-0">
<Helmet>
<meta name="theme-color" content="#911f93" />
</Helmet>
<div className="header-icon px-3 m-0 d-none d-md-block"> <div className="header-icon px-3 m-0 d-none d-md-block">
<Image <Image
alt="brand" alt="brand"
@ -113,10 +120,11 @@ const Header: FunctionComponent<Props> = () => {
</Button> </Button>
<Container fluid> <Container fluid>
<Row noGutters className="flex-grow-1"> <Row noGutters className="flex-grow-1">
<Col xs={6} sm={4} className="d-flex align-items-center"> <Col xs={4} sm={6} className="d-flex align-items-center">
<SearchBar onSearch={SearchItem}></SearchBar> <SearchBar onSearch={SearchItem}></SearchBar>
</Col> </Col>
<Col className="d-flex flex-row align-items-center justify-content-end pr-2"> <Col className="d-flex flex-row align-items-center justify-content-end pr-2">
<NotificationCenter></NotificationCenter>
<Button <Button
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url" href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
target="_blank" target="_blank"
@ -131,10 +139,10 @@ const Header: FunctionComponent<Props> = () => {
variant="warning" variant="warning"
icon={faNetworkWired} icon={faNetworkWired}
> >
Connecting... {isMobile ? "" : "Connecting..."}
</ActionButton> </ActionButton>
) : ( ) : (
dropdown serverActions
)} )}
</Col> </Col>
</Row> </Row>

View File

@ -0,0 +1,240 @@
import {
faBug,
faCircleNotch,
faExclamationTriangle,
faInfoCircle,
faStream,
IconDefinition,
} from "@fortawesome/free-solid-svg-icons";
import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import React, {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Button,
Dropdown,
Overlay,
ProgressBar,
Tooltip,
} from "react-bootstrap";
import { useDidUpdate, useTimeoutWhen } from "rooks";
import { useReduxStore } from "../@redux/hooks/base";
import { BuildKey, useIsArrayExtended } from "../utilities";
import "./notification.scss";
enum State {
Idle,
Working,
Failed,
}
function useTotalProgress(progress: Site.Progress[]) {
return useMemo(() => {
const { value, count } = progress.reduce(
(prev, { value, count }) => {
prev.value += value;
prev.count += count;
return prev;
},
{ value: 0, count: 0 }
);
if (count === 0) {
return 0;
} else {
return (value + 0.001) / count;
}
}, [progress]);
}
function useHasErrorNotification(notifications: Server.Notification[]) {
return useMemo(
() => notifications.find((v) => v.type !== "info") !== undefined,
[notifications]
);
}
const NotificationCenter: FunctionComponent = () => {
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
const dropdownRef = useRef<HTMLDivElement>(null);
const [hasNew, setHasNew] = useState(false);
const hasNewProgress = useIsArrayExtended(progress);
const hasNewNotifications = useIsArrayExtended(notifications);
useDidUpdate(() => {
if (hasNewNotifications || hasNewProgress) {
setHasNew(true);
}
}, [hasNewProgress, hasNewNotifications]);
useDidUpdate(() => {
if (progress.length === 0 && notifications.length === 0) {
setHasNew(false);
}
}, [progress.length, notifications.length]);
const [btnState, setBtnState] = useState(State.Idle);
const totalProgress = useTotalProgress(progress);
const hasError = useHasErrorNotification(notifications);
useEffect(() => {
if (hasError) {
setBtnState(State.Failed);
} else if (totalProgress > 0 && totalProgress < 1.0) {
setBtnState(State.Working);
} else {
setBtnState(State.Idle);
}
}, [totalProgress, hasError]);
const iconProps = useMemo<FontAwesomeIconProps>(() => {
switch (btnState) {
case State.Idle:
return {
icon: faStream,
};
case State.Working:
return {
icon: faCircleNotch,
spin: true,
};
default:
return {
icon: faExclamationTriangle,
};
}
}, [btnState]);
const content = useMemo<React.ReactNode>(() => {
const nodes: JSX.Element[] = [];
nodes.push(
<Dropdown.Header key="notifications-header">
{notifications.length > 0 ? "Notifications" : "No Notifications"}
</Dropdown.Header>
);
nodes.push(
...notifications.map((v, idx) => (
<Dropdown.Item disabled key={BuildKey(idx, v.id, "notification")}>
<Notification {...v}></Notification>
</Dropdown.Item>
))
);
nodes.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
nodes.push(
<Dropdown.Header key="background-task-header">
{progress.length > 0 ? "Background Tasks" : "No Background Tasks"}
</Dropdown.Header>
);
nodes.push(
...progress.map((v, idx) => (
<Dropdown.Item disabled key={BuildKey(idx, v.id, "progress")}>
<Progress {...v}></Progress>
</Dropdown.Item>
))
);
return nodes;
}, [progress, notifications]);
const onToggleClick = useCallback(() => {
setHasNew(false);
}, []);
// Tooltip Controller
const [showTooltip, setTooltip] = useState(false);
useTimeoutWhen(() => setTooltip(false), 3 * 1000, showTooltip);
useDidUpdate(() => {
if (notifier.content) {
setTooltip(true);
}
}, [notifier.timestamp]);
return (
<React.Fragment>
<Dropdown
onClick={onToggleClick}
className={`notification-btn ${hasNew ? "new-item" : ""}`}
ref={dropdownRef}
alignRight
>
<Dropdown.Toggle as={Button} className="dropdown-hidden">
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
</Dropdown.Toggle>
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
</Dropdown>
<Overlay target={dropdownRef} show={showTooltip} placement="bottom">
{(props) => {
return (
<Tooltip id="new-notification-tip" {...props}>
{notifier.content}
</Tooltip>
);
}}
</Overlay>
</React.Fragment>
);
};
const Notification: FunctionComponent<Server.Notification> = ({
type,
message,
}) => {
const icon = useMemo<IconDefinition>(() => {
switch (type) {
case "info":
return faInfoCircle;
case "warning":
return faExclamationTriangle;
default:
return faBug;
}
}, [type]);
return (
<div className="notification-center-notification d-flex flex-nowrap align-items-center justify-content-start my-1">
<FontAwesomeIcon className="mr-2 text-dark" icon={icon}></FontAwesomeIcon>
<span className="text-dark small">{message}</span>
</div>
);
};
const Progress: FunctionComponent<Site.Progress> = ({
name,
value,
count,
header,
}) => {
const isCompleted = value / count > 1;
const displayValue = Math.min(count, value + 1);
return (
<div className="notification-center-progress d-flex flex-column">
<p className="progress-header m-0 h-6 text-dark font-weight-bold">
{header}
</p>
<p className="progress-name m-0 small text-secondary">
{isCompleted ? "Completed successfully" : name}
</p>
<ProgressBar
className="mt-2"
animated={!isCompleted}
now={displayValue / count}
max={1}
label={`${displayValue}/${count}`}
></ProgressBar>
</div>
);
};
export default NotificationCenter;

View File

@ -3,13 +3,12 @@ import { Redirect, Route, Switch, useHistory } from "react-router-dom";
import { useDidMount } from "rooks"; import { useDidMount } from "rooks";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site"; import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
import BlacklistRouter from "../Blacklist/Router"; import BlacklistRouter from "../Blacklist/Router";
import DisplayItemRouter from "../DisplayItem/Router";
import HistoryRouter from "../History/Router"; import HistoryRouter from "../History/Router";
import MovieRouter from "../Movies/Router";
import SeriesRouter from "../Series/Router";
import SettingRouter from "../Settings/Router"; import SettingRouter from "../Settings/Router";
import EmptyPage, { RouterEmptyPath } from "../special-pages/404"; import EmptyPage, { RouterEmptyPath } from "../special-pages/404";
import SystemRouter from "../System/Router"; import SystemRouter from "../System/Router";
import { ScrollToTop } from "../utilites"; import { ScrollToTop } from "../utilities";
import WantedRouter from "../Wanted/Router"; import WantedRouter from "../Wanted/Router";
const Router: FunctionComponent<{ className?: string }> = ({ className }) => { const Router: FunctionComponent<{ className?: string }> = ({ className }) => {
@ -40,16 +39,9 @@ const Router: FunctionComponent<{ className?: string }> = ({ className }) => {
<Route exact path="/"> <Route exact path="/">
<Redirect exact to={redirectPath}></Redirect> <Redirect exact to={redirectPath}></Redirect>
</Route> </Route>
{sonarr && ( <Route path={["/series", "/movies"]}>
<Route path="/series"> <DisplayItemRouter></DisplayItemRouter>
<SeriesRouter></SeriesRouter> </Route>
</Route>
)}
{radarr && (
<Route path="/movies">
<MovieRouter></MovieRouter>
</Route>
)}
<Route path="/wanted"> <Route path="/wanted">
<WantedRouter></WantedRouter> <WantedRouter></WantedRouter>
</Route> </Route>

View File

@ -5,16 +5,21 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { Row } from "react-bootstrap"; import { Row } from "react-bootstrap";
import { Redirect } from "react-router-dom"; import { Provider } from "react-redux";
import { Route, Switch } from "react-router";
import { BrowserRouter, Redirect } from "react-router-dom";
import { useEffectOnceWhen } from "rooks";
import Socketio from "../@modules/socketio";
import { useReduxStore } from "../@redux/hooks/base"; import { useReduxStore } from "../@redux/hooks/base";
import { useNotification } from "../@redux/hooks/site"; import { useNotification } from "../@redux/hooks/site";
import store from "../@redux/store";
import { LoadingIndicator, ModalProvider } from "../components"; import { LoadingIndicator, ModalProvider } from "../components";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
import Auth from "../special-pages/AuthPage";
import ErrorBoundary from "../special-pages/ErrorBoundary";
import LaunchError from "../special-pages/LaunchError"; import LaunchError from "../special-pages/LaunchError";
import UIError from "../special-pages/UIError"; import { Environment } from "../utilities";
import { useHasUpdateInject } from "../utilites";
import Header from "./Header"; import Header from "./Header";
import NotificationContainer from "./notifications";
import Router from "./Router"; import Router from "./Router";
// Sidebar Toggle // Sidebar Toggle
@ -28,18 +33,15 @@ const App: FunctionComponent<Props> = () => {
const notify = useNotification("has-update", 10 * 1000); const notify = useNotification("has-update", 10 * 1000);
// Has any update? // Has any update?
const hasUpdate = useHasUpdateInject(); useEffectOnceWhen(() => {
useEffect(() => { if (Environment.hasUpdate) {
if (initialized) { notify({
if (hasUpdate) { type: "info",
notify({ message: "A new version of Bazarr is ready, restart is required",
type: "info", // TODO: Restart action
message: "A new version of Bazarr is ready, restart is required", });
// TODO: Restart action
});
}
} }
}, [initialized, hasUpdate, notify]); }, initialized === true);
const [sidebar, setSidebar] = useState(false); const [sidebar, setSidebar] = useState(false);
const toggleSidebar = useCallback(() => setSidebar((s) => !s), []); const toggleSidebar = useCallback(() => setSidebar((s) => !s), []);
@ -57,8 +59,8 @@ const App: FunctionComponent<Props> = () => {
} else if (typeof initialized === "string") { } else if (typeof initialized === "string") {
return <LaunchError>{initialized}</LaunchError>; return <LaunchError>{initialized}</LaunchError>;
} }
try { return (
return ( <ErrorBoundary>
<SidebarToggleContext.Provider value={toggleSidebar}> <SidebarToggleContext.Provider value={toggleSidebar}>
<Row noGutters className="header-container"> <Row noGutters className="header-container">
<Header></Header> <Header></Header>
@ -69,12 +71,39 @@ const App: FunctionComponent<Props> = () => {
<Router className="d-flex flex-row flex-grow-1 main-router"></Router> <Router className="d-flex flex-row flex-grow-1 main-router"></Router>
</ModalProvider> </ModalProvider>
</Row> </Row>
<NotificationContainer></NotificationContainer>
</SidebarToggleContext.Provider> </SidebarToggleContext.Provider>
); </ErrorBoundary>
} catch (e) { );
return <UIError error={e}></UIError>;
}
}; };
export default App; const MainRouter: FunctionComponent = () => {
useEffect(() => {
Socketio.initialize();
}, []);
return (
<BrowserRouter basename={Environment.baseUrl}>
<Switch>
<Route exact path="/login">
<Auth></Auth>
</Route>
<Route path="/">
<App></App>
</Route>
</Switch>
</BrowserRouter>
);
};
const Main: FunctionComponent = () => {
return (
<Provider store={store}>
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
{/* <React.StrictMode> */}
<MainRouter></MainRouter>
{/* </React.StrictMode> */}
</Provider>
);
};
export default Main;

View File

@ -0,0 +1,52 @@
@import "../@scss/variable.scss";
@import "../@scss/bazarr.scss";
@function theme-color($key: "primary") {
@return map-get($theme-colors, $key);
}
.notification-btn {
&.new-item {
&::after {
position: absolute;
background-color: red;
content: "";
border-radius: 50%;
height: 6px;
width: 6px;
right: 10%;
top: 10%;
}
}
.dropdown-menu {
max-height: 85vh;
overflow-y: auto;
}
$content-width: 14rem;
.notification-center-progress {
width: $content-width;
max-width: $content-width;
.progress-name {
word-wrap: break-word;
white-space: normal;
}
.progress .progress-bar {
text-shadow: -2px -2px 5px theme-color("primary"),
2px -2px 5px theme-color("primary"), -2px 2px 5px theme-color("primary"),
2px 2px 5px theme-color("primary");
overflow: visible;
}
}
.notification-center-notification {
word-wrap: break-word;
white-space: normal;
width: $content-width;
max-width: $content-width;
}
}

View File

@ -1,117 +0,0 @@
import {
faExclamationTriangle,
faPaperPlane,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { capitalize } from "lodash";
import React, {
FunctionComponent,
useCallback,
useEffect,
useMemo,
} from "react";
import { ProgressBar, Toast } from "react-bootstrap";
import {
siteRemoveNotifications,
siteRemoveProgress,
} from "../../@redux/actions";
import { useReduxAction, useReduxStore } from "../../@redux/hooks/base";
import "./style.scss";
export interface NotificationContainerProps {}
const NotificationContainer: FunctionComponent<NotificationContainerProps> = () => {
const { progress, notifications } = useReduxStore((s) => s.site);
const items = useMemo(() => {
const progressItems = progress.map((v) => (
<ProgressToast key={v.id} {...v}></ProgressToast>
));
const notificationItems = notifications.map((v) => (
<NotificationToast key={v.id} {...v}></NotificationToast>
));
return [...progressItems, ...notificationItems];
}, [notifications, progress]);
return (
<div className="alert-container">
<div className="toast-container">{items}</div>
</div>
);
};
type MessageHolderProps = ReduxStore.Notification & {};
const NotificationToast: FunctionComponent<MessageHolderProps> = (props) => {
const { message, type, id, timeout } = props;
const removeNotification = useReduxAction(siteRemoveNotifications);
const remove = useCallback(() => removeNotification(id), [
removeNotification,
id,
]);
useEffect(() => {
const handle = setTimeout(remove, timeout);
return () => {
clearTimeout(handle);
};
}, [props, remove, timeout]);
return (
<Toast onClose={remove} animation={false}>
<Toast.Header>
<FontAwesomeIcon
className="mr-1"
icon={faExclamationTriangle}
></FontAwesomeIcon>
<strong className="mr-auto">{capitalize(type)}</strong>
</Toast.Header>
<Toast.Body>{message}</Toast.Body>
</Toast>
);
};
type ProgressHolderProps = ReduxStore.Progress & {};
const ProgressToast: FunctionComponent<ProgressHolderProps> = ({
id,
header,
name,
value,
count,
}) => {
const removeProgress = useReduxAction(siteRemoveProgress);
const remove = useCallback(() => removeProgress(id), [removeProgress, id]);
useEffect(() => {
const handle = setTimeout(remove, 10 * 1000);
return () => {
clearTimeout(handle);
};
}, [value, remove]);
const incomplete = value / count < 1;
return (
<Toast onClose={remove}>
<Toast.Header closeButton={false}>
<FontAwesomeIcon className="mr-2" icon={faPaperPlane}></FontAwesomeIcon>
<span className="mr-auto">{header}</span>
</Toast.Header>
<Toast.Body>
<span>{name}</span>
<ProgressBar
className="my-1"
animated={incomplete}
now={value / count}
max={1}
label={`${value}/${count}`}
></ProgressBar>
</Toast.Body>
</Toast>
);
};
export default NotificationContainer;

View File

@ -1,46 +0,0 @@
@import "../../@scss/variable.scss";
@import "../../@scss/bazarr.scss";
@function theme-color($key: "primary") {
@return map-get($theme-colors, $key);
}
.alert-container {
position: fixed;
display: block;
top: 0;
right: 0;
margin-top: $header-height;
z-index: 9999;
.toast-container {
padding: 1rem;
.toast {
width: 16rem;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
.toast-body {
span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 100%;
display: inline-block;
}
.progress {
.progress-bar {
text-shadow: -2px -2px 5px theme-color("primary"),
2px -2px 5px theme-color("primary"),
-2px 2px 5px theme-color("primary"),
2px 2px 5px theme-color("primary");
overflow: visible;
}
}
}
}
}
}

View File

@ -4,16 +4,16 @@ import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useBlacklistMovies } from "../../@redux/hooks"; import { useBlacklistMovies } from "../../@redux/hooks";
import { MoviesApi } from "../../apis"; import { MoviesApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components"; import { AsyncOverlay, ContentHeader } from "../../components";
import Table from "./table"; import Table from "./table";
interface Props {} interface Props {}
const BlacklistMoviesView: FunctionComponent<Props> = () => { const BlacklistMoviesView: FunctionComponent<Props> = () => {
const [blacklist] = useBlacklistMovies(); const blacklist = useBlacklistMovies();
return ( return (
<AsyncStateOverlay state={blacklist}> <AsyncOverlay ctx={blacklist}>
{({ data }) => ( {({ content }) => (
<Container fluid> <Container fluid>
<Helmet> <Helmet>
<title>Movies Blacklist - Bazarr</title> <title>Movies Blacklist - Bazarr</title>
@ -21,18 +21,18 @@ const BlacklistMoviesView: FunctionComponent<Props> = () => {
<ContentHeader> <ContentHeader>
<ContentHeader.AsyncButton <ContentHeader.AsyncButton
icon={faTrash} icon={faTrash}
disabled={data.length === 0} disabled={content?.length === 0}
promise={() => MoviesApi.deleteBlacklist(true)} promise={() => MoviesApi.deleteBlacklist(true)}
> >
Remove All Remove All
</ContentHeader.AsyncButton> </ContentHeader.AsyncButton>
</ContentHeader> </ContentHeader>
<Row> <Row>
<Table blacklist={data}></Table> <Table blacklist={content ?? []}></Table>
</Row> </Row>
</Container> </Container>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
); );
}; };

View File

@ -4,16 +4,16 @@ import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useBlacklistSeries } from "../../@redux/hooks"; import { useBlacklistSeries } from "../../@redux/hooks";
import { EpisodesApi } from "../../apis"; import { EpisodesApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components"; import { AsyncOverlay, ContentHeader } from "../../components";
import Table from "./table"; import Table from "./table";
interface Props {} interface Props {}
const BlacklistSeriesView: FunctionComponent<Props> = () => { const BlacklistSeriesView: FunctionComponent<Props> = () => {
const [blacklist] = useBlacklistSeries(); const blacklist = useBlacklistSeries();
return ( return (
<AsyncStateOverlay state={blacklist}> <AsyncOverlay ctx={blacklist}>
{({ data }) => ( {({ content }) => (
<Container fluid> <Container fluid>
<Helmet> <Helmet>
<title>Series Blacklist - Bazarr</title> <title>Series Blacklist - Bazarr</title>
@ -21,18 +21,18 @@ const BlacklistSeriesView: FunctionComponent<Props> = () => {
<ContentHeader> <ContentHeader>
<ContentHeader.AsyncButton <ContentHeader.AsyncButton
icon={faTrash} icon={faTrash}
disabled={data.length === 0} disabled={content?.length === 0}
promise={() => EpisodesApi.deleteBlacklist(true)} promise={() => EpisodesApi.deleteBlacklist(true)}
> >
Remove All Remove All
</ContentHeader.AsyncButton> </ContentHeader.AsyncButton>
</ContentHeader> </ContentHeader>
<Row> <Row>
<Table blacklist={data}></Table> <Table blacklist={content ?? []}></Table>
</Row> </Row>
</Container> </Container>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
); );
}; };

View File

@ -0,0 +1,194 @@
import {
faAdjust,
faBriefcase,
faCloudUploadAlt,
faHdd,
faSearch,
faSync,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent, useMemo, useState } from "react";
import { Alert, Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
import { dispatchTask } from "../../@modules/task";
import { useIsAnyTaskRunningWithId } from "../../@modules/task/hooks";
import { createTask } from "../../@modules/task/utilities";
import { useEpisodesBy, useProfileBy, useSerieBy } from "../../@redux/hooks";
import { SeriesApi } from "../../apis";
import {
ContentHeader,
ItemEditorModal,
LoadingIndicator,
SeriesUploadModal,
useShowModal,
} from "../../components";
import { RouterEmptyPath } from "../../special-pages/404";
import { useOnLoadedOnce } from "../../utilities";
import ItemOverview from "../generic/ItemOverview";
import Table from "./table";
interface Params {
id: string;
}
interface Props extends RouteComponentProps<Params> {}
const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
const { match } = props;
const id = Number.parseInt(match.params.id);
const series = useSerieBy(id);
const episodes = useEpisodesBy(id);
const serie = series.content;
const available = episodes.content.length !== 0;
const details = useMemo(
() => [
{
icon: faHdd,
text: `${serie?.episodeFileCount} files`,
},
{
icon: faAdjust,
text: serie?.seriesType ?? "",
},
],
[serie]
);
const showModal = useShowModal();
const [valid, setValid] = useState(true);
useOnLoadedOnce(() => {
if (series.content === null) {
setValid(false);
}
}, series);
const profile = useProfileBy(series.content?.profileId);
const hasTask = useIsAnyTaskRunningWithId([
...episodes.content.map((v) => v.sonarrEpisodeId),
id,
]);
if (isNaN(id) || !valid) {
return <Redirect to={RouterEmptyPath}></Redirect>;
}
if (!serie) {
return <LoadingIndicator></LoadingIndicator>;
}
return (
<Container fluid>
<Helmet>
<title>{serie.title} - Bazarr (Series)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Group pos="start">
<ContentHeader.Button
icon={faSync}
disabled={!available || hasTask}
onClick={() => {
const task = createTask(
serie.title,
id,
SeriesApi.action.bind(SeriesApi),
{
action: "scan-disk",
seriesid: id,
}
);
dispatchTask("Scaning disk...", [task], "Scaning...");
}}
>
Scan Disk
</ContentHeader.Button>
<ContentHeader.Button
icon={faSearch}
onClick={() => {
const task = createTask(
serie.title,
id,
SeriesApi.action.bind(SeriesApi),
{
action: "search-missing",
seriesid: id,
}
);
dispatchTask("Searching subtitles...", [task], "Searching...");
}}
disabled={
serie.episodeFileCount === 0 ||
serie.profileId === null ||
!available
}
>
Search
</ContentHeader.Button>
</ContentHeader.Group>
<ContentHeader.Group pos="end">
<ContentHeader.Button
disabled={serie.episodeFileCount === 0 || !available || hasTask}
icon={faBriefcase}
onClick={() => showModal("tools", episodes.content)}
>
Tools
</ContentHeader.Button>
<ContentHeader.Button
disabled={
serie.episodeFileCount === 0 ||
serie.profileId === null ||
!available
}
icon={faCloudUploadAlt}
onClick={() => showModal("upload", serie)}
>
Upload
</ContentHeader.Button>
<ContentHeader.Button
icon={faWrench}
disabled={hasTask}
onClick={() => showModal("edit", serie)}
>
Edit Series
</ContentHeader.Button>
</ContentHeader.Group>
</ContentHeader>
<Row>
<Alert
className="w-100 m-0 py-2"
show={hasTask}
style={{ borderRadius: 0 }}
variant="light"
>
A background task is running for this show, actions are unavailable
</Alert>
</Row>
<Row>
<ItemOverview item={serie} details={details}></ItemOverview>
</Row>
<Row>
<Table
serie={series}
episodes={episodes}
profile={profile}
disabled={hasTask}
></Table>
</Row>
<ItemEditorModal
modalKey="edit"
submit={(form) => SeriesApi.modify(form)}
></ItemEditorModal>
<SeriesUploadModal
modalKey="upload"
episodes={episodes.content}
></SeriesUploadModal>
</Container>
);
};
export default withRouter(SeriesEpisodesView);

View File

@ -9,12 +9,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useCallback, useMemo } from "react"; import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Badge, ButtonGroup } from "react-bootstrap"; import { Badge, ButtonGroup } from "react-bootstrap";
import { Column, TableUpdater } from "react-table"; import { Column, TableUpdater } from "react-table";
import { useProfileItems, useSerieBy } from "../../@redux/hooks"; import { useProfileItemsToLanguages } from "../../@redux/hooks";
import { useShowOnlyDesired } from "../../@redux/hooks/site"; import { useShowOnlyDesired } from "../../@redux/hooks/site";
import { ProvidersApi } from "../../apis"; import { ProvidersApi } from "../../apis";
import { import {
ActionButton, ActionButton,
AsyncStateOverlay, AsyncOverlay,
EpisodeHistoryModal, EpisodeHistoryModal,
GroupTable, GroupTable,
SubtitleToolModal, SubtitleToolModal,
@ -22,16 +22,17 @@ import {
useShowModal, useShowModal,
} from "../../components"; } from "../../components";
import { ManualSearchModal } from "../../components/modals/ManualSearchModal"; import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
import { BuildKey, filterSubtitleBy } from "../../utilites"; import { BuildKey, filterSubtitleBy } from "../../utilities";
import { SubtitleAction } from "./components"; import { SubtitleAction } from "./components";
interface Props { interface Props {
episodes: AsyncState<Item.Episode[]>; serie: Async.Item<Item.Series>;
profile?: Profile.Languages; episodes: Async.Base<Item.Episode[]>;
disabled?: boolean;
profile?: Language.Profile;
} }
const download = (item: any, result: SearchResultType) => { const download = (item: Item.Episode, result: SearchResultType) => {
item = item as Item.Episode;
const { language, hearing_impaired, forced, provider, subtitle } = result; const { language, hearing_impaired, forced, provider, subtitle } = result;
return ProvidersApi.downloadEpisodeSubtitle( return ProvidersApi.downloadEpisodeSubtitle(
item.sonarrSeriesId, item.sonarrSeriesId,
@ -46,12 +47,17 @@ const download = (item: any, result: SearchResultType) => {
); );
}; };
const Table: FunctionComponent<Props> = ({ episodes, profile }) => { const Table: FunctionComponent<Props> = ({
serie,
episodes,
profile,
disabled,
}) => {
const showModal = useShowModal(); const showModal = useShowModal();
const onlyDesired = useShowOnlyDesired(); const onlyDesired = useShowOnlyDesired();
const profileItems = useProfileItems(profile); const profileItems = useProfileItemsToLanguages(profile);
const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>( const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>(
() => [ () => [
@ -100,7 +106,7 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
{ {
Header: "Subtitles", Header: "Subtitles",
accessor: "missing_subtitles", accessor: "missing_subtitles",
Cell: ({ row, loose }) => { Cell: ({ row }) => {
const episode = row.original; const episode = row.original;
const seriesid = episode.sonarrSeriesId; const seriesid = episode.sonarrSeriesId;
@ -141,28 +147,28 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
{ {
Header: "Actions", Header: "Actions",
accessor: "sonarrEpisodeId", accessor: "sonarrEpisodeId",
Cell: ({ row, externalUpdate }) => { Cell: ({ row, update }) => {
const [serie] = useSerieBy(row.original.sonarrSeriesId);
return ( return (
<ButtonGroup> <ButtonGroup>
<ActionButton <ActionButton
icon={faUser} icon={faUser}
disabled={serie.data?.profileId === null} disabled={serie.content?.profileId === null || disabled}
onClick={() => { onClick={() => {
externalUpdate && externalUpdate(row, "manual-search"); update && update(row, "manual-search");
}} }}
></ActionButton> ></ActionButton>
<ActionButton <ActionButton
icon={faHistory} icon={faHistory}
disabled={disabled}
onClick={() => { onClick={() => {
externalUpdate && externalUpdate(row, "history"); update && update(row, "history");
}} }}
></ActionButton> ></ActionButton>
<ActionButton <ActionButton
icon={faBriefcase} icon={faBriefcase}
disabled={disabled}
onClick={() => { onClick={() => {
externalUpdate && externalUpdate(row, "tools"); update && update(row, "tools");
}} }}
></ActionButton> ></ActionButton>
</ButtonGroup> </ButtonGroup>
@ -170,7 +176,7 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
}, },
}, },
], ],
[onlyDesired, profileItems] [onlyDesired, profileItems, serie, disabled]
); );
const updateRow = useCallback<TableUpdater<Item.Episode>>( const updateRow = useCallback<TableUpdater<Item.Episode>>(
@ -186,7 +192,7 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
const maxSeason = useMemo( const maxSeason = useMemo(
() => () =>
episodes.data.reduce<number>( episodes.content.reduce<number>(
(prev, curr) => Math.max(prev, curr.season), (prev, curr) => Math.max(prev, curr.season),
0 0
), ),
@ -195,12 +201,12 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
return ( return (
<React.Fragment> <React.Fragment>
<AsyncStateOverlay state={episodes}> <AsyncOverlay ctx={episodes}>
{({ data }) => ( {({ content }) => (
<GroupTable <GroupTable
columns={columns} columns={columns}
data={data} data={content}
externalUpdate={updateRow} update={updateRow}
initialState={{ initialState={{
sortBy: [ sortBy: [
{ id: "season", desc: true }, { id: "season", desc: true },
@ -214,12 +220,12 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
emptyText="No Episode Found For This Series" emptyText="No Episode Found For This Series"
></GroupTable> ></GroupTable>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
<SubtitleToolModal modalKey="tools" size="lg"></SubtitleToolModal> <SubtitleToolModal modalKey="tools" size="lg"></SubtitleToolModal>
<EpisodeHistoryModal modalKey="history" size="lg"></EpisodeHistoryModal> <EpisodeHistoryModal modalKey="history" size="lg"></EpisodeHistoryModal>
<ManualSearchModal <ManualSearchModal
modalKey="manual-search" modalKey="manual-search"
onSelect={download} download={download}
></ManualSearchModal> ></ManualSearchModal>
</React.Fragment> </React.Fragment>
); );

View File

@ -7,10 +7,13 @@ import {
faUser, faUser,
faWrench, faWrench,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent, useCallback, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import { Container, Row } from "react-bootstrap"; import { Alert, Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom"; import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
import { dispatchTask } from "../../@modules/task";
import { useIsAnyTaskRunningWithId } from "../../@modules/task/hooks";
import { createTask } from "../../@modules/task/utilities";
import { useMovieBy, useProfileBy } from "../../@redux/hooks"; import { useMovieBy, useProfileBy } from "../../@redux/hooks";
import { MoviesApi, ProvidersApi } from "../../apis"; import { MoviesApi, ProvidersApi } from "../../apis";
import { import {
@ -23,13 +26,12 @@ import {
useShowModal, useShowModal,
} from "../../components"; } from "../../components";
import { ManualSearchModal } from "../../components/modals/ManualSearchModal"; import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
import ItemOverview from "../../generic/ItemOverview";
import { RouterEmptyPath } from "../../special-pages/404"; import { RouterEmptyPath } from "../../special-pages/404";
import { useOnLoadingFinish } from "../../utilites"; import { useOnLoadedOnce } from "../../utilities";
import ItemOverview from "../generic/ItemOverview";
import Table from "./table"; import Table from "./table";
const download = (item: any, result: SearchResultType) => { const download = (item: Item.Movie, result: SearchResultType) => {
item = item as Item.Movie;
const { language, hearing_impaired, forced, provider, subtitle } = result; const { language, hearing_impaired, forced, provider, subtitle } = result;
return ProvidersApi.downloadMovieSubtitle(item.radarrId, { return ProvidersApi.downloadMovieSubtitle(item.radarrId, {
language, language,
@ -48,22 +50,22 @@ interface Props extends RouteComponentProps<Params> {}
const MovieDetailView: FunctionComponent<Props> = ({ match }) => { const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
const id = Number.parseInt(match.params.id); const id = Number.parseInt(match.params.id);
const [movie] = useMovieBy(id); const movie = useMovieBy(id);
const item = movie.data; const item = movie.content;
const profile = useProfileBy(movie.data?.profileId); const profile = useProfileBy(movie.content?.profileId);
const showModal = useShowModal(); const showModal = useShowModal();
const [valid, setValid] = useState(true); const [valid, setValid] = useState(true);
const validator = useCallback(() => { const hasTask = useIsAnyTaskRunningWithId([id]);
if (movie.data === null) {
useOnLoadedOnce(() => {
if (movie.content === null) {
setValid(false); setValid(false);
} }
}, [movie.data]); }, movie);
useOnLoadingFinish(movie, validator);
if (isNaN(id) || !valid) { if (isNaN(id) || !valid) {
return <Redirect to={RouterEmptyPath}></Redirect>; return <Redirect to={RouterEmptyPath}></Redirect>;
@ -82,29 +84,42 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
</Helmet> </Helmet>
<ContentHeader> <ContentHeader>
<ContentHeader.Group pos="start"> <ContentHeader.Group pos="start">
<ContentHeader.AsyncButton <ContentHeader.Button
icon={faSync} icon={faSync}
promise={() => disabled={hasTask}
MoviesApi.action({ action: "scan-disk", radarrid: item.radarrId }) onClick={() => {
} const task = createTask(
item.title,
id,
MoviesApi.action.bind(MoviesApi),
{ action: "scan-disk", radarrid: id }
);
dispatchTask("Scaning Disk...", [task], "Scaning...");
}}
> >
Scan Disk Scan Disk
</ContentHeader.AsyncButton> </ContentHeader.Button>
<ContentHeader.AsyncButton <ContentHeader.Button
icon={faSearch} icon={faSearch}
disabled={item.profileId === null} disabled={item.profileId === null}
promise={() => onClick={() => {
MoviesApi.action({ const task = createTask(
action: "search-missing", item.title,
radarrid: item.radarrId, id,
}) MoviesApi.action.bind(MoviesApi),
} {
action: "search-missing",
radarrid: id,
}
);
dispatchTask("Searching subtitles...", [task], "Searching...");
}}
> >
Search Search
</ContentHeader.AsyncButton> </ContentHeader.Button>
<ContentHeader.Button <ContentHeader.Button
icon={faUser} icon={faUser}
disabled={item.profileId === null} disabled={item.profileId === null || hasTask}
onClick={() => showModal<Item.Movie>("manual-search", item)} onClick={() => showModal<Item.Movie>("manual-search", item)}
> >
Manual Manual
@ -117,6 +132,7 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
</ContentHeader.Button> </ContentHeader.Button>
<ContentHeader.Button <ContentHeader.Button
icon={faToolbox} icon={faToolbox}
disabled={hasTask}
onClick={() => showModal("tools", [item])} onClick={() => showModal("tools", [item])}
> >
Tools Tools
@ -125,7 +141,7 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
<ContentHeader.Group pos="end"> <ContentHeader.Group pos="end">
<ContentHeader.Button <ContentHeader.Button
disabled={!allowEdit || item.profileId === null} disabled={!allowEdit || item.profileId === null || hasTask}
icon={faCloudUploadAlt} icon={faCloudUploadAlt}
onClick={() => showModal("upload", item)} onClick={() => showModal("upload", item)}
> >
@ -133,17 +149,28 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
</ContentHeader.Button> </ContentHeader.Button>
<ContentHeader.Button <ContentHeader.Button
icon={faWrench} icon={faWrench}
disabled={hasTask}
onClick={() => showModal("edit", item)} onClick={() => showModal("edit", item)}
> >
Edit Movie Edit Movie
</ContentHeader.Button> </ContentHeader.Button>
</ContentHeader.Group> </ContentHeader.Group>
</ContentHeader> </ContentHeader>
<Row>
<Alert
className="w-100 m-0 py-2"
show={hasTask}
style={{ borderRadius: 0 }}
variant="light"
>
A background task is running for this movie, actions are unavailable
</Alert>
</Row>
<Row> <Row>
<ItemOverview item={item} details={[]}></ItemOverview> <ItemOverview item={item} details={[]}></ItemOverview>
</Row> </Row>
<Row> <Row>
<Table movie={item} profile={profile}></Table> <Table movie={item} profile={profile} disabled={hasTask}></Table>
</Row> </Row>
<ItemEditorModal <ItemEditorModal
modalKey="edit" modalKey="edit"
@ -154,7 +181,7 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
<MovieUploadModal modalKey="upload" size="lg"></MovieUploadModal> <MovieUploadModal modalKey="upload" size="lg"></MovieUploadModal>
<ManualSearchModal <ManualSearchModal
modalKey="manual-search" modalKey="manual-search"
onSelect={download} download={download}
></ManualSearchModal> ></ManualSearchModal>
</Container> </Container>
); );

View File

@ -3,23 +3,24 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useMemo } from "react"; import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap"; import { Badge } from "react-bootstrap";
import { Column } from "react-table"; import { Column } from "react-table";
import { useProfileItems } from "../../@redux/hooks"; import { useProfileItemsToLanguages } from "../../@redux/hooks";
import { useShowOnlyDesired } from "../../@redux/hooks/site"; import { useShowOnlyDesired } from "../../@redux/hooks/site";
import { MoviesApi } from "../../apis"; import { MoviesApi } from "../../apis";
import { AsyncButton, LanguageText, SimpleTable } from "../../components"; import { AsyncButton, LanguageText, SimpleTable } from "../../components";
import { filterSubtitleBy } from "../../utilites"; import { filterSubtitleBy } from "../../utilities";
const missingText = "Missing Subtitles"; const missingText = "Missing Subtitles";
interface Props { interface Props {
movie: Item.Movie; movie: Item.Movie;
profile?: Profile.Languages; disabled?: boolean;
profile?: Language.Profile;
} }
const Table: FunctionComponent<Props> = ({ movie, profile }) => { const Table: FunctionComponent<Props> = ({ movie, profile, disabled }) => {
const onlyDesired = useShowOnlyDesired(); const onlyDesired = useShowOnlyDesired();
const profileItems = useProfileItems(profile); const profileItems = useProfileItemsToLanguages(profile);
const columns: Column<Subtitle>[] = useMemo<Column<Subtitle>[]>( const columns: Column<Subtitle>[] = useMemo<Column<Subtitle>[]>(
() => [ () => [
@ -64,6 +65,7 @@ const Table: FunctionComponent<Props> = ({ movie, profile }) => {
} else if (original.path === missingText) { } else if (original.path === missingText) {
return ( return (
<AsyncButton <AsyncButton
disabled={disabled}
promise={() => promise={() =>
MoviesApi.downloadSubtitles(movie.radarrId, { MoviesApi.downloadSubtitles(movie.radarrId, {
language: original.code2, language: original.code2,
@ -80,6 +82,7 @@ const Table: FunctionComponent<Props> = ({ movie, profile }) => {
} else { } else {
return ( return (
<AsyncButton <AsyncButton
disabled={disabled}
variant="light" variant="light"
size="sm" size="sm"
promise={() => promise={() =>
@ -98,14 +101,14 @@ const Table: FunctionComponent<Props> = ({ movie, profile }) => {
}, },
}, },
], ],
[movie] [movie, disabled]
); );
const data: Subtitle[] = useMemo(() => { const data: Subtitle[] = useMemo(() => {
const missing = movie.missing_subtitles.map((item) => { const missing = movie.missing_subtitles.map((item) => ({
item.path = missingText; ...item,
return item; path: missingText,
}); }));
let raw_subtitles = movie.subtitles; let raw_subtitles = movie.subtitles;
if (onlyDesired) { if (onlyDesired) {

View File

@ -5,19 +5,21 @@ import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap"; import { Badge } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Column } from "react-table"; import { Column } from "react-table";
import { movieUpdateByRange, movieUpdateList } from "../@redux/actions"; import { movieUpdateAll, movieUpdateByRange } from "../../@redux/actions";
import { useRawMovies } from "../@redux/hooks"; import { useLanguageProfiles, useMovieEntities } from "../../@redux/hooks";
import { useReduxAction } from "../@redux/hooks/base"; import { useReduxAction } from "../../@redux/hooks/base";
import { MoviesApi } from "../apis"; import { MoviesApi } from "../../apis";
import { ActionBadge, LanguageText, TextPopover } from "../components"; import { ActionBadge, LanguageText, TextPopover } from "../../components";
import { BuildKey } from "../../utilities";
import BaseItemView from "../generic/BaseItemView"; import BaseItemView from "../generic/BaseItemView";
import { BuildKey } from "../utilites";
interface Props {} interface Props {}
const MovieView: FunctionComponent<Props> = () => { const MovieView: FunctionComponent<Props> = () => {
const [movies] = useRawMovies(); const movies = useMovieEntities();
const load = useReduxAction(movieUpdateByRange); const loader = useReduxAction(movieUpdateByRange);
const profiles = useLanguageProfiles();
const columns: Column<Item.Movie>[] = useMemo<Column<Item.Movie>[]>( const columns: Column<Item.Movie>[] = useMemo<Column<Item.Movie>[]>(
() => [ () => [
{ {
@ -67,14 +69,8 @@ const MovieView: FunctionComponent<Props> = () => {
{ {
Header: "Languages Profile", Header: "Languages Profile",
accessor: "profileId", accessor: "profileId",
Cell: ({ value, loose }) => { Cell: ({ value }) => {
if (loose) { return profiles?.find((v) => v.profileId === value)?.name ?? null;
// Define in generic/BaseItemView/table.tsx
const profiles = loose[0] as Profile.Languages[];
return profiles.find((v) => v.profileId === value)?.name ?? null;
} else {
return null;
}
}, },
}, },
{ {
@ -97,23 +93,25 @@ const MovieView: FunctionComponent<Props> = () => {
{ {
accessor: "radarrId", accessor: "radarrId",
selectHide: true, selectHide: true,
Cell: ({ row, externalUpdate }) => ( Cell: ({ row, update }) => {
<ActionBadge return (
icon={faWrench} <ActionBadge
onClick={() => externalUpdate && externalUpdate(row, "edit")} icon={faWrench}
></ActionBadge> onClick={() => update && update(row, "edit")}
), ></ActionBadge>
);
},
}, },
], ],
[] [profiles]
); );
return ( return (
<BaseItemView <BaseItemView
state={movies} state={movies}
name="Movies" name="Movies"
loader={load} loader={loader}
updateAction={movieUpdateList} updateAction={movieUpdateAll}
columns={columns} columns={columns}
modify={(form) => MoviesApi.modify(form)} modify={(form) => MoviesApi.modify(form)}
></BaseItemView> ></BaseItemView>

View File

@ -0,0 +1,45 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks";
import { RouterEmptyPath } from "../special-pages/404";
import Episodes from "./Episodes";
import MovieDetail from "./MovieDetail";
import Movies from "./Movies";
import Series from "./Series";
interface Props {}
const Router: FunctionComponent<Props> = () => {
const radarr = useIsRadarrEnabled();
const sonarr = useIsSonarrEnabled();
return (
<Switch>
{radarr && (
<Route exact path="/movies">
<Movies></Movies>
</Route>
)}
{radarr && (
<Route path="/movies/:id">
<MovieDetail></MovieDetail>
</Route>
)}
{sonarr && (
<Route exact path="/series">
<Series></Series>
</Route>
)}
{sonarr && (
<Route path="/series/:id">
<Episodes></Episodes>
</Route>
)}
<Route path="*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};
export default Router;

View File

@ -3,19 +3,20 @@ import React, { FunctionComponent, useMemo } from "react";
import { Badge, ProgressBar } from "react-bootstrap"; import { Badge, ProgressBar } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Column } from "react-table"; import { Column } from "react-table";
import { seriesUpdateByRange, seriesUpdateList } from "../@redux/actions"; import { seriesUpdateAll, seriesUpdateByRange } from "../../@redux/actions";
import { useRawSeries } from "../@redux/hooks"; import { useLanguageProfiles, useSerieEntities } from "../../@redux/hooks";
import { useReduxAction } from "../@redux/hooks/base"; import { useReduxAction } from "../../@redux/hooks/base";
import { SeriesApi } from "../apis"; import { SeriesApi } from "../../apis";
import { ActionBadge } from "../components"; import { ActionBadge } from "../../components";
import { BuildKey } from "../../utilities";
import BaseItemView from "../generic/BaseItemView"; import BaseItemView from "../generic/BaseItemView";
import { BuildKey } from "../utilites";
interface Props {} interface Props {}
const SeriesView: FunctionComponent<Props> = () => { const SeriesView: FunctionComponent<Props> = () => {
const [series] = useRawSeries(); const series = useSerieEntities();
const load = useReduxAction(seriesUpdateByRange); const loader = useReduxAction(seriesUpdateByRange);
const profiles = useLanguageProfiles();
const columns: Column<Item.Series>[] = useMemo<Column<Item.Series>[]>( const columns: Column<Item.Series>[] = useMemo<Column<Item.Series>[]>(
() => [ () => [
{ {
@ -53,14 +54,8 @@ const SeriesView: FunctionComponent<Props> = () => {
{ {
Header: "Languages Profile", Header: "Languages Profile",
accessor: "profileId", accessor: "profileId",
Cell: ({ value, loose }) => { Cell: ({ value }) => {
if (loose) { return profiles?.find((v) => v.profileId === value)?.name ?? null;
// Define in generic/BaseItemView/table.tsx
const profiles = loose[0] as Profile.Languages[];
return profiles.find((v) => v.profileId === value)?.name ?? null;
} else {
return null;
}
}, },
}, },
{ {
@ -68,11 +63,8 @@ const SeriesView: FunctionComponent<Props> = () => {
accessor: "episodeFileCount", accessor: "episodeFileCount",
selectHide: true, selectHide: true,
Cell: (row) => { Cell: (row) => {
const { const { episodeFileCount, episodeMissingCount, profileId } =
episodeFileCount, row.row.original;
episodeMissingCount,
profileId,
} = row.row.original;
let progress = 0; let progress = 0;
let label = ""; let label = "";
if (episodeFileCount === 0 || !profileId) { if (episodeFileCount === 0 || !profileId) {
@ -101,25 +93,25 @@ const SeriesView: FunctionComponent<Props> = () => {
{ {
accessor: "sonarrSeriesId", accessor: "sonarrSeriesId",
selectHide: true, selectHide: true,
Cell: ({ row, externalUpdate }) => ( Cell: ({ row, update }) => (
<ActionBadge <ActionBadge
icon={faWrench} icon={faWrench}
onClick={() => { onClick={() => {
externalUpdate && externalUpdate(row, "edit"); update && update(row, "edit");
}} }}
></ActionBadge> ></ActionBadge>
), ),
}, },
], ],
[] [profiles]
); );
return ( return (
<BaseItemView <BaseItemView
state={series} state={series}
name="Series" name="Series"
updateAction={seriesUpdateList} updateAction={seriesUpdateAll}
loader={load} loader={loader}
columns={columns} columns={columns}
modify={(form) => SeriesApi.modify(form)} modify={(form) => SeriesApi.modify(form)}
></BaseItemView> ></BaseItemView>

View File

@ -1,26 +1,27 @@
import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons"; import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons";
import { AsyncThunk } from "@reduxjs/toolkit";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import React, { useCallback, useMemo, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { Container, Dropdown, Row } from "react-bootstrap"; import { Container, Dropdown, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Column } from "react-table"; import { Column } from "react-table";
import { useLanguageProfiles } from "../../@redux/hooks"; import { useIsAnyTaskRunning } from "../../../@modules/task/hooks";
import { useReduxActionWith } from "../../@redux/hooks/base"; import { useLanguageProfiles } from "../../../@redux/hooks";
import { AsyncActionDispatcher } from "../../@redux/types"; import { useAppDispatch } from "../../../@redux/hooks/base";
import { ContentHeader } from "../../components"; import { ContentHeader } from "../../../components";
import { GetItemId, isNonNullable } from "../../utilites"; import { GetItemId, isNonNullable } from "../../../utilities";
import Table from "./table"; import Table from "./table";
export interface SharedProps<T extends Item.Base> { export interface SharedProps<T extends Item.Base> {
name: string; name: string;
loader: (start: number, length: number) => void; loader: (params: Parameter.Range) => void;
columns: Column<T>[]; columns: Column<T>[];
modify: (form: FormType.ModifyItem) => Promise<void>; modify: (form: FormType.ModifyItem) => Promise<void>;
state: AsyncOrderState<T>; state: Async.Entity<T>;
} }
interface Props<T extends Item.Base = Item.Base> extends SharedProps<T> { interface Props<T extends Item.Base = Item.Base> extends SharedProps<T> {
updateAction: (id?: number[]) => AsyncActionDispatcher<any>; updateAction: AsyncThunk<AsyncDataWrapper<T>, void, {}>;
} }
function BaseItemView<T extends Item.Base>({ function BaseItemView<T extends Item.Base>({
@ -32,35 +33,39 @@ function BaseItemView<T extends Item.Base>({
const [pendingEditMode, setPendingEdit] = useState(false); const [pendingEditMode, setPendingEdit] = useState(false);
const [editMode, setEdit] = useState(false); const [editMode, setEdit] = useState(false);
const onUpdated = useCallback(() => { const dispatch = useAppDispatch();
setPendingEdit((edit) => { const update = useCallback(() => {
// Hack to remove all dependencies dispatch(updateAction()).then(() => {
setEdit(edit); setPendingEdit((edit) => {
return edit; // Hack to remove all dependencies
setEdit(edit);
return edit;
});
setDirty([]);
}); });
setDirty([]); }, [dispatch, updateAction]);
}, []);
const update = useReduxActionWith(updateAction, onUpdated);
const [selections, setSelections] = useState<T[]>([]); const [selections, setSelections] = useState<T[]>([]);
const [dirtyItems, setDirty] = useState<T[]>([]); const [dirtyItems, setDirty] = useState<T[]>([]);
const [profiles] = useLanguageProfiles(); const profiles = useLanguageProfiles();
const profileOptions = useMemo<JSX.Element[]>(() => { const profileOptions = useMemo<JSX.Element[]>(() => {
const items: JSX.Element[] = []; const items: JSX.Element[] = [];
items.push( if (profiles) {
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item> items.push(
); <Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>); );
items.push( items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
...profiles.map((v) => ( items.push(
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}> ...profiles.map((v) => (
{v.name} <Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
</Dropdown.Item> {v.name}
)) </Dropdown.Item>
); ))
);
}
return items; return items;
}, [profiles]); }, [profiles]);
@ -72,20 +77,21 @@ function BaseItemView<T extends Item.Base>({
item.profileId = id; item.profileId = id;
return item; return item;
}); });
const newDirty = uniqBy([...newItems, ...dirtyItems], GetItemId); setDirty((dirty) => {
setDirty(newDirty); return uniqBy([...newItems, ...dirty], GetItemId);
});
}, },
[selections, dirtyItems] [selections]
); );
const startEdit = useCallback(() => { const startEdit = useCallback(() => {
if (shared.state.data.order.every(isNonNullable)) { if (shared.state.content.ids.every(isNonNullable)) {
setEdit(true); setEdit(true);
} else { } else {
update(); update();
} }
setPendingEdit(true); setPendingEdit(true);
}, [shared.state.data.order, update]); }, [shared.state.content.ids, update]);
const endEdit = useCallback(() => { const endEdit = useCallback(() => {
setEdit(false); setEdit(false);
@ -94,7 +100,7 @@ function BaseItemView<T extends Item.Base>({
setSelections([]); setSelections([]);
}, []); }, []);
const saveItems = useCallback(() => { const save = useCallback(() => {
const form: FormType.ModifyItem = { const form: FormType.ModifyItem = {
id: [], id: [],
profileid: [], profileid: [],
@ -107,6 +113,8 @@ function BaseItemView<T extends Item.Base>({
return shared.modify(form); return shared.modify(form);
}, [dirtyItems, shared]); }, [dirtyItems, shared]);
const hasTask = useIsAnyTaskRunning();
return ( return (
<Container fluid> <Container fluid>
<Helmet> <Helmet>
@ -132,8 +140,8 @@ function BaseItemView<T extends Item.Base>({
</ContentHeader.Button> </ContentHeader.Button>
<ContentHeader.AsyncButton <ContentHeader.AsyncButton
icon={faCheck} icon={faCheck}
disabled={dirtyItems.length === 0} disabled={dirtyItems.length === 0 || hasTask}
promise={saveItems} promise={save}
onSuccess={endEdit} onSuccess={endEdit}
> >
Save Save
@ -143,7 +151,10 @@ function BaseItemView<T extends Item.Base>({
) : ( ) : (
<ContentHeader.Button <ContentHeader.Button
updating={pendingEditMode !== editMode} updating={pendingEditMode !== editMode}
disabled={state.data.order.length === 0 && state.updating} disabled={
(state.content.ids.length === 0 && state.state === "loading") ||
hasTask
}
icon={faList} icon={faList}
onClick={startEdit} onClick={startEdit}
> >

View File

@ -2,16 +2,15 @@ import { uniqBy } from "lodash";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { TableOptions, TableUpdater, useRowSelect } from "react-table"; import { TableOptions, TableUpdater, useRowSelect } from "react-table";
import { SharedProps } from "."; import { SharedProps } from ".";
import { useLanguageProfiles } from "../../@redux/hooks";
import { import {
AsyncPageTable, AsyncPageTable,
ItemEditorModal, ItemEditorModal,
SimpleTable, SimpleTable,
useShowModal, useShowModal,
} from "../../components"; } from "../../../components";
import { TableStyleProps } from "../../components/tables/BaseTable"; import { TableStyleProps } from "../../../components/tables/BaseTable";
import { useCustomSelection } from "../../components/tables/plugins"; import { useCustomSelection } from "../../../components/tables/plugins";
import { buildOrderList, GetItemId } from "../../utilites"; import { GetItemId, useEntityToList } from "../../../utilities";
interface Props<T extends Item.Base> extends SharedProps<T> { interface Props<T extends Item.Base> extends SharedProps<T> {
dirtyItems: readonly T[]; dirtyItems: readonly T[];
@ -38,21 +37,16 @@ function Table<T extends Item.Base>({
[showModal] [showModal]
); );
const idState = state.data; const orderList = useEntityToList(state.content);
const orderList = useMemo(() => buildOrderList(idState), [idState]); const data = useMemo(
() => uniqBy([...dirtyItems, ...orderList], GetItemId),
const data = useMemo(() => uniqBy([...dirtyItems, ...orderList], GetItemId), [ [dirtyItems, orderList]
dirtyItems, );
orderList,
]);
const [profiles] = useLanguageProfiles();
const options: Partial<TableOptions<T> & TableStyleProps<T>> = { const options: Partial<TableOptions<T> & TableStyleProps<T>> = {
loose: [profiles],
emptyText: `No ${name} Found`, emptyText: `No ${name} Found`,
externalUpdate: updateRow, update: updateRow,
}; };
return ( return (
@ -71,7 +65,7 @@ function Table<T extends Item.Base>({
<AsyncPageTable <AsyncPageTable
{...options} {...options}
columns={columns} columns={columns}
aos={state} entity={state}
loader={loader} loader={loader}
data={[]} data={[]}
></AsyncPageTable> ></AsyncPageTable>

View File

@ -22,9 +22,9 @@ import {
Popover, Popover,
Row, Row,
} from "react-bootstrap"; } from "react-bootstrap";
import { useProfileBy, useProfileItems } from "../@redux/hooks"; import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
import { LanguageText } from "../components"; import { LanguageText } from "../../components";
import { BuildKey, isMovie } from "../utilites"; import { BuildKey, isMovie } from "../../utilities";
interface Props { interface Props {
item: Item.Base; item: Item.Base;
@ -76,7 +76,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
); );
const profile = useProfileBy(item.profileId); const profile = useProfileBy(item.profileId);
const profileItems = useProfileItems(profile); const profileItems = useProfileItemsToLanguages(profile);
const languageBadges = useMemo(() => { const languageBadges = useMemo(() => {
const badges: (JSX.Element | null)[] = []; const badges: (JSX.Element | null)[] = [];

View File

@ -1,7 +1,7 @@
import { faFileExcel } from "@fortawesome/free-solid-svg-icons"; import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { AsyncButton } from "../components"; import { AsyncButton } from "../../components";
interface Props { interface Props {
history: History.Base; history: History.Base;

View File

@ -4,16 +4,19 @@ import React, { FunctionComponent, useMemo } from "react";
import { Badge, OverlayTrigger, Popover } from "react-bootstrap"; import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Column } from "react-table"; import { Column } from "react-table";
import { movieUpdateHistoryByRange } from "../../@redux/actions";
import { useMoviesHistory } from "../../@redux/hooks"; import { useMoviesHistory } from "../../@redux/hooks";
import { useReduxAction } from "../../@redux/hooks/base";
import { MoviesApi } from "../../apis"; import { MoviesApi } from "../../apis";
import { HistoryIcon, LanguageText, TextPopover } from "../../components"; import { HistoryIcon, LanguageText, TextPopover } from "../../components";
import { BlacklistButton } from "../../generic/blacklist"; import { BlacklistButton } from "../../DisplayItem/generic/blacklist";
import HistoryGenericView from "../generic"; import HistoryGenericView from "../generic";
interface Props {} interface Props {}
const MoviesHistoryView: FunctionComponent<Props> = () => { const MoviesHistoryView: FunctionComponent<Props> = () => {
const [movies] = useMoviesHistory(); const movies = useMoviesHistory();
const loader = useReduxAction(movieUpdateHistoryByRange);
const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>( const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>(
() => [ () => [
@ -128,7 +131,8 @@ const MoviesHistoryView: FunctionComponent<Props> = () => {
<HistoryGenericView <HistoryGenericView
type="movies" type="movies"
state={movies} state={movies}
columns={columns as Column<History.Base>[]} loader={loader}
columns={columns}
></HistoryGenericView> ></HistoryGenericView>
); );
}; };

View File

@ -4,16 +4,19 @@ import React, { FunctionComponent, useMemo } from "react";
import { Badge, OverlayTrigger, Popover } from "react-bootstrap"; import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Column } from "react-table"; import { Column } from "react-table";
import { episodesUpdateHistoryByRange } from "../../@redux/actions";
import { useSeriesHistory } from "../../@redux/hooks"; import { useSeriesHistory } from "../../@redux/hooks";
import { useReduxAction } from "../../@redux/hooks/base";
import { EpisodesApi } from "../../apis"; import { EpisodesApi } from "../../apis";
import { HistoryIcon, LanguageText, TextPopover } from "../../components"; import { HistoryIcon, LanguageText, TextPopover } from "../../components";
import { BlacklistButton } from "../../generic/blacklist"; import { BlacklistButton } from "../../DisplayItem/generic/blacklist";
import HistoryGenericView from "../generic"; import HistoryGenericView from "../generic";
interface Props {} interface Props {}
const SeriesHistoryView: FunctionComponent<Props> = () => { const SeriesHistoryView: FunctionComponent<Props> = () => {
const [series] = useSeriesHistory(); const series = useSeriesHistory();
const loader = useReduxAction(episodesUpdateHistoryByRange);
const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>( const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>(
() => [ () => [
@ -137,7 +140,8 @@ const SeriesHistoryView: FunctionComponent<Props> = () => {
<HistoryGenericView <HistoryGenericView
type="series" type="series"
state={series} state={series}
columns={columns as Column<History.Base>[]} loader={loader}
columns={columns}
></HistoryGenericView> ></HistoryGenericView>
); );
}; };

View File

@ -1,5 +1,10 @@
import { merge } from "lodash"; import { merge } from "lodash";
import React, { FunctionComponent, useCallback, useState } from "react"; import React, {
FunctionComponent,
useCallback,
useEffect,
useState,
} from "react";
import { Col, Container } from "react-bootstrap"; import { Col, Container } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { import {
@ -12,6 +17,7 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { useDidMount } from "rooks";
import { import {
HistoryApi, HistoryApi,
ProvidersApi, ProvidersApi,
@ -19,10 +25,10 @@ import {
useAsyncRequest, useAsyncRequest,
} from "../../apis"; } from "../../apis";
import { import {
AsyncOverlay,
AsyncSelector, AsyncSelector,
ContentHeader, ContentHeader,
LanguageSelector, LanguageSelector,
PromiseOverlay,
Selector, Selector,
} from "../../components"; } from "../../components";
import { actionOptions, timeframeOptions } from "./options"; import { actionOptions, timeframeOptions } from "./options";
@ -49,26 +55,32 @@ const SelectorContainer: FunctionComponent = ({ children }) => (
); );
const HistoryStats: FunctionComponent = () => { const HistoryStats: FunctionComponent = () => {
const [languages] = useAsyncRequest(() => SystemApi.languages(true), []); const [languages, updateLanguages] = useAsyncRequest(
SystemApi.languages.bind(SystemApi)
const [providerList] = useAsyncRequest(
() => ProvidersApi.providers(true),
[]
); );
const [providerList, updateProviderParam] = useAsyncRequest(
ProvidersApi.providers.bind(ProvidersApi)
);
const updateProvider = useCallback(
() => updateProviderParam(true),
[updateProviderParam]
);
useDidMount(() => {
updateLanguages(true);
});
const [timeframe, setTimeframe] = useState<History.TimeframeOptions>("month"); const [timeframe, setTimeframe] = useState<History.TimeframeOptions>("month");
const [action, setAction] = useState<Nullable<History.ActionOptions>>(null); const [action, setAction] = useState<Nullable<History.ActionOptions>>(null);
const [lang, setLanguage] = useState<Nullable<Language>>(null); const [lang, setLanguage] = useState<Nullable<Language.Info>>(null);
const [provider, setProvider] = useState<Nullable<System.Provider>>(null); const [provider, setProvider] = useState<Nullable<System.Provider>>(null);
const promise = useCallback(() => { const [stats, update] = useAsyncRequest(HistoryApi.stats.bind(HistoryApi));
return HistoryApi.stats(
timeframe, useEffect(() => {
action ?? undefined, update(timeframe, action ?? undefined, provider?.name, lang?.code2);
provider?.name, }, [timeframe, action, provider?.name, lang?.code2, update]);
lang?.code2
);
}, [timeframe, lang?.code2, action, provider]);
return ( return (
// TODO: Responsive // TODO: Responsive
@ -76,8 +88,8 @@ const HistoryStats: FunctionComponent = () => {
<Helmet> <Helmet>
<title>History Statistics - Bazarr</title> <title>History Statistics - Bazarr</title>
</Helmet> </Helmet>
<PromiseOverlay promise={promise}> <AsyncOverlay ctx={stats}>
{(data) => ( {({ content }) => (
<React.Fragment> <React.Fragment>
<ContentHeader scroll={false}> <ContentHeader scroll={false}>
<SelectorContainer> <SelectorContainer>
@ -103,20 +115,21 @@ const HistoryStats: FunctionComponent = () => {
clearable clearable
state={providerList} state={providerList}
label={providerLabel} label={providerLabel}
update={updateProvider}
onChange={setProvider} onChange={setProvider}
></AsyncSelector> ></AsyncSelector>
</SelectorContainer> </SelectorContainer>
<SelectorContainer> <SelectorContainer>
<LanguageSelector <LanguageSelector
clearable clearable
options={languages.data} options={languages.content ?? []}
value={lang} value={lang}
onChange={setLanguage} onChange={setLanguage}
></LanguageSelector> ></LanguageSelector>
</SelectorContainer> </SelectorContainer>
</ContentHeader> </ContentHeader>
<ResponsiveContainer height="100%"> <ResponsiveContainer height="100%">
<BarChart data={converter(data)}> <BarChart data={content ? converter(content) : []}>
<CartesianGrid strokeDasharray="4 2"></CartesianGrid> <CartesianGrid strokeDasharray="4 2"></CartesianGrid>
<XAxis dataKey="date"></XAxis> <XAxis dataKey="date"></XAxis>
<YAxis allowDecimals={false}></YAxis> <YAxis allowDecimals={false}></YAxis>
@ -128,7 +141,7 @@ const HistoryStats: FunctionComponent = () => {
</ResponsiveContainer> </ResponsiveContainer>
</React.Fragment> </React.Fragment>
)} )}
</PromiseOverlay> </AsyncOverlay>
</Container> </Container>
); );
}; };

View File

@ -1,21 +1,23 @@
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import React, { FunctionComponent } from "react"; import React from "react";
import { Container, Row } from "react-bootstrap"; import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Column } from "react-table"; import { Column } from "react-table";
import { AsyncStateOverlay, PageTable } from "../../components"; import { AsyncPageTable } from "../../components";
interface Props { interface Props<T extends History.Base> {
type: "movies" | "series"; type: "movies" | "series";
state: Readonly<AsyncState<History.Base[]>>; state: Readonly<Async.Entity<T>>;
columns: Column<History.Base>[]; loader: (param: Parameter.Range) => void;
columns: Column<T>[];
} }
const HistoryGenericView: FunctionComponent<Props> = ({ function HistoryGenericView<T extends History.Base = History.Base>({
state, state,
loader,
columns, columns,
type, type,
}) => { }: Props<T>) {
const typeName = capitalize(type); const typeName = capitalize(type);
return ( return (
<Container fluid> <Container fluid>
@ -23,18 +25,16 @@ const HistoryGenericView: FunctionComponent<Props> = ({
<title>{typeName} History - Bazarr</title> <title>{typeName} History - Bazarr</title>
</Helmet> </Helmet>
<Row> <Row>
<AsyncStateOverlay state={state}> <AsyncPageTable
{({ data }) => ( emptyText={`Nothing Found in ${typeName} History`}
<PageTable entity={state}
emptyText={`Nothing Found in ${typeName} History`} loader={loader}
columns={columns} columns={columns}
data={data} data={[]}
></PageTable> ></AsyncPageTable>
)}
</AsyncStateOverlay>
</Row> </Row>
</Container> </Container>
); );
}; }
export default HistoryGenericView; export default HistoryGenericView;

View File

@ -1,21 +0,0 @@
import React, { FunctionComponent } from "react";
import { Route, Switch } from "react-router-dom";
import Movie from ".";
import MovieDetail from "./Detail";
interface Props {}
const Router: FunctionComponent<Props> = () => {
return (
<Switch>
<Route exact path="/movies">
<Movie></Movie>
</Route>
<Route path="/movies/:id">
<MovieDetail></MovieDetail>
</Route>
</Switch>
);
};
export default Router;

View File

@ -1,160 +0,0 @@
import {
faAdjust,
faBriefcase,
faCloudUploadAlt,
faHdd,
faSearch,
faSync,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
import React, {
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
import { useEpisodesBy, useProfileBy, useSerieBy } from "../../@redux/hooks";
import { SeriesApi } from "../../apis";
import {
ContentHeader,
ItemEditorModal,
LoadingIndicator,
SeriesUploadModal,
useShowModal,
} from "../../components";
import ItemOverview from "../../generic/ItemOverview";
import { RouterEmptyPath } from "../../special-pages/404";
import { useOnLoadingFinish } from "../../utilites";
import Table from "./table";
interface Params {
id: string;
}
interface Props extends RouteComponentProps<Params> {}
const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
const { match } = props;
const id = Number.parseInt(match.params.id);
const [serie] = useSerieBy(id);
const item = serie.data;
const [episodes] = useEpisodesBy(serie.data?.sonarrSeriesId);
const available = episodes.data.length !== 0;
const details = useMemo(
() => [
{
icon: faHdd,
text: `${item?.episodeFileCount} files`,
},
{
icon: faAdjust,
text: item?.seriesType ?? "",
},
],
[item]
);
const showModal = useShowModal();
const [valid, setValid] = useState(true);
const validator = useCallback(() => {
if (serie.data === null) {
setValid(false);
}
}, [serie.data]);
useOnLoadingFinish(serie, validator);
const profile = useProfileBy(serie.data?.profileId);
if (isNaN(id) || !valid) {
return <Redirect to={RouterEmptyPath}></Redirect>;
}
if (!item) {
return <LoadingIndicator></LoadingIndicator>;
}
return (
<Container fluid>
<Helmet>
<title>{item.title} - Bazarr (Series)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Group pos="start">
<ContentHeader.AsyncButton
icon={faSync}
disabled={!available}
promise={() =>
SeriesApi.action({ action: "scan-disk", seriesid: id })
}
>
Scan Disk
</ContentHeader.AsyncButton>
<ContentHeader.AsyncButton
icon={faSearch}
promise={() =>
SeriesApi.action({ action: "search-missing", seriesid: id })
}
disabled={
item.episodeFileCount === 0 ||
item.profileId === null ||
!available
}
>
Search
</ContentHeader.AsyncButton>
</ContentHeader.Group>
<ContentHeader.Group pos="end">
<ContentHeader.Button
disabled={item.episodeFileCount === 0 || !available}
icon={faBriefcase}
onClick={() => showModal("tools", episodes.data)}
>
Tools
</ContentHeader.Button>
<ContentHeader.Button
disabled={
item.episodeFileCount === 0 ||
item.profileId === null ||
!available
}
icon={faCloudUploadAlt}
onClick={() => showModal("upload", item)}
>
Upload
</ContentHeader.Button>
<ContentHeader.Button
icon={faWrench}
onClick={() => showModal("edit", item)}
>
Edit Series
</ContentHeader.Button>
</ContentHeader.Group>
</ContentHeader>
<Row>
<ItemOverview item={item} details={details}></ItemOverview>
</Row>
<Row>
<Table episodes={episodes} profile={profile}></Table>
</Row>
<ItemEditorModal
modalKey="edit"
submit={(form) => SeriesApi.modify(form)}
></ItemEditorModal>
<SeriesUploadModal
modalKey="upload"
episodes={episodes.data}
></SeriesUploadModal>
</Container>
);
};
export default withRouter(SeriesEpisodesView);

View File

@ -1,21 +0,0 @@
import React, { FunctionComponent } from "react";
import { Route, Switch } from "react-router-dom";
import Series from ".";
import Episodes from "./Episodes";
interface Props {}
const Router: FunctionComponent<Props> = () => {
return (
<Switch>
<Route exact path="/series">
<Series></Series>
</Route>
<Route path="/series/:id">
<Episodes></Episodes>
</Route>
</Switch>
);
};
export default Router;

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