mirror of https://github.com/morpheus65535/bazarr
Merge development into master
This commit is contained in:
commit
c66d5662b4
|
@ -47,6 +47,10 @@ jobs:
|
|||
run: npm run build
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Unit Test
|
||||
run: npm test
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.UI_ARTIFACT_NAME }}
|
||||
|
|
|
@ -21,8 +21,3 @@ VERSION
|
|||
# Allow
|
||||
!*.dll
|
||||
|
||||
# Frontend
|
||||
node_modules
|
||||
/frontend/dist
|
||||
/frontend/*.local
|
||||
/frontend/build
|
||||
|
|
|
@ -856,7 +856,8 @@ class EpisodesSubtitles(Resource):
|
|||
if sceneName is None: sceneName = "None"
|
||||
|
||||
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')
|
||||
|
||||
_, ext = os.path.splitext(subFile.filename)
|
||||
|
@ -868,6 +869,7 @@ class EpisodesSubtitles(Resource):
|
|||
result = manual_upload_subtitle(path=episodePath,
|
||||
language=language,
|
||||
forced=forced,
|
||||
hi=hi,
|
||||
title=title,
|
||||
scene_name=sceneName,
|
||||
media_type='series',
|
||||
|
@ -878,7 +880,9 @@ class EpisodesSubtitles(Resource):
|
|||
message = result[0]
|
||||
path = result[1]
|
||||
subs_path = result[2]
|
||||
if forced:
|
||||
if hi:
|
||||
language_code = language + ":hi"
|
||||
elif forced:
|
||||
language_code = language + ":forced"
|
||||
else:
|
||||
language_code = language
|
||||
|
@ -1086,6 +1090,7 @@ class MoviesSubtitles(Resource):
|
|||
|
||||
language = request.form.get('language')
|
||||
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')
|
||||
|
||||
_, ext = os.path.splitext(subFile.filename)
|
||||
|
@ -1097,6 +1102,7 @@ class MoviesSubtitles(Resource):
|
|||
result = manual_upload_subtitle(path=moviePath,
|
||||
language=language,
|
||||
forced=forced,
|
||||
hi=hi,
|
||||
title=title,
|
||||
scene_name=sceneName,
|
||||
media_type='movie',
|
||||
|
@ -1107,7 +1113,9 @@ class MoviesSubtitles(Resource):
|
|||
message = result[0]
|
||||
path = result[1]
|
||||
subs_path = result[2]
|
||||
if forced:
|
||||
if hi:
|
||||
language_code = language + ":hi"
|
||||
elif forced:
|
||||
language_code = language + ":forced"
|
||||
else:
|
||||
language_code = language
|
||||
|
@ -1419,7 +1427,8 @@ class EpisodesHistory(Resource):
|
|||
if episodeid:
|
||||
query_conditions.append((TableEpisodes.sonarrEpisodeId == episodeid))
|
||||
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.season.concat('x').concat(TableEpisodes.episode).alias('episode_number'),
|
||||
TableEpisodes.title.alias('episodeTitle'),
|
||||
|
@ -1535,7 +1544,8 @@ class MoviesHistory(Resource):
|
|||
query_conditions.append((TableMovies.radarrId == radarrid))
|
||||
query_condition = reduce(operator.and_, query_conditions)
|
||||
|
||||
movie_history = TableHistoryMovie.select(TableHistoryMovie.action,
|
||||
movie_history = TableHistoryMovie.select(TableHistoryMovie.id,
|
||||
TableHistoryMovie.action,
|
||||
TableMovies.title,
|
||||
TableHistoryMovie.timestamp,
|
||||
TableHistoryMovie.description,
|
||||
|
|
|
@ -91,7 +91,8 @@ defaults = {
|
|||
'series_sync': '60',
|
||||
'episodes_sync': '60',
|
||||
'excluded_tags': '[]',
|
||||
'excluded_series_types': '[]'
|
||||
'excluded_series_types': '[]',
|
||||
'use_ffprobe_cache': 'True'
|
||||
},
|
||||
'radarr': {
|
||||
'ip': '127.0.0.1',
|
||||
|
@ -104,7 +105,8 @@ defaults = {
|
|||
'full_update_hour': '5',
|
||||
'only_monitored': 'False',
|
||||
'movies_sync': '60',
|
||||
'excluded_tags': '[]'
|
||||
'excluded_tags': '[]',
|
||||
'use_ffprobe_cache': 'True'
|
||||
},
|
||||
'proxy': {
|
||||
'type': 'None',
|
||||
|
@ -303,6 +305,8 @@ def save_settings(settings_items):
|
|||
update_path_map = False
|
||||
configure_proxy = False
|
||||
exclusion_updated = False
|
||||
sonarr_exclusion_updated = False
|
||||
radarr_exclusion_updated = False
|
||||
|
||||
# Subzero Mods
|
||||
update_subzero = False
|
||||
|
@ -374,6 +378,13 @@ def save_settings(settings_items):
|
|||
'settings-radarr-only_monitored']:
|
||||
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:
|
||||
region.delete('addic7ed_data')
|
||||
|
@ -465,6 +476,10 @@ def save_settings(settings_items):
|
|||
if exclusion_updated:
|
||||
from event_handler import event_stream
|
||||
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():
|
||||
|
|
|
@ -9,6 +9,7 @@ from enzyme.exceptions import MalformedMKVError
|
|||
from enzyme.exceptions import MalformedMKVError
|
||||
from custom_lang import CustomLanguage
|
||||
from database import TableEpisodes, TableMovies
|
||||
from helper import path_mappings
|
||||
|
||||
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
|
||||
if episode_file_id:
|
||||
cache_key = TableEpisodes.select(TableEpisodes.ffprobe_cache)\
|
||||
.where((TableEpisodes.episode_file_id == episode_file_id) and
|
||||
(TableEpisodes.file_size == file_size))\
|
||||
.where(TableEpisodes.path == path_mappings.path_replace_reverse(file))\
|
||||
.dicts()\
|
||||
.get()
|
||||
elif movie_file_id:
|
||||
cache_key = TableMovies.select(TableMovies.ffprobe_cache)\
|
||||
.where(TableMovies.movie_file_id == movie_file_id and
|
||||
TableMovies.file_size == file_size)\
|
||||
.where(TableMovies.path == path_mappings.path_replace_reverse_movie(file))\
|
||||
.dicts()\
|
||||
.get()
|
||||
else:
|
||||
|
@ -110,7 +109,7 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No
|
|||
if ffprobe_path:
|
||||
api.initialize({"provider": "ffmpeg", "ffmpeg": ffprobe_path})
|
||||
data["ffprobe"] = api.know(file)
|
||||
# if nto, we use enzyme for mkv files
|
||||
# if not, we use enzyme for mkv files
|
||||
else:
|
||||
if os.path.splitext(file)[1] == ".mkv":
|
||||
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
|
||||
if episode_file_id:
|
||||
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()
|
||||
elif movie_file_id:
|
||||
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()
|
||||
return data
|
||||
|
|
|
@ -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,)
|
||||
|
||||
series_count = len(seriesIdList)
|
||||
for i, seriesId in enumerate(seriesIdList, 1):
|
||||
for i, seriesId in enumerate(seriesIdList):
|
||||
sleep()
|
||||
if send_event:
|
||||
show_progress(id='episodes_progress',
|
||||
|
@ -85,12 +85,6 @@ def sync_episodes(series_id=None, send_event=True):
|
|||
episodes_to_add.append(episodeParser(episode))
|
||||
|
||||
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')
|
||||
|
||||
# Remove old episodes from DB
|
||||
|
@ -183,12 +177,10 @@ def sync_one_episode(episode_id):
|
|||
|
||||
else:
|
||||
# For Sonarr v3, we need to update episodes to integrate the episodeFile API endpoint results
|
||||
if not get_sonarr_info.is_legacy():
|
||||
episodeFile = get_episodesFiles_from_sonarr_api(url=url, apikey_sonarr=apikey_sonarr,
|
||||
episode_file_id=existing_episode['episode_file_id'])
|
||||
if episode_data['hasFile']:
|
||||
episode_data['episodeFile'] = episodeFile
|
||||
|
||||
if not get_sonarr_info.is_legacy() and existing_episode and episode_data['hasFile']:
|
||||
episode_data['episodeFile'] = \
|
||||
get_episodesFiles_from_sonarr_api(url=url, apikey_sonarr=apikey_sonarr,
|
||||
episode_file_id=existing_episode['episode_file_id'])
|
||||
episode = episodeParser(episode_data)
|
||||
except Exception:
|
||||
logging.debug('BAZARR cannot get episode returned by SignalR feed from Sonarr API.')
|
||||
|
|
|
@ -63,7 +63,7 @@ def update_movies(send_event=True):
|
|||
|
||||
# Build new and updated movies
|
||||
movies_count = len(movies)
|
||||
for i, movie in enumerate(movies, 1):
|
||||
for i, movie in enumerate(movies):
|
||||
sleep()
|
||||
if send_event:
|
||||
show_progress(id='movies_progress',
|
||||
|
@ -88,14 +88,8 @@ def update_movies(send_event=True):
|
|||
tags_dict=tagsDict,
|
||||
movie_default_profile=movie_default_profile,
|
||||
audio_profiles=audio_profiles))
|
||||
|
||||
|
||||
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')
|
||||
|
||||
# Remove old movies from DB
|
||||
|
|
|
@ -281,7 +281,7 @@ def update_throttled_provider():
|
|||
del tp[provider]
|
||||
set_throttled_providers(str(tp))
|
||||
|
||||
event_stream(type='badges')
|
||||
event_stream(type='badges')
|
||||
|
||||
|
||||
def list_throttled_providers():
|
||||
|
|
|
@ -50,7 +50,7 @@ def update_series(send_event=True):
|
|||
series_to_add = []
|
||||
|
||||
series_count = len(series)
|
||||
for i, show in enumerate(series, 1):
|
||||
for i, show in enumerate(series):
|
||||
sleep()
|
||||
if send_event:
|
||||
show_progress(id='series_progress',
|
||||
|
@ -72,12 +72,6 @@ def update_series(send_event=True):
|
|||
audio_profiles=audio_profiles))
|
||||
|
||||
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')
|
||||
|
||||
# Remove old series from DB
|
||||
|
@ -170,8 +164,7 @@ def update_one_series(series_id, action):
|
|||
series = None
|
||||
|
||||
series_data = get_series_from_sonarr_api(url=url_sonarr(), apikey_sonarr=settings.sonarr.apikey,
|
||||
sonarr_series_id=int(series_id),
|
||||
sonarr_version=get_sonarr_info.version())
|
||||
sonarr_series_id=int(series_id))
|
||||
|
||||
if not series_data:
|
||||
return
|
||||
|
|
|
@ -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,
|
||||
downloaded_language_code2, downloaded_language_code3, audio_language,
|
||||
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':
|
||||
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,
|
||||
downloaded_language_code2, downloaded_language_code3, audio_language,
|
||||
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':
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
single = settings.general.getboolean('single_language')
|
||||
|
@ -663,11 +665,24 @@ def manual_upload_subtitle(path, language, forced, title, scene_name, media_type
|
|||
return
|
||||
|
||||
subtitle_path = saved_subtitles[0].storage_path
|
||||
message = language_from_alpha3(language) + (" forced" if forced else "") + " Subtitles manually uploaded."
|
||||
|
||||
uploaded_language_code3 = language
|
||||
uploaded_language = language_from_alpha3(uploaded_language_code3)
|
||||
uploaded_language_code2 = alpha2_from_alpha3(uploaded_language_code3)
|
||||
if hi:
|
||||
modifier_string = " HI"
|
||||
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_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 :
|
||||
command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_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)
|
||||
|
||||
if media_type == 'series':
|
||||
|
@ -729,7 +745,8 @@ def series_download_subtitles(no):
|
|||
.where(reduce(operator.and_, conditions))\
|
||||
.dicts()
|
||||
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
|
||||
|
||||
providers_list = get_providers()
|
||||
|
@ -737,7 +754,7 @@ def series_download_subtitles(no):
|
|||
|
||||
count_episodes_details = len(episodes_details)
|
||||
|
||||
for i, episode in enumerate(episodes_details, 1):
|
||||
for i, episode in enumerate(episodes_details):
|
||||
if providers_list:
|
||||
show_progress(id='series_search_progress_{}'.format(no),
|
||||
header='Searching missing subtitles...',
|
||||
|
@ -796,13 +813,6 @@ def series_download_subtitles(no):
|
|||
logging.info("BAZARR All providers are throttled")
|
||||
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))
|
||||
|
||||
|
||||
|
@ -841,7 +851,7 @@ def episode_download_subtitles(no, send_progress=False):
|
|||
episode['season'],
|
||||
episode['episode'],
|
||||
episode['episodeTitle']),
|
||||
value=1,
|
||||
value=0,
|
||||
count=1)
|
||||
for language in ast.literal_eval(episode['missing_subtitles']):
|
||||
# confirm if language is still missing or if cutoff have been reached
|
||||
|
@ -921,7 +931,7 @@ def movies_download_subtitles(no):
|
|||
else:
|
||||
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
|
||||
confirmed_missing_subs = TableMovies.select(TableMovies.missing_subtitles)\
|
||||
.where(TableMovies.radarrId == movie['radarrId'])\
|
||||
|
@ -975,17 +985,10 @@ def movies_download_subtitles(no):
|
|||
logging.info("BAZARR All providers are throttled")
|
||||
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))
|
||||
|
||||
|
||||
def wanted_download_subtitles(sonarr_series_id):
|
||||
def wanted_download_subtitles(sonarr_episode_id):
|
||||
episodes_details = TableEpisodes.select(TableEpisodes.path,
|
||||
TableEpisodes.missing_subtitles,
|
||||
TableEpisodes.sonarrEpisodeId,
|
||||
|
@ -995,7 +998,7 @@ def wanted_download_subtitles(sonarr_series_id):
|
|||
TableEpisodes.failedAttempts,
|
||||
TableShows.title)\
|
||||
.join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId))\
|
||||
.where((TableEpisodes.sonarrSeriesId == sonarr_series_id))\
|
||||
.where((TableEpisodes.sonarrEpisodeId == sonarr_episode_id))\
|
||||
.dicts()
|
||||
episodes_details = list(episodes_details)
|
||||
|
||||
|
@ -1159,6 +1162,7 @@ def wanted_search_missing_subtitles_series():
|
|||
conditions = [(TableEpisodes.missing_subtitles != '[]')]
|
||||
conditions += get_exclusion_clause('series')
|
||||
episodes = TableEpisodes.select(TableEpisodes.sonarrSeriesId,
|
||||
TableEpisodes.sonarrEpisodeId,
|
||||
TableShows.tags,
|
||||
TableEpisodes.monitored,
|
||||
TableShows.title,
|
||||
|
@ -1172,7 +1176,7 @@ def wanted_search_missing_subtitles_series():
|
|||
episodes = list(episodes)
|
||||
|
||||
count_episodes = len(episodes)
|
||||
for i, episode in enumerate(episodes, 1):
|
||||
for i, episode in enumerate(episodes):
|
||||
show_progress(id='wanted_episodes_progress',
|
||||
header='Searching subtitles...',
|
||||
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()
|
||||
if providers:
|
||||
wanted_download_subtitles(episode['sonarrSeriesId'])
|
||||
wanted_download_subtitles(episode['sonarrEpisodeId'])
|
||||
else:
|
||||
logging.info("BAZARR All providers are throttled")
|
||||
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')
|
||||
|
||||
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)
|
||||
|
||||
count_movies = len(movies)
|
||||
for i, movie in enumerate(movies, 1):
|
||||
for i, movie in enumerate(movies):
|
||||
show_progress(id='wanted_movies_progress',
|
||||
header='Searching subtitles...',
|
||||
name=movie['title'],
|
||||
|
@ -1226,12 +1224,6 @@ def wanted_search_missing_subtitles_movies():
|
|||
logging.info("BAZARR All providers are throttled")
|
||||
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')
|
||||
|
||||
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()
|
||||
|
||||
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',
|
||||
header='Upgrading episodes subtitles...',
|
||||
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)
|
||||
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')
|
||||
|
||||
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',
|
||||
header='Upgrading movies subtitles...',
|
||||
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)
|
||||
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')
|
||||
|
||||
logging.info('BAZARR Finished searching for Subtitles to upgrade. Check History for more information.')
|
||||
|
|
|
@ -96,16 +96,33 @@ class 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):
|
||||
is_forced = ":forced" if forced else ""
|
||||
is_forced_string = " forced" if forced else ""
|
||||
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):
|
||||
if hi:
|
||||
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('{{episode}}', episode)
|
||||
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_language}}', str(language) + is_forced_string)
|
||||
pp_command = pp_command.replace('{{subtitles_language_code2}}', str(language_code2) + is_forced)
|
||||
pp_command = pp_command.replace('{{subtitles_language_code3}}', str(language_code3) + is_forced)
|
||||
pp_command = pp_command.replace('{{subtitles_language}}', str(language))
|
||||
pp_command = pp_command.replace('{{subtitles_language_code2}}', str(language_code2))
|
||||
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_code2}}', str(episode_language_code2))
|
||||
pp_command = pp_command.replace('{{episode_language_code3}}', str(episode_language_code3))
|
||||
|
|
|
@ -446,23 +446,19 @@ def list_missing_subtitles_movies(no=None, send_event=True):
|
|||
|
||||
|
||||
def series_full_scan_subtitles():
|
||||
use_ffprobe_cache = settings.sonarr.getboolean('use_ffprobe_cache')
|
||||
|
||||
episodes = TableEpisodes.select(TableEpisodes.path).dicts()
|
||||
|
||||
count_episodes = len(episodes)
|
||||
for i, episode in enumerate(episodes, 1):
|
||||
for i, episode in enumerate(episodes):
|
||||
sleep()
|
||||
show_progress(id='episodes_disk_scan',
|
||||
header='Full disk scan...',
|
||||
name='Episodes subtitles',
|
||||
value=i,
|
||||
count=count_episodes)
|
||||
store_subtitles(episode['path'], path_mappings.path_replace(episode['path']))
|
||||
|
||||
show_progress(id='episodes_disk_scan',
|
||||
header='Full disk scan...',
|
||||
name='Completed successfully',
|
||||
value=count_episodes,
|
||||
count=count_episodes)
|
||||
store_subtitles(episode['path'], path_mappings.path_replace(episode['path']), use_cache=use_ffprobe_cache)
|
||||
|
||||
hide_progress(id='episodes_disk_scan')
|
||||
|
||||
|
@ -470,23 +466,20 @@ def series_full_scan_subtitles():
|
|||
|
||||
|
||||
def movies_full_scan_subtitles():
|
||||
use_ffprobe_cache = settings.radarr.getboolean('use_ffprobe_cache')
|
||||
|
||||
movies = TableMovies.select(TableMovies.path).dicts()
|
||||
|
||||
count_movies = len(movies)
|
||||
for i, movie in enumerate(movies, 1):
|
||||
for i, movie in enumerate(movies):
|
||||
sleep()
|
||||
show_progress(id='movies_disk_scan',
|
||||
header='Full disk scan...',
|
||||
name='Movies subtitles',
|
||||
value=i,
|
||||
count=count_movies)
|
||||
store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']))
|
||||
|
||||
show_progress(id='movies_disk_scan',
|
||||
header='Full disk scan...',
|
||||
name='Completed successfully',
|
||||
value=count_movies,
|
||||
count=count_movies)
|
||||
store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']),
|
||||
use_cache=use_ffprobe_cache)
|
||||
|
||||
hide_progress(id='movies_disk_scan')
|
||||
|
||||
|
|
|
@ -207,7 +207,6 @@ if settings.general.getboolean('use_sonarr'):
|
|||
greenlets.append(Greenlet.spawn(sonarr_signalr_client.start))
|
||||
if settings.general.getboolean('use_radarr'):
|
||||
greenlets.append(Greenlet.spawn(radarr_signalr_client.start))
|
||||
joinall(greenlets)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
128
bazarr/score.py
128
bazarr/score.py
|
@ -1,5 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
|
@ -10,6 +12,72 @@ from database import TableCustomScoreProfiles as profiles_table
|
|||
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:
|
||||
table = profiles_table
|
||||
conditions_table = conditions_table
|
||||
|
@ -24,11 +92,12 @@ class CustomScoreProfile:
|
|||
|
||||
def load_conditions(self):
|
||||
try:
|
||||
self._conditions = list(
|
||||
self.conditions_table.select()
|
||||
self._conditions = [
|
||||
Condition.from_dict(item)
|
||||
for item in self.conditions_table.select()
|
||||
.where(self.conditions_table.profile_id == self.id)
|
||||
.dicts()
|
||||
)
|
||||
]
|
||||
except self.conditions_table.DoesNotExist:
|
||||
logger.debug("Conditions not found for %s", self)
|
||||
self._conditions = []
|
||||
|
@ -42,50 +111,28 @@ class CustomScoreProfile:
|
|||
|
||||
# Always return False if no conditions are set
|
||||
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
|
||||
|
||||
logger.debug("Checking conditions for %s profile", self)
|
||||
met = self._check_conditions(subtitle)
|
||||
logger.debug("Profile conditions met? %s", met)
|
||||
return met
|
||||
return self._check_conditions(subtitle)
|
||||
|
||||
def _check_conditions(self, subtitle):
|
||||
checkers = {
|
||||
"provider": subtitle.provider_name,
|
||||
"uploader": subtitle.uploader,
|
||||
"language": subtitle.language,
|
||||
"regex": subtitle.release_info,
|
||||
}
|
||||
logger.debug("Checking conditions for %s profile", self)
|
||||
|
||||
matches = []
|
||||
for condition in self._conditions:
|
||||
# Condition dict example:
|
||||
# {type: provider, value: subdivx, required: False, negate: False}
|
||||
key = condition.get("type")
|
||||
sub_value = checkers.get(key)
|
||||
if sub_value is None:
|
||||
continue
|
||||
matched = condition.check(subtitle)
|
||||
|
||||
cond_value = condition.get("value", "")
|
||||
negate = condition.get("negate", False)
|
||||
|
||||
logger.debug("Checking %s: %s (condition: %s)", key, sub_value, 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)
|
||||
if matched is True:
|
||||
logger.debug("%s Condition met", condition)
|
||||
matches.append(True)
|
||||
elif condition.required and not matched:
|
||||
logger.debug("%s not met, discarding profile", condition)
|
||||
return False
|
||||
|
||||
return True in matches
|
||||
met = True in matches
|
||||
logger.debug("Profile conditions met? %s", met)
|
||||
return met
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ScoreProfile {self.name} (score: {self.score})>"
|
||||
|
@ -217,5 +264,12 @@ class MovieScore(Score):
|
|||
self.data.update(kwargs["movie_scores"])
|
||||
|
||||
|
||||
_registered_conditions = {
|
||||
"provider": ProviderCondition,
|
||||
"uploader": UploaderCondition,
|
||||
"language": LanguageCondition,
|
||||
"regex": RegexCondition,
|
||||
}
|
||||
|
||||
series_score = SeriesScore.from_config(**get_settings())
|
||||
movie_score = MovieScore.from_config(**get_settings())
|
||||
|
|
|
@ -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):
|
||||
language_code_convert_dict = {
|
||||
'he': 'iw',
|
||||
}
|
||||
|
||||
to_lang = alpha3_from_alpha2(to_lang)
|
||||
lang_obj = Language(to_lang)
|
||||
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:
|
||||
try:
|
||||
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:
|
||||
return False
|
||||
else:
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "react-app"
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
*.local
|
||||
build
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
[ -n "$CI" ] && exit 0
|
||||
|
||||
cd frontend
|
||||
npx pretty-quick --staged
|
|
@ -1,10 +1,12 @@
|
|||
# Bazarr Frontend
|
||||
|
||||
## Dependencies
|
||||
* [Node.js](https://nodejs.org/)
|
||||
* npm (included in Node.js)
|
||||
|
||||
- [Node.js](https://nodejs.org/)
|
||||
- npm (included in Node.js)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Clone or download this repository
|
||||
|
||||
```
|
||||
|
@ -17,18 +19,36 @@
|
|||
```
|
||||
$ 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
|
||||
```
|
||||
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
|
||||
```
|
||||
9. Run the web client for local development
|
||||
|
||||
7. Run the web development tool
|
||||
|
||||
```
|
||||
$ npm start
|
||||
|
@ -46,6 +66,12 @@ Open `http://localhost:3000` to view it in the browser.
|
|||
The page will reload if you make edits.
|
||||
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`
|
||||
|
||||
Builds the app for production to the `build` folder.
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -11,6 +11,7 @@
|
|||
"bugs": {
|
||||
"url": "https://github.com/morpheus65535/bazarr/issues"
|
||||
},
|
||||
"private": true,
|
||||
"homepage": "./",
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^4.2.2",
|
||||
|
@ -19,21 +20,9 @@
|
|||
"@fortawesome/free-regular-svg-icons": "^5.15",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15",
|
||||
"@fortawesome/react-fontawesome": "^0.1.11",
|
||||
"@types/bootstrap": "^5",
|
||||
"@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",
|
||||
"@reduxjs/toolkit": "^1.6",
|
||||
"axios": "^0.21",
|
||||
"bootstrap": "^4",
|
||||
"http-proxy-middleware": "^0.19",
|
||||
"lodash": "^4",
|
||||
"rc-slider": "^9.7",
|
||||
"react": "^17",
|
||||
|
@ -46,17 +35,24 @@
|
|||
"react-select": "^4",
|
||||
"react-table": "^7",
|
||||
"recharts": "^2.0.8",
|
||||
"redux-actions": "^2",
|
||||
"redux-logger": "^3",
|
||||
"redux-promise": "^0.6",
|
||||
"redux-thunk": "^2.3",
|
||||
"rooks": "^5",
|
||||
"sass": "^1",
|
||||
"socket.io-client": "^4",
|
||||
"typescript": "^4"
|
||||
},
|
||||
"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-plugin-organize-imports": "^2",
|
||||
"pretty-quick": "^3.1"
|
||||
|
@ -65,10 +61,8 @@
|
|||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"lint": "prettier --write --ignore-unknown ."
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
"lint": "prettier --write --ignore-unknown .",
|
||||
"prepare": "cd .. && husky install frontend/.husky"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
@ -81,10 +75,5 @@
|
|||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "pretty-quick --staged"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { debounce, forIn, remove, uniq } from "lodash";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { getBaseUrl } from "../utilites";
|
||||
import { conditionalLog, log } from "../utilites/logger";
|
||||
import { Environment } from "../../utilities";
|
||||
import { conditionalLog, log } from "../../utilities/logger";
|
||||
import { createDefaultReducer } from "./reducer";
|
||||
|
||||
class SocketIOClient {
|
||||
|
@ -12,9 +12,8 @@ class SocketIOClient {
|
|||
private reducers: SocketIO.Reducer[];
|
||||
|
||||
constructor() {
|
||||
const baseUrl = getBaseUrl();
|
||||
this.socket = io({
|
||||
path: `${baseUrl}/api/socket.io`,
|
||||
path: `${Environment.baseUrl}/api/socket.io`,
|
||||
transports: ["polling", "websocket"],
|
||||
upgrade: true,
|
||||
rememberUpgrade: true,
|
||||
|
@ -65,12 +64,13 @@ class SocketIOClient {
|
|||
if (!(e.type in records)) {
|
||||
records[e.type] = {};
|
||||
}
|
||||
|
||||
const record = records[e.type]!;
|
||||
if (!(e.action in record)) {
|
||||
record[e.action] = [];
|
||||
}
|
||||
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) => {
|
||||
ids = uniq(ids);
|
||||
const action = handler[key];
|
||||
if (typeof action == "function") {
|
||||
const action = handler[key as SocketIO.ActionType];
|
||||
if (action) {
|
||||
action(ids);
|
||||
} else if (anyAction === undefined) {
|
||||
log("warning", "Unhandle action of SocketIO event", key, type);
|
||||
log("warning", "Unhandled SocketIO event", key, type);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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),
|
||||
},
|
||||
];
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
namespace SocketIO {
|
||||
declare namespace SocketIO {
|
||||
type EventType = NumEventType | NullEventType | SpecialEventType;
|
||||
|
||||
type NumEventType =
|
||||
|
@ -16,6 +16,8 @@ namespace SocketIO {
|
|||
| "episode-history"
|
||||
| "movie-blacklist"
|
||||
| "movie-history"
|
||||
| "reset-episode-wanted"
|
||||
| "reset-movie-wanted"
|
||||
| "badges"
|
||||
| "task"
|
||||
| "settings"
|
||||
|
@ -23,25 +25,26 @@ namespace SocketIO {
|
|||
|
||||
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]: {
|
||||
key: P;
|
||||
any?: () => void;
|
||||
update?: ActionFn<T>;
|
||||
delete?: ActionFn<D extends never ? T : D>;
|
||||
} & LooseObject;
|
||||
// TODO: Typing
|
||||
any?: ActionHandler<null>;
|
||||
update?: ActionHandler<U>;
|
||||
delete?: ActionHandler<D>;
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
type Event = {
|
||||
type: EventType;
|
||||
action: string;
|
||||
action: ActionType;
|
||||
payload: any;
|
||||
};
|
||||
|
||||
type ActionFn<T> = (payload?: T[]) => void;
|
||||
type ActionHandler<T> = T extends null ? () => void : (payload: T[]) => void;
|
||||
|
||||
type Reducer =
|
||||
| ReducerCreator<NumEventType, number>
|
||||
|
@ -49,9 +52,13 @@ namespace SocketIO {
|
|||
| ReducerCreator<"message", string>
|
||||
| ReducerCreator<"progress", CustomEvent.Progress, string>;
|
||||
|
||||
type ActionRecord = OptionalRecord<EventType, StrictObject<any[]>>;
|
||||
type ActionRecord = {
|
||||
[P in EventType]?: {
|
||||
[R in ActionType]?: any[];
|
||||
};
|
||||
};
|
||||
|
||||
namespace CustomEvent {
|
||||
type Progress = ReduxStore.Progress;
|
||||
type Progress = Site.Progress;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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>[];
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -1,47 +1,84 @@
|
|||
import { createDeleteAction } from "../../@socketio/reducer";
|
||||
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
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(
|
||||
MOVIES_UPDATE_LIST,
|
||||
(id?: number[]) => MoviesApi.movies(id)
|
||||
export const movieUpdateByRange = createAsyncThunk(
|
||||
"movies/update/range",
|
||||
async (params: Parameter.Range) => {
|
||||
const response = await MoviesApi.moviesBy(params);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieDeleteItems = createDeleteAction(MOVIES_DELETE_ITEMS);
|
||||
|
||||
export const movieUpdateWantedList = createAsyncAction(
|
||||
MOVIES_UPDATE_WANTED_LIST,
|
||||
(radarrid: number[]) => MoviesApi.wantedBy(radarrid)
|
||||
export const movieUpdateById = createAsyncThunk(
|
||||
"movies/update/id",
|
||||
async (ids: number[]) => {
|
||||
const response = await MoviesApi.movies(ids);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieDeleteWantedItems = createDeleteAction(
|
||||
MOVIES_DELETE_WANTED_ITEMS
|
||||
export const movieUpdateAll = createAsyncThunk(
|
||||
"movies/update/all",
|
||||
async () => {
|
||||
const response = await MoviesApi.movies();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieUpdateWantedByRange = createAsyncAction(
|
||||
MOVIES_UPDATE_WANTED_LIST,
|
||||
(start: number, length: number) => MoviesApi.wanted(start, length)
|
||||
export const movieRemoveById = createAction<number[]>("movies/remove");
|
||||
|
||||
export const movieMarkDirtyById = createAction<number[]>(
|
||||
"movies/mark_dirty/id"
|
||||
);
|
||||
|
||||
export const movieUpdateHistoryList = createAsyncAction(
|
||||
MOVIES_UPDATE_HISTORY_LIST,
|
||||
() => MoviesApi.history()
|
||||
export const movieUpdateWantedById = createAsyncThunk(
|
||||
"movies/wanted/update/id",
|
||||
async (ids: number[]) => {
|
||||
const response = await MoviesApi.wantedBy(ids);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const movieUpdateByRange = createAsyncAction(
|
||||
MOVIES_UPDATE_LIST,
|
||||
(start: number, length: number) => MoviesApi.moviesBy(start, length)
|
||||
export const movieRemoveWantedById = createAction<number[]>(
|
||||
"movies/wanted/remove/id"
|
||||
);
|
||||
|
||||
export const movieUpdateBlacklist = createAsyncAction(
|
||||
MOVIES_UPDATE_BLACKLIST,
|
||||
() => MoviesApi.blacklist()
|
||||
export const movieResetWanted = createAction("movies/wanted/reset");
|
||||
|
||||
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"
|
||||
);
|
||||
|
|
|
@ -1,61 +1,106 @@
|
|||
import { createDeleteAction } from "../../@socketio/reducer";
|
||||
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
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(
|
||||
SERIES_UPDATE_WANTED_LIST,
|
||||
(episodeid: number[]) => EpisodesApi.wantedBy(episodeid)
|
||||
export const seriesUpdateWantedById = createAsyncThunk(
|
||||
"series/wanted/update/id",
|
||||
async (episodeid: number[]) => {
|
||||
const response = await EpisodesApi.wantedBy(episodeid);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesDeleteWantedItems = createDeleteAction(
|
||||
SERIES_DELETE_WANTED_ITEMS
|
||||
export const seriesUpdateWantedByRange = createAsyncThunk(
|
||||
"series/wanted/update/range",
|
||||
async (params: Parameter.Range) => {
|
||||
const response = await EpisodesApi.wanted(params);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesUpdateWantedByRange = createAsyncAction(
|
||||
SERIES_UPDATE_WANTED_LIST,
|
||||
(start: number, length: number) => EpisodesApi.wanted(start, length)
|
||||
export const seriesRemoveWantedById = createAction<number[]>(
|
||||
"series/wanted/remove/id"
|
||||
);
|
||||
|
||||
export const seriesUpdateList = createAsyncAction(
|
||||
SERIES_UPDATE_LIST,
|
||||
(id?: number[]) => SeriesApi.series(id)
|
||||
export const seriesResetWanted = createAction("series/wanted/reset");
|
||||
|
||||
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(
|
||||
SERIES_UPDATE_EPISODE_LIST,
|
||||
(seriesid: number[]) => EpisodesApi.bySeriesId(seriesid)
|
||||
export const seriesMarkDirtyById = createAction<number[]>(
|
||||
"series/mark_dirty/id"
|
||||
);
|
||||
|
||||
export const episodeDeleteItems = createDeleteAction(SERIES_DELETE_EPISODES);
|
||||
|
||||
export const episodeUpdateById = createAsyncAction(
|
||||
SERIES_UPDATE_EPISODE_LIST,
|
||||
(episodeid: number[]) => EpisodesApi.byEpisodeId(episodeid)
|
||||
export const seriesUpdateById = createAsyncThunk(
|
||||
"series/update/id",
|
||||
async (ids: number[]) => {
|
||||
const response = await SeriesApi.series(ids);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesUpdateByRange = createAsyncAction(
|
||||
SERIES_UPDATE_LIST,
|
||||
(start: number, length: number) => SeriesApi.seriesBy(start, length)
|
||||
export const seriesUpdateAll = createAsyncThunk(
|
||||
"series/update/all",
|
||||
async () => {
|
||||
const response = await SeriesApi.series();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesUpdateHistoryList = createAsyncAction(
|
||||
SERIES_UPDATE_HISTORY_LIST,
|
||||
() => EpisodesApi.history()
|
||||
export const seriesUpdateByRange = createAsyncThunk(
|
||||
"series/update/range",
|
||||
async (params: Parameter.Range) => {
|
||||
const response = await SeriesApi.seriesBy(params);
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const seriesUpdateBlacklist = createAsyncAction(
|
||||
SERIES_UPDATE_BLACKLIST,
|
||||
() => EpisodesApi.blacklist()
|
||||
export const episodesRemoveById = createAction<number[]>("episodes/remove");
|
||||
|
||||
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"
|
||||
);
|
||||
|
|
|
@ -1,63 +1,60 @@
|
|||
import { createAction } from "redux-actions";
|
||||
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { BadgesApi } from "../../apis";
|
||||
import {
|
||||
SITE_BADGE_UPDATE,
|
||||
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";
|
||||
import { waitFor } from "../../utilities";
|
||||
import { systemUpdateAllSettings } from "./system";
|
||||
|
||||
export const bootstrap = createCallbackAction(
|
||||
() => [systemUpdateLanguagesAll(), systemUpdateSettings(), badgeUpdateAll()],
|
||||
() => siteInitialized(),
|
||||
() => siteInitializationFailed()
|
||||
export const siteBootstrap = createAsyncThunk(
|
||||
"site/bootstrap",
|
||||
(_: undefined, { dispatch }) => {
|
||||
return Promise.all([
|
||||
dispatch(systemUpdateAllSettings()),
|
||||
dispatch(siteUpdateBadges()),
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Override error messages
|
||||
export const siteInitializationFailed = createAction(SITE_INITIALIZE_FAILED);
|
||||
|
||||
const siteInitialized = createAction(SITE_INITIALIZED);
|
||||
|
||||
export const siteRedirectToAuth = createAction(SITE_NEED_AUTH);
|
||||
|
||||
export const badgeUpdateAll = createAsyncAction(SITE_BADGE_UPDATE, () =>
|
||||
BadgesApi.all()
|
||||
export const siteUpdateInitialization = createAction<string | true>(
|
||||
"site/initialization/update"
|
||||
);
|
||||
|
||||
export const siteAddNotifications = createAction(
|
||||
SITE_NOTIFICATIONS_ADD,
|
||||
(notification: ReduxStore.Notification[]) => notification
|
||||
export const siteRedirectToAuth = createAction("site/redirect_auth");
|
||||
|
||||
export const siteAddNotifications = createAction<Server.Notification[]>(
|
||||
"site/notifications/add"
|
||||
);
|
||||
|
||||
export const siteRemoveNotifications = createAction(
|
||||
SITE_NOTIFICATIONS_REMOVE,
|
||||
(id: string) => id
|
||||
export const siteRemoveNotifications = createAction<string>(
|
||||
"site/notifications/remove"
|
||||
);
|
||||
|
||||
export const siteAddProgress = createAction(
|
||||
SITE_PROGRESS_ADD,
|
||||
(progress: ReduxStore.Progress[]) => progress
|
||||
export const siteAddProgress =
|
||||
createAction<Site.Progress[]>("site/progress/add");
|
||||
|
||||
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(
|
||||
SITE_PROGRESS_REMOVE,
|
||||
(id: string) => id
|
||||
export const siteUpdateNotifier = createAction<string>(
|
||||
"site/progress/update_notifier"
|
||||
);
|
||||
|
||||
export const siteChangeSidebar = createAction(
|
||||
SITE_SIDEBAR_UPDATE,
|
||||
(id: string) => id
|
||||
);
|
||||
export const siteChangeSidebar = createAction<string>("site/sidebar/update");
|
||||
|
||||
export const siteUpdateOffline = createAction(
|
||||
SITE_OFFLINE_UPDATE,
|
||||
(state: boolean) => state
|
||||
export const siteUpdateOffline = createAction<boolean>("site/offline/update");
|
||||
|
||||
export const siteUpdateBadges = createAsyncThunk(
|
||||
"site/badges/update",
|
||||
async () => {
|
||||
const response = await BadgesApi.all();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,64 +1,87 @@
|
|||
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
|
||||
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(() => [
|
||||
systemUpdateLanguages(),
|
||||
systemUpdateLanguagesProfiles(),
|
||||
]);
|
||||
|
||||
export const systemUpdateLanguages = createAsyncAction(
|
||||
SYSTEM_UPDATE_LANGUAGES_LIST,
|
||||
() => SystemApi.languages()
|
||||
export const systemUpdateAllSettings = createAsyncThunk(
|
||||
"system/update",
|
||||
async (_: undefined, { dispatch }) => {
|
||||
await Promise.all([
|
||||
dispatch(systemUpdateSettings()),
|
||||
dispatch(systemUpdateLanguages()),
|
||||
dispatch(systemUpdateLanguagesProfiles()),
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateLanguagesProfiles = createAsyncAction(
|
||||
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
|
||||
() => SystemApi.languagesProfileList()
|
||||
export const systemUpdateLanguages = createAsyncThunk(
|
||||
"system/languages/update",
|
||||
async () => {
|
||||
const response = await SystemApi.languages();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateStatus = createAsyncAction(SYSTEM_UPDATE_STATUS, () =>
|
||||
SystemApi.status()
|
||||
export const systemUpdateLanguagesProfiles = createAsyncThunk(
|
||||
"system/languages/profile/update",
|
||||
async () => {
|
||||
const response = await SystemApi.languagesProfileList();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateHealth = createAsyncAction(SYSTEM_UPDATE_HEALTH, () =>
|
||||
SystemApi.health()
|
||||
export const systemUpdateStatus = createAsyncThunk(
|
||||
"system/status/update",
|
||||
async () => {
|
||||
const response = await SystemApi.status();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateTasks = createAsyncAction(SYSTEM_UPDATE_TASKS, () =>
|
||||
SystemApi.getTasks()
|
||||
export const systemUpdateHealth = createAsyncThunk(
|
||||
"system/health/update",
|
||||
async () => {
|
||||
const response = await SystemApi.health();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateLogs = createAsyncAction(SYSTEM_UPDATE_LOGS, () =>
|
||||
SystemApi.logs()
|
||||
export const systemMarkTasksDirty = createAction("system/tasks/mark_dirty");
|
||||
|
||||
export const systemUpdateTasks = createAsyncThunk(
|
||||
"system/tasks/update",
|
||||
async () => {
|
||||
const response = await SystemApi.tasks();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateReleases = createAsyncAction(
|
||||
SYSTEM_UPDATE_RELEASES,
|
||||
() => SystemApi.releases()
|
||||
export const systemUpdateLogs = createAsyncThunk(
|
||||
"system/logs/update",
|
||||
async () => {
|
||||
const response = await SystemApi.logs();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateSettings = createAsyncAction(
|
||||
SYSTEM_UPDATE_SETTINGS,
|
||||
() => SystemApi.settings()
|
||||
export const systemUpdateReleases = createAsyncThunk(
|
||||
"system/releases/update",
|
||||
async () => {
|
||||
const response = await SystemApi.releases();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const providerUpdateList = createAsyncAction(
|
||||
SYSTEM_UPDATE_PROVIDERS,
|
||||
() => ProvidersApi.providers()
|
||||
export const systemUpdateSettings = createAsyncThunk(
|
||||
"system/settings/update",
|
||||
async () => {
|
||||
const response = await SystemApi.settings();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
export const systemUpdateSettingsAll = createAsyncCombineAction(() => [
|
||||
systemUpdateSettings(),
|
||||
systemUpdateLanguagesAll(),
|
||||
]);
|
||||
export const providerUpdateList = createAsyncThunk(
|
||||
"providers/update",
|
||||
async () => {
|
||||
const response = await ProvidersApi.providers();
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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";
|
|
@ -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]);
|
||||
}
|
|
@ -1,36 +1,24 @@
|
|||
import { ActionCreator } from "@reduxjs/toolkit";
|
||||
import { useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { createCallbackAction } from "../actions/factory";
|
||||
import { ActionCallback, AsyncActionDispatcher } from "../types";
|
||||
import { AppDispatch, RootState } from "../store";
|
||||
|
||||
// function use
|
||||
export function useReduxStore<T extends (store: ReduxStore) => any>(
|
||||
export function useReduxStore<T extends (store: RootState) => any>(
|
||||
selector: T
|
||||
) {
|
||||
return useSelector<ReduxStore, ReturnType<T>>(selector);
|
||||
return useSelector<RootState, ReturnType<T>>(selector);
|
||||
}
|
||||
|
||||
export function useReduxAction<T extends (...args: any[]) => void>(action: T) {
|
||||
const dispatch = useDispatch();
|
||||
return useCallback((...args: Parameters<T>) => dispatch(action(...args)), [
|
||||
action,
|
||||
dispatch,
|
||||
]);
|
||||
export function useAppDispatch() {
|
||||
return useDispatch<AppDispatch>();
|
||||
}
|
||||
|
||||
export function useReduxActionWith<
|
||||
T extends (...args: any[]) => AsyncActionDispatcher<any>
|
||||
>(action: T, success: ActionCallback) {
|
||||
const dispatch = useDispatch();
|
||||
// TODO: Fix type
|
||||
export function useReduxAction<T extends ActionCreator<any>>(action: T) {
|
||||
const dispatch = useAppDispatch();
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
const callbackAction = createCallbackAction(
|
||||
() => [action(...args)],
|
||||
success
|
||||
);
|
||||
|
||||
dispatch(callbackAction());
|
||||
},
|
||||
[dispatch, action, success]
|
||||
(...args: Parameters<T>) => dispatch(action(...args)),
|
||||
[action, dispatch]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,393 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useSocketIOReducer, useWrapToOptionalId } from "../../@socketio/hooks";
|
||||
import { buildOrderList } from "../../utilites";
|
||||
import {
|
||||
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);
|
||||
}
|
||||
export * from "./movies";
|
||||
export * from "./series";
|
||||
export * from "./site";
|
||||
export * from "./system";
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -7,8 +7,8 @@ export function useNotification(id: string, timeout: number = 5000) {
|
|||
const add = useReduxAction(siteAddNotifications);
|
||||
|
||||
return useCallback(
|
||||
(msg: Omit<ReduxStore.Notification, "id" | "timeout">) => {
|
||||
const notification: ReduxStore.Notification = {
|
||||
(msg: Omit<Server.Notification, "id" | "timeout">) => {
|
||||
const notification: Server.Notification = {
|
||||
...msg,
|
||||
id,
|
||||
timeout,
|
||||
|
@ -24,18 +24,18 @@ export function useIsOffline() {
|
|||
}
|
||||
|
||||
export function useIsSonarrEnabled() {
|
||||
const [settings] = useSystemSettings();
|
||||
return settings.data?.general.use_sonarr ?? true;
|
||||
const settings = useSystemSettings();
|
||||
return settings.content?.general.use_sonarr ?? true;
|
||||
}
|
||||
|
||||
export function useIsRadarrEnabled() {
|
||||
const [settings] = useSystemSettings();
|
||||
return settings.data?.general.use_radarr ?? true;
|
||||
const settings = useSystemSettings();
|
||||
return settings.content?.general.use_radarr ?? true;
|
||||
}
|
||||
|
||||
export function useShowOnlyDesired() {
|
||||
const [settings] = useSystemSettings();
|
||||
return settings.data?.general.embedded_subs_show_desired ?? false;
|
||||
const settings = useSystemSettings();
|
||||
return settings.content?.general.embedded_subs_show_desired ?? false;
|
||||
}
|
||||
|
||||
export function useSetSidebar(key: string) {
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
}
|
|
@ -1,12 +1,13 @@
|
|||
import { combineReducers } from "redux";
|
||||
import movie from "./movie";
|
||||
import movies from "./movie";
|
||||
import series from "./series";
|
||||
import site from "./site";
|
||||
import system from "./system";
|
||||
|
||||
export default combineReducers({
|
||||
system,
|
||||
const AllReducers = {
|
||||
movies,
|
||||
series,
|
||||
movie,
|
||||
site,
|
||||
});
|
||||
system,
|
||||
};
|
||||
|
||||
export default AllReducers;
|
||||
|
|
|
@ -1,81 +1,68 @@
|
|||
import { Action, handleActions } from "redux-actions";
|
||||
import { createReducer } from "@reduxjs/toolkit";
|
||||
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 { AsyncAction } from "../types";
|
||||
import { defaultAOS } from "../utils";
|
||||
movieMarkBlacklistDirty,
|
||||
movieMarkDirtyById,
|
||||
movieMarkHistoryDirty,
|
||||
movieMarkWantedDirtyById,
|
||||
movieRemoveById,
|
||||
movieRemoveWantedById,
|
||||
movieResetHistory,
|
||||
movieResetWanted,
|
||||
movieUpdateAll,
|
||||
movieUpdateBlacklist,
|
||||
movieUpdateById,
|
||||
movieUpdateByRange,
|
||||
movieUpdateHistoryByRange,
|
||||
movieUpdateWantedById,
|
||||
movieUpdateWantedByRange,
|
||||
} from "../actions";
|
||||
import { AsyncUtility } from "../utils";
|
||||
import {
|
||||
deleteOrderListItemBy,
|
||||
updateAsyncState,
|
||||
updateOrderIdState,
|
||||
} from "../utils/mapper";
|
||||
createAsyncEntityReducer,
|
||||
createAsyncItemReducer,
|
||||
} from "../utils/factory";
|
||||
|
||||
const reducer = handleActions<ReduxStore.Movie, any>(
|
||||
{
|
||||
[MOVIES_UPDATE_WANTED_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Wanted.Movie>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
wantedMovieList: updateOrderIdState(
|
||||
action,
|
||||
state.wantedMovieList,
|
||||
"radarrId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[MOVIES_DELETE_WANTED_ITEMS]: (state, action: Action<number[]>) => {
|
||||
return {
|
||||
...state,
|
||||
wantedMovieList: deleteOrderListItemBy(action, state.wantedMovieList),
|
||||
};
|
||||
},
|
||||
[MOVIES_UPDATE_HISTORY_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<History.Movie[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
historyList: updateAsyncState(action, state.historyList.data),
|
||||
};
|
||||
},
|
||||
[MOVIES_UPDATE_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Item.Movie>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
movieList: updateOrderIdState(action, state.movieList, "radarrId"),
|
||||
};
|
||||
},
|
||||
[MOVIES_DELETE_ITEMS]: (state, action: Action<number[]>) => {
|
||||
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: [] },
|
||||
}
|
||||
);
|
||||
interface Movie {
|
||||
movieList: Async.Entity<Item.Movie>;
|
||||
wantedMovieList: Async.Entity<Wanted.Movie>;
|
||||
historyList: Async.Entity<History.Movie>;
|
||||
blacklist: Async.Item<Blacklist.Movie[]>;
|
||||
}
|
||||
|
||||
const defaultMovie: Movie = {
|
||||
movieList: AsyncUtility.getDefaultEntity("radarrId"),
|
||||
wantedMovieList: AsyncUtility.getDefaultEntity("radarrId"),
|
||||
historyList: AsyncUtility.getDefaultEntity("id"),
|
||||
blacklist: AsyncUtility.getDefaultItem(),
|
||||
};
|
||||
|
||||
const reducer = createReducer(defaultMovie, (builder) => {
|
||||
createAsyncEntityReducer(builder, (s) => s.movieList, {
|
||||
range: movieUpdateByRange,
|
||||
ids: movieUpdateById,
|
||||
removeIds: movieRemoveById,
|
||||
all: movieUpdateAll,
|
||||
dirty: movieMarkDirtyById,
|
||||
});
|
||||
|
||||
createAsyncEntityReducer(builder, (s) => s.wantedMovieList, {
|
||||
range: movieUpdateWantedByRange,
|
||||
ids: movieUpdateWantedById,
|
||||
removeIds: movieRemoveWantedById,
|
||||
dirty: movieMarkWantedDirtyById,
|
||||
reset: movieResetWanted,
|
||||
});
|
||||
|
||||
createAsyncEntityReducer(builder, (s) => s.historyList, {
|
||||
range: movieUpdateHistoryByRange,
|
||||
dirty: movieMarkHistoryDirty,
|
||||
reset: movieResetHistory,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.blacklist, {
|
||||
all: movieUpdateBlacklist,
|
||||
dirty: movieMarkBlacklistDirty,
|
||||
});
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
|
|
|
@ -1,116 +1,100 @@
|
|||
import { Action, handleActions } from "redux-actions";
|
||||
import { createReducer } from "@reduxjs/toolkit";
|
||||
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 { AsyncAction } from "../types";
|
||||
import { defaultAOS } from "../utils";
|
||||
episodesMarkBlacklistDirty,
|
||||
episodesMarkDirtyById,
|
||||
episodesMarkHistoryDirty,
|
||||
episodesRemoveById,
|
||||
episodesResetHistory,
|
||||
episodesUpdateBlacklist,
|
||||
episodesUpdateHistoryByRange,
|
||||
episodeUpdateById,
|
||||
episodeUpdateBySeriesId,
|
||||
seriesMarkDirtyById,
|
||||
seriesMarkWantedDirtyById,
|
||||
seriesRemoveById,
|
||||
seriesRemoveWantedById,
|
||||
seriesResetWanted,
|
||||
seriesUpdateAll,
|
||||
seriesUpdateById,
|
||||
seriesUpdateByRange,
|
||||
seriesUpdateWantedById,
|
||||
seriesUpdateWantedByRange,
|
||||
} from "../actions";
|
||||
import { AsyncUtility, ReducerUtility } from "../utils";
|
||||
import {
|
||||
deleteAsyncListItemBy,
|
||||
deleteOrderListItemBy,
|
||||
updateAsyncList,
|
||||
updateAsyncState,
|
||||
updateOrderIdState,
|
||||
} from "../utils/mapper";
|
||||
createAsyncEntityReducer,
|
||||
createAsyncItemReducer,
|
||||
createAsyncListReducer,
|
||||
} from "../utils/factory";
|
||||
|
||||
const reducer = handleActions<ReduxStore.Series, any>(
|
||||
{
|
||||
[SERIES_UPDATE_WANTED_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Wanted.Episode>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
wantedEpisodesList: updateOrderIdState(
|
||||
action,
|
||||
state.wantedEpisodesList,
|
||||
"sonarrEpisodeId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_DELETE_WANTED_ITEMS]: (state, action: Action<number[]>) => {
|
||||
return {
|
||||
...state,
|
||||
wantedEpisodesList: deleteOrderListItemBy(
|
||||
action,
|
||||
state.wantedEpisodesList
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_EPISODE_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<Item.Episode[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
episodeList: updateAsyncList(
|
||||
action,
|
||||
state.episodeList,
|
||||
"sonarrEpisodeId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_DELETE_EPISODES]: (state, action: Action<number[]>) => {
|
||||
return {
|
||||
...state,
|
||||
episodeList: deleteAsyncListItemBy(
|
||||
action,
|
||||
state.episodeList,
|
||||
"sonarrEpisodeId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_HISTORY_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<History.Episode[]>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
historyList: updateAsyncState(action, state.historyList.data),
|
||||
};
|
||||
},
|
||||
[SERIES_UPDATE_LIST]: (
|
||||
state,
|
||||
action: AsyncAction<AsyncDataWrapper<Item.Series>>
|
||||
) => {
|
||||
return {
|
||||
...state,
|
||||
seriesList: updateOrderIdState(
|
||||
action,
|
||||
state.seriesList,
|
||||
"sonarrSeriesId"
|
||||
),
|
||||
};
|
||||
},
|
||||
[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: [] },
|
||||
}
|
||||
);
|
||||
interface Series {
|
||||
seriesList: Async.Entity<Item.Series>;
|
||||
wantedEpisodesList: Async.Entity<Wanted.Episode>;
|
||||
episodeList: Async.List<Item.Episode>;
|
||||
historyList: Async.Entity<History.Episode>;
|
||||
blacklist: Async.Item<Blacklist.Episode[]>;
|
||||
}
|
||||
|
||||
const defaultSeries: Series = {
|
||||
seriesList: AsyncUtility.getDefaultEntity("sonarrSeriesId"),
|
||||
wantedEpisodesList: AsyncUtility.getDefaultEntity("sonarrEpisodeId"),
|
||||
episodeList: AsyncUtility.getDefaultList("sonarrEpisodeId"),
|
||||
historyList: AsyncUtility.getDefaultEntity("id"),
|
||||
blacklist: AsyncUtility.getDefaultItem(),
|
||||
};
|
||||
|
||||
const reducer = createReducer(defaultSeries, (builder) => {
|
||||
createAsyncEntityReducer(builder, (s) => s.seriesList, {
|
||||
range: seriesUpdateByRange,
|
||||
ids: seriesUpdateById,
|
||||
removeIds: seriesRemoveById,
|
||||
all: seriesUpdateAll,
|
||||
});
|
||||
|
||||
builder.addCase(seriesMarkDirtyById, (state, action) => {
|
||||
const series = state.seriesList;
|
||||
const dirtyIds = action.payload.map(String);
|
||||
|
||||
ReducerUtility.markDirty(series, dirtyIds);
|
||||
|
||||
// Update episode list
|
||||
const episodes = state.episodeList;
|
||||
const dirtyIdsSet = new Set(dirtyIds);
|
||||
const dirtyEpisodeIds = episodes.content
|
||||
.filter((v) => dirtyIdsSet.has(v.sonarrSeriesId.toString()))
|
||||
.map((v) => String(v.sonarrEpisodeId));
|
||||
|
||||
ReducerUtility.markDirty(episodes, dirtyEpisodeIds);
|
||||
});
|
||||
|
||||
createAsyncEntityReducer(builder, (s) => s.wantedEpisodesList, {
|
||||
range: seriesUpdateWantedByRange,
|
||||
ids: seriesUpdateWantedById,
|
||||
removeIds: seriesRemoveWantedById,
|
||||
dirty: seriesMarkWantedDirtyById,
|
||||
reset: seriesResetWanted,
|
||||
});
|
||||
|
||||
createAsyncEntityReducer(builder, (s) => s.historyList, {
|
||||
range: episodesUpdateHistoryByRange,
|
||||
dirty: episodesMarkHistoryDirty,
|
||||
reset: episodesResetHistory,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.blacklist, {
|
||||
all: episodesUpdateBlacklist,
|
||||
dirty: episodesMarkBlacklistDirty,
|
||||
});
|
||||
|
||||
createAsyncListReducer(builder, (s) => s.episodeList, {
|
||||
ids: episodeUpdateBySeriesId,
|
||||
});
|
||||
|
||||
createAsyncListReducer(builder, (s) => s.episodeList, {
|
||||
ids: episodeUpdateById,
|
||||
removeIds: episodesRemoveById,
|
||||
dirty: episodesMarkDirtyById,
|
||||
});
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
|
|
|
@ -1,100 +1,130 @@
|
|||
import { remove, uniqBy } from "lodash";
|
||||
import { Action, handleActions } from "redux-actions";
|
||||
import { createReducer } from "@reduxjs/toolkit";
|
||||
import { intersectionWith, pullAllWith, remove, sortBy, uniqBy } from "lodash";
|
||||
import apis from "../../apis";
|
||||
import { isProdEnv } from "../../utilities";
|
||||
import {
|
||||
SITE_BADGE_UPDATE,
|
||||
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 { AsyncAction } from "../types";
|
||||
siteAddNotifications,
|
||||
siteAddProgress,
|
||||
siteBootstrap,
|
||||
siteChangeSidebar,
|
||||
siteRedirectToAuth,
|
||||
siteRemoveNotifications,
|
||||
siteRemoveProgress,
|
||||
siteUpdateBadges,
|
||||
siteUpdateInitialization,
|
||||
siteUpdateNotifier,
|
||||
siteUpdateOffline,
|
||||
siteUpdateProgressCount,
|
||||
} from "../actions/site";
|
||||
|
||||
const reducer = handleActions<ReduxStore.Site, any>(
|
||||
{
|
||||
[SITE_NEED_AUTH]: (state) => {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
apis.danger_resetApi("NEED_AUTH");
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
auth: false,
|
||||
};
|
||||
},
|
||||
[SITE_INITIALIZED]: (state) => ({
|
||||
...state,
|
||||
initialized: true,
|
||||
}),
|
||||
[SITE_INITIALIZE_FAILED]: (state) => ({
|
||||
...state,
|
||||
initialized: "An Error Occurred When Initializing Bazarr UI",
|
||||
}),
|
||||
[SITE_NOTIFICATIONS_ADD]: (
|
||||
state,
|
||||
action: Action<ReduxStore.Notification[]>
|
||||
) => {
|
||||
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 };
|
||||
},
|
||||
interface Site {
|
||||
// Initialization state or error message
|
||||
initialized: boolean | string;
|
||||
offline: boolean;
|
||||
auth: boolean;
|
||||
progress: Site.Progress[];
|
||||
notifier: {
|
||||
content: string | null;
|
||||
timestamp: string;
|
||||
};
|
||||
notifications: Server.Notification[];
|
||||
sidebar: string;
|
||||
badges: Badge;
|
||||
}
|
||||
|
||||
const defaultSite: Site = {
|
||||
initialized: false,
|
||||
auth: true,
|
||||
progress: [],
|
||||
notifier: {
|
||||
content: null,
|
||||
timestamp: String(Date.now()),
|
||||
},
|
||||
{
|
||||
initialized: false,
|
||||
auth: true,
|
||||
progress: [],
|
||||
notifications: [],
|
||||
sidebar: "",
|
||||
badges: {
|
||||
movies: 0,
|
||||
episodes: 0,
|
||||
providers: 0,
|
||||
status: 0,
|
||||
},
|
||||
offline: false,
|
||||
}
|
||||
);
|
||||
notifications: [],
|
||||
sidebar: "",
|
||||
badges: {
|
||||
movies: 0,
|
||||
episodes: 0,
|
||||
providers: 0,
|
||||
status: 0,
|
||||
},
|
||||
offline: false,
|
||||
};
|
||||
|
||||
const reducer = createReducer(defaultSite, (builder) => {
|
||||
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;
|
||||
|
|
|
@ -1,121 +1,74 @@
|
|||
import { handleActions } from "redux-actions";
|
||||
import { createReducer } from "@reduxjs/toolkit";
|
||||
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 { updateAsyncState } from "../utils/mapper";
|
||||
providerUpdateList,
|
||||
systemMarkTasksDirty,
|
||||
systemUpdateHealth,
|
||||
systemUpdateLanguages,
|
||||
systemUpdateLanguagesProfiles,
|
||||
systemUpdateLogs,
|
||||
systemUpdateReleases,
|
||||
systemUpdateSettings,
|
||||
systemUpdateStatus,
|
||||
systemUpdateTasks,
|
||||
} from "../actions";
|
||||
import { AsyncUtility } from "../utils";
|
||||
import { createAsyncItemReducer } from "../utils/factory";
|
||||
|
||||
const reducer = handleActions<ReduxStore.System, any>(
|
||||
{
|
||||
[SYSTEM_UPDATE_LANGUAGES_LIST]: (state, action) => {
|
||||
const languages = updateAsyncState<Array<ApiLanguage>>(action, []);
|
||||
const enabledLanguage: AsyncState<ApiLanguage[]> = {
|
||||
...languages,
|
||||
data: languages.data.filter((v) => v.enabled),
|
||||
};
|
||||
const newState = {
|
||||
...state,
|
||||
languages,
|
||||
enabledLanguage,
|
||||
};
|
||||
return newState;
|
||||
},
|
||||
[SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST]: (state, action) => {
|
||||
const newState = {
|
||||
...state,
|
||||
languagesProfiles: updateAsyncState<Array<Profile.Languages>>(
|
||||
action,
|
||||
[]
|
||||
),
|
||||
};
|
||||
return newState;
|
||||
},
|
||||
[SYSTEM_UPDATE_STATUS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
status: updateAsyncState<System.Status | undefined>(
|
||||
action,
|
||||
state.status.data
|
||||
),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_HEALTH]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
health: updateAsyncState(action, state.health.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_TASKS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
tasks: updateAsyncState<Array<System.Task>>(action, state.tasks.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_PROVIDERS]: (state, action) => {
|
||||
return {
|
||||
...state,
|
||||
providers: updateAsyncState(action, state.providers.data),
|
||||
};
|
||||
},
|
||||
[SYSTEM_UPDATE_LOGS]: (state, action) => {
|
||||
return {
|
||||
...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,
|
||||
},
|
||||
}
|
||||
);
|
||||
interface System {
|
||||
languages: Async.Item<Language.Server[]>;
|
||||
languagesProfiles: Async.Item<Language.Profile[]>;
|
||||
status: Async.Item<System.Status>;
|
||||
health: Async.Item<System.Health[]>;
|
||||
tasks: Async.Item<System.Task[]>;
|
||||
providers: Async.Item<System.Provider[]>;
|
||||
logs: Async.Item<System.Log[]>;
|
||||
releases: Async.Item<ReleaseInfo[]>;
|
||||
settings: Async.Item<Settings>;
|
||||
}
|
||||
|
||||
const defaultSystem: System = {
|
||||
languages: AsyncUtility.getDefaultItem(),
|
||||
languagesProfiles: AsyncUtility.getDefaultItem(),
|
||||
status: AsyncUtility.getDefaultItem(),
|
||||
health: AsyncUtility.getDefaultItem(),
|
||||
tasks: AsyncUtility.getDefaultItem(),
|
||||
providers: AsyncUtility.getDefaultItem(),
|
||||
logs: AsyncUtility.getDefaultItem(),
|
||||
releases: AsyncUtility.getDefaultItem(),
|
||||
settings: AsyncUtility.getDefaultItem(),
|
||||
};
|
||||
|
||||
const reducer = createReducer(defaultSystem, (builder) => {
|
||||
createAsyncItemReducer(builder, (s) => s.languages, {
|
||||
all: systemUpdateLanguages,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.languagesProfiles, {
|
||||
all: systemUpdateLanguagesProfiles,
|
||||
});
|
||||
createAsyncItemReducer(builder, (s) => s.status, { all: systemUpdateStatus });
|
||||
createAsyncItemReducer(builder, (s) => s.settings, {
|
||||
all: systemUpdateSettings,
|
||||
});
|
||||
createAsyncItemReducer(builder, (s) => s.releases, {
|
||||
all: systemUpdateReleases,
|
||||
});
|
||||
createAsyncItemReducer(builder, (s) => s.logs, {
|
||||
all: systemUpdateLogs,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.health, {
|
||||
all: systemUpdateHealth,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.tasks, {
|
||||
all: systemUpdateTasks,
|
||||
dirty: systemMarkTasksDirty,
|
||||
});
|
||||
|
||||
createAsyncItemReducer(builder, (s) => s.providers, {
|
||||
all: providerUpdateList,
|
||||
});
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
|
|
|
@ -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>>;
|
||||
}
|
||||
}
|
|
@ -1,17 +1,15 @@
|
|||
import { applyMiddleware, createStore } from "redux";
|
||||
import logger from "redux-logger";
|
||||
import promise from "redux-promise";
|
||||
import trunk from "redux-thunk";
|
||||
import rootReducer from "../reducers";
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import apis from "../../apis";
|
||||
import reducer from "../reducers";
|
||||
|
||||
const plugins = [promise, trunk];
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
});
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
process.env["REACT_APP_LOG_REDUX_EVENT"] !== "false"
|
||||
) {
|
||||
plugins.push(logger);
|
||||
}
|
||||
// FIXME
|
||||
apis.dispatch = store.dispatch;
|
||||
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
|
||||
const store = createStore(rootReducer, applyMiddleware(...plugins));
|
||||
export default store;
|
||||
|
|
|
@ -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" },
|
||||
];
|
|
@ -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;
|
|
@ -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({});
|
||||
});
|
|
@ -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";
|
||||
});
|
||||
}
|
|
@ -1,10 +1,88 @@
|
|||
export function defaultAOS(): AsyncOrderState<any> {
|
||||
return {
|
||||
updating: true,
|
||||
data: {
|
||||
items: [],
|
||||
order: [],
|
||||
dirty: false,
|
||||
},
|
||||
};
|
||||
import { Draft } from "@reduxjs/toolkit";
|
||||
import { difference, pullAll, uniq } from "lodash";
|
||||
|
||||
export namespace AsyncUtility {
|
||||
export function getDefaultItem<T>(): Async.Item<T> {
|
||||
return {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ td {
|
|||
|
||||
.dropdown-hidden {
|
||||
&::after {
|
||||
display: none;
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
}
|
|
@ -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),
|
||||
},
|
||||
];
|
||||
}
|
|
@ -1,5 +1,3 @@
|
|||
type LanguageCodeType = string;
|
||||
|
||||
interface Badge {
|
||||
episodes: number;
|
||||
movies: number;
|
||||
|
@ -7,35 +5,40 @@ interface Badge {
|
|||
status: number;
|
||||
}
|
||||
|
||||
interface ApiLanguage {
|
||||
code2: LanguageCodeType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
declare namespace Language {
|
||||
type CodeType = string;
|
||||
interface Server {
|
||||
code2: CodeType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
type Language = Omit<ApiLanguage, "enabled"> & {
|
||||
// TODO: Make things unify
|
||||
hi?: boolean;
|
||||
forced?: boolean;
|
||||
};
|
||||
interface Info {
|
||||
code2: CodeType;
|
||||
name: string;
|
||||
hi?: boolean;
|
||||
forced?: boolean;
|
||||
}
|
||||
|
||||
namespace Profile {
|
||||
interface Item {
|
||||
interface ProfileItem {
|
||||
id: number;
|
||||
audio_exclude: PythonBoolean;
|
||||
forced: PythonBoolean;
|
||||
hi: PythonBoolean;
|
||||
language: LanguageCodeType;
|
||||
language: CodeType;
|
||||
}
|
||||
interface Languages {
|
||||
|
||||
interface Profile {
|
||||
name: string;
|
||||
profileId: number;
|
||||
cutoff: number | null;
|
||||
items: Item[];
|
||||
items: ProfileItem[];
|
||||
}
|
||||
}
|
||||
|
||||
interface Subtitle extends Language {
|
||||
interface Subtitle {
|
||||
code2: Language.CodeType;
|
||||
name: string;
|
||||
forced: boolean;
|
||||
hi: boolean;
|
||||
path: string | null;
|
||||
|
@ -91,15 +94,15 @@ interface TitleType {
|
|||
}
|
||||
|
||||
interface AudioLanguageType {
|
||||
audio_language: Language[];
|
||||
audio_language: Language.Info[];
|
||||
}
|
||||
|
||||
interface ItemHistoryType {
|
||||
language: Language;
|
||||
language: Language.Info;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
namespace Item {
|
||||
declare namespace Item {
|
||||
type Base = PathType &
|
||||
TitleType &
|
||||
TagType &
|
||||
|
@ -152,7 +155,7 @@ namespace Item {
|
|||
};
|
||||
}
|
||||
|
||||
namespace Wanted {
|
||||
declare namespace Wanted {
|
||||
type Base = MonitoredType &
|
||||
TagType &
|
||||
SceneNameType & {
|
||||
|
@ -171,7 +174,7 @@ namespace Wanted {
|
|||
type Movie = Base & MovieIdType & TitleType;
|
||||
}
|
||||
|
||||
namespace Blacklist {
|
||||
declare namespace Blacklist {
|
||||
type Base = ItemHistoryType & {
|
||||
parsed_timestamp: string;
|
||||
timestamp: string;
|
||||
|
@ -187,16 +190,17 @@ namespace Blacklist {
|
|||
};
|
||||
}
|
||||
|
||||
namespace History {
|
||||
declare namespace History {
|
||||
type Base = SubtitlePathType &
|
||||
TagType &
|
||||
MonitoredType &
|
||||
Partial<ItemHistoryType> & {
|
||||
id: number;
|
||||
action: number;
|
||||
blacklisted: boolean;
|
||||
score?: string;
|
||||
subs_id?: string;
|
||||
raw_timestamp: int;
|
||||
raw_timestamp: number;
|
||||
parsed_timestamp: string;
|
||||
timestamp: string;
|
||||
description: string;
|
||||
|
@ -225,6 +229,13 @@ namespace History {
|
|||
type ActionOptions = 1 | 2 | 3;
|
||||
}
|
||||
|
||||
declare namespace Parameter {
|
||||
interface Range {
|
||||
start: number;
|
||||
length: number;
|
||||
}
|
||||
}
|
||||
|
||||
interface SearchResultType {
|
||||
matches: string[];
|
||||
dont_matches: string[];
|
||||
|
|
|
@ -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[];
|
||||
};
|
||||
}
|
|
@ -11,22 +11,6 @@ type FileTree = {
|
|||
|
||||
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> = {
|
||||
label: string;
|
||||
value: PAYLOAD;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
namespace FormType {
|
||||
declare namespace FormType {
|
||||
interface ModifyItem {
|
||||
id: number[];
|
||||
profileid: (number | null)[];
|
||||
|
@ -57,7 +57,7 @@ namespace FormType {
|
|||
interface AddBlacklist {
|
||||
provider: string;
|
||||
subs_id: string;
|
||||
language: LanguageCodeType;
|
||||
language: Language.CodeType;
|
||||
subtitles_path: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// @ts-nocheck
|
||||
// TODO: Fine a better solution for this
|
||||
|
||||
import {
|
||||
UseColumnOrderInstanceProps,
|
||||
UseColumnOrderState,
|
||||
|
@ -48,8 +51,7 @@ declare module "react-table" {
|
|||
|
||||
interface CustomTableProps<D extends Record<string, unknown>>
|
||||
extends useSelectionProps<D> {
|
||||
externalUpdate?: TableUpdater<D>;
|
||||
loose?: any[];
|
||||
update?: TableUpdater<D>;
|
||||
}
|
||||
|
||||
interface CustomTableState<D extends Record<string, unknown>>
|
||||
|
|
|
@ -20,11 +20,11 @@ interface Settings {
|
|||
napisy24: Settings.Napisy24;
|
||||
subscene: Settings.Subscene;
|
||||
betaseries: Settings.Betaseries;
|
||||
titlovi: Settings.titlovi;
|
||||
titlovi: Settings.Titlovi;
|
||||
notifications: Settings.Notifications;
|
||||
}
|
||||
|
||||
namespace Settings {
|
||||
declare namespace Settings {
|
||||
interface General {
|
||||
adaptive_searching: boolean;
|
||||
anti_captcha_provider?: string;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
namespace System {
|
||||
declare namespace System {
|
||||
interface Task {
|
||||
interval: string;
|
||||
job_id: string;
|
||||
|
|
|
@ -17,6 +17,14 @@ type Pair<T = string> = {
|
|||
value: T;
|
||||
};
|
||||
|
||||
type EntityStruct<T> = {
|
||||
keyName: keyof T;
|
||||
ids: (string | null)[];
|
||||
entities: {
|
||||
[id: string]: T;
|
||||
};
|
||||
};
|
||||
|
||||
interface DataWrapper<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 KeysOfType<D, T> = NonNullable<
|
||||
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 };
|
||||
type OptionalRecord<T extends string | number, D> = { [P in T]?: D };
|
||||
|
||||
interface IdState<T> {
|
||||
[key: number]: Readonly<T>;
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
Navbar,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { SidebarToggleContext } from ".";
|
||||
import { siteRedirectToAuth } from "../@redux/actions";
|
||||
import { useSystemSettings } from "../@redux/hooks";
|
||||
|
@ -23,8 +24,9 @@ import { useIsOffline } from "../@redux/hooks/site";
|
|||
import logo from "../@static/logo64.png";
|
||||
import { SystemApi } from "../apis";
|
||||
import { ActionButton, SearchBar, SearchResult } from "../components";
|
||||
import { useGotoHomepage } from "../utilites";
|
||||
import { useGotoHomepage, useIsMobile } from "../utilities";
|
||||
import "./header.scss";
|
||||
import NotificationCenter from "./Notification";
|
||||
|
||||
async function SearchItem(text: string) {
|
||||
const results = await SystemApi.search(text);
|
||||
|
@ -50,15 +52,17 @@ interface Props {}
|
|||
const Header: FunctionComponent<Props> = () => {
|
||||
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 offline = useIsOffline();
|
||||
|
||||
const dropdown = useMemo(
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const serverActions = useMemo(
|
||||
() => (
|
||||
<Dropdown alignRight>
|
||||
<Dropdown.Toggle className="dropdown-hidden" as={Button}>
|
||||
|
@ -98,6 +102,9 @@ const Header: FunctionComponent<Props> = () => {
|
|||
|
||||
return (
|
||||
<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">
|
||||
<Image
|
||||
alt="brand"
|
||||
|
@ -113,10 +120,11 @@ const Header: FunctionComponent<Props> = () => {
|
|||
</Button>
|
||||
<Container fluid>
|
||||
<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>
|
||||
</Col>
|
||||
<Col className="d-flex flex-row align-items-center justify-content-end pr-2">
|
||||
<NotificationCenter></NotificationCenter>
|
||||
<Button
|
||||
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
|
||||
target="_blank"
|
||||
|
@ -131,10 +139,10 @@ const Header: FunctionComponent<Props> = () => {
|
|||
variant="warning"
|
||||
icon={faNetworkWired}
|
||||
>
|
||||
Connecting...
|
||||
{isMobile ? "" : "Connecting..."}
|
||||
</ActionButton>
|
||||
) : (
|
||||
dropdown
|
||||
serverActions
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
|
@ -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;
|
|
@ -3,13 +3,12 @@ import { Redirect, Route, Switch, useHistory } from "react-router-dom";
|
|||
import { useDidMount } from "rooks";
|
||||
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
|
||||
import BlacklistRouter from "../Blacklist/Router";
|
||||
import DisplayItemRouter from "../DisplayItem/Router";
|
||||
import HistoryRouter from "../History/Router";
|
||||
import MovieRouter from "../Movies/Router";
|
||||
import SeriesRouter from "../Series/Router";
|
||||
import SettingRouter from "../Settings/Router";
|
||||
import EmptyPage, { RouterEmptyPath } from "../special-pages/404";
|
||||
import SystemRouter from "../System/Router";
|
||||
import { ScrollToTop } from "../utilites";
|
||||
import { ScrollToTop } from "../utilities";
|
||||
import WantedRouter from "../Wanted/Router";
|
||||
|
||||
const Router: FunctionComponent<{ className?: string }> = ({ className }) => {
|
||||
|
@ -40,16 +39,9 @@ const Router: FunctionComponent<{ className?: string }> = ({ className }) => {
|
|||
<Route exact path="/">
|
||||
<Redirect exact to={redirectPath}></Redirect>
|
||||
</Route>
|
||||
{sonarr && (
|
||||
<Route path="/series">
|
||||
<SeriesRouter></SeriesRouter>
|
||||
</Route>
|
||||
)}
|
||||
{radarr && (
|
||||
<Route path="/movies">
|
||||
<MovieRouter></MovieRouter>
|
||||
</Route>
|
||||
)}
|
||||
<Route path={["/series", "/movies"]}>
|
||||
<DisplayItemRouter></DisplayItemRouter>
|
||||
</Route>
|
||||
<Route path="/wanted">
|
||||
<WantedRouter></WantedRouter>
|
||||
</Route>
|
||||
|
|
|
@ -5,16 +5,21 @@ import React, {
|
|||
useState,
|
||||
} from "react";
|
||||
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 { useNotification } from "../@redux/hooks/site";
|
||||
import store from "../@redux/store";
|
||||
import { LoadingIndicator, ModalProvider } from "../components";
|
||||
import Sidebar from "../Sidebar";
|
||||
import Auth from "../special-pages/AuthPage";
|
||||
import ErrorBoundary from "../special-pages/ErrorBoundary";
|
||||
import LaunchError from "../special-pages/LaunchError";
|
||||
import UIError from "../special-pages/UIError";
|
||||
import { useHasUpdateInject } from "../utilites";
|
||||
import { Environment } from "../utilities";
|
||||
import Header from "./Header";
|
||||
import NotificationContainer from "./notifications";
|
||||
import Router from "./Router";
|
||||
|
||||
// Sidebar Toggle
|
||||
|
@ -28,18 +33,15 @@ const App: FunctionComponent<Props> = () => {
|
|||
const notify = useNotification("has-update", 10 * 1000);
|
||||
|
||||
// Has any update?
|
||||
const hasUpdate = useHasUpdateInject();
|
||||
useEffect(() => {
|
||||
if (initialized) {
|
||||
if (hasUpdate) {
|
||||
notify({
|
||||
type: "info",
|
||||
message: "A new version of Bazarr is ready, restart is required",
|
||||
// TODO: Restart action
|
||||
});
|
||||
}
|
||||
useEffectOnceWhen(() => {
|
||||
if (Environment.hasUpdate) {
|
||||
notify({
|
||||
type: "info",
|
||||
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 toggleSidebar = useCallback(() => setSidebar((s) => !s), []);
|
||||
|
@ -57,8 +59,8 @@ const App: FunctionComponent<Props> = () => {
|
|||
} else if (typeof initialized === "string") {
|
||||
return <LaunchError>{initialized}</LaunchError>;
|
||||
}
|
||||
try {
|
||||
return (
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SidebarToggleContext.Provider value={toggleSidebar}>
|
||||
<Row noGutters className="header-container">
|
||||
<Header></Header>
|
||||
|
@ -69,12 +71,39 @@ const App: FunctionComponent<Props> = () => {
|
|||
<Router className="d-flex flex-row flex-grow-1 main-router"></Router>
|
||||
</ModalProvider>
|
||||
</Row>
|
||||
<NotificationContainer></NotificationContainer>
|
||||
</SidebarToggleContext.Provider>
|
||||
);
|
||||
} catch (e) {
|
||||
return <UIError error={e}></UIError>;
|
||||
}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,16 +4,16 @@ import { Container, Row } from "react-bootstrap";
|
|||
import { Helmet } from "react-helmet";
|
||||
import { useBlacklistMovies } from "../../@redux/hooks";
|
||||
import { MoviesApi } from "../../apis";
|
||||
import { AsyncStateOverlay, ContentHeader } from "../../components";
|
||||
import { AsyncOverlay, ContentHeader } from "../../components";
|
||||
import Table from "./table";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const BlacklistMoviesView: FunctionComponent<Props> = () => {
|
||||
const [blacklist] = useBlacklistMovies();
|
||||
const blacklist = useBlacklistMovies();
|
||||
return (
|
||||
<AsyncStateOverlay state={blacklist}>
|
||||
{({ data }) => (
|
||||
<AsyncOverlay ctx={blacklist}>
|
||||
{({ content }) => (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>Movies Blacklist - Bazarr</title>
|
||||
|
@ -21,18 +21,18 @@ const BlacklistMoviesView: FunctionComponent<Props> = () => {
|
|||
<ContentHeader>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faTrash}
|
||||
disabled={data.length === 0}
|
||||
disabled={content?.length === 0}
|
||||
promise={() => MoviesApi.deleteBlacklist(true)}
|
||||
>
|
||||
Remove All
|
||||
</ContentHeader.AsyncButton>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<Table blacklist={data}></Table>
|
||||
<Table blacklist={content ?? []}></Table>
|
||||
</Row>
|
||||
</Container>
|
||||
)}
|
||||
</AsyncStateOverlay>
|
||||
</AsyncOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -4,16 +4,16 @@ import { Container, Row } from "react-bootstrap";
|
|||
import { Helmet } from "react-helmet";
|
||||
import { useBlacklistSeries } from "../../@redux/hooks";
|
||||
import { EpisodesApi } from "../../apis";
|
||||
import { AsyncStateOverlay, ContentHeader } from "../../components";
|
||||
import { AsyncOverlay, ContentHeader } from "../../components";
|
||||
import Table from "./table";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const BlacklistSeriesView: FunctionComponent<Props> = () => {
|
||||
const [blacklist] = useBlacklistSeries();
|
||||
const blacklist = useBlacklistSeries();
|
||||
return (
|
||||
<AsyncStateOverlay state={blacklist}>
|
||||
{({ data }) => (
|
||||
<AsyncOverlay ctx={blacklist}>
|
||||
{({ content }) => (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
<title>Series Blacklist - Bazarr</title>
|
||||
|
@ -21,18 +21,18 @@ const BlacklistSeriesView: FunctionComponent<Props> = () => {
|
|||
<ContentHeader>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faTrash}
|
||||
disabled={data.length === 0}
|
||||
disabled={content?.length === 0}
|
||||
promise={() => EpisodesApi.deleteBlacklist(true)}
|
||||
>
|
||||
Remove All
|
||||
</ContentHeader.AsyncButton>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<Table blacklist={data}></Table>
|
||||
<Table blacklist={content ?? []}></Table>
|
||||
</Row>
|
||||
</Container>
|
||||
)}
|
||||
</AsyncStateOverlay>
|
||||
</AsyncOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -9,12 +9,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||
import React, { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Badge, ButtonGroup } from "react-bootstrap";
|
||||
import { Column, TableUpdater } from "react-table";
|
||||
import { useProfileItems, useSerieBy } from "../../@redux/hooks";
|
||||
import { useProfileItemsToLanguages } from "../../@redux/hooks";
|
||||
import { useShowOnlyDesired } from "../../@redux/hooks/site";
|
||||
import { ProvidersApi } from "../../apis";
|
||||
import {
|
||||
ActionButton,
|
||||
AsyncStateOverlay,
|
||||
AsyncOverlay,
|
||||
EpisodeHistoryModal,
|
||||
GroupTable,
|
||||
SubtitleToolModal,
|
||||
|
@ -22,16 +22,17 @@ import {
|
|||
useShowModal,
|
||||
} from "../../components";
|
||||
import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
|
||||
import { BuildKey, filterSubtitleBy } from "../../utilites";
|
||||
import { BuildKey, filterSubtitleBy } from "../../utilities";
|
||||
import { SubtitleAction } from "./components";
|
||||
|
||||
interface Props {
|
||||
episodes: AsyncState<Item.Episode[]>;
|
||||
profile?: Profile.Languages;
|
||||
serie: Async.Item<Item.Series>;
|
||||
episodes: Async.Base<Item.Episode[]>;
|
||||
disabled?: boolean;
|
||||
profile?: Language.Profile;
|
||||
}
|
||||
|
||||
const download = (item: any, result: SearchResultType) => {
|
||||
item = item as Item.Episode;
|
||||
const download = (item: Item.Episode, result: SearchResultType) => {
|
||||
const { language, hearing_impaired, forced, provider, subtitle } = result;
|
||||
return ProvidersApi.downloadEpisodeSubtitle(
|
||||
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 onlyDesired = useShowOnlyDesired();
|
||||
|
||||
const profileItems = useProfileItems(profile);
|
||||
const profileItems = useProfileItemsToLanguages(profile);
|
||||
|
||||
const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>(
|
||||
() => [
|
||||
|
@ -100,7 +106,7 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
|
|||
{
|
||||
Header: "Subtitles",
|
||||
accessor: "missing_subtitles",
|
||||
Cell: ({ row, loose }) => {
|
||||
Cell: ({ row }) => {
|
||||
const episode = row.original;
|
||||
|
||||
const seriesid = episode.sonarrSeriesId;
|
||||
|
@ -141,28 +147,28 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
|
|||
{
|
||||
Header: "Actions",
|
||||
accessor: "sonarrEpisodeId",
|
||||
Cell: ({ row, externalUpdate }) => {
|
||||
const [serie] = useSerieBy(row.original.sonarrSeriesId);
|
||||
|
||||
Cell: ({ row, update }) => {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<ActionButton
|
||||
icon={faUser}
|
||||
disabled={serie.data?.profileId === null}
|
||||
disabled={serie.content?.profileId === null || disabled}
|
||||
onClick={() => {
|
||||
externalUpdate && externalUpdate(row, "manual-search");
|
||||
update && update(row, "manual-search");
|
||||
}}
|
||||
></ActionButton>
|
||||
<ActionButton
|
||||
icon={faHistory}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
externalUpdate && externalUpdate(row, "history");
|
||||
update && update(row, "history");
|
||||
}}
|
||||
></ActionButton>
|
||||
<ActionButton
|
||||
icon={faBriefcase}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
externalUpdate && externalUpdate(row, "tools");
|
||||
update && update(row, "tools");
|
||||
}}
|
||||
></ActionButton>
|
||||
</ButtonGroup>
|
||||
|
@ -170,7 +176,7 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
|
|||
},
|
||||
},
|
||||
],
|
||||
[onlyDesired, profileItems]
|
||||
[onlyDesired, profileItems, serie, disabled]
|
||||
);
|
||||
|
||||
const updateRow = useCallback<TableUpdater<Item.Episode>>(
|
||||
|
@ -186,7 +192,7 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
|
|||
|
||||
const maxSeason = useMemo(
|
||||
() =>
|
||||
episodes.data.reduce<number>(
|
||||
episodes.content.reduce<number>(
|
||||
(prev, curr) => Math.max(prev, curr.season),
|
||||
0
|
||||
),
|
||||
|
@ -195,12 +201,12 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
|
|||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<AsyncStateOverlay state={episodes}>
|
||||
{({ data }) => (
|
||||
<AsyncOverlay ctx={episodes}>
|
||||
{({ content }) => (
|
||||
<GroupTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
externalUpdate={updateRow}
|
||||
data={content}
|
||||
update={updateRow}
|
||||
initialState={{
|
||||
sortBy: [
|
||||
{ id: "season", desc: true },
|
||||
|
@ -214,12 +220,12 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
|
|||
emptyText="No Episode Found For This Series"
|
||||
></GroupTable>
|
||||
)}
|
||||
</AsyncStateOverlay>
|
||||
</AsyncOverlay>
|
||||
<SubtitleToolModal modalKey="tools" size="lg"></SubtitleToolModal>
|
||||
<EpisodeHistoryModal modalKey="history" size="lg"></EpisodeHistoryModal>
|
||||
<ManualSearchModal
|
||||
modalKey="manual-search"
|
||||
onSelect={download}
|
||||
download={download}
|
||||
></ManualSearchModal>
|
||||
</React.Fragment>
|
||||
);
|
|
@ -7,10 +7,13 @@ import {
|
|||
faUser,
|
||||
faWrench,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import React, { FunctionComponent, 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 { useMovieBy, useProfileBy } from "../../@redux/hooks";
|
||||
import { MoviesApi, ProvidersApi } from "../../apis";
|
||||
import {
|
||||
|
@ -23,13 +26,12 @@ import {
|
|||
useShowModal,
|
||||
} from "../../components";
|
||||
import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
|
||||
import ItemOverview from "../../generic/ItemOverview";
|
||||
import { RouterEmptyPath } from "../../special-pages/404";
|
||||
import { useOnLoadingFinish } from "../../utilites";
|
||||
import { useOnLoadedOnce } from "../../utilities";
|
||||
import ItemOverview from "../generic/ItemOverview";
|
||||
import Table from "./table";
|
||||
|
||||
const download = (item: any, result: SearchResultType) => {
|
||||
item = item as Item.Movie;
|
||||
const download = (item: Item.Movie, result: SearchResultType) => {
|
||||
const { language, hearing_impaired, forced, provider, subtitle } = result;
|
||||
return ProvidersApi.downloadMovieSubtitle(item.radarrId, {
|
||||
language,
|
||||
|
@ -48,22 +50,22 @@ interface Props extends RouteComponentProps<Params> {}
|
|||
|
||||
const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
|
||||
const id = Number.parseInt(match.params.id);
|
||||
const [movie] = useMovieBy(id);
|
||||
const item = movie.data;
|
||||
const movie = useMovieBy(id);
|
||||
const item = movie.content;
|
||||
|
||||
const profile = useProfileBy(movie.data?.profileId);
|
||||
const profile = useProfileBy(movie.content?.profileId);
|
||||
|
||||
const showModal = useShowModal();
|
||||
|
||||
const [valid, setValid] = useState(true);
|
||||
|
||||
const validator = useCallback(() => {
|
||||
if (movie.data === null) {
|
||||
const hasTask = useIsAnyTaskRunningWithId([id]);
|
||||
|
||||
useOnLoadedOnce(() => {
|
||||
if (movie.content === null) {
|
||||
setValid(false);
|
||||
}
|
||||
}, [movie.data]);
|
||||
|
||||
useOnLoadingFinish(movie, validator);
|
||||
}, movie);
|
||||
|
||||
if (isNaN(id) || !valid) {
|
||||
return <Redirect to={RouterEmptyPath}></Redirect>;
|
||||
|
@ -82,29 +84,42 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
|
|||
</Helmet>
|
||||
<ContentHeader>
|
||||
<ContentHeader.Group pos="start">
|
||||
<ContentHeader.AsyncButton
|
||||
<ContentHeader.Button
|
||||
icon={faSync}
|
||||
promise={() =>
|
||||
MoviesApi.action({ action: "scan-disk", radarrid: item.radarrId })
|
||||
}
|
||||
disabled={hasTask}
|
||||
onClick={() => {
|
||||
const task = createTask(
|
||||
item.title,
|
||||
id,
|
||||
MoviesApi.action.bind(MoviesApi),
|
||||
{ action: "scan-disk", radarrid: id }
|
||||
);
|
||||
dispatchTask("Scaning Disk...", [task], "Scaning...");
|
||||
}}
|
||||
>
|
||||
Scan Disk
|
||||
</ContentHeader.AsyncButton>
|
||||
<ContentHeader.AsyncButton
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faSearch}
|
||||
disabled={item.profileId === null}
|
||||
promise={() =>
|
||||
MoviesApi.action({
|
||||
action: "search-missing",
|
||||
radarrid: item.radarrId,
|
||||
})
|
||||
}
|
||||
onClick={() => {
|
||||
const task = createTask(
|
||||
item.title,
|
||||
id,
|
||||
MoviesApi.action.bind(MoviesApi),
|
||||
{
|
||||
action: "search-missing",
|
||||
radarrid: id,
|
||||
}
|
||||
);
|
||||
dispatchTask("Searching subtitles...", [task], "Searching...");
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</ContentHeader.AsyncButton>
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faUser}
|
||||
disabled={item.profileId === null}
|
||||
disabled={item.profileId === null || hasTask}
|
||||
onClick={() => showModal<Item.Movie>("manual-search", item)}
|
||||
>
|
||||
Manual
|
||||
|
@ -117,6 +132,7 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
|
|||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faToolbox}
|
||||
disabled={hasTask}
|
||||
onClick={() => showModal("tools", [item])}
|
||||
>
|
||||
Tools
|
||||
|
@ -125,7 +141,7 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
|
|||
|
||||
<ContentHeader.Group pos="end">
|
||||
<ContentHeader.Button
|
||||
disabled={!allowEdit || item.profileId === null}
|
||||
disabled={!allowEdit || item.profileId === null || hasTask}
|
||||
icon={faCloudUploadAlt}
|
||||
onClick={() => showModal("upload", item)}
|
||||
>
|
||||
|
@ -133,17 +149,28 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
|
|||
</ContentHeader.Button>
|
||||
<ContentHeader.Button
|
||||
icon={faWrench}
|
||||
disabled={hasTask}
|
||||
onClick={() => showModal("edit", item)}
|
||||
>
|
||||
Edit Movie
|
||||
</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 movie, actions are unavailable
|
||||
</Alert>
|
||||
</Row>
|
||||
<Row>
|
||||
<ItemOverview item={item} details={[]}></ItemOverview>
|
||||
</Row>
|
||||
<Row>
|
||||
<Table movie={item} profile={profile}></Table>
|
||||
<Table movie={item} profile={profile} disabled={hasTask}></Table>
|
||||
</Row>
|
||||
<ItemEditorModal
|
||||
modalKey="edit"
|
||||
|
@ -154,7 +181,7 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
|
|||
<MovieUploadModal modalKey="upload" size="lg"></MovieUploadModal>
|
||||
<ManualSearchModal
|
||||
modalKey="manual-search"
|
||||
onSelect={download}
|
||||
download={download}
|
||||
></ManualSearchModal>
|
||||
</Container>
|
||||
);
|
|
@ -3,23 +3,24 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { Badge } from "react-bootstrap";
|
||||
import { Column } from "react-table";
|
||||
import { useProfileItems } from "../../@redux/hooks";
|
||||
import { useProfileItemsToLanguages } from "../../@redux/hooks";
|
||||
import { useShowOnlyDesired } from "../../@redux/hooks/site";
|
||||
import { MoviesApi } from "../../apis";
|
||||
import { AsyncButton, LanguageText, SimpleTable } from "../../components";
|
||||
import { filterSubtitleBy } from "../../utilites";
|
||||
import { filterSubtitleBy } from "../../utilities";
|
||||
|
||||
const missingText = "Missing Subtitles";
|
||||
|
||||
interface Props {
|
||||
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 profileItems = useProfileItems(profile);
|
||||
const profileItems = useProfileItemsToLanguages(profile);
|
||||
|
||||
const columns: Column<Subtitle>[] = useMemo<Column<Subtitle>[]>(
|
||||
() => [
|
||||
|
@ -64,6 +65,7 @@ const Table: FunctionComponent<Props> = ({ movie, profile }) => {
|
|||
} else if (original.path === missingText) {
|
||||
return (
|
||||
<AsyncButton
|
||||
disabled={disabled}
|
||||
promise={() =>
|
||||
MoviesApi.downloadSubtitles(movie.radarrId, {
|
||||
language: original.code2,
|
||||
|
@ -80,6 +82,7 @@ const Table: FunctionComponent<Props> = ({ movie, profile }) => {
|
|||
} else {
|
||||
return (
|
||||
<AsyncButton
|
||||
disabled={disabled}
|
||||
variant="light"
|
||||
size="sm"
|
||||
promise={() =>
|
||||
|
@ -98,14 +101,14 @@ const Table: FunctionComponent<Props> = ({ movie, profile }) => {
|
|||
},
|
||||
},
|
||||
],
|
||||
[movie]
|
||||
[movie, disabled]
|
||||
);
|
||||
|
||||
const data: Subtitle[] = useMemo(() => {
|
||||
const missing = movie.missing_subtitles.map((item) => {
|
||||
item.path = missingText;
|
||||
return item;
|
||||
});
|
||||
const missing = movie.missing_subtitles.map((item) => ({
|
||||
...item,
|
||||
path: missingText,
|
||||
}));
|
||||
|
||||
let raw_subtitles = movie.subtitles;
|
||||
if (onlyDesired) {
|
|
@ -5,19 +5,21 @@ import React, { FunctionComponent, useMemo } from "react";
|
|||
import { Badge } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Column } from "react-table";
|
||||
import { movieUpdateByRange, movieUpdateList } from "../@redux/actions";
|
||||
import { useRawMovies } from "../@redux/hooks";
|
||||
import { useReduxAction } from "../@redux/hooks/base";
|
||||
import { MoviesApi } from "../apis";
|
||||
import { ActionBadge, LanguageText, TextPopover } from "../components";
|
||||
import { movieUpdateAll, movieUpdateByRange } from "../../@redux/actions";
|
||||
import { useLanguageProfiles, useMovieEntities } from "../../@redux/hooks";
|
||||
import { useReduxAction } from "../../@redux/hooks/base";
|
||||
import { MoviesApi } from "../../apis";
|
||||
import { ActionBadge, LanguageText, TextPopover } from "../../components";
|
||||
import { BuildKey } from "../../utilities";
|
||||
import BaseItemView from "../generic/BaseItemView";
|
||||
import { BuildKey } from "../utilites";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const MovieView: FunctionComponent<Props> = () => {
|
||||
const [movies] = useRawMovies();
|
||||
const load = useReduxAction(movieUpdateByRange);
|
||||
const movies = useMovieEntities();
|
||||
const loader = useReduxAction(movieUpdateByRange);
|
||||
const profiles = useLanguageProfiles();
|
||||
|
||||
const columns: Column<Item.Movie>[] = useMemo<Column<Item.Movie>[]>(
|
||||
() => [
|
||||
{
|
||||
|
@ -67,14 +69,8 @@ const MovieView: FunctionComponent<Props> = () => {
|
|||
{
|
||||
Header: "Languages Profile",
|
||||
accessor: "profileId",
|
||||
Cell: ({ value, loose }) => {
|
||||
if (loose) {
|
||||
// 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;
|
||||
}
|
||||
Cell: ({ value }) => {
|
||||
return profiles?.find((v) => v.profileId === value)?.name ?? null;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -97,23 +93,25 @@ const MovieView: FunctionComponent<Props> = () => {
|
|||
{
|
||||
accessor: "radarrId",
|
||||
selectHide: true,
|
||||
Cell: ({ row, externalUpdate }) => (
|
||||
<ActionBadge
|
||||
icon={faWrench}
|
||||
onClick={() => externalUpdate && externalUpdate(row, "edit")}
|
||||
></ActionBadge>
|
||||
),
|
||||
Cell: ({ row, update }) => {
|
||||
return (
|
||||
<ActionBadge
|
||||
icon={faWrench}
|
||||
onClick={() => update && update(row, "edit")}
|
||||
></ActionBadge>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
[profiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseItemView
|
||||
state={movies}
|
||||
name="Movies"
|
||||
loader={load}
|
||||
updateAction={movieUpdateList}
|
||||
loader={loader}
|
||||
updateAction={movieUpdateAll}
|
||||
columns={columns}
|
||||
modify={(form) => MoviesApi.modify(form)}
|
||||
></BaseItemView>
|
|
@ -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;
|
|
@ -3,19 +3,20 @@ import React, { FunctionComponent, useMemo } from "react";
|
|||
import { Badge, ProgressBar } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Column } from "react-table";
|
||||
import { seriesUpdateByRange, seriesUpdateList } from "../@redux/actions";
|
||||
import { useRawSeries } from "../@redux/hooks";
|
||||
import { useReduxAction } from "../@redux/hooks/base";
|
||||
import { SeriesApi } from "../apis";
|
||||
import { ActionBadge } from "../components";
|
||||
import { seriesUpdateAll, seriesUpdateByRange } from "../../@redux/actions";
|
||||
import { useLanguageProfiles, useSerieEntities } from "../../@redux/hooks";
|
||||
import { useReduxAction } from "../../@redux/hooks/base";
|
||||
import { SeriesApi } from "../../apis";
|
||||
import { ActionBadge } from "../../components";
|
||||
import { BuildKey } from "../../utilities";
|
||||
import BaseItemView from "../generic/BaseItemView";
|
||||
import { BuildKey } from "../utilites";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const SeriesView: FunctionComponent<Props> = () => {
|
||||
const [series] = useRawSeries();
|
||||
const load = useReduxAction(seriesUpdateByRange);
|
||||
const series = useSerieEntities();
|
||||
const loader = useReduxAction(seriesUpdateByRange);
|
||||
const profiles = useLanguageProfiles();
|
||||
const columns: Column<Item.Series>[] = useMemo<Column<Item.Series>[]>(
|
||||
() => [
|
||||
{
|
||||
|
@ -53,14 +54,8 @@ const SeriesView: FunctionComponent<Props> = () => {
|
|||
{
|
||||
Header: "Languages Profile",
|
||||
accessor: "profileId",
|
||||
Cell: ({ value, loose }) => {
|
||||
if (loose) {
|
||||
// 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;
|
||||
}
|
||||
Cell: ({ value }) => {
|
||||
return profiles?.find((v) => v.profileId === value)?.name ?? null;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -68,11 +63,8 @@ const SeriesView: FunctionComponent<Props> = () => {
|
|||
accessor: "episodeFileCount",
|
||||
selectHide: true,
|
||||
Cell: (row) => {
|
||||
const {
|
||||
episodeFileCount,
|
||||
episodeMissingCount,
|
||||
profileId,
|
||||
} = row.row.original;
|
||||
const { episodeFileCount, episodeMissingCount, profileId } =
|
||||
row.row.original;
|
||||
let progress = 0;
|
||||
let label = "";
|
||||
if (episodeFileCount === 0 || !profileId) {
|
||||
|
@ -101,25 +93,25 @@ const SeriesView: FunctionComponent<Props> = () => {
|
|||
{
|
||||
accessor: "sonarrSeriesId",
|
||||
selectHide: true,
|
||||
Cell: ({ row, externalUpdate }) => (
|
||||
Cell: ({ row, update }) => (
|
||||
<ActionBadge
|
||||
icon={faWrench}
|
||||
onClick={() => {
|
||||
externalUpdate && externalUpdate(row, "edit");
|
||||
update && update(row, "edit");
|
||||
}}
|
||||
></ActionBadge>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
[profiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseItemView
|
||||
state={series}
|
||||
name="Series"
|
||||
updateAction={seriesUpdateList}
|
||||
loader={load}
|
||||
updateAction={seriesUpdateAll}
|
||||
loader={loader}
|
||||
columns={columns}
|
||||
modify={(form) => SeriesApi.modify(form)}
|
||||
></BaseItemView>
|
|
@ -1,26 +1,27 @@
|
|||
import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { AsyncThunk } from "@reduxjs/toolkit";
|
||||
import { uniqBy } from "lodash";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Container, Dropdown, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Column } from "react-table";
|
||||
import { useLanguageProfiles } from "../../@redux/hooks";
|
||||
import { useReduxActionWith } from "../../@redux/hooks/base";
|
||||
import { AsyncActionDispatcher } from "../../@redux/types";
|
||||
import { ContentHeader } from "../../components";
|
||||
import { GetItemId, isNonNullable } from "../../utilites";
|
||||
import { useIsAnyTaskRunning } from "../../../@modules/task/hooks";
|
||||
import { useLanguageProfiles } from "../../../@redux/hooks";
|
||||
import { useAppDispatch } from "../../../@redux/hooks/base";
|
||||
import { ContentHeader } from "../../../components";
|
||||
import { GetItemId, isNonNullable } from "../../../utilities";
|
||||
import Table from "./table";
|
||||
|
||||
export interface SharedProps<T extends Item.Base> {
|
||||
name: string;
|
||||
loader: (start: number, length: number) => void;
|
||||
loader: (params: Parameter.Range) => void;
|
||||
columns: Column<T>[];
|
||||
modify: (form: FormType.ModifyItem) => Promise<void>;
|
||||
state: AsyncOrderState<T>;
|
||||
state: Async.Entity<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>({
|
||||
|
@ -32,35 +33,39 @@ function BaseItemView<T extends Item.Base>({
|
|||
const [pendingEditMode, setPendingEdit] = useState(false);
|
||||
const [editMode, setEdit] = useState(false);
|
||||
|
||||
const onUpdated = useCallback(() => {
|
||||
setPendingEdit((edit) => {
|
||||
// Hack to remove all dependencies
|
||||
setEdit(edit);
|
||||
return edit;
|
||||
const dispatch = useAppDispatch();
|
||||
const update = useCallback(() => {
|
||||
dispatch(updateAction()).then(() => {
|
||||
setPendingEdit((edit) => {
|
||||
// Hack to remove all dependencies
|
||||
setEdit(edit);
|
||||
return edit;
|
||||
});
|
||||
setDirty([]);
|
||||
});
|
||||
setDirty([]);
|
||||
}, []);
|
||||
|
||||
const update = useReduxActionWith(updateAction, onUpdated);
|
||||
}, [dispatch, updateAction]);
|
||||
|
||||
const [selections, setSelections] = useState<T[]>([]);
|
||||
const [dirtyItems, setDirty] = useState<T[]>([]);
|
||||
|
||||
const [profiles] = useLanguageProfiles();
|
||||
const profiles = useLanguageProfiles();
|
||||
|
||||
const profileOptions = useMemo<JSX.Element[]>(() => {
|
||||
const items: JSX.Element[] = [];
|
||||
items.push(
|
||||
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
|
||||
);
|
||||
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
|
||||
items.push(
|
||||
...profiles.map((v) => (
|
||||
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
|
||||
{v.name}
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
if (profiles) {
|
||||
items.push(
|
||||
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
|
||||
);
|
||||
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
|
||||
items.push(
|
||||
...profiles.map((v) => (
|
||||
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
|
||||
{v.name}
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [profiles]);
|
||||
|
||||
|
@ -72,20 +77,21 @@ function BaseItemView<T extends Item.Base>({
|
|||
item.profileId = id;
|
||||
return item;
|
||||
});
|
||||
const newDirty = uniqBy([...newItems, ...dirtyItems], GetItemId);
|
||||
setDirty(newDirty);
|
||||
setDirty((dirty) => {
|
||||
return uniqBy([...newItems, ...dirty], GetItemId);
|
||||
});
|
||||
},
|
||||
[selections, dirtyItems]
|
||||
[selections]
|
||||
);
|
||||
|
||||
const startEdit = useCallback(() => {
|
||||
if (shared.state.data.order.every(isNonNullable)) {
|
||||
if (shared.state.content.ids.every(isNonNullable)) {
|
||||
setEdit(true);
|
||||
} else {
|
||||
update();
|
||||
}
|
||||
setPendingEdit(true);
|
||||
}, [shared.state.data.order, update]);
|
||||
}, [shared.state.content.ids, update]);
|
||||
|
||||
const endEdit = useCallback(() => {
|
||||
setEdit(false);
|
||||
|
@ -94,7 +100,7 @@ function BaseItemView<T extends Item.Base>({
|
|||
setSelections([]);
|
||||
}, []);
|
||||
|
||||
const saveItems = useCallback(() => {
|
||||
const save = useCallback(() => {
|
||||
const form: FormType.ModifyItem = {
|
||||
id: [],
|
||||
profileid: [],
|
||||
|
@ -107,6 +113,8 @@ function BaseItemView<T extends Item.Base>({
|
|||
return shared.modify(form);
|
||||
}, [dirtyItems, shared]);
|
||||
|
||||
const hasTask = useIsAnyTaskRunning();
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<Helmet>
|
||||
|
@ -132,8 +140,8 @@ function BaseItemView<T extends Item.Base>({
|
|||
</ContentHeader.Button>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faCheck}
|
||||
disabled={dirtyItems.length === 0}
|
||||
promise={saveItems}
|
||||
disabled={dirtyItems.length === 0 || hasTask}
|
||||
promise={save}
|
||||
onSuccess={endEdit}
|
||||
>
|
||||
Save
|
||||
|
@ -143,7 +151,10 @@ function BaseItemView<T extends Item.Base>({
|
|||
) : (
|
||||
<ContentHeader.Button
|
||||
updating={pendingEditMode !== editMode}
|
||||
disabled={state.data.order.length === 0 && state.updating}
|
||||
disabled={
|
||||
(state.content.ids.length === 0 && state.state === "loading") ||
|
||||
hasTask
|
||||
}
|
||||
icon={faList}
|
||||
onClick={startEdit}
|
||||
>
|
|
@ -2,16 +2,15 @@ import { uniqBy } from "lodash";
|
|||
import React, { useCallback, useMemo } from "react";
|
||||
import { TableOptions, TableUpdater, useRowSelect } from "react-table";
|
||||
import { SharedProps } from ".";
|
||||
import { useLanguageProfiles } from "../../@redux/hooks";
|
||||
import {
|
||||
AsyncPageTable,
|
||||
ItemEditorModal,
|
||||
SimpleTable,
|
||||
useShowModal,
|
||||
} from "../../components";
|
||||
import { TableStyleProps } from "../../components/tables/BaseTable";
|
||||
import { useCustomSelection } from "../../components/tables/plugins";
|
||||
import { buildOrderList, GetItemId } from "../../utilites";
|
||||
} from "../../../components";
|
||||
import { TableStyleProps } from "../../../components/tables/BaseTable";
|
||||
import { useCustomSelection } from "../../../components/tables/plugins";
|
||||
import { GetItemId, useEntityToList } from "../../../utilities";
|
||||
|
||||
interface Props<T extends Item.Base> extends SharedProps<T> {
|
||||
dirtyItems: readonly T[];
|
||||
|
@ -38,21 +37,16 @@ function Table<T extends Item.Base>({
|
|||
[showModal]
|
||||
);
|
||||
|
||||
const idState = state.data;
|
||||
const orderList = useEntityToList(state.content);
|
||||
|
||||
const orderList = useMemo(() => buildOrderList(idState), [idState]);
|
||||
|
||||
const data = useMemo(() => uniqBy([...dirtyItems, ...orderList], GetItemId), [
|
||||
dirtyItems,
|
||||
orderList,
|
||||
]);
|
||||
|
||||
const [profiles] = useLanguageProfiles();
|
||||
const data = useMemo(
|
||||
() => uniqBy([...dirtyItems, ...orderList], GetItemId),
|
||||
[dirtyItems, orderList]
|
||||
);
|
||||
|
||||
const options: Partial<TableOptions<T> & TableStyleProps<T>> = {
|
||||
loose: [profiles],
|
||||
emptyText: `No ${name} Found`,
|
||||
externalUpdate: updateRow,
|
||||
update: updateRow,
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -71,7 +65,7 @@ function Table<T extends Item.Base>({
|
|||
<AsyncPageTable
|
||||
{...options}
|
||||
columns={columns}
|
||||
aos={state}
|
||||
entity={state}
|
||||
loader={loader}
|
||||
data={[]}
|
||||
></AsyncPageTable>
|
|
@ -22,9 +22,9 @@ import {
|
|||
Popover,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { useProfileBy, useProfileItems } from "../@redux/hooks";
|
||||
import { LanguageText } from "../components";
|
||||
import { BuildKey, isMovie } from "../utilites";
|
||||
import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
|
||||
import { LanguageText } from "../../components";
|
||||
import { BuildKey, isMovie } from "../../utilities";
|
||||
|
||||
interface Props {
|
||||
item: Item.Base;
|
||||
|
@ -76,7 +76,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
|
|||
);
|
||||
|
||||
const profile = useProfileBy(item.profileId);
|
||||
const profileItems = useProfileItems(profile);
|
||||
const profileItems = useProfileItemsToLanguages(profile);
|
||||
|
||||
const languageBadges = useMemo(() => {
|
||||
const badges: (JSX.Element | null)[] = [];
|
|
@ -1,7 +1,7 @@
|
|||
import { faFileExcel } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { AsyncButton } from "../components";
|
||||
import { AsyncButton } from "../../components";
|
||||
|
||||
interface Props {
|
||||
history: History.Base;
|
|
@ -4,16 +4,19 @@ import React, { FunctionComponent, useMemo } from "react";
|
|||
import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Column } from "react-table";
|
||||
import { movieUpdateHistoryByRange } from "../../@redux/actions";
|
||||
import { useMoviesHistory } from "../../@redux/hooks";
|
||||
import { useReduxAction } from "../../@redux/hooks/base";
|
||||
import { MoviesApi } from "../../apis";
|
||||
import { HistoryIcon, LanguageText, TextPopover } from "../../components";
|
||||
import { BlacklistButton } from "../../generic/blacklist";
|
||||
import { BlacklistButton } from "../../DisplayItem/generic/blacklist";
|
||||
import HistoryGenericView from "../generic";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const MoviesHistoryView: FunctionComponent<Props> = () => {
|
||||
const [movies] = useMoviesHistory();
|
||||
const movies = useMoviesHistory();
|
||||
const loader = useReduxAction(movieUpdateHistoryByRange);
|
||||
|
||||
const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>(
|
||||
() => [
|
||||
|
@ -128,7 +131,8 @@ const MoviesHistoryView: FunctionComponent<Props> = () => {
|
|||
<HistoryGenericView
|
||||
type="movies"
|
||||
state={movies}
|
||||
columns={columns as Column<History.Base>[]}
|
||||
loader={loader}
|
||||
columns={columns}
|
||||
></HistoryGenericView>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,16 +4,19 @@ import React, { FunctionComponent, useMemo } from "react";
|
|||
import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Column } from "react-table";
|
||||
import { episodesUpdateHistoryByRange } from "../../@redux/actions";
|
||||
import { useSeriesHistory } from "../../@redux/hooks";
|
||||
import { useReduxAction } from "../../@redux/hooks/base";
|
||||
import { EpisodesApi } from "../../apis";
|
||||
import { HistoryIcon, LanguageText, TextPopover } from "../../components";
|
||||
import { BlacklistButton } from "../../generic/blacklist";
|
||||
import { BlacklistButton } from "../../DisplayItem/generic/blacklist";
|
||||
import HistoryGenericView from "../generic";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const SeriesHistoryView: FunctionComponent<Props> = () => {
|
||||
const [series] = useSeriesHistory();
|
||||
const series = useSeriesHistory();
|
||||
const loader = useReduxAction(episodesUpdateHistoryByRange);
|
||||
|
||||
const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>(
|
||||
() => [
|
||||
|
@ -137,7 +140,8 @@ const SeriesHistoryView: FunctionComponent<Props> = () => {
|
|||
<HistoryGenericView
|
||||
type="series"
|
||||
state={series}
|
||||
columns={columns as Column<History.Base>[]}
|
||||
loader={loader}
|
||||
columns={columns}
|
||||
></HistoryGenericView>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
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 { Helmet } from "react-helmet";
|
||||
import {
|
||||
|
@ -12,6 +17,7 @@ import {
|
|||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useDidMount } from "rooks";
|
||||
import {
|
||||
HistoryApi,
|
||||
ProvidersApi,
|
||||
|
@ -19,10 +25,10 @@ import {
|
|||
useAsyncRequest,
|
||||
} from "../../apis";
|
||||
import {
|
||||
AsyncOverlay,
|
||||
AsyncSelector,
|
||||
ContentHeader,
|
||||
LanguageSelector,
|
||||
PromiseOverlay,
|
||||
Selector,
|
||||
} from "../../components";
|
||||
import { actionOptions, timeframeOptions } from "./options";
|
||||
|
@ -49,26 +55,32 @@ const SelectorContainer: FunctionComponent = ({ children }) => (
|
|||
);
|
||||
|
||||
const HistoryStats: FunctionComponent = () => {
|
||||
const [languages] = useAsyncRequest(() => SystemApi.languages(true), []);
|
||||
|
||||
const [providerList] = useAsyncRequest(
|
||||
() => ProvidersApi.providers(true),
|
||||
[]
|
||||
const [languages, updateLanguages] = useAsyncRequest(
|
||||
SystemApi.languages.bind(SystemApi)
|
||||
);
|
||||
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 [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 promise = useCallback(() => {
|
||||
return HistoryApi.stats(
|
||||
timeframe,
|
||||
action ?? undefined,
|
||||
provider?.name,
|
||||
lang?.code2
|
||||
);
|
||||
}, [timeframe, lang?.code2, action, provider]);
|
||||
const [stats, update] = useAsyncRequest(HistoryApi.stats.bind(HistoryApi));
|
||||
|
||||
useEffect(() => {
|
||||
update(timeframe, action ?? undefined, provider?.name, lang?.code2);
|
||||
}, [timeframe, action, provider?.name, lang?.code2, update]);
|
||||
|
||||
return (
|
||||
// TODO: Responsive
|
||||
|
@ -76,8 +88,8 @@ const HistoryStats: FunctionComponent = () => {
|
|||
<Helmet>
|
||||
<title>History Statistics - Bazarr</title>
|
||||
</Helmet>
|
||||
<PromiseOverlay promise={promise}>
|
||||
{(data) => (
|
||||
<AsyncOverlay ctx={stats}>
|
||||
{({ content }) => (
|
||||
<React.Fragment>
|
||||
<ContentHeader scroll={false}>
|
||||
<SelectorContainer>
|
||||
|
@ -103,20 +115,21 @@ const HistoryStats: FunctionComponent = () => {
|
|||
clearable
|
||||
state={providerList}
|
||||
label={providerLabel}
|
||||
update={updateProvider}
|
||||
onChange={setProvider}
|
||||
></AsyncSelector>
|
||||
</SelectorContainer>
|
||||
<SelectorContainer>
|
||||
<LanguageSelector
|
||||
clearable
|
||||
options={languages.data}
|
||||
options={languages.content ?? []}
|
||||
value={lang}
|
||||
onChange={setLanguage}
|
||||
></LanguageSelector>
|
||||
</SelectorContainer>
|
||||
</ContentHeader>
|
||||
<ResponsiveContainer height="100%">
|
||||
<BarChart data={converter(data)}>
|
||||
<BarChart data={content ? converter(content) : []}>
|
||||
<CartesianGrid strokeDasharray="4 2"></CartesianGrid>
|
||||
<XAxis dataKey="date"></XAxis>
|
||||
<YAxis allowDecimals={false}></YAxis>
|
||||
|
@ -128,7 +141,7 @@ const HistoryStats: FunctionComponent = () => {
|
|||
</ResponsiveContainer>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</PromiseOverlay>
|
||||
</AsyncOverlay>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
import { capitalize } from "lodash";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import React from "react";
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
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";
|
||||
state: Readonly<AsyncState<History.Base[]>>;
|
||||
columns: Column<History.Base>[];
|
||||
state: Readonly<Async.Entity<T>>;
|
||||
loader: (param: Parameter.Range) => void;
|
||||
columns: Column<T>[];
|
||||
}
|
||||
|
||||
const HistoryGenericView: FunctionComponent<Props> = ({
|
||||
function HistoryGenericView<T extends History.Base = History.Base>({
|
||||
state,
|
||||
loader,
|
||||
columns,
|
||||
type,
|
||||
}) => {
|
||||
}: Props<T>) {
|
||||
const typeName = capitalize(type);
|
||||
return (
|
||||
<Container fluid>
|
||||
|
@ -23,18 +25,16 @@ const HistoryGenericView: FunctionComponent<Props> = ({
|
|||
<title>{typeName} History - Bazarr</title>
|
||||
</Helmet>
|
||||
<Row>
|
||||
<AsyncStateOverlay state={state}>
|
||||
{({ data }) => (
|
||||
<PageTable
|
||||
emptyText={`Nothing Found in ${typeName} History`}
|
||||
columns={columns}
|
||||
data={data}
|
||||
></PageTable>
|
||||
)}
|
||||
</AsyncStateOverlay>
|
||||
<AsyncPageTable
|
||||
emptyText={`Nothing Found in ${typeName} History`}
|
||||
entity={state}
|
||||
loader={loader}
|
||||
columns={columns}
|
||||
data={[]}
|
||||
></AsyncPageTable>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default HistoryGenericView;
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
|
@ -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
Loading…
Reference in New Issue