Merge development into master

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

View File

@ -47,6 +47,10 @@ jobs:
run: npm run build
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 }}

5
.gitignore vendored
View File

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

View File

@ -856,7 +856,8 @@ class EpisodesSubtitles(Resource):
if sceneName is None: sceneName = "None"
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,

View File

@ -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():

View File

@ -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

View File

@ -44,7 +44,7 @@ def sync_episodes(series_id=None, send_event=True):
seriesIdList = get_series_from_sonarr_api(series_id=series_id, url=url_sonarr(), apikey_sonarr=apikey_sonarr,)
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.')

View File

@ -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

View File

@ -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():

View File

@ -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

View File

@ -249,7 +249,8 @@ def download_subtitle(path, language, audio_language, hi, forced, providers, pro
command = pp_replace(postprocessing_cmd, path, downloaded_path, downloaded_language,
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.')

View File

@ -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))

View File

@ -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')

View File

@ -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__":

View File

@ -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())

View File

@ -429,6 +429,10 @@ def subtitles_apply_mods(language, subtitle_path, mods):
def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
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:

View File

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

24
frontend/.env.development Normal file
View File

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

3
frontend/.eslintrc.json Normal file
View File

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

4
frontend/.gitignore vendored Normal file
View File

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

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

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

View File

@ -1,10 +1,12 @@
# Bazarr Frontend
## 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.

13687
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}
}

View File

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

View File

@ -1,7 +1,7 @@
import { debounce, forIn, remove, uniq } from "lodash";
import { 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);
}
});
});

View File

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

View File

@ -1,4 +1,4 @@
namespace SocketIO {
declare namespace SocketIO {
type EventType = NumEventType | NullEventType | SpecialEventType;
type 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;
}
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,47 +1,84 @@
import { createDeleteAction } from "../../@socketio/reducer";
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { MoviesApi } from "../../apis";
import {
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"
);

View File

@ -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"
);

View File

@ -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;
}
);

View File

@ -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;
}
);

View File

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

View File

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

View File

@ -1,36 +1,24 @@
import { ActionCreator } from "@reduxjs/toolkit";
import { useCallback } from "react";
import { 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]
);
}

View File

@ -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";

View File

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

View File

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

View File

@ -7,8 +7,8 @@ export function useNotification(id: string, timeout: number = 5000) {
const add = useReduxAction(siteAddNotifications);
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) {

View File

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

View File

@ -1,12 +1,13 @@
import { combineReducers } from "redux";
import 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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -1,17 +1,15 @@
import { applyMiddleware, createStore } from "redux";
import 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;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,88 @@
export function defaultAOS(): AsyncOrderState<any> {
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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
type LanguageCodeType = string;
interface Badge {
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[];

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

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

View File

@ -11,22 +11,6 @@ type FileTree = {
type StorageType = string | null;
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;

View File

@ -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;
}

View File

@ -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>>

View File

@ -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;

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

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

View File

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

View File

@ -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>;

View File

@ -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>

View File

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

View File

@ -3,13 +3,12 @@ import { Redirect, Route, Switch, useHistory } from "react-router-dom";
import { useDidMount } from "rooks";
import { 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>

View File

@ -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;

View File

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

View File

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

View File

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

View File

@ -4,16 +4,16 @@ import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { 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>
);
};

View File

@ -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>
);
};

View File

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

View File

@ -9,12 +9,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { 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>
);

View File

@ -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>
);

View File

@ -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) {

View File

@ -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>

View File

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

View File

@ -3,19 +3,20 @@ import React, { FunctionComponent, useMemo } from "react";
import { Badge, ProgressBar } from "react-bootstrap";
import { 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>

View File

@ -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}
>

View File

@ -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>

View File

@ -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)[] = [];

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;

View File

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

View File

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

View File

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

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