Added live update of UI using websocket. Make sure your reverse proxy upgrade the connection!

pull/1403/head^2
morpheus65535 3 years ago committed by GitHub
parent 09a31cf9a4
commit 72b6ab3c6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -20,7 +20,7 @@ from logger import empty_log
from init import *
import logging
from database import database, get_exclusion_clause, get_profiles_list, get_desired_languages, get_profile_id_name, \
get_audio_profile_languages, update_profile_id_list
get_audio_profile_languages, update_profile_id_list, convert_list_to_clause
from helper import path_mappings
from get_languages import language_from_alpha2, language_from_alpha3, alpha2_from_alpha3, alpha3_from_alpha2
from get_subtitle import download_subtitle, series_download_subtitles, manual_search, manual_download_subtitle, \
@ -31,7 +31,7 @@ from list_subtitles import store_subtitles, store_subtitles_movie, series_scan_s
list_missing_subtitles, list_missing_subtitles_movies
from utils import history_log, history_log_movie, blacklist_log, blacklist_delete, blacklist_delete_all, \
blacklist_log_movie, blacklist_delete_movie, blacklist_delete_all_movie, get_sonarr_version, get_radarr_version, \
delete_subtitles, subtitles_apply_mods, translate_subtitles_file, check_credentials
delete_subtitles, subtitles_apply_mods, translate_subtitles_file, check_credentials, get_health_issues
from get_providers import get_providers, get_providers_auth, list_throttled_providers, reset_throttled_providers, \
get_throttled_providers, set_throttled_providers
from event_handler import event_stream
@ -125,8 +125,6 @@ def postprocessSeries(item):
if 'path' in item:
item['path'] = path_mappings.path_replace(item['path'])
# Confirm if path exist
item['exist'] = os.path.isdir(item['path'])
# map poster and fanart to server proxy
if 'poster' in item:
@ -138,9 +136,7 @@ def postprocessSeries(item):
item['fanart'] = f"{base_url}/images/series{fanart}"
def postprocessEpisode(item, desired=None):
if desired is None:
desired = []
def postprocessEpisode(item):
postprocess(item)
if 'audio_language' in item and item['audio_language'] is not None:
item['audio_language'] = get_audio_profile_languages(episode_id=item['sonarrEpisodeId'])
@ -168,10 +164,6 @@ def postprocessEpisode(item, desired=None):
item.update({"subtitles": subtitles})
if settings.general.getboolean('embedded_subs_show_desired'):
item['subtitles'] = [x for x in item['subtitles'] if
x['code2'] in desired or x['path']]
# Parse missing subtitles
if 'missing_subtitles' in item:
if item['missing_subtitles'] is None:
@ -195,11 +187,9 @@ def postprocessEpisode(item, desired=None):
item["sceneName"] = item["scene_name"]
del item["scene_name"]
if 'path' in item:
if item['path']:
# Provide mapped path
item['path'] = path_mappings.path_replace(item['path'])
item['exist'] = os.path.isfile(item['path'])
if 'path' in item and item['path']:
# Provide mapped path
item['path'] = path_mappings.path_replace(item['path'])
# TODO: Move
@ -270,8 +260,6 @@ def postprocessMovie(item):
if 'path' in item:
if item['path']:
item['path'] = path_mappings.path_replace_movie(item['path'])
# Confirm if path exist
item['exist'] = os.path.isfile(item['path'])
if 'subtitles_path' in item:
# Provide mapped subtitles path
@ -319,7 +307,7 @@ class System(Resource):
return '', 204
class BadgesSeries(Resource):
class Badges(Resource):
@authenticate
def get(self):
missing_episodes = database.execute("SELECT table_shows.tags, table_episodes.monitored, table_shows.seriesType "
@ -334,10 +322,13 @@ class BadgesSeries(Resource):
throttled_providers = len(eval(str(get_throttled_providers())))
health_issues = len(get_health_issues())
result = {
"episodes": missing_episodes,
"movies": missing_movies,
"providers": throttled_providers
"providers": throttled_providers,
"status": health_issues
}
return jsonify(result)
@ -421,7 +412,8 @@ class SystemSettings(Resource):
if len(enabled_languages) != 0:
database.execute("UPDATE table_settings_languages SET enabled=0")
for code in enabled_languages:
database.execute("UPDATE table_settings_languages SET enabled=1 WHERE code2=?", (code,))
database.execute("UPDATE table_settings_languages SET enabled=1 WHERE code2=?",(code,))
event_stream("languages")
languages_profiles = request.form.get('languages-profiles')
if languages_profiles:
@ -451,6 +443,7 @@ class SystemSettings(Resource):
database.execute('DELETE FROM table_languages_profiles WHERE profileId = ?', (profileId,))
update_profile_id_list()
event_stream("languages")
if settings.general.getboolean('use_sonarr'):
scheduler.add_job(list_missing_subtitles, kwargs={'send_event': False})
@ -465,6 +458,7 @@ class SystemSettings(Resource):
(item['enabled'], item['url'], item['name']))
save_settings(zip(request.form.keys(), request.form.listvalues()))
event_stream("settings")
return '', 204
@ -533,6 +527,12 @@ class SystemStatus(Resource):
return jsonify(data=system_status)
class SystemHealth(Resource):
@authenticate
def get(self):
return jsonify(data=get_health_issues())
class SystemReleases(Resource):
@authenticate
def get(self):
@ -577,9 +577,8 @@ class Series(Resource):
count = database.execute("SELECT COUNT(*) as count FROM table_shows", only_one=True)['count']
if len(seriesId) != 0:
seriesIdList = ','.join(seriesId)
result = database.execute(
f"SELECT * FROM table_shows WHERE sonarrSeriesId in ({seriesIdList}) ORDER BY sortTitle ASC")
f"SELECT * FROM table_shows WHERE sonarrSeriesId in {convert_list_to_clause(seriesId)} ORDER BY sortTitle ASC")
else:
result = database.execute("SELECT * FROM table_shows ORDER BY sortTitle ASC LIMIT ? OFFSET ?"
, (length, start))
@ -627,9 +626,10 @@ class Series(Resource):
database.execute("UPDATE table_shows SET profileId=? WHERE sonarrSeriesId=?", (profileId, seriesId))
list_missing_subtitles(no=seriesId)
list_missing_subtitles(no=seriesId, send_event=False)
# event_stream(type='series', action='update', series=seriesId)
event_stream(type='series', payload=seriesId)
event_stream(type='badges')
return '', 204
@ -653,23 +653,20 @@ class Series(Resource):
class Episodes(Resource):
@authenticate
def get(self):
seriesId = request.args.get('seriesid')
episodeId = request.args.get('episodeid')
if episodeId:
result = database.execute("SELECT * FROM table_episodes WHERE sonarrEpisodeId=?", (episodeId,))
elif seriesId:
result = database.execute("SELECT * FROM table_episodes WHERE sonarrSeriesId=? ORDER BY season DESC, "
"episode DESC", (seriesId,))
seriesId = request.args.getlist('seriesid[]')
episodeId = request.args.getlist('episodeid[]')
if len(episodeId) > 0:
result = database.execute(f"SELECT * FROM table_episodes WHERE sonarrEpisodeId in {convert_list_to_clause(episodeId)}")
elif len(seriesId) > 0:
result = database.execute("SELECT * FROM table_episodes "
f"WHERE sonarrSeriesId in {convert_list_to_clause(seriesId)} ORDER BY season DESC, "
"episode DESC")
else:
return "Series ID not provided", 400
profileId = database.execute("SELECT profileId FROM table_shows WHERE sonarrSeriesId = ?", (seriesId,),
only_one=True)['profileId']
desired_languages = str(get_desired_languages(profileId))
desired = ast.literal_eval(desired_languages)
return "Series or Episode ID not provided", 400
for item in result:
postprocessEpisode(item, desired)
postprocessEpisode(item)
return jsonify(data=result)
@ -727,7 +724,7 @@ class EpisodesSubtitles(Resource):
send_notifications(sonarrSeriesId, sonarrEpisodeId, message)
store_subtitles(path, episodePath)
else:
event_stream(type='episode', action='update', series=int(sonarrSeriesId), episode=int(sonarrEpisodeId))
event_stream(type='episode', payload=sonarrEpisodeId)
except OSError:
pass
@ -820,14 +817,12 @@ class Movies(Resource):
def get(self):
start = request.args.get('start') or 0
length = request.args.get('length') or -1
id = request.args.getlist('radarrid[]')
radarrId = request.args.getlist('radarrid[]')
count = database.execute("SELECT COUNT(*) as count FROM table_movies", only_one=True)['count']
if len(id) != 0:
movieIdList = ','.join(id)
result = database.execute(
f"SELECT * FROM table_movies WHERE radarrId in ({movieIdList}) ORDER BY sortTitle ASC")
if len(radarrId) != 0:
result = database.execute(f"SELECT * FROM table_movies WHERE radarrId in {convert_list_to_clause(radarrId)} ORDER BY sortTitle ASC")
else:
result = database.execute("SELECT * FROM table_movies ORDER BY sortTitle ASC LIMIT ? OFFSET ?",
(length, start))
@ -857,7 +852,8 @@ class Movies(Resource):
list_missing_subtitles_movies(no=radarrId)
# event_stream(type='movies', action='update', movie=radarrId)
event_stream(type='movies', payload=radarrId)
event_stream(type='badges')
return '', 204
@ -933,7 +929,7 @@ class MoviesSubtitles(Resource):
send_notifications_movie(radarrId, message)
store_subtitles_movie(path, moviePath)
else:
event_stream(type='movie', action='update', movie=int(radarrId))
event_stream(type='movie', payload=radarrId)
except OSError:
pass
@ -1442,17 +1438,30 @@ class HistoryStats(Resource):
class EpisodesWanted(Resource):
@authenticate
def get(self):
start = request.args.get('start') or 0
length = request.args.get('length') or -1
data = database.execute("SELECT table_shows.title as seriesTitle, table_episodes.monitored, "
"table_episodes.season || 'x' || table_episodes.episode as episode_number, "
"table_episodes.title as episodeTitle, table_episodes.missing_subtitles, "
"table_episodes.sonarrSeriesId, "
"table_episodes.sonarrEpisodeId, table_episodes.scene_name as sceneName, table_shows.tags, "
"table_episodes.failedAttempts, table_shows.seriesType FROM table_episodes INNER JOIN "
"table_shows on table_shows.sonarrSeriesId = table_episodes.sonarrSeriesId WHERE "
"table_episodes.missing_subtitles != '[]'" + get_exclusion_clause('series') +
" ORDER BY table_episodes._rowid_ DESC LIMIT ? OFFSET ?", (length, start))
episodeid = request.args.getlist('episodeid[]')
if len(episodeid) > 0:
data = database.execute("SELECT table_shows.title as seriesTitle, table_episodes.monitored, "
"table_episodes.season || 'x' || table_episodes.episode as episode_number, "
"table_episodes.title as episodeTitle, table_episodes.missing_subtitles, "
"table_episodes.sonarrSeriesId, "
"table_episodes.sonarrEpisodeId, table_episodes.scene_name as sceneName, table_shows.tags, "
"table_episodes.failedAttempts, table_shows.seriesType FROM table_episodes INNER JOIN "
"table_shows on table_shows.sonarrSeriesId = table_episodes.sonarrSeriesId WHERE "
"table_episodes.missing_subtitles != '[]'" + get_exclusion_clause('series') +
f" AND sonarrEpisodeId in {convert_list_to_clause(episodeid)}")
pass
else:
start = request.args.get('start') or 0
length = request.args.get('length') or -1
data = database.execute("SELECT table_shows.title as seriesTitle, table_episodes.monitored, "
"table_episodes.season || 'x' || table_episodes.episode as episode_number, "
"table_episodes.title as episodeTitle, table_episodes.missing_subtitles, "
"table_episodes.sonarrSeriesId, "
"table_episodes.sonarrEpisodeId, table_episodes.scene_name as sceneName, table_shows.tags, "
"table_episodes.failedAttempts, table_shows.seriesType FROM table_episodes INNER JOIN "
"table_shows on table_shows.sonarrSeriesId = table_episodes.sonarrSeriesId WHERE "
"table_episodes.missing_subtitles != '[]'" + get_exclusion_clause('series') +
" ORDER BY table_episodes._rowid_ DESC LIMIT ? OFFSET ?", (length, start))
for item in data:
postprocessEpisode(item)
@ -1469,20 +1478,28 @@ class EpisodesWanted(Resource):
class MoviesWanted(Resource):
@authenticate
def get(self):
start = request.args.get('start') or 0
length = request.args.get('length') or -1
data = database.execute("SELECT title, missing_subtitles, radarrId, sceneName, "
"failedAttempts, tags, monitored FROM table_movies WHERE missing_subtitles != '[]'" +
get_exclusion_clause('movie') +
" ORDER BY _rowid_ DESC LIMIT ? OFFSET ?", (length, start))
radarrid = request.args.getlist("radarrid[]")
if len(radarrid) > 0:
result = database.execute("SELECT title, missing_subtitles, radarrId, sceneName, "
"failedAttempts, tags, monitored FROM table_movies WHERE missing_subtitles != '[]'" +
get_exclusion_clause('movie') +
f" AND radarrId in {convert_list_to_clause(radarrid)}")
pass
else:
start = request.args.get('start') or 0
length = request.args.get('length') or -1
result = database.execute("SELECT title, missing_subtitles, radarrId, sceneName, "
"failedAttempts, tags, monitored FROM table_movies WHERE missing_subtitles != '[]'" +
get_exclusion_clause('movie') +
" ORDER BY _rowid_ DESC LIMIT ? OFFSET ?", (length, start))
for item in data:
for item in result:
postprocessMovie(item)
count = database.execute("SELECT COUNT(*) as count FROM table_movies WHERE missing_subtitles != '[]'" +
get_exclusion_clause('movie'), only_one=True)['count']
return jsonify(data=data, total=count)
return jsonify(data=result, total=count)
# GET: get blacklist
@ -1540,7 +1557,7 @@ class EpisodesBlacklist(Resource):
sonarr_series_id=sonarr_series_id,
sonarr_episode_id=sonarr_episode_id)
episode_download_subtitles(sonarr_episode_id)
event_stream(type='episodeHistory')
event_stream(type='episode-history')
return '', 200
@authenticate
@ -1606,7 +1623,7 @@ class MoviesBlacklist(Resource):
subtitles_path=subtitles_path,
radarr_id=radarr_id)
movies_download_subtitles(radarr_id)
event_stream(type='movieHistory')
event_stream(type='movie-history')
return '', 200
@authenticate
@ -1746,7 +1763,7 @@ class BrowseRadarrFS(Resource):
return jsonify(data)
api.add_resource(BadgesSeries, '/badges')
api.add_resource(Badges, '/badges')
api.add_resource(Providers, '/providers')
api.add_resource(ProviderMovies, '/providers/movies')
@ -1758,6 +1775,7 @@ api.add_resource(SystemAccount, '/system/account')
api.add_resource(SystemTasks, '/system/tasks')
api.add_resource(SystemLogs, '/system/logs')
api.add_resource(SystemStatus, '/system/status')
api.add_resource(SystemHealth, '/system/health')
api.add_resource(SystemReleases, '/system/releases')
api.add_resource(SystemSettings, '/system/settings')
api.add_resource(Languages, '/system/languages')

@ -38,7 +38,7 @@ def create_app():
toolbar = DebugToolbarExtension(app)
socketio.init_app(app, path=base_url.rstrip('/')+'/socket.io', cors_allowed_origins='*', async_mode='threading')
socketio.init_app(app, path=base_url.rstrip('/')+'/api/socket.io', cors_allowed_origins='*', async_mode='gevent')
return app

@ -397,8 +397,7 @@ def save_settings(settings_items):
if exclusion_updated:
from event_handler import event_stream
event_stream(type='badges_series')
event_stream(type='badges_movies')
event_stream(type='badges')
def url_sonarr():

@ -160,6 +160,12 @@ def db_upgrade():
database.execute("CREATE TABLE IF NOT EXISTS table_blacklist_movie (radarr_id integer, timestamp integer, "
"provider text, subs_id text, language text)")
# Create rootfolder tables
database.execute("CREATE TABLE IF NOT EXISTS table_shows_rootfolder (id integer, path text, accessible integer, "
"error text)")
database.execute("CREATE TABLE IF NOT EXISTS table_movies_rootfolder (id integer, path text, accessible integer, "
"error text)")
# Create languages profiles table and populate it
lang_table_content = database.execute("SELECT * FROM table_languages_profiles")
if isinstance(lang_table_content, list):
@ -483,3 +489,9 @@ def get_audio_profile_languages(series_id=None, episode_id=None, movie_id=None):
)
return audio_languages
def convert_list_to_clause(arr: list):
if isinstance(arr, list):
return f"({','.join(str(x) for x in arr)})"
else:
return ""

@ -1,23 +1,19 @@
# coding=utf-8
import json
from app import socketio
def event_stream(type=None, action=None, series=None, episode=None, movie=None, task=None):
def event_stream(type, action="update", payload=None):
"""
:param type: The type of element.
:type type: str
:param action: The action type of element from insert, update, delete.
:param action: The action type of element from update and delete.
:type action: str
:param series: The series id.
:type series: str
:param episode: The episode id.
:type episode: str
:param movie: The movie id.
:type movie: str
:param task: The task id.
:type task: str
:param payload: The payload to send, can be anything
"""
socketio.emit('event', json.dumps({"type": type, "action": action, "series": series, "episode": episode,
"movie": movie, "task": task}))
try:
payload = int(payload)
except (ValueError, TypeError):
pass
socketio.emit("data", {"type": type, "action": action, "payload": payload})

@ -143,8 +143,7 @@ def sync_episodes():
episode_to_delete = database.execute("SELECT sonarrSeriesId, sonarrEpisodeId FROM table_episodes WHERE "
"sonarrEpisodeId=?", (removed_episode,), only_one=True)
database.execute("DELETE FROM table_episodes WHERE sonarrEpisodeId=?", (removed_episode,))
event_stream(type='episode', action='delete', series=episode_to_delete['sonarrSeriesId'],
episode=episode_to_delete['sonarrEpisodeId'])
event_stream(type='episode', action='delete', payload=episode_to_delete['sonarrEpisodeId'])
# Update existing episodes in DB
episode_in_db_list = []
@ -175,8 +174,7 @@ def sync_episodes():
altered_episodes.append([added_episode['sonarrEpisodeId'],
added_episode['path'],
added_episode['monitored']])
event_stream(type='episode', action='insert', series=added_episode['sonarrSeriesId'],
episode=added_episode['sonarrEpisodeId'])
event_stream(type='episode', payload=added_episode['sonarrEpisodeId'])
else:
logging.debug('BAZARR unable to insert this episode into the database:{}'.format(path_mappings.path_replace(added_episode['path'])))

@ -8,6 +8,7 @@ from config import settings, url_radarr
from helper import path_mappings
from utils import get_radarr_version
from list_subtitles import store_subtitles_movie, movies_full_scan_subtitles
from get_rootfolder import check_radarr_rootfolder
from get_subtitle import movies_download_subtitles
from database import database, dict_converter, get_exclusion_clause
@ -21,6 +22,7 @@ def update_all_movies():
def update_movies():
check_radarr_rootfolder()
logging.debug('BAZARR Starting movie sync from Radarr.')
apikey_radarr = settings.radarr.apikey

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

@ -0,0 +1,116 @@
# coding=utf-8
import os
import requests
import logging
from config import settings, url_sonarr, url_radarr
from helper import path_mappings
from database import database
headers = {"User-Agent": os.environ["SZ_USER_AGENT"]}
def get_sonarr_rootfolder():
apikey_sonarr = settings.sonarr.apikey
sonarr_rootfolder = []
# Get root folder data from Sonarr
url_sonarr_api_rootfolder = url_sonarr() + "/api/rootfolder?apikey=" + apikey_sonarr
try:
rootfolder = requests.get(url_sonarr_api_rootfolder, timeout=60, verify=False, headers=headers)
except requests.exceptions.ConnectionError:
logging.exception("BAZARR Error trying to get rootfolder from Sonarr. Connection Error.")
return []
except requests.exceptions.Timeout:
logging.exception("BAZARR Error trying to get rootfolder from Sonarr. Timeout Error.")
return []
except requests.exceptions.RequestException:
logging.exception("BAZARR Error trying to get rootfolder from Sonarr.")
return []
else:
for folder in rootfolder.json():
sonarr_rootfolder.append({'id': folder['id'], 'path': folder['path']})
db_rootfolder = database.execute('SELECT id, path FROM table_shows_rootfolder')
rootfolder_to_remove = [x for x in db_rootfolder if not
next((item for item in sonarr_rootfolder if item['id'] == x['id']), False)]
rootfolder_to_update = [x for x in sonarr_rootfolder if
next((item for item in db_rootfolder if item['id'] == x['id']), False)]
rootfolder_to_insert = [x for x in sonarr_rootfolder if not
next((item for item in db_rootfolder if item['id'] == x['id']), False)]
for item in rootfolder_to_remove:
database.execute('DELETE FROM table_shows_rootfolder WHERE id = ?', (item['id'],))
for item in rootfolder_to_update:
database.execute('UPDATE table_shows_rootfolder SET path=? WHERE id = ?', (item['path'], item['id']))
for item in rootfolder_to_insert:
database.execute('INSERT INTO table_shows_rootfolder (id, path) VALUES (?, ?)', (item['id'], item['path']))
def check_sonarr_rootfolder():
get_sonarr_rootfolder()
rootfolder = database.execute('SELECT id, path FROM table_shows_rootfolder')
for item in rootfolder:
if not os.path.isdir(path_mappings.path_replace(item['path'])):
database.execute("UPDATE table_shows_rootfolder SET accessible = 0, error = 'This Sonarr root directory "
"does not seems to be accessible by Bazarr. Please check path mapping.' WHERE id = ?",
(item['id'],))
elif not os.access(path_mappings.path_replace(item['path']), os.W_OK):
database.execute("UPDATE table_shows_rootfolder SET accessible = 0, error = 'Bazarr cannot write to "
"this directory' WHERE id = ?", (item['id'],))
else:
database.execute("UPDATE table_shows_rootfolder SET accessible = 1, error = '' WHERE id = ?", (item['id'],))
def get_radarr_rootfolder():
apikey_radarr = settings.radarr.apikey
radarr_rootfolder = []
# Get root folder data from Radarr
url_radarr_api_rootfolder = url_radarr() + "/api/rootfolder?apikey=" + apikey_radarr
try:
rootfolder = requests.get(url_radarr_api_rootfolder, timeout=60, verify=False, headers=headers)
except requests.exceptions.ConnectionError:
logging.exception("BAZARR Error trying to get rootfolder from Radarr. Connection Error.")
return []
except requests.exceptions.Timeout:
logging.exception("BAZARR Error trying to get rootfolder from Radarr. Timeout Error.")
return []
except requests.exceptions.RequestException:
logging.exception("BAZARR Error trying to get rootfolder from Radarr.")
return []
else:
for folder in rootfolder.json():
radarr_rootfolder.append({'id': folder['id'], 'path': folder['path']})
db_rootfolder = database.execute('SELECT id, path FROM table_movies_rootfolder')
rootfolder_to_remove = [x for x in db_rootfolder if not
next((item for item in radarr_rootfolder if item['id'] == x['id']), False)]
rootfolder_to_update = [x for x in radarr_rootfolder if
next((item for item in db_rootfolder if item['id'] == x['id']), False)]
rootfolder_to_insert = [x for x in radarr_rootfolder if not
next((item for item in db_rootfolder if item['id'] == x['id']), False)]
for item in rootfolder_to_remove:
database.execute('DELETE FROM table_movies_rootfolder WHERE id = ?', (item['id'],))
for item in rootfolder_to_update:
database.execute('UPDATE table_movies_rootfolder SET path=? WHERE id = ?', (item['path'], item['id']))
for item in rootfolder_to_insert:
database.execute('INSERT INTO table_movies_rootfolder (id, path) VALUES (?, ?)', (item['id'], item['path']))
def check_radarr_rootfolder():
get_radarr_rootfolder()
rootfolder = database.execute('SELECT id, path FROM table_movies_rootfolder')
for item in rootfolder:
if not os.path.isdir(path_mappings.path_replace_movie(item['path'])):
database.execute("UPDATE table_movies_rootfolder SET accessible = 0, error = 'This Radarr root directory "
"does not seems to be accessible by Bazarr. Please check path mapping.' WHERE id = ?",
(item['id'],))
elif not os.access(path_mappings.path_replace_movie(item['path']), os.W_OK):
database.execute("UPDATE table_movies_rootfolder SET accessible = 0, error = 'Bazarr cannot write to "
"this directory' WHERE id = ?", (item['id'],))
else:
database.execute("UPDATE table_movies_rootfolder SET accessible = 1, error = '' WHERE id = ?",
(item['id'],))

@ -6,6 +6,7 @@ import logging
from config import settings, url_sonarr
from list_subtitles import list_missing_subtitles
from get_rootfolder import check_sonarr_rootfolder
from database import database, dict_converter
from utils import get_sonarr_version
from helper import path_mappings
@ -15,6 +16,7 @@ headers = {"User-Agent": os.environ["SZ_USER_AGENT"]}
def update_series():
check_sonarr_rootfolder()
apikey_sonarr = settings.sonarr.apikey
if apikey_sonarr is None:
return
@ -125,7 +127,7 @@ def update_series():
for series in removed_series:
database.execute("DELETE FROM table_shows WHERE sonarrSeriesId=?",(series,))
event_stream(type='series', action='delete', series=series)
event_stream(type='series', action='delete', payload=series)
# Update existing series in DB
series_in_db_list = []
@ -141,7 +143,7 @@ def update_series():
query = dict_converter.convert(updated_series)
database.execute('''UPDATE table_shows SET ''' + query.keys_update + ''' WHERE sonarrSeriesId = ?''',
query.values + (updated_series['sonarrSeriesId'],))
event_stream(type='series', action='update', series=updated_series['sonarrSeriesId'])
event_stream(type='series', payload=updated_series['sonarrSeriesId'])
# Insert new series in DB
for added_series in series_to_add:
@ -155,7 +157,7 @@ def update_series():
logging.debug('BAZARR unable to insert this series into the database:',
path_mappings.path_replace(added_series['path']))
event_stream(type='series', action='insert', series=added_series['sonarrSeriesId'])
event_stream(type='series', series=added_series['sonarrSeriesId'])
logging.debug('BAZARR All series synced from Sonarr into database.')

@ -33,6 +33,7 @@ from subsyncer import subsync
from guessit import guessit
from database import database, dict_mapper, get_exclusion_clause, get_profiles_list, get_audio_profile_languages, \
get_desired_languages
from event_handler import event_stream
from embedded_subs_reader import parse_video_metadata
from analytics import track_event
@ -982,6 +983,7 @@ def wanted_download_subtitles(path, l, count_episodes):
store_subtitles(episode['path'], path_mappings.path_replace(episode['path']))
history_log(1, episode['sonarrSeriesId'], episode['sonarrEpisodeId'], message, path,
language_code, provider, score, subs_id, subs_path)
event_stream(type='episode-wanted', action='delete', payload=episode['sonarrEpisodeId'])
send_notifications(episode['sonarrSeriesId'], episode['sonarrEpisodeId'], message)
else:
logging.debug(
@ -1050,6 +1052,7 @@ def wanted_download_subtitles_movie(path, l, count_movies):
store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']))
history_log_movie(1, movie['radarrId'], message, path, language_code, provider, score,
subs_id, subs_path)
event_stream(type='movie-wanted', action='delete', payload=movie['radarrId'])
send_notifications_movie(movie['radarrId'], message)
else:
logging.info(

@ -45,7 +45,7 @@ import logging
# deploy requirements.txt
if not args.no_update:
try:
import lxml, numpy, webrtcvad
import lxml, numpy, webrtcvad, gevent, geventwebsocket
except ImportError:
try:
import pip

@ -365,9 +365,8 @@ def list_missing_subtitles(no=None, epno=None, send_event=True):
(missing_subtitles_text, episode_subtitles['sonarrEpisodeId']))
if send_event:
event_stream(type='episode', action='update', series=episode_subtitles['sonarrSeriesId'],
episode=episode_subtitles['sonarrEpisodeId'])
event_stream(type='badges_series')
event_stream(type='episode', payload=episode_subtitles['sonarrEpisodeId'])
event_stream(type='badges')
def list_missing_subtitles_movies(no=None, epno=None, send_event=True):
@ -475,8 +474,8 @@ def list_missing_subtitles_movies(no=None, epno=None, send_event=True):
(missing_subtitles_text, movie_subtitles['radarrId']))
if send_event:
event_stream(type='movie', action='update', movie=movie_subtitles['radarrId'])
event_stream(type='badges_movies')
event_stream(type='movie', payload=movie_subtitles['radarrId'])
event_stream(type='badges')
def series_full_scan_subtitles():

@ -104,7 +104,7 @@ def configure_logging(debug=False):
logging.getLogger("ffsubsync.ffsubsync").setLevel(logging.ERROR)
logging.getLogger("srt").setLevel(logging.ERROR)
logging.getLogger("waitress").setLevel(logging.CRITICAL)
logging.getLogger("geventwebsocket.handler").setLevel(logging.WARNING)
logging.getLogger("knowit").setLevel(logging.CRITICAL)
logging.getLogger("enzyme").setLevel(logging.CRITICAL)
logging.getLogger("guessit").setLevel(logging.WARNING)

@ -6,7 +6,7 @@ from get_series import update_series
from config import settings
from get_subtitle import wanted_search_missing_subtitles_series, wanted_search_missing_subtitles_movies, \
upgrade_subtitles
from utils import cache_maintenance
from utils import cache_maintenance, check_health
from get_args import args
if not args.no_update:
from check_update import check_if_new_update, check_releases
@ -36,18 +36,19 @@ class Scheduler:
def task_listener_add(event):
if event.job_id not in self.__running_tasks:
self.__running_tasks.append(event.job_id)
event_stream(type='task', task=event.job_id)
event_stream(type='task')
def task_listener_remove(event):
if event.job_id in self.__running_tasks:
self.__running_tasks.remove(event.job_id)
event_stream(type='task', task=event.job_id)
event_stream(type='task')
self.aps_scheduler.add_listener(task_listener_add, EVENT_JOB_SUBMITTED)
self.aps_scheduler.add_listener(task_listener_remove, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
# configure all tasks
self.__cache_cleanup_task()
self.__check_health_task()
self.update_configurable_tasks()
self.aps_scheduler.start()
@ -161,6 +162,10 @@ class Scheduler:
self.aps_scheduler.add_job(cache_maintenance, IntervalTrigger(hours=24), max_instances=1, coalesce=True,
misfire_grace_time=15, id='cache_cleanup', name='Cache maintenance')
def __check_health_task(self):
self.aps_scheduler.add_job(check_health, IntervalTrigger(hours=6), max_instances=1, coalesce=True,
misfire_grace_time=15, id='check_health', name='Check health')
def __sonarr_full_update_task(self):
if settings.general.getboolean('use_sonarr'):
full_update = settings.sonarr.full_update

@ -4,7 +4,8 @@ import warnings
import logging
import os
import io
from waitress.server import create_server
from gevent import pywsgi
from geventwebsocket.handler import WebSocketHandler
from get_args import args
from config import settings, base_url
@ -30,10 +31,10 @@ class Server:
self.server = app.run(host=str(settings.general.ip),
port=(int(args.port) if args.port else int(settings.general.port)))
else:
self.server = create_server(app,
host=str(settings.general.ip),
port=int(args.port) if args.port else int(settings.general.port),
threads=24)
self.server = pywsgi.WSGIServer((str(settings.general.ip),
int(args.port) if args.port else int(settings.general.port)),
app,
handler_class=WebSocketHandler)
def start(self):
try:
@ -41,13 +42,13 @@ class Server:
'BAZARR is started and waiting for request on http://' + str(settings.general.ip) + ':' + (str(
args.port) if args.port else str(settings.general.port)) + str(base_url))
if not args.dev:
self.server.run()
self.server.serve_forever()
except KeyboardInterrupt:
self.shutdown()
def shutdown(self):
try:
self.server.close()
self.server.stop()
except Exception as e:
logging.error('BAZARR Cannot stop Waitress: ' + repr(e))
else:
@ -64,7 +65,7 @@ class Server:
def restart(self):
try:
self.server.close()
self.server.stop()
except Exception as e:
logging.error('BAZARR Cannot stop Waitress: ' + repr(e))
else:

@ -40,24 +40,24 @@ def history_log(action, sonarr_series_id, sonarr_episode_id, description, video_
"video_path, language, provider, score, subs_id, subtitles_path) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(action, sonarr_series_id, sonarr_episode_id, time.time(), description, video_path, language,
provider, score, subs_id, subtitles_path))
event_stream(type='episodeHistory')
event_stream(type='episode-history')
def blacklist_log(sonarr_series_id, sonarr_episode_id, provider, subs_id, language):
database.execute("INSERT INTO table_blacklist (sonarr_series_id, sonarr_episode_id, timestamp, provider, "
"subs_id, language) VALUES (?,?,?,?,?,?)",
(sonarr_series_id, sonarr_episode_id, time.time(), provider, subs_id, language))
event_stream(type='episodeBlacklist')
event_stream(type='episode-blacklist')
def blacklist_delete(provider, subs_id):
database.execute("DELETE FROM table_blacklist WHERE provider=? AND subs_id=?", (provider, subs_id))
event_stream(type='episodeBlacklist')
event_stream(type='episode-blacklist', action='delete')
def blacklist_delete_all():
database.execute("DELETE FROM table_blacklist")
event_stream(type='episodeBlacklist')
event_stream(type='episode-blacklist', action='delete')
def history_log_movie(action, radarr_id, description, video_path=None, language=None, provider=None, score=None,
@ -65,23 +65,23 @@ def history_log_movie(action, radarr_id, description, video_path=None, language=
database.execute("INSERT INTO table_history_movie (action, radarrId, timestamp, description, video_path, language, "
"provider, score, subs_id, subtitles_path) VALUES (?,?,?,?,?,?,?,?,?,?)",
(action, radarr_id, time.time(), description, video_path, language, provider, score, subs_id, subtitles_path))
event_stream(type='movieHistory')
event_stream(type='movie-history')
def blacklist_log_movie(radarr_id, provider, subs_id, language):
database.execute("INSERT INTO table_blacklist_movie (radarr_id, timestamp, provider, subs_id, language) "
"VALUES (?,?,?,?,?)", (radarr_id, time.time(), provider, subs_id, language))
event_stream(type='movieBlacklist')
event_stream(type='movie-blacklist')
def blacklist_delete_movie(provider, subs_id):
database.execute("DELETE FROM table_blacklist_movie WHERE provider=? AND subs_id=?", (provider, subs_id))
event_stream(type='movieBlacklist')
event_stream(type='movie-blacklist', action='delete')
def blacklist_delete_all_movie():
database.execute("DELETE FROM table_blacklist_movie")
event_stream(type='movieBlacklist')
event_stream(type='movie-blacklist', action='delete')
@region.cache_on_arguments()
@ -401,7 +401,39 @@ def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
return dest_srt_file
def check_credentials(user, pw):
username = settings.auth.username
password = settings.auth.password
return hashlib.md5(pw.encode('utf-8')).hexdigest() == password and user == username
return hashlib.md5(pw.encode('utf-8')).hexdigest() == password and user == username
def check_health():
from get_rootfolder import check_sonarr_rootfolder, check_radarr_rootfolder
if settings.general.getboolean('use_sonarr'):
check_sonarr_rootfolder()
if settings.general.getboolean('use_radarr'):
check_radarr_rootfolder()
event_stream(type='badges')
def get_health_issues():
# this function must return a list of dictionaries consisting of to keys: object and issue
health_issues = []
# get Sonarr rootfolder issues
if settings.general.getboolean('use_sonarr'):
rootfolder = database.execute('SELECT path, accessible, error FROM table_shows_rootfolder WHERE accessible = 0')
for item in rootfolder:
health_issues.append({'object': path_mappings.path_replace(item['path']),
'issue': item['error']})
# get Radarr rootfolder issues
if settings.general.getboolean('use_radarr'):
rootfolder = database.execute('SELECT path, accessible, error FROM table_movies_rootfolder '
'WHERE accessible = 0')
for item in rootfolder:
health_issues.append({'object': path_mappings.path_replace_movie(item['path']),
'issue': item['error']})
return health_issues

@ -31,6 +31,7 @@
"@types/redux-promise": "^0.5.0",
"axios": "^0.21.0",
"bootstrap": "^4.0.0",
"http-proxy-middleware": "^0.19.1",
"lodash": "^4.0.0",
"rc-slider": "^9.7.1",
"react": "^16.0.0",
@ -48,6 +49,7 @@
"redux-promise": "^0.6.0",
"redux-thunk": "^2.3.0",
"sass": "^1.0.0",
"socket.io-client": "^4.0.0",
"typescript": "^4.0.0"
},
"devDependencies": {
@ -2793,6 +2795,11 @@
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
"integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
},
"node_modules/@types/component-emitter": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz",
"integrity": "sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg=="
},
"node_modules/@types/d3-path": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
@ -4544,6 +4551,11 @@
"babylon": "bin/babylon.js"
}
},
"node_modules/backo2": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
"integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
},
"node_modules/balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@ -4577,6 +4589,14 @@
"node": ">=0.10.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz",
"integrity": "sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -7069,6 +7089,33 @@
"once": "^1.4.0"
}
},
"node_modules/engine.io-client": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-5.0.1.tgz",
"integrity": "sha512-CQtGN3YwfvbxVwpPugcsHe5rHT4KgT49CEcQppNtu9N7WxbPN0MAG27lGaem7bvtCFtGNLSL+GEqXsFSz36jTg==",
"dependencies": {
"base64-arraybuffer": "0.1.4",
"component-emitter": "~1.3.0",
"debug": "~4.3.1",
"engine.io-parser": "~4.0.1",
"has-cors": "1.1.0",
"parseqs": "0.0.6",
"parseuri": "0.0.6",
"ws": "~7.4.2",
"yeast": "0.1.2"
}
},
"node_modules/engine.io-parser": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-4.0.2.tgz",
"integrity": "sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg==",
"dependencies": {
"base64-arraybuffer": "0.1.4"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
@ -9397,6 +9444,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-cors": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
"integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk="
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -13634,6 +13686,16 @@
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
"integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
},
"node_modules/parseqs": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz",
"integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w=="
},
"node_modules/parseuri": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz",
"integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow=="
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -18161,6 +18223,36 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"node_modules/socket.io-client": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.0.1.tgz",
"integrity": "sha512-6AkaEG5zrVuSVW294cH1chioag9i1OqnCYjKwTc3EBGXbnyb98Lw7yMa40ifLjFj3y6fsFKsd0llbUZUCRf3Qw==",
"dependencies": {
"@types/component-emitter": "^1.2.10",
"backo2": "~1.0.2",
"component-emitter": "~1.3.0",
"debug": "~4.3.1",
"engine.io-client": "~5.0.0",
"parseuri": "0.0.6",
"socket.io-parser": "~4.0.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.4.tgz",
"integrity": "sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g==",
"dependencies": {
"@types/component-emitter": "^1.2.10",
"component-emitter": "~1.3.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/sockjs": {
"version": "0.3.21",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.21.tgz",
@ -22025,6 +22117,11 @@
"node": ">=8"
}
},
"node_modules/yeast": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
"integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk="
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@ -24144,6 +24241,11 @@
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz",
"integrity": "sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw=="
},
"@types/component-emitter": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.10.tgz",
"integrity": "sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg=="
},
"@types/d3-path": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz",
@ -25605,6 +25707,11 @@
"resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz",
"integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ=="
},
"backo2": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
"integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
},
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@ -25634,6 +25741,11 @@
}
}
},
"base64-arraybuffer": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz",
"integrity": "sha1-mBjHngWbE1X5fgQooBfIOOkLqBI="
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -27674,6 +27786,30 @@
"once": "^1.4.0"
}
},
"engine.io-client": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-5.0.1.tgz",
"integrity": "sha512-CQtGN3YwfvbxVwpPugcsHe5rHT4KgT49CEcQppNtu9N7WxbPN0MAG27lGaem7bvtCFtGNLSL+GEqXsFSz36jTg==",
"requires": {
"base64-arraybuffer": "0.1.4",
"component-emitter": "~1.3.0",
"debug": "~4.3.1",
"engine.io-parser": "~4.0.1",
"has-cors": "1.1.0",
"parseqs": "0.0.6",
"parseuri": "0.0.6",
"ws": "~7.4.2",
"yeast": "0.1.2"
}
},
"engine.io-parser": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-4.0.2.tgz",
"integrity": "sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg==",
"requires": {
"base64-arraybuffer": "0.1.4"
}
},
"enhanced-resolve": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz",
@ -29466,6 +29602,11 @@
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
"integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA=="
},
"has-cors": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
"integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk="
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -32737,6 +32878,16 @@
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
"integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
},
"parseqs": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz",
"integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w=="
},
"parseuri": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz",
"integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow=="
},
"parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -36328,6 +36479,30 @@
}
}
},
"socket.io-client": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.0.1.tgz",
"integrity": "sha512-6AkaEG5zrVuSVW294cH1chioag9i1OqnCYjKwTc3EBGXbnyb98Lw7yMa40ifLjFj3y6fsFKsd0llbUZUCRf3Qw==",
"requires": {
"@types/component-emitter": "^1.2.10",
"backo2": "~1.0.2",
"component-emitter": "~1.3.0",
"debug": "~4.3.1",
"engine.io-client": "~5.0.0",
"parseuri": "0.0.6",
"socket.io-parser": "~4.0.4"
}
},
"socket.io-parser": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.4.tgz",
"integrity": "sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g==",
"requires": {
"@types/component-emitter": "^1.2.10",
"component-emitter": "~1.3.0",
"debug": "~4.3.1"
}
},
"sockjs": {
"version": "0.3.21",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.21.tgz",
@ -39451,6 +39626,11 @@
}
}
},
"yeast": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
"integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk="
},
"yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

@ -12,7 +12,6 @@
"url": "https://github.com/morpheus65535/bazarr/issues"
},
"homepage": "./",
"proxy": "http://localhost:6767",
"dependencies": {
"@fontsource/roboto": "^4.2.2",
"@fortawesome/fontawesome-svg-core": "^1.2.0",
@ -36,6 +35,7 @@
"@types/redux-promise": "^0.5.0",
"axios": "^0.21.0",
"bootstrap": "^4.0.0",
"http-proxy-middleware": "^0.19.1",
"lodash": "^4.0.0",
"rc-slider": "^9.7.1",
"react": "^16.0.0",
@ -53,6 +53,7 @@
"redux-promise": "^0.6.0",
"redux-thunk": "^2.3.0",
"sass": "^1.0.0",
"socket.io-client": "^4.0.0",
"typescript": "^4.0.0"
},
"devDependencies": {

@ -1,5 +1,4 @@
import { isEqual } from "lodash";
import { log } from "../../utilites/logger";
import { createAction } from "redux-actions";
import {
ActionCallback,
ActionDispatcher,
@ -10,42 +9,12 @@ import {
PromiseCreator,
} from "../types";
// Limiter the call to API
const gLimiter: Map<PromiseCreator, Date> = new Map();
const gArgs: Map<PromiseCreator, any[]> = new Map();
const LIMIT_CALL_MS = 200;
function asyncActionFactory<T extends PromiseCreator>(
type: string,
promise: T,
args: Parameters<T>
): AsyncActionDispatcher<PromiseType<ReturnType<T>>> {
return (dispatch) => {
const previousArgs = gArgs.get(promise);
const date = new Date();
if (isEqual(previousArgs, args)) {
// Get last execute date
const previousExec = gLimiter.get(promise);
if (previousExec) {
const distInMs = date.getTime() - previousExec.getTime();
if (distInMs < LIMIT_CALL_MS) {
log(
"warning",
"Multiple calls to API within the range",
promise,
args
);
return Promise.resolve();
}
}
} else {
gArgs.set(promise, args);
}
gLimiter.set(promise, date);
dispatch({
type,
payload: {
@ -153,3 +122,8 @@ export function createCallbackAction<T extends AsyncActionCreator>(
return (...args: Parameters<T>) =>
callbackActionFactory(fn(args), success, error);
}
// Helper
export function createDeleteAction(type: string): SocketIO.ActionFn {
return createAction(type, (id?: number[]) => id ?? []);
}

@ -1,5 +1,4 @@
export * from "./movie";
export * from "./providers";
export * from "./series";
export * from "./site";
export * from "./system";

@ -1,58 +1,45 @@
import { MoviesApi } from "../../apis";
import {
MOVIES_DELETE_ITEMS,
MOVIES_DELETE_WANTED_ITEMS,
MOVIES_UPDATE_BLACKLIST,
MOVIES_UPDATE_HISTORY_LIST,
MOVIES_UPDATE_INFO,
MOVIES_UPDATE_LIST,
MOVIES_UPDATE_RANGE,
MOVIES_UPDATE_WANTED_LIST,
MOVIES_UPDATE_WANTED_RANGE,
} from "../constants";
import {
createAsyncAction,
createAsyncCombineAction,
createCombineAction,
} from "./factory";
import { badgeUpdateAll } from "./site";
export const movieUpdateList = createAsyncAction(MOVIES_UPDATE_LIST, () =>
MoviesApi.movies()
import { createAsyncAction, createDeleteAction } from "./factory";
export const movieUpdateList = createAsyncAction(
MOVIES_UPDATE_LIST,
(id?: number[]) => MoviesApi.movies(id)
);
const movieUpdateWantedList = createAsyncAction(
export const movieDeleteItems = createDeleteAction(MOVIES_DELETE_ITEMS);
export const movieUpdateWantedList = createAsyncAction(
MOVIES_UPDATE_WANTED_LIST,
(radarrid?: number) => MoviesApi.wantedBy(radarrid)
(radarrid: number[]) => MoviesApi.wantedBy(radarrid)
);
export const movieDeleteWantedItems = createDeleteAction(
MOVIES_DELETE_WANTED_ITEMS
);
export const movieUpdateWantedByRange = createAsyncAction(
MOVIES_UPDATE_WANTED_RANGE,
MOVIES_UPDATE_WANTED_LIST,
(start: number, length: number) => MoviesApi.wanted(start, length)
);
export const movieUpdateWantedBy = createCombineAction((radarrid?: number) => [
movieUpdateWantedList(radarrid),
badgeUpdateAll(),
]);
export const movieUpdateHistoryList = createAsyncAction(
MOVIES_UPDATE_HISTORY_LIST,
() => MoviesApi.history()
);
export const movieUpdateByRange = createAsyncAction(
MOVIES_UPDATE_RANGE,
MOVIES_UPDATE_LIST,
(start: number, length: number) => MoviesApi.moviesBy(start, length)
);
const movieUpdateInfo = createAsyncAction(MOVIES_UPDATE_INFO, (id?: number[]) =>
MoviesApi.movies(id)
);
export const movieUpdateInfoAll = createAsyncCombineAction((id?: number[]) => [
movieUpdateInfo(id),
badgeUpdateAll(),
]);
export const movieUpdateBlacklist = createAsyncAction(
MOVIES_UPDATE_BLACKLIST,
() => MoviesApi.blacklist()

@ -1,13 +0,0 @@
import { ProvidersApi } from "../../apis";
import { PROVIDER_UPDATE_LIST } from "../constants";
import { createAsyncAction, createCombineAction } from "./factory";
import { badgeUpdateAll } from "./site";
const providerUpdateList = createAsyncAction(PROVIDER_UPDATE_LIST, () =>
ProvidersApi.providers()
);
export const providerUpdateAll = createCombineAction(() => [
providerUpdateList(),
badgeUpdateAll(),
]);

@ -1,50 +1,52 @@
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_INFO,
SERIES_UPDATE_RANGE,
SERIES_UPDATE_LIST,
SERIES_UPDATE_WANTED_LIST,
SERIES_UPDATE_WANTED_RANGE,
} from "../constants";
import {
createAsyncAction,
createAsyncCombineAction,
createCombineAction,
} from "./factory";
import { badgeUpdateAll } from "./site";
import { createAsyncAction, createDeleteAction } from "./factory";
const seriesUpdateWantedList = createAsyncAction(
export const seriesUpdateWantedList = createAsyncAction(
SERIES_UPDATE_WANTED_LIST,
(episodeid?: number) => EpisodesApi.wantedBy(episodeid)
(episodeid: number[]) => EpisodesApi.wantedBy(episodeid)
);
const seriesUpdateBy = createAsyncAction(SERIES_UPDATE_INFO, (id?: number[]) =>
SeriesApi.series(id)
export const seriesDeleteWantedItems = createDeleteAction(
SERIES_DELETE_WANTED_ITEMS
);
const episodeUpdateBy = createAsyncAction(
SERIES_UPDATE_EPISODE_LIST,
(seriesid: number) => EpisodesApi.bySeriesId(seriesid)
export const seriesUpdateWantedByRange = createAsyncAction(
SERIES_UPDATE_WANTED_LIST,
(start: number, length: number) => EpisodesApi.wanted(start, length)
);
export const seriesUpdateByRange = createAsyncAction(
SERIES_UPDATE_RANGE,
(start: number, length: number) => SeriesApi.seriesBy(start, length)
export const seriesUpdateList = createAsyncAction(
SERIES_UPDATE_LIST,
(id?: number[]) => SeriesApi.series(id)
);
export const seriesUpdateWantedByRange = createAsyncAction(
SERIES_UPDATE_WANTED_RANGE,
(start: number, length: number) => EpisodesApi.wanted(start, length)
export const seriesDeleteItems = createDeleteAction(SERIES_DELETE_ITEMS);
export const episodeUpdateBy = createAsyncAction(
SERIES_UPDATE_EPISODE_LIST,
(seriesid: number[]) => EpisodesApi.bySeriesId(seriesid)
);
export const seriesUpdateWantedBy = createCombineAction(
(episodeid?: number) => [seriesUpdateWantedList(episodeid), badgeUpdateAll()]
export const episodeDeleteItems = createDeleteAction(SERIES_DELETE_EPISODES);
export const episodeUpdateById = createAsyncAction(
SERIES_UPDATE_EPISODE_LIST,
(episodeid: number[]) => EpisodesApi.byEpisodeId(episodeid)
);
export const episodeUpdateBySeriesId = createCombineAction(
(seriesid: number) => [episodeUpdateBy(seriesid), badgeUpdateAll()]
export const seriesUpdateByRange = createAsyncAction(
SERIES_UPDATE_LIST,
(start: number, length: number) => SeriesApi.seriesBy(start, length)
);
export const seriesUpdateHistoryList = createAsyncAction(
@ -52,10 +54,6 @@ export const seriesUpdateHistoryList = createAsyncAction(
() => EpisodesApi.history()
);
export const seriesUpdateInfoAll = createAsyncCombineAction(
(seriesid?: number[]) => [seriesUpdateBy(seriesid), badgeUpdateAll()]
);
export const seriesUpdateBlacklist = createAsyncAction(
SERIES_UPDATE_BLACKLIST,
() => EpisodesApi.blacklist()

@ -16,7 +16,7 @@ import { createAsyncAction, createCallbackAction } from "./factory";
import { systemUpdateLanguagesAll, systemUpdateSettings } from "./system";
export const bootstrap = createCallbackAction(
() => [systemUpdateLanguagesAll(), systemUpdateSettings()],
() => [systemUpdateLanguagesAll(), systemUpdateSettings(), badgeUpdateAll()],
() => siteInitialized(),
() => siteInitializeFailed()
);
@ -36,17 +36,17 @@ export const siteSaveLocalstorage = createAction(
(settings: LooseObject) => settings
);
export const siteAddError = createAction(
export const siteAddNotification = createAction(
SITE_NOTIFICATIONS_ADD,
(err: ReduxStore.Notification) => err
);
export const siteRemoveError = createAction(
export const siteRemoveNotification = createAction(
SITE_NOTIFICATIONS_REMOVE,
(id: string) => id
);
export const siteRemoveErrorByTimestamp = createAction(
export const siteRemoveNotificationByTime = createAction(
SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP,
(date: Date) => date
);

@ -1,10 +1,10 @@
import { Action } from "redux-actions";
import { SystemApi } from "../../apis";
import { ProvidersApi, SystemApi } from "../../apis";
import {
SYSTEM_RUN_TASK,
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,
@ -31,17 +31,14 @@ export const systemUpdateStatus = createAsyncAction(SYSTEM_UPDATE_STATUS, () =>
SystemApi.status()
);
export const systemUpdateHealth = createAsyncAction(SYSTEM_UPDATE_HEALTH, () =>
SystemApi.health()
);
export const systemUpdateTasks = createAsyncAction(SYSTEM_UPDATE_TASKS, () =>
SystemApi.getTasks()
);
export function systemRunTasks(id: string): Action<string> {
return {
type: SYSTEM_RUN_TASK,
payload: id,
};
}
export const systemUpdateLogs = createAsyncAction(SYSTEM_UPDATE_LOGS, () =>
SystemApi.logs()
);
@ -56,6 +53,11 @@ export const systemUpdateSettings = createAsyncAction(
() => SystemApi.settings()
);
export const providerUpdateList = createAsyncAction(
SYSTEM_UPDATE_PROVIDERS,
() => ProvidersApi.providers()
);
export const systemUpdateSettingsAll = createAsyncCombineAction(() => [
systemUpdateSettings(),
systemUpdateLanguagesAll(),

@ -1,33 +1,33 @@
// Provider action
export const PROVIDER_UPDATE_LIST = "UPDATE_PROVIDER_LIST";
// 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_RUN_TASK = "SYSTEM_RUN_TASK";
export const SYSTEM_UPDATE_PROVIDERS = "SYSTEM_UPDATE_PROVIDERS";
// Series action
export const SERIES_UPDATE_WANTED_RANGE = "SERIES_UPDATE_WANTED_RANGE";
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_INFO = "UPDATE_SEIRES_INFO";
export const SERIES_UPDATE_RANGE = "SERIES_UPDATE_RANGE";
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_UPDATE_WANTED_RANGE = "MOVIES_UPDATE_WANTED_RANGE";
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_INFO = "UPDATE_MOVIE_INFO";
export const MOVIES_UPDATE_RANGE = "MOVIES_UPDATE_RANGE";
export const MOVIES_UPDATE_BLACKLIST = "UPDATE_MOVIES_BLACKLIST";
// Site Action

@ -1,19 +1,29 @@
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { useSocketIOReducer, useWrapToOptionalId } from "../../@socketio/hooks";
import { buildOrderList } from "../../utilites";
import {
episodeUpdateBySeriesId,
episodeDeleteItems,
episodeUpdateBy,
episodeUpdateById,
movieDeleteWantedItems,
movieUpdateBlacklist,
movieUpdateHistoryList,
movieUpdateInfoAll,
movieUpdateWantedBy,
providerUpdateAll,
movieUpdateList,
movieUpdateWantedList,
providerUpdateList,
seriesDeleteWantedItems,
seriesUpdateBlacklist,
seriesUpdateHistoryList,
seriesUpdateInfoAll,
seriesUpdateWantedBy,
seriesUpdateList,
seriesUpdateWantedList,
systemUpdateHealth,
systemUpdateLanguages,
systemUpdateLanguagesProfiles,
systemUpdateLogs,
systemUpdateReleases,
systemUpdateSettingsAll,
systemUpdateStatus,
systemUpdateTasks,
} from "../actions";
import { useReduxAction, useReduxStore } from "./base";
@ -25,9 +35,71 @@ function stateBuilder<T, D extends (...args: any[]) => any>(
}
export function useSystemSettings() {
const action = useReduxAction(systemUpdateSettingsAll);
const update = useReduxAction(systemUpdateSettingsAll);
const items = useReduxStore((s) => s.system.settings);
return stateBuilder(items, action);
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);
useSocketIOReducer("task", update);
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() {
@ -92,9 +164,9 @@ export function useProfileItems(profile?: Profile.Languages) {
}
export function useRawSeries() {
const action = useReduxAction(seriesUpdateInfoAll);
const update = useReduxAction(seriesUpdateList);
const items = useReduxStore((d) => d.series.seriesList);
return stateBuilder(items, action);
return stateBuilder(items, update);
}
export function useSeries(order = true) {
@ -118,7 +190,6 @@ export function useSeries(order = true) {
export function useSerieBy(id?: number) {
const [series, updateSerie] = useRawSeries();
const updateEpisodes = useReduxAction(episodeUpdateBySeriesId);
const serie = useMemo<AsyncState<Item.Series | null>>(() => {
const items = series.data.items;
let item: Item.Series | null = null;
@ -134,18 +205,22 @@ export function useSerieBy(id?: number) {
const update = useCallback(() => {
if (id && !isNaN(id)) {
updateSerie([id]);
updateEpisodes(id);
}
}, [id, updateSerie, updateEpisodes]);
}, [id, updateSerie]);
useEffect(() => {
if (serie.data === null) {
update();
}
}, [serie.data, update]);
return stateBuilder(serie, update);
}
export function useEpisodesBy(seriesId?: number) {
const action = useReduxAction(episodeUpdateBySeriesId);
const callback = useCallback(() => {
const action = useReduxAction(episodeUpdateBy);
const update = useCallback(() => {
if (seriesId !== undefined && !isNaN(seriesId)) {
action(seriesId);
action([seriesId]);
}
}, [action, seriesId]);
@ -153,24 +228,38 @@ export function useEpisodesBy(seriesId?: number) {
const items = useMemo(() => {
if (seriesId !== undefined && !isNaN(seriesId)) {
return list.data[seriesId] ?? [];
return list.data.filter((v) => v.sonarrSeriesId === seriesId);
} else {
return [];
}
}, [seriesId, list.data]);
const state: AsyncState<Item.Episode[]> = {
...list,
data: items,
};
const state: AsyncState<Item.Episode[]> = useMemo(
() => ({
...list,
data: items,
}),
[list, items]
);
const actionById = useReduxAction(episodeUpdateById);
const wrapActionById = useWrapToOptionalId(actionById);
const deleteAction = useReduxAction(episodeDeleteItems);
useSocketIOReducer("episode", undefined, wrapActionById, deleteAction);
const wrapAction = useWrapToOptionalId(action);
useSocketIOReducer("series", undefined, wrapAction);
return stateBuilder(state, callback);
useEffect(() => {
update();
}, [update]);
return stateBuilder(state, update);
}
export function useRawMovies() {
const action = useReduxAction(movieUpdateInfoAll);
const update = useReduxAction(movieUpdateList);
const items = useReduxStore((d) => d.movie.movieList);
return stateBuilder(items, action);
return stateBuilder(items, update);
}
export function useMovies(order = true) {
@ -212,54 +301,80 @@ export function useMovieBy(id?: number) {
}
}, [id, updateMovies]);
useEffect(() => {
if (movie.data === null) {
update();
}
}, [movie.data, update]);
return stateBuilder(movie, update);
}
export function useWantedSeries() {
const action = useReduxAction(seriesUpdateWantedBy);
const update = useReduxAction(seriesUpdateWantedList);
const items = useReduxStore((d) => d.series.wantedEpisodesList);
return stateBuilder(items, action);
const updateAction = useWrapToOptionalId(update);
const deleteAction = useReduxAction(seriesDeleteWantedItems);
useSocketIOReducer("episode-wanted", undefined, updateAction, deleteAction);
return stateBuilder(items, update);
}
export function useWantedMovies() {
const action = useReduxAction(movieUpdateWantedBy);
const update = useReduxAction(movieUpdateWantedList);
const items = useReduxStore((d) => d.movie.wantedMovieList);
return stateBuilder(items, action);
}
export function useProviders() {
const action = useReduxAction(providerUpdateAll);
const items = useReduxStore((d) => d.system.providers);
const updateAction = useWrapToOptionalId(update);
const deleteAction = useReduxAction(movieDeleteWantedItems);
useSocketIOReducer("movie-wanted", undefined, updateAction, deleteAction);
return stateBuilder(items, action);
return stateBuilder(items, update);
}
export function useBlacklistMovies() {
const action = useReduxAction(movieUpdateBlacklist);
const update = useReduxAction(movieUpdateBlacklist);
const items = useReduxStore((d) => d.movie.blacklist);
return stateBuilder(items, action);
useSocketIOReducer("movie-blacklist", update);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useBlacklistSeries() {
const action = useReduxAction(seriesUpdateBlacklist);
const update = useReduxAction(seriesUpdateBlacklist);
const items = useReduxStore((d) => d.series.blacklist);
return stateBuilder(items, action);
useSocketIOReducer("episode-blacklist", update);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useMoviesHistory() {
const action = useReduxAction(movieUpdateHistoryList);
const update = useReduxAction(movieUpdateHistoryList);
const items = useReduxStore((s) => s.movie.historyList);
return stateBuilder(items, action);
useSocketIOReducer("movie-history", update);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSeriesHistory() {
const action = useReduxAction(seriesUpdateHistoryList);
const update = useReduxAction(seriesUpdateHistoryList);
const items = useReduxStore((s) => s.series.historyList);
return stateBuilder(items, action);
useSocketIOReducer("episode-history", update);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}

@ -1,11 +1,15 @@
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import { useSystemSettings } from ".";
import { siteAddError, siteRemoveErrorByTimestamp } from "../actions";
import {
siteAddNotification,
siteChangeSidebar,
siteRemoveNotificationByTime,
} from "../actions";
import { useReduxAction, useReduxStore } from "./base";
export function useNotification(id: string, sec: number = 5) {
const add = useReduxAction(siteAddError);
const remove = useReduxAction(siteRemoveErrorByTimestamp);
const add = useReduxAction(siteAddNotification);
const remove = useReduxAction(siteRemoveNotificationByTime);
return useCallback(
(msg: Omit<ReduxStore.Notification, "id" | "timestamp">) => {
@ -34,3 +38,15 @@ export function useIsRadarrEnabled() {
const [settings] = useSystemSettings();
return settings.data?.general.use_radarr ?? true;
}
export function useShowOnlyDesired() {
const [settings] = useSystemSettings();
return settings.data?.general.embedded_subs_show_desired ?? false;
}
export function useSetSidebar(key: string) {
const update = useReduxAction(siteChangeSidebar);
useEffect(() => {
update(key);
}, [update, key]);
}

@ -1,112 +0,0 @@
import { mergeArray } from "../../utilites";
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: AsyncState<OrderIdState<T>>,
id: ItemIdType<T>
): AsyncState<OrderIdState<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 { data, total } = action.payload.item as AsyncDataWrapper<T>;
const [start, length] = action.payload.parameters;
// Convert item list to object
const idState: IdState<T> = data.reduce<IdState<T>>((prev, curr) => {
const tid = curr[id];
prev[tid] = curr;
return prev;
}, {});
const dataOrder: number[] = data.map((v) => v[id]);
let newItems = { ...state.data.items, ...idState };
let newOrder = state.data.order;
const countDist = total - newOrder.length;
if (countDist > 0) {
newOrder.push(...Array(countDist).fill(null));
} else if (countDist < 0) {
// Completely drop old data if list has shrinked
newOrder = Array(total).fill(null);
newItems = { ...idState };
}
if (typeof start === "number" && typeof length === "number") {
newOrder.splice(start, length, ...dataOrder);
} else if (start === undefined) {
// Full Update
newOrder = dataOrder;
}
return {
updating: false,
data: {
items: newItems,
order: newOrder,
},
};
}
}
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 list = state.data as T[];
const payload = action.payload.item as T[];
const result = mergeArray(list, payload, (l, r) => l[match] === r[match]);
return {
updating: false,
data: result,
};
}
}

@ -1,14 +1,19 @@
import { handleActions } from "redux-actions";
import { Action, handleActions } from "redux-actions";
import {
MOVIES_DELETE_ITEMS,
MOVIES_DELETE_WANTED_ITEMS,
MOVIES_UPDATE_BLACKLIST,
MOVIES_UPDATE_HISTORY_LIST,
MOVIES_UPDATE_INFO,
MOVIES_UPDATE_RANGE,
MOVIES_UPDATE_LIST,
MOVIES_UPDATE_WANTED_LIST,
MOVIES_UPDATE_WANTED_RANGE,
} from "../constants";
import { AsyncAction } from "../types";
import { updateAsyncState, updateOrderIdState } from "./mapper";
import { defaultAOS } from "../utils";
import {
deleteOrderListItemBy,
updateAsyncState,
updateOrderIdState,
} from "../utils/mapper";
const reducer = handleActions<ReduxStore.Movie, any>(
{
@ -25,17 +30,10 @@ const reducer = handleActions<ReduxStore.Movie, any>(
),
};
},
[MOVIES_UPDATE_WANTED_RANGE]: (
state,
action: AsyncAction<AsyncDataWrapper<Wanted.Movie>>
) => {
[MOVIES_DELETE_WANTED_ITEMS]: (state, action: Action<number[]>) => {
return {
...state,
wantedMovieList: updateOrderIdState(
action,
state.wantedMovieList,
"radarrId"
),
wantedMovieList: deleteOrderListItemBy(action, state.wantedMovieList),
};
},
[MOVIES_UPDATE_HISTORY_LIST]: (
@ -47,7 +45,7 @@ const reducer = handleActions<ReduxStore.Movie, any>(
historyList: updateAsyncState(action, state.historyList.data),
};
},
[MOVIES_UPDATE_INFO]: (
[MOVIES_UPDATE_LIST]: (
state,
action: AsyncAction<AsyncDataWrapper<Item.Movie>>
) => {
@ -56,13 +54,10 @@ const reducer = handleActions<ReduxStore.Movie, any>(
movieList: updateOrderIdState(action, state.movieList, "radarrId"),
};
},
[MOVIES_UPDATE_RANGE]: (
state,
action: AsyncAction<AsyncDataWrapper<Item.Movie>>
) => {
[MOVIES_DELETE_ITEMS]: (state, action: Action<number[]>) => {
return {
...state,
movieList: updateOrderIdState(action, state.movieList, "radarrId"),
movieList: deleteOrderListItemBy(action, state.movieList),
};
},
[MOVIES_UPDATE_BLACKLIST]: (
@ -76,8 +71,8 @@ const reducer = handleActions<ReduxStore.Movie, any>(
},
},
{
movieList: { updating: true, data: { items: {}, order: [] } },
wantedMovieList: { updating: true, data: { items: {}, order: [] } },
movieList: defaultAOS(),
wantedMovieList: defaultAOS(),
historyList: { updating: true, data: [] },
blacklist: { updating: true, data: [] },
}

@ -1,15 +1,23 @@
import { handleActions } from "redux-actions";
import { Action, handleActions } from "redux-actions";
import {
SERIES_DELETE_EPISODES,
SERIES_DELETE_ITEMS,
SERIES_DELETE_WANTED_ITEMS,
SERIES_UPDATE_BLACKLIST,
SERIES_UPDATE_EPISODE_LIST,
SERIES_UPDATE_HISTORY_LIST,
SERIES_UPDATE_INFO,
SERIES_UPDATE_RANGE,
SERIES_UPDATE_LIST,
SERIES_UPDATE_WANTED_LIST,
SERIES_UPDATE_WANTED_RANGE,
} from "../constants";
import { AsyncAction } from "../types";
import { updateAsyncState, updateOrderIdState } from "./mapper";
import { defaultAOS } from "../utils";
import {
deleteAsyncListItemBy,
deleteOrderListItemBy,
updateAsyncList,
updateAsyncState,
updateOrderIdState,
} from "../utils/mapper";
const reducer = handleActions<ReduxStore.Series, any>(
{
@ -26,16 +34,12 @@ const reducer = handleActions<ReduxStore.Series, any>(
),
};
},
[SERIES_UPDATE_WANTED_RANGE]: (
state,
action: AsyncAction<AsyncDataWrapper<Wanted.Episode>>
) => {
[SERIES_DELETE_WANTED_ITEMS]: (state, action: Action<number[]>) => {
return {
...state,
wantedEpisodesList: updateOrderIdState(
wantedEpisodesList: deleteOrderListItemBy(
action,
state.wantedEpisodesList,
"sonarrEpisodeId"
state.wantedEpisodesList
),
};
},
@ -43,22 +47,23 @@ const reducer = handleActions<ReduxStore.Series, any>(
state,
action: AsyncAction<Item.Episode[]>
) => {
const { updating, error, data: items } = updateAsyncState(action, []);
const stateItems = { ...state.episodeList.data };
if (items.length > 0) {
const id = items[0].sonarrSeriesId;
stateItems[id] = items;
}
return {
...state,
episodeList: {
updating,
error,
data: stateItems,
},
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]: (
@ -70,7 +75,7 @@ const reducer = handleActions<ReduxStore.Series, any>(
historyList: updateAsyncState(action, state.historyList.data),
};
},
[SERIES_UPDATE_INFO]: (
[SERIES_UPDATE_LIST]: (
state,
action: AsyncAction<AsyncDataWrapper<Item.Series>>
) => {
@ -83,17 +88,10 @@ const reducer = handleActions<ReduxStore.Series, any>(
),
};
},
[SERIES_UPDATE_RANGE]: (
state,
action: AsyncAction<AsyncDataWrapper<Item.Series>>
) => {
[SERIES_DELETE_ITEMS]: (state, action: Action<number[]>) => {
return {
...state,
seriesList: updateOrderIdState(
action,
state.seriesList,
"sonarrSeriesId"
),
seriesList: deleteOrderListItemBy(action, state.seriesList),
};
},
[SERIES_UPDATE_BLACKLIST]: (
@ -107,9 +105,9 @@ const reducer = handleActions<ReduxStore.Series, any>(
},
},
{
seriesList: { updating: true, data: { items: {}, order: [] } },
wantedEpisodesList: { updating: true, data: { items: {}, order: [] } },
episodeList: { updating: true, data: {} },
seriesList: defaultAOS(),
wantedEpisodesList: defaultAOS(),
episodeList: { updating: true, data: [] },
historyList: { updating: true, data: [] },
blacklist: { updating: true, data: [] },
}

@ -101,6 +101,7 @@ const reducer = handleActions<ReduxStore.Site, any>(
movies: 0,
episodes: 0,
providers: 0,
status: 0,
},
offline: false,
...updateLocalStorage(),

@ -1,16 +1,16 @@
import { Action, handleActions } from "redux-actions";
import { handleActions } from "redux-actions";
import {
PROVIDER_UPDATE_LIST,
SYSTEM_RUN_TASK,
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 "./mapper";
import { updateAsyncState } from "../utils/mapper";
const reducer = handleActions<ReduxStore.System, any>(
{
@ -46,32 +46,19 @@ const reducer = handleActions<ReduxStore.System, any>(
),
};
},
[SYSTEM_UPDATE_TASKS]: (state, action) => {
[SYSTEM_UPDATE_HEALTH]: (state, action) => {
return {
...state,
tasks: updateAsyncState<Array<System.Task>>(action, state.tasks.data),
health: updateAsyncState(action, state.health.data),
};
},
[SYSTEM_RUN_TASK]: (state, action: Action<string>) => {
const id = action.payload;
const tasks = state.tasks;
const newItems = [...tasks.data];
const idx = newItems.findIndex((v) => v.job_id === id);
if (idx !== -1) {
newItems[idx].job_running = true;
}
[SYSTEM_UPDATE_TASKS]: (state, action) => {
return {
...state,
tasks: {
...tasks,
data: newItems,
},
tasks: updateAsyncState<Array<System.Task>>(action, state.tasks.data),
};
},
[PROVIDER_UPDATE_LIST]: (state, action) => {
[SYSTEM_UPDATE_PROVIDERS]: (state, action) => {
return {
...state,
providers: updateAsyncState(action, state.providers.data),
@ -104,6 +91,10 @@ const reducer = handleActions<ReduxStore.System, any>(
updating: true,
data: undefined,
},
health: {
updating: true,
data: [],
},
tasks: {
updating: true,
data: [],

@ -1,12 +1,3 @@
interface IdState<T> {
[key: number]: Readonly<T>;
}
interface OrderIdState<T> {
items: IdState<T>;
order: (number | null)[];
}
interface ReduxStore {
system: ReduxStore.System;
series: ReduxStore.Series;
@ -38,6 +29,7 @@ namespace ReduxStore {
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>>;
@ -46,16 +38,16 @@ namespace ReduxStore {
}
interface Series {
seriesList: AsyncState<OrderIdState<Item.Series>>;
wantedEpisodesList: AsyncState<OrderIdState<Wanted.Episode>>;
episodeList: AsyncState<IdState<Item.Episode[]>>;
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: AsyncState<OrderIdState<Item.Movie>>;
wantedMovieList: AsyncState<OrderIdState<Wanted.Movie>>;
movieList: AsyncOrderState<Item.Movie>;
wantedMovieList: AsyncOrderState<Wanted.Movie>;
historyList: AsyncState<Array<History.Movie>>;
blacklist: AsyncState<Array<Blacklist.Movie>>;
}

@ -0,0 +1,10 @@
export function defaultAOS(): AsyncOrderState<any> {
return {
updating: true,
data: {
items: [],
order: [],
fetched: false,
},
};
}

@ -0,0 +1,181 @@
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,
fetched: true,
},
updating: true,
};
} else if (action.error !== undefined) {
return {
data: {
...state.data,
fetched: 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: {
fetched: 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: {
fetched: 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,
};
}
}

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

@ -0,0 +1,123 @@
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 { createDefaultReducer } from "./reducer";
class SocketIOClient {
private socket: Socket;
private events: SocketIO.Event[];
private debounceReduce: () => void;
private reducers: SocketIO.Reducer[];
constructor() {
const baseUrl = getBaseUrl();
this.socket = io({
path: `${baseUrl}/api/socket.io`,
transports: ["polling", "websocket"],
upgrade: true,
rememberUpgrade: true,
autoConnect: false,
});
this.socket.on("connect", this.onConnect.bind(this));
this.socket.on("disconnect", this.onDisconnect.bind(this));
this.socket.on("connect_error", this.onDisconnect.bind(this));
this.socket.on("data", this.onEvent.bind(this));
this.events = [];
this.debounceReduce = debounce(this.reduce, 200);
this.reducers = [];
}
initialize() {
this.reducers.push(...createDefaultReducer());
this.socket.connect();
// Debug Command
window._socketio = {
dump: this.dump.bind(this),
emit: this.onEvent.bind(this),
};
}
private dump() {
console.log("SocketIO reducers", this.reducers);
}
addReducer(reducer: SocketIO.Reducer) {
this.reducers.push(reducer);
}
removeReducer(reducer: SocketIO.Reducer) {
const removed = remove(this.reducers, (r) => r === reducer);
conditionalLog(removed.length === 0, "Fail to remove reducer", reducer);
}
private reduce() {
const events = [...this.events];
this.events = [];
const records: SocketIO.ActionRecord = {};
events.forEach((e) => {
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);
}
});
forIn(records, (element, type) => {
if (element) {
const handlers = this.reducers.filter((v) => v.key === type);
if (handlers.length === 0) {
log("warning", "Unhandle SocketIO event", type);
return;
}
// eslint-disable-next-line no-loop-func
handlers.forEach((handler) => {
const anyAction = handler.any;
if (anyAction) {
anyAction();
}
forIn(element, (ids, key) => {
ids = uniq(ids);
const action = handler[key as SocketIO.ActionType];
if (action) {
action(ids);
} else if (anyAction === undefined) {
log("warning", "Unhandle action of SocketIO event", key, type);
}
});
});
}
});
}
private onConnect() {
log("info", "Socket.IO has connected");
this.onEvent({ type: "connect", action: "update", payload: null });
}
private onDisconnect() {
log("warning", "Socket.IO has disconnected");
this.onEvent({ type: "disconnect", action: "update", payload: null });
}
private onEvent(event: SocketIO.Event) {
log("info", "Socket.IO receives", event);
this.events.push(event);
this.debounceReduce();
}
}
export default new SocketIOClient();

@ -0,0 +1,55 @@
import {
badgeUpdateAll,
bootstrap,
movieDeleteItems,
movieUpdateList,
seriesDeleteItems,
seriesUpdateList,
siteUpdateOffline,
systemUpdateLanguagesAll,
systemUpdateSettings,
} from "../@redux/actions";
import reduxStore from "../@redux/store";
function bindToReduxStore(fn: (ids?: number[]) => any): SocketIO.ActionFn {
return (ids?: number[]) => reduxStore.dispatch(fn(ids));
}
export function createDefaultReducer(): SocketIO.Reducer[] {
return [
{
key: "connect",
any: () => reduxStore.dispatch(siteUpdateOffline(false)),
},
{
key: "connect",
any: () => reduxStore.dispatch<any>(bootstrap()),
},
{
key: "disconnect",
any: () => reduxStore.dispatch(siteUpdateOffline(true)),
},
{
key: "series",
update: bindToReduxStore(seriesUpdateList),
delete: bindToReduxStore(seriesDeleteItems),
},
{
key: "movie",
update: bindToReduxStore(movieUpdateList),
delete: bindToReduxStore(movieDeleteItems),
},
{
key: "settings",
any: bindToReduxStore(systemUpdateSettings),
},
{
key: "languages",
any: bindToReduxStore(systemUpdateLanguagesAll),
},
{
key: "badges",
any: bindToReduxStore(badgeUpdateAll),
},
];
}

@ -4,6 +4,7 @@ interface Badge {
episodes: number;
movies: number;
providers: number;
status: number;
}
interface ApiLanguage {
@ -40,7 +41,6 @@ interface Subtitle extends Language {
interface PathType {
path: string;
exist: boolean;
}
interface SubtitlePathType {

@ -11,12 +11,20 @@ type FileTree = {
type StorageType = string | null;
interface OrderIdState<T> {
items: IdState<T>;
order: (number | null)[];
fetched: 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> = {
@ -32,3 +40,5 @@ type SimpleStateType<T> = [
T,
((item: T) => void) | ((fn: (item: T) => T) => void)
];
type Factory<T> = () => T;

@ -32,7 +32,6 @@ import {
UseSortByState,
} from "react-table";
import {} from "../components/tables/plugins";
import { PageControlAction } from "../components/tables/types";
declare module "react-table" {
// take this file as-is, or comment out the sections that don't apply to your plugin configuration
@ -40,17 +39,6 @@ declare module "react-table" {
// Customize of React Table
type TableUpdater<D extends object> = (row: Row<D>, ...others: any[]) => void;
interface useAsyncPaginationProps<D extends Record<string, unknown>> {
asyncLoader?: (start: number, length: number) => void;
asyncState?: AsyncState<OrderIdState<D>>;
asyncId?: (item: D) => number;
}
interface useAsyncPaginationState<D extends Record<string, unknown>> {
pageToLoad?: PageControlAction;
needLoadingScreen?: boolean;
}
interface useSelectionProps<D extends Record<string, unknown>> {
isSelecting?: boolean;
onSelect?: (items: D[]) => void;
@ -59,15 +47,13 @@ declare module "react-table" {
interface useSelectionState<D extends Record<string, unknown>> {}
interface CustomTableProps<D extends Record<string, unknown>>
extends useSelectionProps<D>,
useAsyncPaginationProps<D> {
extends useSelectionProps<D> {
externalUpdate?: TableUpdater<D>;
loose?: any[];
}
interface CustomTableState<D extends Record<string, unknown>>
extends useSelectionState<D>,
useAsyncPaginationState<D> {}
extends useSelectionState<D> {}
export interface TableOptions<
D extends Record<string, unknown>

@ -0,0 +1,39 @@
namespace SocketIO {
type EventType =
| "connect"
| "disconnect"
| "movie"
| "series"
| "episode"
| "episode-history"
| "episode-blacklist"
| "episode-wanted"
| "movie-history"
| "movie-blacklist"
| "movie-wanted"
| "badges"
| "task"
| "settings"
| "languages"
| "message";
type ActionType = "update" | "delete";
interface Event {
type: EventType;
action: ActionType;
payload: any; // TODO: Use specific types
}
type ActionFn = (payload?: any[]) => void;
type Reducer = {
key: EventType;
any?: () => any;
} & Partial<Record<ActionType, ActionFn>>;
type ActionRecord = OptionalRecord<
EventType,
OptionalRecord<ActionType, any[]>
>;
}

@ -18,6 +18,11 @@ namespace System {
sonarr_version: string;
}
interface Health {
object: string;
issue: string;
}
interface Provider {
name: string;
status: string;

@ -37,3 +37,9 @@ type KeysOfType<D, T> = NonNullable<
>;
type ItemIdType<T> = KeysOfType<T, number>;
type OptionalRecord<T, D> = { [P in T]?: D };
interface IdState<T> {
[key: number]: Readonly<T>;
}

@ -1,6 +1,12 @@
interface SocketIODebugger {
dump: () => void;
emit: (event: SocketIO.Event) => void;
}
declare global {
interface Window {
Bazarr: BazarrServer;
_socketio: SocketIODebugger;
}
}

@ -5,13 +5,7 @@ import {
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, {
FunctionComponent,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import React, { FunctionComponent, useContext, useMemo } from "react";
import {
Button,
Col,
@ -100,12 +94,6 @@ const Header: FunctionComponent<Props> = () => {
[canLogout, setNeedAuth]
);
const [reconnecting, setReconnect] = useState(false);
const reconnect = useCallback(() => {
setReconnect(true);
SystemApi.status().finally(() => setReconnect(false));
}, []);
const goHome = useGotoHomepage();
return (
@ -137,13 +125,13 @@ const Header: FunctionComponent<Props> = () => {
</Button>
{offline ? (
<ActionButton
loading={reconnecting}
loading
alwaysShowText
className="ml-2"
variant="warning"
icon={faNetworkWired}
onClick={reconnect}
>
Reconnect
Connecting...
</ActionButton>
) : (
dropdown

@ -6,8 +6,7 @@ import React, {
} from "react";
import { Row } from "react-bootstrap";
import { Redirect } from "react-router-dom";
import { bootstrap as ReduxBootstrap } from "../@redux/actions";
import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
import { useReduxStore } from "../@redux/hooks/base";
import { useNotification } from "../@redux/hooks/site";
import { LoadingIndicator, ModalProvider } from "../components";
import Sidebar from "../Sidebar";
@ -24,8 +23,6 @@ export const SidebarToggleContext = React.createContext<() => void>(() => {});
interface Props {}
const App: FunctionComponent<Props> = () => {
const bootstrap = useReduxAction(ReduxBootstrap);
const { initialized, auth } = useReduxStore((s) => s.site);
const notify = useNotification("has-update", 10);
@ -44,10 +41,6 @@ const App: FunctionComponent<Props> = () => {
}
}, [initialized, hasUpdate, notify]);
useEffect(() => {
bootstrap();
}, [bootstrap]);
const [sidebar, setSidebar] = useState(false);
const toggleSidebar = useCallback(() => setSidebar(!sidebar), [sidebar]);

@ -3,22 +3,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { capitalize } from "lodash";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Toast } from "react-bootstrap";
import { siteRemoveError } from "../../@redux/actions";
import { siteRemoveNotification } from "../../@redux/actions";
import { useReduxAction, useReduxStore } from "../../@redux/hooks/base";
import "./style.scss";
function useNotificationList() {
return useReduxStore((s) => s.site.notifications);
}
function useRemoveNotification() {
return useReduxAction(siteRemoveError);
}
export interface NotificationContainerProps {}
const NotificationContainer: FunctionComponent<NotificationContainerProps> = () => {
const list = useNotificationList();
const list = useReduxStore((s) => s.site.notifications);
const items = useMemo(
() =>
@ -38,7 +30,7 @@ type MessageHolderProps = ReduxStore.Notification & {};
const NotificationToast: FunctionComponent<MessageHolderProps> = (props) => {
const { message, id, type } = props;
const removeNotification = useRemoveNotification();
const removeNotification = useReduxAction(siteRemoveNotification);
const remove = useCallback(() => removeNotification(id), [
removeNotification,

@ -5,17 +5,15 @@ import { Helmet } from "react-helmet";
import { useBlacklistMovies } from "../../@redux/hooks";
import { MoviesApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components";
import { useAutoUpdate } from "../../utilites/hooks";
import Table from "./table";
interface Props {}
const BlacklistMoviesView: FunctionComponent<Props> = () => {
const [blacklist, update] = useBlacklistMovies();
useAutoUpdate(update);
const [blacklist] = useBlacklistMovies();
return (
<AsyncStateOverlay state={blacklist}>
{(data) => (
{({ data }) => (
<Container fluid>
<Helmet>
<title>Movies Blacklist - Bazarr</title>
@ -25,13 +23,12 @@ const BlacklistMoviesView: FunctionComponent<Props> = () => {
icon={faTrash}
disabled={data.length === 0}
promise={() => MoviesApi.deleteBlacklist(true)}
onSuccess={update}
>
Remove All
</ContentHeader.AsyncButton>
</ContentHeader>
<Row>
<Table blacklist={data} update={update}></Table>
<Table blacklist={data}></Table>
</Row>
</Container>
)}

@ -13,10 +13,9 @@ import {
interface Props {
blacklist: readonly Blacklist.Movie[];
update: () => void;
}
const Table: FunctionComponent<Props> = ({ blacklist, update }) => {
const Table: FunctionComponent<Props> = ({ blacklist }) => {
const columns = useMemo<Column<Blacklist.Movie>[]>(
() => [
{
@ -78,7 +77,6 @@ const Table: FunctionComponent<Props> = ({ blacklist, update }) => {
subs_id,
})
}
onSuccess={update}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</AsyncButton>
@ -86,7 +84,7 @@ const Table: FunctionComponent<Props> = ({ blacklist, update }) => {
},
},
],
[update]
[]
);
return (
<PageTable

@ -1,6 +1,10 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
import {
useIsRadarrEnabled,
useIsSonarrEnabled,
useSetSidebar,
} from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import BlacklistMovies from "./Movies";
import BlacklistSeries from "./Series";
@ -8,6 +12,8 @@ import BlacklistSeries from "./Series";
const Router: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
useSetSidebar("Blacklist");
return (
<Switch>
{sonarr && (

@ -5,17 +5,15 @@ import { Helmet } from "react-helmet";
import { useBlacklistSeries } from "../../@redux/hooks";
import { EpisodesApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components";
import { useAutoUpdate } from "../../utilites";
import Table from "./table";
interface Props {}
const BlacklistSeriesView: FunctionComponent<Props> = () => {
const [blacklist, update] = useBlacklistSeries();
useAutoUpdate(update);
const [blacklist] = useBlacklistSeries();
return (
<AsyncStateOverlay state={blacklist}>
{(data) => (
{({ data }) => (
<Container fluid>
<Helmet>
<title>Series Blacklist - Bazarr</title>
@ -25,13 +23,12 @@ const BlacklistSeriesView: FunctionComponent<Props> = () => {
icon={faTrash}
disabled={data.length === 0}
promise={() => EpisodesApi.deleteBlacklist(true)}
onSuccess={update}
>
Remove All
</ContentHeader.AsyncButton>
</ContentHeader>
<Row>
<Table blacklist={data} update={update}></Table>
<Table blacklist={data}></Table>
</Row>
</Container>
)}

@ -13,10 +13,9 @@ import {
interface Props {
blacklist: readonly Blacklist.Episode[];
update: () => void;
}
const Table: FunctionComponent<Props> = ({ blacklist, update }) => {
const Table: FunctionComponent<Props> = ({ blacklist }) => {
const columns = useMemo<Column<Blacklist.Episode>[]>(
() => [
{
@ -84,7 +83,6 @@ const Table: FunctionComponent<Props> = ({ blacklist, update }) => {
subs_id,
})
}
onSuccess={update}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</AsyncButton>
@ -92,7 +90,7 @@ const Table: FunctionComponent<Props> = ({ blacklist, update }) => {
},
},
],
[update]
[]
);
return (
<PageTable

@ -1,25 +1,19 @@
import { faInfoCircle, faRecycle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import React, { FunctionComponent, useMemo } from "react";
import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column, Row } from "react-table";
import { Column } from "react-table";
import { useMoviesHistory } from "../../@redux/hooks";
import { MoviesApi } from "../../apis";
import { HistoryIcon, LanguageText, TextPopover } from "../../components";
import { BlacklistButton } from "../../generic/blacklist";
import { useAutoUpdate } from "../../utilites/hooks";
import HistoryGenericView from "../generic";
interface Props {}
const MoviesHistoryView: FunctionComponent<Props> = () => {
const [movies, update] = useMoviesHistory();
useAutoUpdate(update);
const tableUpdate = useCallback((row: Row<History.Base>) => update(), [
update,
]);
const [movies] = useMoviesHistory();
const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>(
() => [
@ -114,12 +108,11 @@ const MoviesHistoryView: FunctionComponent<Props> = () => {
},
{
accessor: "blacklisted",
Cell: ({ row, externalUpdate }) => {
Cell: ({ row }) => {
const original = row.original;
return (
<BlacklistButton
history={original}
update={() => externalUpdate && externalUpdate(row)}
promise={(form) =>
MoviesApi.addBlacklist(original.radarrId, form)
}
@ -136,7 +129,6 @@ const MoviesHistoryView: FunctionComponent<Props> = () => {
type="movies"
state={movies}
columns={columns as Column<History.Base>[]}
tableUpdater={tableUpdate}
></HistoryGenericView>
);
};

@ -1,6 +1,10 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
import {
useIsRadarrEnabled,
useIsSonarrEnabled,
useSetSidebar,
} from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import MoviesHistory from "./Movies";
import SeriesHistory from "./Series";
@ -9,6 +13,8 @@ import HistoryStats from "./Statistics";
const Router: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
useSetSidebar("History");
return (
<Switch>
{sonarr && (

@ -1,25 +1,19 @@
import { faInfoCircle, faRecycle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import React, { FunctionComponent, useMemo } from "react";
import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column, Row } from "react-table";
import { Column } from "react-table";
import { useSeriesHistory } from "../../@redux/hooks";
import { EpisodesApi } from "../../apis";
import { HistoryIcon, LanguageText, TextPopover } from "../../components";
import { BlacklistButton } from "../../generic/blacklist";
import { useAutoUpdate } from "../../utilites/hooks";
import HistoryGenericView from "../generic";
interface Props {}
const SeriesHistoryView: FunctionComponent<Props> = () => {
const [series, update] = useSeriesHistory();
useAutoUpdate(update);
const tableUpdate = useCallback((row: Row<History.Base>) => update(), [
update,
]);
const [series] = useSeriesHistory();
const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>(
() => [
@ -121,14 +115,13 @@ const SeriesHistoryView: FunctionComponent<Props> = () => {
},
{
accessor: "blacklisted",
Cell: ({ row, externalUpdate }) => {
Cell: ({ row }) => {
const original = row.original;
const { sonarrEpisodeId, sonarrSeriesId } = original;
return (
<BlacklistButton
history={original}
update={() => externalUpdate && externalUpdate(row)}
promise={(form) =>
EpisodesApi.addBlacklist(sonarrSeriesId, sonarrEpisodeId, form)
}
@ -145,7 +138,6 @@ const SeriesHistoryView: FunctionComponent<Props> = () => {
type="series"
state={series}
columns={columns as Column<History.Base>[]}
tableUpdater={tableUpdate}
></HistoryGenericView>
);
};

@ -12,7 +12,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { useLanguages, useProviders } from "../../@redux/hooks";
import { useLanguages, useSystemProviders } from "../../@redux/hooks";
import { HistoryApi } from "../../apis";
import {
AsyncSelector,
@ -21,7 +21,6 @@ import {
PromiseOverlay,
Selector,
} from "../../components";
import { useAutoUpdate } from "../../utilites/hooks";
import { actionOptions, timeframeOptions } from "./options";
function converter(item: History.Stat) {
@ -48,8 +47,7 @@ const SelectorContainer: FunctionComponent = ({ children }) => (
const HistoryStats: FunctionComponent = () => {
const [languages] = useLanguages(true);
const [providerList, update] = useProviders();
useAutoUpdate(update);
const [providerList] = useSystemProviders();
const [timeframe, setTimeframe] = useState<History.TimeframeOptions>("month");
const [action, setAction] = useState<Nullable<History.ActionOptions>>(null);

@ -2,21 +2,19 @@ import { capitalize } from "lodash";
import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Column, TableUpdater } from "react-table";
import { Column } from "react-table";
import { AsyncStateOverlay, PageTable } from "../../components";
interface Props {
type: "movies" | "series";
state: Readonly<AsyncState<History.Base[]>>;
columns: Column<History.Base>[];
tableUpdater?: TableUpdater<History.Base>;
}
const HistoryGenericView: FunctionComponent<Props> = ({
state,
columns,
type,
tableUpdater,
}) => {
const typeName = capitalize(type);
return (
@ -26,12 +24,11 @@ const HistoryGenericView: FunctionComponent<Props> = ({
</Helmet>
<Row>
<AsyncStateOverlay state={state}>
{(data) => (
{({ data }) => (
<PageTable
emptyText={`Nothing Found in ${typeName} History`}
columns={columns}
data={data}
externalUpdate={tableUpdater}
></PageTable>
)}
</AsyncStateOverlay>

@ -25,7 +25,7 @@ import {
import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
import ItemOverview from "../../generic/ItemOverview";
import { RouterEmptyPath } from "../../special-pages/404";
import { useAutoUpdate, useWhenLoadingFinish } from "../../utilites";
import { useWhenLoadingFinish } from "../../utilites";
import Table from "./table";
const download = (item: any, result: SearchResultType) => {
@ -48,8 +48,7 @@ interface Props extends RouteComponentProps<Params> {}
const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
const id = Number.parseInt(match.params.id);
const [movie, update] = useMovieBy(id);
useAutoUpdate(update);
const [movie] = useMovieBy(id);
const item = movie.data;
const showModal = useShowModal();
@ -86,7 +85,6 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
promise={() =>
MoviesApi.action({ action: "scan-disk", radarrid: item.radarrId })
}
onSuccess={update}
>
Scan Disk
</ContentHeader.AsyncButton>
@ -99,7 +97,6 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
radarrid: item.radarrId,
})
}
onSuccess={update}
>
Search
</ContentHeader.AsyncButton>
@ -144,23 +141,17 @@ const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
<ItemOverview item={item} details={[]}></ItemOverview>
</Row>
<Row>
<Table movie={item} update={update}></Table>
<Table movie={item}></Table>
</Row>
<ItemEditorModal
modalKey="edit"
submit={(form) => MoviesApi.modify(form)}
onSuccess={update}
></ItemEditorModal>
<SubtitleToolModal
modalKey="tools"
size="lg"
update={update}
></SubtitleToolModal>
<SubtitleToolModal modalKey="tools" size="lg"></SubtitleToolModal>
<MovieHistoryModal modalKey="history" size="lg"></MovieHistoryModal>
<MovieUploadModal modalKey="upload" size="lg"></MovieUploadModal>
<ManualSearchModal
modalKey="manual-search"
onDownload={update}
onSelect={download}
></ManualSearchModal>
</Container>

@ -1,8 +1,11 @@
import { faSearch, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { intersectionWith } from "lodash";
import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap";
import { Column } from "react-table";
import { useProfileItems } from "../../@redux/hooks";
import { useShowOnlyDesired } from "../../@redux/hooks/site";
import { MoviesApi } from "../../apis";
import { AsyncButton, LanguageText, SimpleTable } from "../../components";
@ -10,11 +13,13 @@ const missingText = "Missing Subtitles";
interface Props {
movie: Item.Movie;
update: (id: number) => void;
profile?: Profile.Languages;
}
const Table: FunctionComponent<Props> = (props) => {
const { movie, update } = props;
const Table: FunctionComponent<Props> = ({ movie, profile }) => {
const onlyDesired = useShowOnlyDesired();
const profileItems = useProfileItems(profile);
const columns: Column<Subtitle>[] = useMemo<Column<Subtitle>[]>(
() => [
@ -66,7 +71,6 @@ const Table: FunctionComponent<Props> = (props) => {
forced: original.forced,
})
}
onSuccess={() => update(movie.radarrId)}
variant="light"
size="sm"
>
@ -86,7 +90,6 @@ const Table: FunctionComponent<Props> = (props) => {
path: original.path ?? "",
})
}
onSuccess={() => update(movie.radarrId)}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</AsyncButton>
@ -95,7 +98,7 @@ const Table: FunctionComponent<Props> = (props) => {
},
},
],
[movie, update]
[movie]
);
const data: Subtitle[] = useMemo(() => {
@ -104,8 +107,17 @@ const Table: FunctionComponent<Props> = (props) => {
return item;
});
return movie.subtitles.concat(missing);
}, [movie.missing_subtitles, movie.subtitles]);
let raw_subtitles = movie.subtitles;
if (onlyDesired) {
raw_subtitles = intersectionWith(
raw_subtitles,
profileItems,
(l, r) => l.code2 === r.code2
);
}
return [...raw_subtitles, ...missing];
}, [movie.missing_subtitles, movie.subtitles, onlyDesired, profileItems]);
return (
<SimpleTable

@ -1,16 +1,11 @@
import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
import {
faBookmark,
faCheck,
faExclamationTriangle,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column } from "react-table";
import { movieUpdateByRange, movieUpdateInfoAll } from "../@redux/actions";
import { movieUpdateByRange, movieUpdateList } from "../@redux/actions";
import { useRawMovies } from "../@redux/hooks";
import { useReduxAction } from "../@redux/hooks/base";
import { MoviesApi } from "../apis";
@ -54,21 +49,6 @@ const MovieView: FunctionComponent<Props> = () => {
}
},
},
{
Header: "Exist",
accessor: "exist",
selectHide: true,
Cell: ({ row, value }) => {
const exist = value;
const { path } = row.original;
return (
<FontAwesomeIcon
title={path}
icon={exist ? faCheck : faExclamationTriangle}
></FontAwesomeIcon>
);
},
},
{
Header: "Audio",
accessor: "audio_language",
@ -133,8 +113,8 @@ const MovieView: FunctionComponent<Props> = () => {
state={movies}
name="Movies"
loader={load}
updateAction={movieUpdateInfoAll}
columns={columns as Column<Item.Base>[]}
updateAction={movieUpdateList}
columns={columns}
modify={(form) => MoviesApi.modify(form)}
></BaseItemView>
);

@ -2,7 +2,6 @@ import { faSearch, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent } from "react";
import { Badge } from "react-bootstrap";
import { useSerieBy } from "../../@redux/hooks";
import { EpisodesApi } from "../../apis";
import { AsyncButton, LanguageText } from "../../components";
@ -21,8 +20,6 @@ export const SubtitleAction: FunctionComponent<Props> = ({
}) => {
const { hi, forced } = subtitle;
const [, update] = useSerieBy(seriesid);
const path = subtitle.path;
if (missing || path) {
@ -46,7 +43,6 @@ export const SubtitleAction: FunctionComponent<Props> = ({
return null;
}
}}
onSuccess={update}
as={Badge}
className="mr-1"
variant={missing ? "primary" : "secondary"}

@ -16,7 +16,7 @@ import React, {
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
import { useEpisodesBy, useSerieBy } from "../../@redux/hooks";
import { useEpisodesBy, useProfileBy, useSerieBy } from "../../@redux/hooks";
import { SeriesApi } from "../../apis";
import {
ContentHeader,
@ -27,7 +27,7 @@ import {
} from "../../components";
import ItemOverview from "../../generic/ItemOverview";
import { RouterEmptyPath } from "../../special-pages/404";
import { useAutoUpdate, useWhenLoadingFinish } from "../../utilites";
import { useWhenLoadingFinish } from "../../utilites";
import Table from "./table";
interface Params {
@ -39,13 +39,11 @@ interface Props extends RouteComponentProps<Params> {}
const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
const { match } = props;
const id = Number.parseInt(match.params.id);
const [serie, update] = useSerieBy(id);
const [serie] = useSerieBy(id);
const item = serie.data;
const [episodes] = useEpisodesBy(serie.data?.sonarrSeriesId);
useAutoUpdate(update);
const available = episodes.data.length !== 0;
const details = useMemo(
@ -74,6 +72,8 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
useWhenLoadingFinish(serie, validator);
const profile = useProfileBy(serie.data?.profileId);
if (isNaN(id) || !valid) {
return <Redirect to={RouterEmptyPath}></Redirect>;
}
@ -95,7 +95,6 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
promise={() =>
SeriesApi.action({ action: "scan-disk", seriesid: id })
}
onSuccess={update}
>
Scan Disk
</ContentHeader.AsyncButton>
@ -104,7 +103,6 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
promise={() =>
SeriesApi.action({ action: "search-missing", seriesid: id })
}
onSuccess={update}
disabled={
item.episodeFileCount === 0 ||
item.profileId === null ||
@ -145,14 +143,16 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
<ItemOverview item={item} details={details}></ItemOverview>
</Row>
<Row>
<Table episodes={episodes} update={update}></Table>
<Table episodes={episodes} profile={profile}></Table>
</Row>
<ItemEditorModal
modalKey="edit"
submit={(form) => SeriesApi.modify(form)}
onSuccess={update}
></ItemEditorModal>
<SeriesUploadModal modalKey="upload"></SeriesUploadModal>
<SeriesUploadModal
modalKey="upload"
episodes={episodes.data}
></SeriesUploadModal>
</Container>
);
};

@ -6,10 +6,12 @@ import {
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { intersectionWith } from "lodash";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Badge, ButtonGroup } from "react-bootstrap";
import { Column, TableOptions, TableUpdater } from "react-table";
import { useSerieBy } from "../../@redux/hooks";
import { Column, TableUpdater } from "react-table";
import { useProfileItems, useSerieBy } from "../../@redux/hooks";
import { useShowOnlyDesired } from "../../@redux/hooks/site";
import { ProvidersApi } from "../../apis";
import {
ActionButton,
@ -26,7 +28,7 @@ import { SubtitleAction } from "./components";
interface Props {
episodes: AsyncState<Item.Episode[]>;
update: () => void;
profile?: Profile.Languages;
}
const download = (item: any, result: SearchResultType) => {
@ -45,9 +47,13 @@ const download = (item: any, result: SearchResultType) => {
);
};
const Table: FunctionComponent<Props> = ({ episodes, update }) => {
const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
const showModal = useShowModal();
const onlyDesired = useShowOnlyDesired();
const profileItems = useProfileItems(profile);
const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>(
() => [
{
@ -113,7 +119,16 @@ const Table: FunctionComponent<Props> = ({ episodes, update }) => {
></SubtitleAction>
));
const subtitles = episode.subtitles.map((val, idx) => (
let raw_subtitles = episode.subtitles;
if (onlyDesired) {
raw_subtitles = intersectionWith(
raw_subtitles,
profileItems,
(l, r) => l.code2 === r.code2
);
}
const subtitles = raw_subtitles.map((val, idx) => (
<SubtitleAction
key={BuildKey(idx, val.code2, "valid")}
seriesid={seriesid}
@ -160,7 +175,7 @@ const Table: FunctionComponent<Props> = ({ episodes, update }) => {
},
},
],
[]
[onlyDesired, profileItems]
);
const updateRow = useCallback<TableUpdater<Item.Episode>>(
@ -183,43 +198,32 @@ const Table: FunctionComponent<Props> = ({ episodes, update }) => {
[episodes]
);
const options: TableOptions<Item.Episode> = useMemo(() => {
return {
columns,
data: episodes.data,
externalUpdate: updateRow,
initialState: {
sortBy: [
{ id: "season", desc: true },
{ id: "episode", desc: true },
],
groupBy: ["season"],
expanded: {
[`season:${maxSeason}`]: true,
},
},
};
}, [episodes, columns, maxSeason, updateRow]);
return (
<React.Fragment>
<AsyncStateOverlay state={episodes}>
{() => (
{({ data }) => (
<GroupTable
columns={columns}
data={data}
externalUpdate={updateRow}
initialState={{
sortBy: [
{ id: "season", desc: true },
{ id: "episode", desc: true },
],
groupBy: ["season"],
expanded: {
[`season:${maxSeason}`]: true,
},
}}
emptyText="No Episode Found For This Series"
{...options}
></GroupTable>
)}
</AsyncStateOverlay>
<SubtitleToolModal
modalKey="tools"
size="lg"
update={update}
></SubtitleToolModal>
<SubtitleToolModal modalKey="tools" size="lg"></SubtitleToolModal>
<EpisodeHistoryModal modalKey="history" size="lg"></EpisodeHistoryModal>
<ManualSearchModal
modalKey="manual-search"
onDownload={update}
onSelect={download}
></ManualSearchModal>
</React.Fragment>

@ -1,14 +1,9 @@
import {
faCheck,
faExclamationTriangle,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faWrench } from "@fortawesome/free-solid-svg-icons";
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, seriesUpdateInfoAll } from "../@redux/actions";
import { seriesUpdateByRange, seriesUpdateList } from "../@redux/actions";
import { useRawSeries } from "../@redux/hooks";
import { useReduxAction } from "../@redux/hooks/base";
import { SeriesApi } from "../apis";
@ -40,21 +35,6 @@ const SeriesView: FunctionComponent<Props> = () => {
}
},
},
{
Header: "Exist",
accessor: "exist",
selectHide: true,
Cell: (row) => {
const exist = row.value;
const { path } = row.row.original;
return (
<FontAwesomeIcon
title={path}
icon={exist ? faCheck : faExclamationTriangle}
></FontAwesomeIcon>
);
},
},
{
Header: "Audio",
accessor: "audio_language",
@ -138,9 +118,9 @@ const SeriesView: FunctionComponent<Props> = () => {
<BaseItemView
state={series}
name="Series"
updateAction={seriesUpdateInfoAll}
updateAction={seriesUpdateList}
loader={load}
columns={columns as Column<Item.Base>[]}
columns={columns}
modify={(form) => SeriesApi.modify(form)}
></BaseItemView>
);

@ -17,18 +17,13 @@ import {
useShowModal,
} from "../../components";
import { BuildKey } from "../../utilites";
import { ColCard, useLatestMergeArray, useUpdateArray } from "../components";
import { ColCard, useLatestArray, useUpdateArray } from "../components";
import { notificationsKey } from "../keys";
interface ModalProps {
selections: readonly Settings.NotificationInfo[];
}
const notificationComparer = (
one: Settings.NotificationInfo,
another: Settings.NotificationInfo
) => one.name === another.name;
const NotificationModal: FunctionComponent<ModalProps & BaseModalProps> = ({
selections,
...modal
@ -46,7 +41,7 @@ const NotificationModal: FunctionComponent<ModalProps & BaseModalProps> = ({
const update = useUpdateArray<Settings.NotificationInfo>(
notificationsKey,
notificationComparer
"name"
);
const payload = usePayload<Settings.NotificationInfo>(modal.modalKey);
@ -158,10 +153,10 @@ const NotificationModal: FunctionComponent<ModalProps & BaseModalProps> = ({
};
export const NotificationView: FunctionComponent = () => {
const notifications = useLatestMergeArray<Settings.NotificationInfo>(
const notifications = useLatestArray<Settings.NotificationInfo>(
notificationsKey,
notificationComparer,
(settings) => settings.notifications.providers
"name",
(s) => s.notifications.providers
);
const showModal = useShowModal();

@ -1,9 +1,9 @@
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useEffect } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { systemUpdateSettings } from "../@redux/actions";
import { useReduxAction } from "../@redux/hooks/base";
import { useSetSidebar } from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import { useAutoUpdate } from "../utilites/hooks";
import General from "./General";
import Languages from "./Languages";
import Notifications from "./Notifications";
@ -18,8 +18,9 @@ interface Props {}
const Router: FunctionComponent<Props> = () => {
const update = useReduxAction(systemUpdateSettings);
useAutoUpdate(update);
useEffect(() => update, [update]);
useSetSidebar("Settings");
return (
<Switch>
<Route exact path="/settings">

@ -1,8 +1,7 @@
import { isArray, isEqual } from "lodash";
import { isArray, uniqBy } from "lodash";
import { useCallback, useContext, useMemo } from "react";
import { useStore } from "react-redux";
import { useSystemSettings } from "../../@redux/hooks";
import { mergeArray } from "../../utilites";
import { log } from "../../utilites/logger";
import { StagedChangesContext } from "./provider";
@ -96,40 +95,6 @@ export function useExtract<T>(
}
}
export function useUpdateArray<T>(
key: string,
compare?: (one: T, another: T) => boolean
) {
const update = useSingleUpdate();
const stagedValue = useStagedValues();
if (compare === undefined) {
compare = isEqual;
}
const staged: T[] = useMemo(() => {
if (key in stagedValue) {
return stagedValue[key];
} else {
return [];
}
}, [key, stagedValue]);
return useCallback(
(v: T) => {
const newArray = [...staged];
const idx = newArray.findIndex((inn) => compare!(inn, v));
if (idx !== -1) {
newArray[idx] = v;
} else {
newArray.push(v);
}
update(newArray, key);
},
[compare, staged, key, update]
);
}
export function useLatest<T>(
key: string,
validate: ValidateFuncType<T>,
@ -144,19 +109,14 @@ export function useLatest<T>(
}
}
// Merge Two Array
export function useLatestMergeArray<T>(
export function useLatestArray<T>(
key: string,
compare: Comparer<T>,
compare: keyof T,
override?: OverrideFuncType<T[]>
): Readonly<Nullable<T[]>> {
const extractValue = useExtract<T[]>(key, isArray, override);
const stagedValue = useStagedValues();
if (compare === undefined) {
compare = isEqual;
}
let staged: T[] | undefined = undefined;
if (key in stagedValue) {
staged = stagedValue[key];
@ -164,9 +124,30 @@ export function useLatestMergeArray<T>(
return useMemo(() => {
if (staged !== undefined && extractValue) {
return mergeArray(extractValue, staged, compare);
return uniqBy([...staged, ...extractValue], compare);
} else {
return extractValue;
}
}, [extractValue, staged, compare]);
}
export function useUpdateArray<T>(key: string, compare: keyof T) {
const update = useSingleUpdate();
const stagedValue = useStagedValues();
const staged: T[] = useMemo(() => {
if (key in stagedValue) {
return stagedValue[key];
} else {
return [];
}
}, [key, stagedValue]);
return useCallback(
(v: T) => {
const newArray = uniqBy([v, ...staged], compare);
update(newArray, key);
},
[compare, staged, key, update]
);
}

@ -10,13 +10,12 @@ import React, {
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Prompt } from "react-router";
import {
siteSaveLocalstorage,
systemUpdateSettingsAll,
} from "../../@redux/actions";
import { useReduxAction, useReduxActionWith } from "../../@redux/hooks/base";
import { siteSaveLocalstorage } from "../../@redux/actions";
import { useSystemSettings } from "../../@redux/hooks";
import { useReduxAction } from "../../@redux/hooks/base";
import { SystemApi } from "../../apis";
import { ContentHeader } from "../../components";
import { useWhenLoadingFinish } from "../../utilites";
import { log } from "../../utilites/logger";
import {
enabledLanguageKey,
@ -66,17 +65,15 @@ const SettingsProvider: FunctionComponent<Props> = (props) => {
setUpdating(false);
}, []);
const update = useReduxActionWith(systemUpdateSettingsAll, cleanup);
const [settings] = useSystemSettings();
useWhenLoadingFinish(settings, cleanup);
const saveSettings = useCallback(
(settings: LooseObject) => {
submitHooks(settings);
setUpdating(true);
log("info", "submitting settings", settings);
SystemApi.setSettings(settings).finally(update);
},
[update]
);
const saveSettings = useCallback((settings: LooseObject) => {
submitHooks(settings);
setUpdating(true);
log("info", "submitting settings", settings);
SystemApi.setSettings(settings);
}, []);
const saveLocalStorage = useCallback(
(settings: LooseObject) => {

@ -1,17 +1,10 @@
import React, {
FunctionComponent,
useContext,
useEffect,
useMemo,
} from "react";
import React, { FunctionComponent, useContext, useMemo } from "react";
import { Container, Image, ListGroup } from "react-bootstrap";
import { useHistory } from "react-router-dom";
import { badgeUpdateAll, siteChangeSidebar } from "../@redux/actions";
import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
import { useReduxStore } from "../@redux/hooks/base";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
import logo from "../@static/logo64.png";
import { SidebarToggleContext } from "../App";
import { useAutoUpdate, useGotoHomepage } from "../utilites/hooks";
import { useGotoHomepage } from "../utilites/hooks";
import {
BadgesContext,
CollapseItem,
@ -22,25 +15,16 @@ import { RadarrDisabledKey, SidebarList, SonarrDisabledKey } from "./list";
import "./style.scss";
import { BadgeProvider } from "./types";
export function useSidebarKey() {
return useReduxStore((s) => s.site.sidebar);
}
export function useUpdateSidebar() {
return useReduxAction(siteChangeSidebar);
}
interface Props {
open?: boolean;
}
const Sidebar: FunctionComponent<Props> = ({ open }) => {
const updateBadges = useReduxAction(badgeUpdateAll);
useAutoUpdate(updateBadges);
const toggle = useContext(SidebarToggleContext);
const { movies, episodes, providers } = useReduxStore((s) => s.site.badges);
const { movies, episodes, providers, status } = useReduxStore(
(s) => s.site.badges
);
const sonarrEnabled = useIsSonarrEnabled();
const radarrEnabled = useIsRadarrEnabled();
@ -53,9 +37,10 @@ const Sidebar: FunctionComponent<Props> = ({ open }) => {
},
System: {
Providers: providers,
Status: status,
},
}),
[movies, episodes, providers, sonarrEnabled, radarrEnabled]
[movies, episodes, providers, sonarrEnabled, radarrEnabled, status]
);
const hiddenKeys = useMemo<string[]>(() => {
@ -69,20 +54,6 @@ const Sidebar: FunctionComponent<Props> = ({ open }) => {
return list;
}, [sonarrEnabled, radarrEnabled]);
const history = useHistory();
const updateSidebar = useUpdateSidebar();
useEffect(() => {
const path = history.location.pathname.split("/");
const len = path.length;
if (len >= 3) {
updateSidebar(path[len - 2]);
} else {
updateSidebar(path[len - 1]);
}
}, [history.location.pathname, updateSidebar]);
const cls = ["sidebar-container"];
const overlay = ["sidebar-overlay"];

@ -3,7 +3,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useContext, useMemo } from "react";
import { Badge, Collapse, ListGroupItem } from "react-bootstrap";
import { NavLink } from "react-router-dom";
import { useSidebarKey, useUpdateSidebar } from ".";
import { siteChangeSidebar } from "../@redux/actions";
import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
import { SidebarToggleContext } from "../App";
import {
BadgeProvider,
@ -16,6 +17,14 @@ export const HiddenKeysContext = React.createContext<string[]>([]);
export const BadgesContext = React.createContext<BadgeProvider>({});
function useToggleSidebar() {
return useReduxAction(siteChangeSidebar);
}
function useSidebarKey() {
return useReduxStore((s) => s.site.sidebar);
}
export const LinkItem: FunctionComponent<LinkItemType> = ({
link,
name,
@ -60,10 +69,8 @@ export const CollapseItem: FunctionComponent<CollapseItemType> = ({
const hiddenKeys = useContext(HiddenKeysContext);
const toggleSidebar = useContext(SidebarToggleContext);
const itemKey = name.toLowerCase();
const sidebarKey = useSidebarKey();
const updateSidebar = useUpdateSidebar();
const updateSidebar = useToggleSidebar();
const [badgeValue, childValue] = useMemo<
[Nullable<number>, Nullable<ChildBadgeProvider>]
@ -86,7 +93,7 @@ export const CollapseItem: FunctionComponent<CollapseItemType> = ({
return [badge, child];
}, [badges, name]);
const active = useMemo(() => sidebarKey === itemKey, [sidebarKey, itemKey]);
const active = useMemo(() => sidebarKey === name, [sidebarKey, name]);
const collapseBoxClass = useMemo(
() => `sidebar-collapse-box ${active ? "active" : ""}`,
@ -133,7 +140,7 @@ export const CollapseItem: FunctionComponent<CollapseItemType> = ({
if (active) {
updateSidebar("");
} else {
updateSidebar(itemKey);
updateSidebar(name);
}
}}
>

@ -2,20 +2,16 @@ import { faDownload, faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { systemUpdateLogs } from "../../@redux/actions";
import { useReduxAction, useReduxStore } from "../../@redux/hooks/base";
import { useSystemLogs } from "../../@redux/hooks";
import { SystemApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components";
import { useBaseUrl } from "../../utilites";
import { useAutoUpdate } from "../../utilites/hooks";
import Table from "./table";
interface Props {}
const SystemLogsView: FunctionComponent<Props> = () => {
const logs = useReduxStore(({ system }) => system.logs);
const update = useReduxAction(systemUpdateLogs);
useAutoUpdate(update);
const [logs, update] = useSystemLogs();
const [resetting, setReset] = useState(false);
@ -27,7 +23,7 @@ const SystemLogsView: FunctionComponent<Props> = () => {
return (
<AsyncStateOverlay state={logs}>
{(data) => (
{({ data }) => (
<Container fluid>
<Helmet>
<title>Logs - Bazarr (System)</title>

@ -2,21 +2,19 @@ import { faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { useProviders } from "../../@redux/hooks";
import { useSystemProviders } from "../../@redux/hooks";
import { ProvidersApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components";
import { useAutoUpdate } from "../../utilites/hooks";
import Table from "./table";
interface Props {}
const SystemProvidersView: FunctionComponent<Props> = () => {
const [providers, update] = useProviders();
useAutoUpdate(update);
const [providers, update] = useSystemProviders();
return (
<AsyncStateOverlay state={providers}>
{(data) => (
{({ data }) => (
<Container fluid>
<Helmet>
<title>Providers - Bazarr (System)</title>

@ -1,28 +1,24 @@
import React, { FunctionComponent, useMemo } from "react";
import { Badge, Card, Col, Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { systemUpdateReleases } from "../../@redux/actions";
import { useReduxAction, useReduxStore } from "../../@redux/hooks/base";
import { useSystemReleases } from "../../@redux/hooks";
import { AsyncStateOverlay } from "../../components";
import { BuildKey } from "../../utilites";
import { useAutoUpdate } from "../../utilites/hooks";
interface Props {}
const ReleasesView: FunctionComponent<Props> = () => {
const releases = useReduxStore(({ system }) => system.releases);
const update = useReduxAction(systemUpdateReleases);
useAutoUpdate(update);
const [releases] = useSystemReleases();
return (
<AsyncStateOverlay state={releases}>
{(item) => (
{({ data }) => (
<Container fluid className="px-5 py-4 bg-light">
<Helmet>
<title>Releases - Bazarr (System)</title>
</Helmet>
<Row>
{item.map((v, idx) => (
{data.map((v, idx) => (
<Col xs={12} key={BuildKey(idx, v.date)}>
<InfoElement {...v}></InfoElement>
</Col>

@ -1,5 +1,6 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { useSetSidebar } from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import Logs from "./Logs";
import Providers from "./Providers";
@ -8,6 +9,7 @@ import Status from "./Status";
import Tasks from "./Tasks";
const Router: FunctionComponent = () => {
useSetSidebar("System");
return (
<Switch>
<Route exact path="/system/tasks">

@ -9,10 +9,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent } from "react";
import { Col, Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { systemUpdateStatus } from "../../@redux/actions";
import { useReduxAction, useReduxStore } from "../../@redux/hooks/base";
import { useSystemHealth, useSystemStatus } from "../../@redux/hooks";
import { AsyncStateOverlay } from "../../components";
import { GithubRepoRoot } from "../../constants";
import { useAutoUpdate } from "../../utilites/hooks";
import Table from "./table";
interface InfoProps {
title: string;
@ -65,15 +65,28 @@ const InfoContainer: FunctionComponent<{ title: string }> = ({
interface Props {}
const SystemStatusView: FunctionComponent<Props> = () => {
const status = useReduxStore((s) => s.system.status.data);
const update = useReduxAction(systemUpdateStatus);
useAutoUpdate(update);
const [health] = useSystemHealth();
const [status] = useSystemStatus();
let health_table;
if (health.data.length) {
health_table = (
<AsyncStateOverlay state={health}>
{({ data }) => <Table health={data}></Table>}
</AsyncStateOverlay>
);
} else {
health_table = "No issues with your configuration";
}
return (
<Container className="p-5">
<Helmet>
<title>Status - Bazarr (System)</title>
</Helmet>
<Row>
<InfoContainer title="Health">{health_table}</InfoContainer>
</Row>
<Row>
<InfoContainer title="About">
<CRow title="Bazarr Version">

@ -0,0 +1,27 @@
import React, { FunctionComponent, useMemo } from "react";
import { Column } from "react-table";
import { SimpleTable } from "../../components";
interface Props {
health: readonly System.Health[];
}
const Table: FunctionComponent<Props> = (props) => {
const columns: Column<System.Health>[] = useMemo<Column<System.Health>[]>(
() => [
{
Header: "Object",
accessor: "object",
},
{
Header: "Issue",
accessor: "issue",
},
],
[]
);
return <SimpleTable columns={columns} data={props.health}></SimpleTable>;
};
export default Table;

@ -2,24 +2,18 @@ import { faSync } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { systemUpdateTasks } from "../../@redux/actions";
import { useReduxAction, useReduxStore } from "../../@redux/hooks/base";
import { useSystemTasks } from "../../@redux/hooks";
import { AsyncStateOverlay, ContentHeader } from "../../components";
import { useAutoUpdate } from "../../utilites";
import Table from "./table";
interface Props {}
const SystemTasksView: FunctionComponent<Props> = () => {
const tasks = useReduxStore((s) => s.system.tasks);
const update = useReduxAction(systemUpdateTasks);
// TODO: Use Websocket
useAutoUpdate(update, 10 * 1000);
const [tasks, update] = useSystemTasks();
return (
<AsyncStateOverlay state={tasks}>
{(data) => (
{({ data }) => (
<Container fluid>
<Helmet>
<title>Tasks - Bazarr (System)</title>

@ -2,8 +2,6 @@ import { faSync } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useMemo } from "react";
import { Column } from "react-table";
import { systemRunTasks } from "../../@redux/actions";
import { useReduxAction } from "../../@redux/hooks/base";
import { SystemApi } from "../../apis";
import { AsyncButton, SimpleTable } from "../../components";
@ -12,7 +10,6 @@ interface Props {
}
const Table: FunctionComponent<Props> = ({ tasks }) => {
const run = useReduxAction(systemRunTasks);
const columns: Column<System.Task>[] = useMemo<Column<System.Task>[]>(
() => [
{
@ -37,10 +34,10 @@ const Table: FunctionComponent<Props> = ({ tasks }) => {
return (
<AsyncButton
promise={() => SystemApi.runTask(job_id)}
onSuccess={() => run(job_id)}
variant="light"
size="sm"
disabled={row.value}
animation={false}
>
<FontAwesomeIcon icon={faSync} spin={row.value}></FontAwesomeIcon>
</AsyncButton>
@ -48,7 +45,7 @@ const Table: FunctionComponent<Props> = ({ tasks }) => {
},
},
],
[run]
[]
);
return <SimpleTable columns={columns} data={tasks}></SimpleTable>;

@ -15,7 +15,7 @@ import GenericWantedView from "../generic";
interface Props {}
const WantedMoviesView: FunctionComponent<Props> = () => {
const [movies, update] = useWantedMovies();
const [movies] = useWantedMovies();
const loader = useReduxAction(movieUpdateWantedByRange);
@ -74,9 +74,8 @@ const WantedMoviesView: FunctionComponent<Props> = () => {
return (
<GenericWantedView
type="movies"
columns={columns as Column<Wanted.Base>[]}
columns={columns}
state={movies}
update={update}
loader={loader}
searchAll={searchAll}
></GenericWantedView>

@ -1,6 +1,10 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
import {
useIsRadarrEnabled,
useIsSonarrEnabled,
useSetSidebar,
} from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import Movies from "./Movies";
import Series from "./Series";
@ -8,6 +12,8 @@ import Series from "./Series";
const Router: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
useSetSidebar("Wanted");
return (
<Switch>
{sonarr && (

@ -15,7 +15,7 @@ import GenericWantedView from "../generic";
interface Props {}
const WantedSeriesView: FunctionComponent<Props> = () => {
const [series, update] = useWantedSeries();
const [series] = useWantedSeries();
const loader = useReduxAction(seriesUpdateWantedByRange);
@ -82,9 +82,8 @@ const WantedSeriesView: FunctionComponent<Props> = () => {
return (
<GenericWantedView
type="series"
columns={columns as Column<Wanted.Base>[]}
columns={columns}
state={series}
update={update}
loader={loader}
searchAll={searchAll}
></GenericWantedView>

@ -1,39 +1,29 @@
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { capitalize } from "lodash";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import React from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Column, TableUpdater } from "react-table";
import { ContentHeader, PageTable } from "../../components";
import { buildOrderList, GetItemId } from "../../utilites";
import { Column } from "react-table";
import { AsyncPageTable, ContentHeader } from "../../components";
interface Props {
interface Props<T extends Wanted.Base> {
type: "movies" | "series";
columns: Column<Wanted.Base>[];
state: Readonly<AsyncState<OrderIdState<Wanted.Base>>>;
columns: Column<T>[];
state: Readonly<AsyncOrderState<T>>;
loader: (start: number, length: number) => void;
update: (id?: number) => void;
searchAll: () => Promise<void>;
}
const GenericWantedView: FunctionComponent<Props> = ({
function GenericWantedView<T extends Wanted.Base>({
type,
columns,
state,
update,
loader,
searchAll,
}) => {
}: Props<T>) {
const typeName = capitalize(type);
const data = useMemo(() => buildOrderList(state.data), [state.data]);
const updater = useCallback<TableUpdater<Wanted.Base>>(
(row, id: number) => {
update(id);
},
[update]
);
const dataCount = Object.keys(state.data.items).length;
return (
<Container fluid>
@ -42,28 +32,24 @@ const GenericWantedView: FunctionComponent<Props> = ({
</Helmet>
<ContentHeader>
<ContentHeader.AsyncButton
disabled={data.length === 0}
disabled={dataCount === 0}
promise={searchAll}
onSuccess={update as () => void}
icon={faSearch}
>
Search All
</ContentHeader.AsyncButton>
</ContentHeader>
<Row>
<PageTable
async
asyncState={state}
asyncId={GetItemId}
asyncLoader={loader}
<AsyncPageTable
aos={state}
loader={loader}
emptyText={`No Missing ${typeName} Subtitles`}
columns={columns}
externalUpdate={updater}
data={data}
></PageTable>
data={[]}
></AsyncPageTable>
</Row>
</Container>
);
};
}
export default GenericWantedView;

@ -5,7 +5,7 @@ class EpisodeApi extends BaseApi {
super("/episodes");
}
async bySeriesId(seriesid: number): Promise<Array<Item.Episode>> {
async bySeriesId(seriesid: number[]): Promise<Array<Item.Episode>> {
return new Promise<Array<Item.Episode>>((resolve, reject) => {
this.get<DataWrapper<Array<Item.Episode>>>("", { seriesid })
.then((result) => {
@ -17,11 +17,11 @@ class EpisodeApi extends BaseApi {
});
}
async wanted(start: number, length: number) {
return new Promise<AsyncDataWrapper<Wanted.Episode>>((resolve, reject) => {
this.get<AsyncDataWrapper<Wanted.Episode>>("/wanted", { start, length })
async byEpisodeId(episodeid: number[]): Promise<Array<Item.Episode>> {
return new Promise<Array<Item.Episode>>((resolve, reject) => {
this.get<DataWrapper<Array<Item.Episode>>>("", { episodeid })
.then((result) => {
resolve(result.data);
resolve(result.data.data);
})
.catch((reason) => {
reject(reason);
@ -29,10 +29,9 @@ class EpisodeApi extends BaseApi {
});
}
// TODO: Implement this on backend
async wantedBy(episodeid?: number) {
async wanted(start: number, length: number) {
return new Promise<AsyncDataWrapper<Wanted.Episode>>((resolve, reject) => {
this.get<AsyncDataWrapper<Wanted.Episode>>("/wanted", { episodeid })
this.get<AsyncDataWrapper<Wanted.Episode>>("/wanted", { start, length })
.then((result) => {
resolve(result.data);
})
@ -42,11 +41,11 @@ class EpisodeApi extends BaseApi {
});
}
async byEpisodeId(episodeid: number): Promise<Array<Item.Episode>> {
return new Promise<Array<Item.Episode>>((resolve, reject) => {
this.get<DataWrapper<Array<Item.Episode>>>("", { episodeid })
async wantedBy(episodeid: number[]) {
return new Promise<AsyncDataWrapper<Wanted.Episode>>((resolve, reject) => {
this.get<AsyncDataWrapper<Wanted.Episode>>("/wanted", { episodeid })
.then((result) => {
resolve(result.data.data);
resolve(result.data);
})
.catch((reason) => {
reject(reason);

@ -1,18 +1,16 @@
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
import { siteRedirectToAuth, siteUpdateOffline } from "../@redux/actions";
import reduxStore from "../@redux/store";
import { getBaseUrl } from "../utilites";
class Api {
axios!: AxiosInstance;
source!: CancelTokenSource;
constructor() {
const baseUrl = `${getBaseUrl()}/api/`;
if (process.env.NODE_ENV === "development") {
this.initialize("/api/", process.env["REACT_APP_APIKEY"]!);
this.initialize(baseUrl, process.env["REACT_APP_APIKEY"]!);
} else {
const baseUrl =
window.Bazarr.baseUrl === "/"
? "/api/"
: `${window.Bazarr.baseUrl}/api/`;
this.initialize(baseUrl, window.Bazarr.apiKey);
}
}
@ -34,7 +32,6 @@ class Api {
this.axios.interceptors.response.use(
(resp) => {
this.onOnline();
if (resp.status >= 200 && resp.status < 300) {
return Promise.resolve(resp);
} else {
@ -46,9 +43,7 @@ class Api {
if (error.response) {
const response = error.response;
this.handleError(response.status);
this.onOnline();
} else {
this.onOffline();
error.message = "You have disconnected to Bazarr backend";
}
return Promise.reject(error);

@ -37,9 +37,9 @@ class MovieApi extends BaseApi {
});
}
async movies(id?: number[]) {
async movies(radarrid?: number[]) {
return new Promise<AsyncDataWrapper<Item.Movie>>((resolve, reject) => {
this.get<AsyncDataWrapper<Item.Movie>>("", { radarrid: id })
this.get<AsyncDataWrapper<Item.Movie>>("", { radarrid })
.then((result) => {
resolve(result.data);
})
@ -81,8 +81,7 @@ class MovieApi extends BaseApi {
});
}
// TODO: Implement this on backend
async wantedBy(radarrid?: number) {
async wantedBy(radarrid: number[]) {
return new Promise<AsyncDataWrapper<Wanted.Movie>>((resolve, reject) => {
this.get<AsyncDataWrapper<Wanted.Movie>>("/wanted", { radarrid })
.then((result) => {

@ -5,9 +5,9 @@ class SeriesApi extends BaseApi {
super("/series");
}
async series(id?: number[]) {
async series(seriesid?: number[]) {
return new Promise<AsyncDataWrapper<Item.Series>>((resolve, reject) => {
this.get<AsyncDataWrapper<Item.Series>>("", { seriesid: id })
this.get<AsyncDataWrapper<Item.Series>>("", { seriesid })
.then((result) => {
resolve(result.data);
})

@ -89,6 +89,18 @@ class SystemApi extends BaseApi {
});
}
async health() {
return new Promise<System.Health>((resolve, reject) => {
this.get<DataWrapper<System.Health>>("/health")
.then((result) => {
resolve(result.data.data);
})
.catch((reason) => {
reject(reason);
});
});
}
async logs() {
return new Promise<Array<System.Log>>((resolve, reject) => {
this.get<DataWrapper<Array<System.Log>>>("/logs")

@ -25,10 +25,15 @@ enum RequestState {
Invalid,
}
interface ChildProps<T> {
data: NonNullable<Readonly<T>>;
error?: Error;
}
interface AsyncStateOverlayProps<T> {
state: AsyncState<T>;
exist?: (item: T) => boolean;
children?: (item: NonNullable<Readonly<T>>, error?: Error) => JSX.Element;
children?: FunctionComponent<ChildProps<T>>;
}
function defaultExist(item: any) {
@ -83,7 +88,7 @@ export function AsyncStateOverlay<T>(props: AsyncStateOverlayProps<T>) {
}
}
return children ? children(state.data!, state.error) : null;
return children ? children({ data: state.data!, error: state.error }) : null;
}
interface PromiseProps<T> {
@ -156,6 +161,7 @@ interface AsyncButtonProps<T> {
onChange?: (v: boolean) => void;
noReset?: boolean;
animation?: boolean;
promise: () => Promise<T> | null;
onSuccess?: (result: T) => void;
@ -171,6 +177,7 @@ export function AsyncButton<T>(
promise,
onSuccess,
noReset,
animation,
error,
onChange,
disabled,
@ -230,15 +237,19 @@ export function AsyncButton<T>(
}
}, [error, onChange, promise, onSuccess, state]);
const showAnimation = animation ?? true;
let children = propChildren;
if (loading) {
children = <FontAwesomeIcon icon={faCircleNotch} spin></FontAwesomeIcon>;
}
if (showAnimation) {
if (loading) {
children = <FontAwesomeIcon icon={faCircleNotch} spin></FontAwesomeIcon>;
}
if (state === RequestState.Success) {
children = <FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>;
} else if (state === RequestState.Error) {
children = <FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>;
if (state === RequestState.Success) {
children = <FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>;
} else if (state === RequestState.Error) {
children = <FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>;
}
}
return (

@ -53,6 +53,7 @@ export const ActionButton: FunctionComponent<ActionButtonProps> = ({
interface ActionButtonItemProps {
loading?: boolean;
alwaysShowText?: boolean;
icon: IconDefinition;
children?: string;
}
@ -61,7 +62,9 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
icon,
children,
loading,
alwaysShowText,
}) => {
const showText = alwaysShowText === true || loading !== true;
return (
<React.Fragment>
<FontAwesomeIcon
@ -69,7 +72,7 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
icon={loading ? faCircleNotch : icon}
spin={loading}
></FontAwesomeIcon>
{children && !loading ? (
{children && showText ? (
<span className="ml-2 font-weight-bold">{children}</span>
) : null}
</React.Fragment>

@ -105,7 +105,7 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
return (
<BaseModal title={`History - ${movie?.title ?? ""}`} {...modal}>
<AsyncStateOverlay state={history}>
{(data) => (
{({ data }) => (
<PageTable
emptyText="No History Found"
columns={columns}
@ -208,7 +208,7 @@ export const EpisodeHistoryModal: FunctionComponent<
return (
<BaseModal title={`History - ${episode?.title ?? ""}`} {...props}>
<AsyncStateOverlay state={history}>
{(data) => (
{({ data }) => (
<PageTable
emptyText="No History Found"
columns={columns}

@ -7,12 +7,7 @@ import {
useCloseModal,
usePayload,
} from "..";
import {
useLanguageBy,
useLanguages,
useMovieBy,
useProfileBy,
} from "../../@redux/hooks";
import { useLanguageBy, useLanguages, useProfileBy } from "../../@redux/hooks";
import { MoviesApi } from "../../apis";
import BaseModal, { BaseModalProps } from "./BaseModal";
interface MovieProps {}
@ -25,7 +20,6 @@ const MovieUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
const [availableLanguages] = useLanguages(true);
const movie = usePayload<Item.Movie>(modal.modalKey);
const [, update] = useMovieBy(movie?.radarrId);
const closeModal = useCloseModal();
@ -63,10 +57,7 @@ const MovieUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
return null;
}
}}
onSuccess={() => {
closeModal();
update();
}}
onSuccess={closeModal}
>
Upload
</AsyncButton>

@ -24,11 +24,7 @@ import {
useCloseModal,
usePayload,
} from "..";
import {
useEpisodesBy,
useProfileBy,
useProfileItems,
} from "../../@redux/hooks";
import { useProfileBy, useProfileItems } from "../../@redux/hooks";
import { EpisodesApi, SubtitlesApi } from "../../apis";
import { Selector } from "../inputs";
import BaseModal, { BaseModalProps } from "./BaseModal";
@ -59,15 +55,16 @@ type EpisodeMap = {
[name: string]: Item.Episode;
};
interface MovieProps {}
interface SerieProps {
episodes: readonly Item.Episode[];
}
const SeriesUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
modal
) => {
const SeriesUploadModal: FunctionComponent<SerieProps & BaseModalProps> = ({
episodes,
...modal
}) => {
const series = usePayload<Item.Series>(modal.modalKey);
const [episodes, updateEpisodes] = useEpisodesBy(series?.sonarrSeriesId);
const [uploading, setUpload] = useState(false);
const closeModal = useCloseModal();
@ -122,7 +119,7 @@ const SeriesUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
const results = await SubtitlesApi.info(names);
const episodeMap = results.reduce<EpisodeMap>((prev, curr) => {
const ep = episodes.data.find(
const ep = episodes.find(
(v) => v.season === curr.season && v.episode === curr.episode
);
if (ep) {
@ -140,7 +137,7 @@ const SeriesUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
);
}
},
[episodes.data]
[episodes]
);
const updateLanguage = useCallback(
@ -386,7 +383,6 @@ const SeriesUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
onSuccess={() => {
closeModal();
setFiles([]);
updateEpisodes();
}}
>
Upload
@ -419,7 +415,7 @@ const SeriesUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
<SimpleTable
columns={columns}
data={pending}
loose={[uploading, processState, episodes.data]}
loose={[uploading, processState, episodes]}
responsive={false}
externalUpdate={updateItem}
></SimpleTable>

@ -330,14 +330,9 @@ const TranslateModal: FunctionComponent<BaseModalProps & ToolModalProps> = ({
);
};
interface STMProps {
update: () => void;
}
interface STMProps {}
const STM: FunctionComponent<BaseModalProps & STMProps> = ({
update,
...props
}) => {
const STM: FunctionComponent<BaseModalProps & STMProps> = ({ ...props }) => {
const items = usePayload<SupportType[]>(props.modalKey);
const [updating, setUpdate] = useState<boolean>(false);
@ -380,10 +375,8 @@ const STM: FunctionComponent<BaseModalProps & STMProps> = ({
setProcessState(states);
}
setUpdate(false);
update();
},
[closeUntil, selections, update]
[closeUntil, selections]
);
const showModal = useShowModal();

@ -0,0 +1,128 @@
import { isNull } from "lodash";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { PluginHook, TableOptions, useTable } from "react-table";
import { LoadingIndicator } from "..";
import { useReduxStore } from "../../@redux/hooks/base";
import { buildOrderListFrom, isNonNullable, ScrollToTop } from "../../utilites";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl";
import { useDefaultSettings } from "./plugins";
type Props<T extends object> = TableOptions<T> &
TableStyleProps<T> & {
plugins?: PluginHook<T>[];
aos: AsyncOrderState<T>;
loader: (start: number, length: number) => void;
};
export default function AsyncPageTable<T extends object>(props: Props<T>) {
const { aos, plugins, loader, ...remain } = props;
const { style, options } = useStyleAndOptions(remain);
const {
updating,
data: { order, items, fetched },
} = aos;
const allPlugins: PluginHook<T>[] = [useDefaultSettings];
if (plugins) {
allPlugins.push(...plugins);
}
// Impl a new pagination system instead of hooking into the existing one
const [pageIndex, setIndex] = useState(0);
const pageSize = useReduxStore((s) => s.site.pageSize);
const totalRows = order.length;
const pageCount = Math.ceil(totalRows / pageSize);
const previous = useCallback(() => {
setIndex((idx) => idx - 1);
}, []);
const next = useCallback(() => {
setIndex((idx) => idx + 1);
}, []);
const goto = useCallback((idx: number) => {
setIndex(idx);
}, []);
const pageStart = pageIndex * pageSize;
const pageEnd = pageStart + pageSize;
const visibleItemIds = useMemo(() => order.slice(pageStart, pageEnd), [
pageStart,
pageEnd,
order,
]);
const newData = useMemo(() => buildOrderListFrom(items, visibleItemIds), [
items,
visibleItemIds,
]);
const newOptions = useMemo<TableOptions<T>>(
() => ({
...options,
data: newData,
}),
[options, newData]
);
const instance = useTable(newOptions, ...allPlugins);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
} = instance;
useEffect(() => {
ScrollToTop();
}, [pageIndex]);
useEffect(() => {
const needInit = visibleItemIds.length === 0 && fetched === false;
const needRefresh = !visibleItemIds.every(isNonNullable);
if (needInit || needRefresh) {
loader(pageStart, pageSize);
}
}, [visibleItemIds, pageStart, pageSize, loader, fetched]);
const showLoading = useMemo(
() =>
updating && (visibleItemIds.every(isNull) || visibleItemIds.length === 0),
[visibleItemIds, updating]
);
if (showLoading) {
return <LoadingIndicator></LoadingIndicator>;
}
return (
<React.Fragment>
<BaseTable
{...style}
headers={headerGroups}
rows={rows}
prepareRow={prepareRow}
tableProps={getTableProps()}
tableBodyProps={getTableBodyProps()}
></BaseTable>
<PageControl
count={pageCount}
index={pageIndex}
size={pageSize}
total={totalRows}
canPrevious={pageIndex > 0}
canNext={pageIndex < pageCount - 1}
previous={previous}
next={next}
goto={goto}
></PageControl>
</React.Fragment>
);
}

@ -79,12 +79,12 @@ const PageControl: FunctionComponent<Props> = ({
<Pagination className="m-0" hidden={count <= 1}>
<Pagination.Prev
onClick={previous}
disabled={!canPrevious && loading}
disabled={!canPrevious || loading}
></Pagination.Prev>
{pageButtons}
<Pagination.Next
onClick={next}
disabled={!canNext && loading}
disabled={!canNext || loading}
></Pagination.Next>
</Pagination>
</Col>

@ -1,5 +1,5 @@
import { isNull, isUndefined } from "lodash";
import React, { useCallback, useEffect } from "react";
import { isUndefined } from "lodash";
import React, { useEffect } from "react";
import {
PluginHook,
TableOptions,
@ -9,33 +9,23 @@ import {
} from "react-table";
import { useReduxStore } from "../../@redux/hooks/base";
import { ScrollToTop } from "../../utilites";
import { AsyncStateOverlay } from "../async";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl";
import {
useAsyncPagination,
useCustomSelection,
useDefaultSettings,
} from "./plugins";
import { useCustomSelection, useDefaultSettings } from "./plugins";
type Props<T extends object> = TableOptions<T> &
TableStyleProps<T> & {
async?: boolean;
canSelect?: boolean;
autoScroll?: boolean;
plugins?: PluginHook<T>[];
};
export default function PageTable<T extends object>(props: Props<T>) {
const { async, autoScroll, canSelect, plugins, ...remain } = props;
const { autoScroll, canSelect, plugins, ...remain } = props;
const { style, options } = useStyleAndOptions(remain);
const allPlugins: PluginHook<T>[] = [useDefaultSettings, usePagination];
if (async) {
allPlugins.push(useAsyncPagination);
}
if (canSelect) {
allPlugins.push(useRowSelect, useCustomSelection);
}
@ -62,7 +52,7 @@ export default function PageTable<T extends object>(props: Props<T>) {
nextPage,
previousPage,
setPageSize,
state: { pageIndex, pageSize, pageToLoad, needLoadingScreen },
state: { pageIndex, pageSize },
} = instance;
const globalPageSize = useReduxStore((s) => s.site.pageSize);
@ -91,28 +81,6 @@ export default function PageTable<T extends object>(props: Props<T>) {
setPageSize,
]);
const total = options.asyncState
? options.asyncState.data.order.length
: rows.length;
const orderIdStateValidater = useCallback(
(state: OrderIdState<any>) => {
const start = pageIndex * pageSize;
const end = start + pageSize;
return state.order.slice(start, end).every(isNull) === false;
},
[pageIndex, pageSize]
);
if (needLoadingScreen && options.asyncState) {
return (
<AsyncStateOverlay
state={options.asyncState}
exist={orderIdStateValidater}
></AsyncStateOverlay>
);
}
return (
<React.Fragment>
<BaseTable
@ -124,11 +92,10 @@ export default function PageTable<T extends object>(props: Props<T>) {
tableBodyProps={getTableBodyProps()}
></BaseTable>
<PageControl
loadState={pageToLoad}
count={pageCount}
index={pageIndex}
size={pageSize}
total={total}
total={rows.length}
canPrevious={canPreviousPage}
canNext={canNextPage}
previous={previousPage}

@ -1,3 +1,4 @@
export { default as AsyncPageTable } from "./AsyncPageTable";
export { default as GroupTable } from "./GroupTable";
export { default as PageTable } from "./PageTable";
export { default as SimpleTable } from "./SimpleTable";

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

Loading…
Cancel
Save