Merge branch 'development'

# Conflicts:
#	frontend/package-lock.json
pull/1832/head
morpheus65535 2 years ago
commit 135bdf2d45

@ -15,43 +15,55 @@ on:
branches: [development]
env:
UI_DIRECTORY: ./frontend
UI_ARTIFACT_NAME: ui
UI_DIRECTORY: ./frontend
UI_ARTIFACT_NAME: ui
jobs:
Frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 1
- name: Cache node_modules
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: '${{ env.UI_DIRECTORY }}/node_modules'
path: "${{ env.UI_DIRECTORY }}/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-modules-
- name: Setup NodeJS
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: "15.x"
node-version: "16"
- name: Install dependencies
run: npm install
working-directory: ${{ env.UI_DIRECTORY }}
- name: Build
run: npm run build
- name: Check Types
run: npm run check:ts
working-directory: ${{ env.UI_DIRECTORY }}
- name: Check Styles
run: npm run check
working-directory: ${{ env.UI_DIRECTORY }}
- name: Check Format
run: npm run check:fmt
working-directory: ${{ env.UI_DIRECTORY }}
- name: Unit Test
run: npm test
working-directory: ${{ env.UI_DIRECTORY }}
- uses: actions/upload-artifact@v2
- name: Build
run: npm run build:ci
working-directory: ${{ env.UI_DIRECTORY }}
- uses: actions/upload-artifact@v3
with:
name: ${{ env.UI_ARTIFACT_NAME }}
path: "${{ env.UI_DIRECTORY }}/build"
@ -62,17 +74,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 1
- name: Set up Python 3.8
uses: actions/setup-python@v2
uses: actions/setup-python@v3
with:
python-version: '3.8'
python-version: "3.8"
- name: Install UI
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: ${{ env.UI_ARTIFACT_NAME }}
path: "${{ env.UI_DIRECTORY }}/build"

@ -18,7 +18,7 @@ jobs:
exit 1
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: ${{ env.FETCH_DEPTH }}
ref: development
@ -29,14 +29,14 @@ jobs:
git fetch --depth ${{ env.FETCH_DEPTH }} --tags
- name: Cache node_modules
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: '${{ env.UI_DIRECTORY }}/node_modules'
path: "${{ env.UI_DIRECTORY }}/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-modules-
- name: Setup NodeJS
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: "15.x"
@ -69,4 +69,4 @@ jobs:
release-it --ci --increment prerelease --preRelease=beta
else
echo "**** Cannot find changes! Skipping... ****"
fi
fi

@ -22,23 +22,23 @@ jobs:
exit 1
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: 0
ref: development
- name: Setup Git
run: git config --global user.name "github-actions"
- name: Cache node_modules
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: '${{ env.UI_DIRECTORY }}/node_modules'
path: "${{ env.UI_DIRECTORY }}/node_modules"
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-modules-
- name: Setup NodeJS
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: "15.x"
@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Merge development -> master
uses: devmasx/merge-branch@1.4.0

@ -16,13 +16,13 @@ jobs:
exit 1
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
fetch-depth: ${{ env.FETCH_DEPTH }}
ref: development
- name: Setup NodeJS
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: "15.x"
@ -35,9 +35,9 @@ jobs:
working-directory: ${{ env.UI_DIRECTORY }}
- name: Set up Python 3.8
uses: actions/setup-python@v2
uses: actions/setup-python@v3
with:
python-version: '3.8'
python-version: "3.8"
- name: Install Python dependencies
run: |

3
.gitignore vendored

@ -10,6 +10,9 @@ bazarr.pid
.idea
.vscode
# LSP
pyrightconfig.json
# Middleware
VERSION

@ -79,6 +79,7 @@ class EpisodesHistory(Resource):
TableHistory.score,
TableShows.tags,
TableHistory.action,
TableHistory.video_path,
TableHistory.subtitles_path,
TableHistory.sonarrEpisodeId,
TableHistory.provider,
@ -101,7 +102,8 @@ class EpisodesHistory(Resource):
if {"video_path": str(item['path']), "timestamp": float(item['timestamp']), "score": str(item['score']),
"tags": str(item['tags']), "monitored": str(item['monitored']),
"seriesType": str(item['seriesType'])} in upgradable_episodes_not_perfect: # noqa: E129
if os.path.isfile(path_mappings.path_replace(item['subtitles_path'])):
if os.path.exists(path_mappings.path_replace(item['subtitles_path'])) and \
os.path.exists(path_mappings.path_replace(item['video_path'])):
item.update({"upgradable": True})
del item['path']

@ -18,7 +18,7 @@ from ..utils import authenticate
class HistoryStats(Resource):
@authenticate
def get(self):
timeframe = request.args.get('timeframe') or 'month'
timeframe = request.args.get('timeFrame') or 'month'
action = request.args.get('action') or 'All'
provider = request.args.get('provider') or 'All'
language = request.args.get('language') or 'All'

@ -79,7 +79,8 @@ class MoviesHistory(Resource):
TableHistoryMovie.score,
TableHistoryMovie.subs_id,
TableHistoryMovie.provider,
TableHistoryMovie.subtitles_path)\
TableHistoryMovie.subtitles_path,
TableHistoryMovie.video_path)\
.join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId))\
.where(query_condition)\
.order_by(TableHistoryMovie.timestamp.desc())\
@ -96,7 +97,8 @@ class MoviesHistory(Resource):
item.update({"upgradable": False})
if {"video_path": str(item['path']), "timestamp": float(item['timestamp']), "score": str(item['score']),
"tags": str(item['tags']), "monitored": str(item['monitored'])} in upgradable_movies_not_perfect: # noqa: E129
if os.path.isfile(path_mappings.path_replace_movie(item['subtitles_path'])):
if os.path.exists(path_mappings.path_replace_movie(item['subtitles_path'])) and \
os.path.exists(path_mappings.path_replace_movie(item['video_path'])):
item.update({"upgradable": True})
del item['path']

@ -66,6 +66,7 @@ class ProviderEpisodes(Resource):
hi = request.form.get('hi').capitalize()
forced = request.form.get('forced').capitalize()
use_original_format = request.form.get('original_format').capitalize()
selected_provider = request.form.get('provider')
subtitle = request.form.get('subtitle')
@ -77,8 +78,7 @@ class ProviderEpisodes(Resource):
try:
result = manual_download_subtitle(episodePath, audio_language, hi, forced, subtitle, selected_provider,
sceneName, title, 'series',
profile_id=get_profile_id(episode_id=sonarrEpisodeId))
sceneName, title, 'series', use_original_format, profile_id=get_profile_id(episode_id=sonarrEpisodeId))
if result is not None:
message = result[0]
path = result[1]

@ -13,6 +13,7 @@ from notifier import send_notifications_movie
from list_subtitles import store_subtitles_movie
from ..utils import authenticate
import logging
class ProviderMovies(Resource):
@ -64,6 +65,8 @@ class ProviderMovies(Resource):
hi = request.form.get('hi').capitalize()
forced = request.form.get('forced').capitalize()
use_original_format = request.form.get('original_format').capitalize()
logging.debug(f"use_original_format {use_original_format}")
selected_provider = request.form.get('provider')
subtitle = request.form.get('subtitle')
@ -75,7 +78,7 @@ class ProviderMovies(Resource):
try:
result = manual_download_subtitle(moviePath, audio_language, hi, forced, subtitle, selected_provider,
sceneName, title, 'movie', profile_id=get_profile_id(movie_id=radarrId))
sceneName, title, 'movie', use_original_format, profile_id=get_profile_id(movie_id=radarrId))
if result is not None:
message = result[0]
path = result[1]

@ -73,7 +73,8 @@ class Subtitles(Resource):
else:
return '', 404
else:
subtitles_apply_mods(language, subtitles_path, [action])
use_original_format = True if request.form.get('original_format') == 'true' else False
subtitles_apply_mods(language, subtitles_path, [action], use_original_format)
# apply chmod if required
chmod = int(settings.general.chmod, 8) if not sys.platform.startswith(

@ -59,6 +59,7 @@ class SystemSettings(Resource):
TableLanguagesProfiles.items: json.dumps(item['items']),
TableLanguagesProfiles.mustContain: item['mustContain'],
TableLanguagesProfiles.mustNotContain: item['mustNotContain'],
TableLanguagesProfiles.originalFormat: item['originalFormat'] if item['originalFormat'] != 'null' else None,
})\
.where(TableLanguagesProfiles.profileId == item['profileId'])\
.execute()
@ -72,6 +73,7 @@ class SystemSettings(Resource):
TableLanguagesProfiles.items: json.dumps(item['items']),
TableLanguagesProfiles.mustContain: item['mustContain'],
TableLanguagesProfiles.mustNotContain: item['mustNotContain'],
TableLanguagesProfiles.originalFormat: item['originalFormat'] if item['originalFormat'] != 'null' else None,
}).execute()
for profileId in existing:
# Unassign this profileId from series and movies

@ -4,9 +4,13 @@ from flask import Blueprint
from flask_restful import Api
from .plex import WebHooksPlex
from .sonarr import WebHooksSonarr
from .radarr import WebHooksRadarr
api_bp_webhooks = Blueprint('api_webhooks', __name__)
api = Api(api_bp_webhooks)
api.add_resource(WebHooksPlex, '/webhooks/plex')
api.add_resource(WebHooksSonarr, '/webhooks/sonarr')
api.add_resource(WebHooksRadarr, '/webhooks/radarr')

@ -3,7 +3,7 @@
import json
import requests
import os
import re
import logging
from flask import request
from flask_restful import Resource
@ -46,8 +46,12 @@ class WebHooksPlex(Resource):
r = requests.get('https://imdb.com/title/{}'.format(episode_imdb_id),
headers={"User-Agent": os.environ["SZ_USER_AGENT"]})
soup = bso(r.content, "html.parser")
series_imdb_id = soup.find('a', {'class': re.compile(r'SeriesParentLink__ParentTextLink')})['href'].split('/')[2]
script_tag = soup.find(id='__NEXT_DATA__')
script_tag_json = script_tag.string
show_metadata_dict = json.loads(script_tag_json)
series_imdb_id = show_metadata_dict['props']['pageProps']['aboveTheFoldData']['series']['series']['id']
except Exception:
logging.debug('BAZARR is unable to get series IMDB id.')
return '', 404
else:
sonarrEpisodeId = TableEpisodes.select(TableEpisodes.sonarrEpisodeId) \

@ -0,0 +1,28 @@
# coding=utf-8
from flask import request
from flask_restful import Resource
from database import TableMovies
from get_subtitle.mass_download import movies_download_subtitles
from list_subtitles import store_subtitles_movie
from helper import path_mappings
from ..utils import authenticate
class WebHooksRadarr(Resource):
@authenticate
def post(self):
movie_file_id = request.form.get('radarr_moviefile_id')
radarrMovieId = TableMovies.select(TableMovies.radarrId,
TableMovies.path) \
.where(TableMovies.movie_file_id == movie_file_id) \
.dicts() \
.get_or_none()
if radarrMovieId:
store_subtitles_movie(radarrMovieId['path'], path_mappings.path_replace_movie(radarrMovieId['path']))
movies_download_subtitles(no=radarrMovieId['radarrId'])
return '', 200

@ -0,0 +1,29 @@
# coding=utf-8
from flask import request
from flask_restful import Resource
from database import TableEpisodes, TableShows
from get_subtitle.mass_download import episode_download_subtitles
from list_subtitles import store_subtitles
from helper import path_mappings
from ..utils import authenticate
class WebHooksSonarr(Resource):
@authenticate
def post(self):
episode_file_id = request.form.get('sonarr_episodefile_id')
sonarrEpisodeId = TableEpisodes.select(TableEpisodes.sonarrEpisodeId,
TableEpisodes.path) \
.join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) \
.where(TableEpisodes.episode_file_id == episode_file_id) \
.dicts() \
.get_or_none()
if sonarrEpisodeId:
store_subtitles(sonarrEpisodeId['path'], path_mappings.path_replace(sonarrEpisodeId['path']))
episode_download_subtitles(no=sonarrEpisodeId['sonarrEpisodeId'], send_progress=True)
return '', 200

@ -33,12 +33,14 @@ def get_restore_path():
def get_backup_files(fullpath=True):
backup_file_pattern = os.path.join(get_backup_path(), 'bazarr_backup_v*.zip')
file_list = glob(backup_file_pattern)
file_list.sort(key=os.path.getmtime)
if fullpath:
return file_list
else:
return [{
'type': 'backup',
'filename': os.path.basename(x),
'size': sizeof_fmt(os.path.getsize(x)),
'date': datetime.fromtimestamp(os.path.getmtime(x)).strftime("%b %d %Y")
} for x in file_list]
@ -178,7 +180,7 @@ def backup_rotation():
logging.debug(f'Cleaning up backup files older than {backup_retention} days')
for file in backup_files:
if datetime.fromtimestamp(os.path.getmtime(file)) + timedelta(days=backup_retention) < datetime.utcnow():
if datetime.fromtimestamp(os.path.getmtime(file)) + timedelta(days=int(backup_retention)) < datetime.utcnow():
logging.debug(f'Deleting old backup file {file}')
try:
os.remove(file)
@ -195,3 +197,11 @@ def delete_backup_file(filename):
except OSError:
logging.debug(f'Unable to delete backup file {backup_file_path}')
return False
def sizeof_fmt(num, suffix="B"):
for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]:
if abs(num) < 1000.0:
return f"{num:3.1f} {unit}{suffix}"
num /= 1000.0
return f"{num:.1f} Y{suffix}"

@ -194,16 +194,20 @@ def update_cleaner(zipfile, bazarr_dir, config_dir):
separator + '__pycache__' + separator + '$']
if os.path.abspath(bazarr_dir).lower() == os.path.abspath(config_dir).lower():
# for users who installed Bazarr inside the config directory (ie: `%programdata%\Bazarr` on windows)
with os.scandir(config_dir) as directories:
for directory in directories:
if directory.is_dir():
dir_to_ignore.append('^' + directory.name + os.path.sep)
dir_to_ignore.append('^backup' + separator)
dir_to_ignore.append('^cache' + separator)
dir_to_ignore.append('^config' + separator)
dir_to_ignore.append('^db' + separator)
dir_to_ignore.append('^log' + separator)
dir_to_ignore.append('^restore' + separator)
dir_to_ignore.append('^update' + separator)
elif os.path.abspath(bazarr_dir).lower() in os.path.abspath(config_dir).lower():
# when config directory is a child of Bazarr installation directory
dir_to_ignore.append('^' + os.path.relpath(config_dir, bazarr_dir) + os.path.sep)
dir_to_ignore_regex = re.compile('(?:% s)' % '|'.join(dir_to_ignore))
dir_to_ignore.append('^' + os.path.relpath(config_dir, bazarr_dir) + separator)
dir_to_ignore_regex_string = '(?:% s)' % '|'.join(dir_to_ignore)
logging.debug(f'BAZARR upgrade leftover cleaner will ignore directories matching this '
f'regex: {dir_to_ignore_regex.pattern}')
f'regex: {dir_to_ignore_regex_string}')
dir_to_ignore_regex = re.compile(dir_to_ignore_regex_string)
file_to_ignore = ['nssm.exe', '7za.exe', 'unins000.exe', 'unins000.dat']
logging.debug('BAZARR upgrade leftover cleaner will ignore those files: {}'.format(', '.join(file_to_ignore)))

@ -103,7 +103,8 @@ defaults = {
'excluded_tags': '[]',
'excluded_series_types': '[]',
'use_ffprobe_cache': 'True',
'exclude_season_zero': 'False'
'exclude_season_zero': 'False',
'defer_search_signalr': 'False'
},
'radarr': {
'ip': '127.0.0.1',
@ -117,7 +118,8 @@ defaults = {
'only_monitored': 'False',
'movies_sync': '60',
'excluded_tags': '[]',
'use_ffprobe_cache': 'True'
'use_ffprobe_cache': 'True',
'defer_search_signalr': 'False'
},
'proxy': {
'type': 'None',
@ -208,7 +210,8 @@ defaults = {
'include_ass': 'True',
'include_srt': 'True',
'hi_fallback': 'False',
'mergerfs_mode': 'False'
'mergerfs_mode': 'False',
'timeout': '600',
},
'subsync': {
'use_subsync': 'False',
@ -256,8 +259,7 @@ base_url = settings.general.base_url.rstrip('/')
ignore_keys = ['flask_secret_key',
'page_size',
'page_size_manual_search',
'throtteled_providers']
'page_size_manual_search']
raw_keys = ['movie_default_forced', 'serie_default_forced']
@ -424,26 +426,44 @@ def save_settings(settings_items):
if key == 'settings-addic7ed-username':
if key != settings.addic7ed.username:
region.delete('addic7ed_data')
elif key == 'settings-addic7ed-password':
if key != settings.addic7ed.password:
region.delete('addic7ed_data')
if key == 'settings-legendasdivx-username':
if key != settings.legendasdivx.username:
region.delete('legendasdivx_cookies2')
elif key == 'settings-legendasdivx-password':
if key != settings.legendasdivx.password:
region.delete('legendasdivx_cookies2')
if key == 'settings-opensubtitles-username':
if key != settings.opensubtitles.username:
region.delete('os_token')
elif key == 'settings-opensubtitles-password':
if key != settings.opensubtitles.password:
region.delete('os_token')
if key == 'settings-opensubtitlescom-username':
if key != settings.opensubtitlescom.username:
region.delete('oscom_token')
elif key == 'settings-opensubtitlescom-password':
if key != settings.opensubtitlescom.password:
region.delete('oscom_token')
if key == 'settings-subscene-username':
if key != settings.subscene.username:
region.delete('subscene_cookies2')
elif key == 'settings-subscene-password':
if key != settings.subscene.password:
region.delete('subscene_cookies2')
if key == 'settings-titlovi-username':
if key != settings.titlovi.username:
region.delete('titlovi_token')
elif key == 'settings-titlovi-password':
if key != settings.titlovi.password:
region.delete('titlovi_token')
if settings_keys[0] == 'settings':
settings[settings_keys[1]][settings_keys[2]] = str(value)

@ -131,6 +131,7 @@ class TableHistoryMovie(BaseModel):
class TableLanguagesProfiles(BaseModel):
cutoff = IntegerField(null=True)
originalFormat = BooleanField(null=True)
items = TextField()
name = TextField()
profileId = AutoField()
@ -332,6 +333,7 @@ def migrate_db():
migrator.add_column('table_history_movie', 'subtitles_path', TextField(null=True)),
migrator.add_column('table_languages_profiles', 'mustContain', TextField(null=True)),
migrator.add_column('table_languages_profiles', 'mustNotContain', TextField(null=True)),
migrator.add_column('table_languages_profiles', 'originalFormat', BooleanField(null=True)),
)
@ -396,27 +398,24 @@ def get_exclusion_clause(exclusion_type):
def update_profile_id_list():
global profile_id_list
profile_id_list = TableLanguagesProfiles.select(TableLanguagesProfiles.profileId,
TableLanguagesProfiles.name,
TableLanguagesProfiles.cutoff,
TableLanguagesProfiles.items,
TableLanguagesProfiles.mustContain,
TableLanguagesProfiles.mustNotContain).dicts()
TableLanguagesProfiles.mustNotContain,
TableLanguagesProfiles.originalFormat).dicts()
profile_id_list = list(profile_id_list)
for profile in profile_id_list:
profile['items'] = json.loads(profile['items'])
profile['mustContain'] = ast.literal_eval(profile['mustContain']) if profile['mustContain'] else \
profile['mustContain']
profile['mustNotContain'] = ast.literal_eval(profile['mustNotContain']) if profile['mustNotContain'] else \
profile['mustNotContain']
profile['mustContain'] = ast.literal_eval(profile['mustContain']) if profile['mustContain'] else []
profile['mustNotContain'] = ast.literal_eval(profile['mustNotContain']) if profile['mustNotContain'] else []
return profile_id_list
def get_profiles_list(profile_id=None):
try:
len(profile_id_list)
except NameError:
update_profile_id_list()
profile_id_list = update_profile_id_list()
if profile_id and profile_id != 'null':
for profile in profile_id_list:
@ -428,13 +427,11 @@ def get_profiles_list(profile_id=None):
def get_desired_languages(profile_id):
languages = []
if not len(profile_id_list):
update_profile_id_list()
profile_id_list = update_profile_id_list()
if profile_id and profile_id != 'null':
for profile in profile_id_list:
profileId, name, cutoff, items, mustContain, mustNotContain = profile.values()
profileId, name, cutoff, items, mustContain, mustNotContain, originalFormat = profile.values()
if profileId == int(profile_id):
languages = [x['language'] for x in items]
break
@ -444,13 +441,11 @@ def get_desired_languages(profile_id):
def get_profile_id_name(profile_id):
name_from_id = None
if not len(profile_id_list):
update_profile_id_list()
profile_id_list = update_profile_id_list()
if profile_id and profile_id != 'null':
for profile in profile_id_list:
profileId, name, cutoff, items, mustContain, mustNotContain = profile.values()
profileId, name, cutoff, items, mustContain, mustNotContain, originalFormat = profile.values()
if profileId == int(profile_id):
name_from_id = name
break
@ -460,14 +455,12 @@ def get_profile_id_name(profile_id):
def get_profile_cutoff(profile_id):
cutoff_language = None
if not len(profile_id_list):
update_profile_id_list()
profile_id_list = update_profile_id_list()
if profile_id and profile_id != 'null':
cutoff_language = []
for profile in profile_id_list:
profileId, name, cutoff, items, mustContain, mustNotContain = profile.values()
profileId, name, cutoff, items, mustContain, mustNotContain, originalFormat = profile.values()
if cutoff:
if profileId == int(profile_id):
for item in items:

@ -106,8 +106,7 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No
# if we have ffprobe available
if ffprobe_path:
api.initialize({"provider": "ffmpeg", "ffmpeg": ffprobe_path})
data["ffprobe"] = api.know(file)
data["ffprobe"] = api.know(video_path=file, context={"provider": "ffmpeg", "ffmpeg": ffprobe_path})
# if not, we use enzyme for mkv files
else:
if os.path.splitext(file)[1] == ".mkv":

@ -5,6 +5,7 @@ import argparse
from distutils.util import strtobool
no_update = bool(os.environ.get("NO_UPDATE", False))
parser = argparse.ArgumentParser()
@ -16,8 +17,9 @@ def get_args():
dest="config_dir", help="Directory containing the configuration (default: %s)" % config_dir)
parser.add_argument('-p', '--port', type=int, metavar="PORT", dest="port",
help="Port number (default: 6767)")
parser.add_argument('--no-update', default=False, type=bool, const=True, metavar="BOOL", nargs="?",
help="Disable update functionality (default: False)")
if not no_update:
parser.add_argument('--no-update', default=False, type=bool, const=True, metavar="BOOL", nargs="?",
help="Disable update functionality (default: False)")
parser.add_argument('--debug', default=False, type=bool, const=True, metavar="BOOL", nargs="?",
help="Enable console debugging (default: False)")
parser.add_argument('--release-update', default=False, type=bool, const=True, metavar="BOOL", nargs="?",
@ -31,3 +33,5 @@ def get_args():
args = get_args()
if no_update:
args.no_update = True

@ -165,7 +165,7 @@ def sync_episodes(series_id=None, send_event=True):
logging.debug('BAZARR All episodes synced from Sonarr into database.')
def sync_one_episode(episode_id):
def sync_one_episode(episode_id, defer_search=False):
logging.debug('BAZARR syncing this specific episode from Sonarr: {}'.format(episode_id))
url = url_sonarr()
apikey_sonarr = settings.sonarr.apikey
@ -239,9 +239,13 @@ def sync_one_episode(episode_id):
store_subtitles(episode['path'], path_mappings.path_replace(episode['path']))
# Downloading missing subtitles
logging.debug('BAZARR downloading missing subtitles for this episode: {}'.format(path_mappings.path_replace(
episode['path'])))
episode_download_subtitles(episode_id)
if defer_search:
logging.debug('BAZARR searching for missing subtitles is deferred until scheduled task execution for this '
'episode: {}'.format(path_mappings.path_replace(episode['path'])))
else:
logging.debug('BAZARR downloading missing subtitles for this episode: {}'.format(path_mappings.path_replace(
episode['path'])))
episode_download_subtitles(episode_id)
def SonarrFormatAudioCodec(audio_codec):

@ -166,7 +166,7 @@ def update_movies(send_event=True):
logging.debug('BAZARR All movies synced from Radarr into database.')
def update_one_movie(movie_id, action):
def update_one_movie(movie_id, action, defer_search=False):
logging.debug('BAZARR syncing this specific movie from Radarr: {}'.format(movie_id))
# Check if there's a row in database for this movie ID
@ -262,9 +262,13 @@ def update_one_movie(movie_id, action):
store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']))
# Downloading missing subtitles
logging.debug('BAZARR downloading missing subtitles for this movie: {}'.format(path_mappings.path_replace_movie(
movie['path'])))
movies_download_subtitles(movie_id)
if defer_search:
logging.debug('BAZARR searching for missing subtitles is deferred until scheduled task execution for this '
'movie: {}'.format(path_mappings.path_replace_movie(movie['path'])))
else:
logging.debug('BAZARR downloading missing subtitles for this movie: {}'.format(path_mappings.path_replace_movie(
movie['path'])))
movies_download_subtitles(movie_id)
def get_profile_list():

@ -8,35 +8,40 @@ import pretty
import time
import socket
import requests
import tzlocal
from get_args import args
from config import settings, get_array_from
from event_handler import event_stream
from utils import get_binary, blacklist_log, blacklist_log_movie
from subliminal_patch.exceptions import TooManyRequests, APIThrottled, ParseResponseError, IPAddressBlocked, MustGetBlacklisted
from subliminal_patch.exceptions import TooManyRequests, APIThrottled, ParseResponseError, IPAddressBlocked, \
MustGetBlacklisted, SearchLimitReached
from subliminal.providers.opensubtitles import DownloadLimitReached
from subliminal.exceptions import DownloadLimitExceeded, ServiceUnavailable
from subliminal import region as subliminal_cache_region
from subliminal_patch.extensions import provider_registry
def time_until_end_of_day(dt=None):
def time_until_midnight(timezone):
# type: (datetime.datetime) -> datetime.timedelta
"""
Get timedelta until end of day on the datetime passed, or current time.
Get timedelta until midnight.
"""
if dt is None:
dt = datetime.datetime.now()
tomorrow = dt + datetime.timedelta(days=1)
return datetime.datetime.combine(tomorrow, datetime.time.min) - dt
now_in_tz = datetime.datetime.now(tz=timezone)
midnight = now_in_tz.replace(hour=0, minute=0, second=0, microsecond=0) + \
datetime.timedelta(days=1)
return midnight - now_in_tz
# Titulky resets its download limits at the start of a new day from its perspective - the Europe/Prague timezone
# Needs to convert to offset-naive dt
titulky_server_local_time = datetime.datetime.now(tz=pytz.timezone('Europe/Prague')).replace(tzinfo=None)
titulky_limit_reset_datetime = time_until_end_of_day(dt=titulky_server_local_time)
titulky_limit_reset_timedelta = time_until_midnight(timezone=pytz.timezone('Europe/Prague'))
hours_until_end_of_day = time_until_end_of_day().seconds // 3600 + 1
# LegendasDivx reset its searches limit at approximately midnight, Lisbon time, everyday.
legendasdivx_limit_reset_timedelta = time_until_midnight(timezone=pytz.timezone('Europe/Lisbon')) + \
datetime.timedelta(minutes=15)
hours_until_end_of_day = time_until_midnight(timezone=tzlocal.get_localzone()).days + 1
VALID_THROTTLE_EXCEPTIONS = (TooManyRequests, DownloadLimitExceeded, ServiceUnavailable, APIThrottled,
ParseResponseError, IPAddressBlocked)
@ -71,14 +76,19 @@ PROVIDER_THROTTLE_MAP = {
IPAddressBlocked: (datetime.timedelta(hours=1), "1 hours"),
},
"titulky": {
DownloadLimitExceeded: (titulky_limit_reset_datetime, f"{titulky_limit_reset_datetime.seconds // 3600 + 1} hours")
DownloadLimitExceeded: (titulky_limit_reset_timedelta, f"{titulky_limit_reset_timedelta.seconds // 3600 + 1} hours")
},
"legendasdivx": {
TooManyRequests: (datetime.timedelta(hours=3), "3 hours"),
DownloadLimitExceeded: (
datetime.timedelta(hours=hours_until_end_of_day), "{} hours".format(str(hours_until_end_of_day))),
legendasdivx_limit_reset_timedelta,
f"{legendasdivx_limit_reset_timedelta.seconds // 3600 + 1} hours"),
IPAddressBlocked: (
datetime.timedelta(hours=hours_until_end_of_day), "{} hours".format(str(hours_until_end_of_day))),
legendasdivx_limit_reset_timedelta,
f"{legendasdivx_limit_reset_timedelta.seconds // 3600 + 1} hours"),
SearchLimitReached: (
legendasdivx_limit_reset_timedelta,
f"{legendasdivx_limit_reset_timedelta.seconds // 3600 + 1} hours"),
}
}
@ -215,6 +225,7 @@ def get_providers_auth():
'cache_dir': os.path.join(args.config_dir, "cache"),
'ffprobe_path': _FFPROBE_BINARY,
'ffmpeg_path': _FFMPEG_BINARY,
'timeout': settings.embeddedsubtitles.timeout,
}
}
@ -297,7 +308,7 @@ def update_throttled_provider():
for provider in list(tp):
if provider not in providers_list:
del tp[provider]
settings.general.throtteled_providers = str(tp)
set_throttled_providers(str(tp))
reason, until, throttle_desc = tp.get(provider, (None, None, None))

@ -72,8 +72,10 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_
if not subtitles:
continue
subtitle_formats = set()
for s in subtitles:
s.mods = subz_mods
subtitle_formats.add(s.format)
try:
fld = get_target_folder(path)
@ -84,7 +86,7 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_
tags=None, # fixme
directory=fld,
chmod=chmod,
# formats=("srt", "vtt")
formats=tuple(subtitle_formats),
path_decoder=force_unicode
)
except Exception as e:

@ -31,7 +31,7 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
pool = _get_pool(media_type, profile_id)
language_set, initial_language_set = _get_language_obj(profile_id=profile_id)
language_set, initial_language_set, original_format = _get_language_obj(profile_id=profile_id)
also_forced = any([x.forced for x in initial_language_set])
_set_forced_providers(also_forced=also_forced, pool=pool)
@ -136,6 +136,7 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
provider=s.provider_name,
subtitle=codecs.encode(pickle.dumps(s.make_picklable()), "base64").decode(),
url=s.page_link,
original_format=original_format,
matches=list(matches),
dont_matches=list(not_matched),
release_info=releases,
@ -153,7 +154,7 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
@update_pools
def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provider, sceneName, title, media_type,
profile_id):
use_original_format, profile_id):
logging.debug('BAZARR Manually downloading Subtitles for this file: ' + path)
if settings.general.getboolean('utf8_encode'):
@ -170,6 +171,8 @@ def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provide
subtitle.language.forced = True
else:
subtitle.language.forced = False
if use_original_format == 'True':
subtitle.use_original_format = use_original_format
subtitle.mods = get_array_from(settings.general.subzero_mods)
video = get_video(force_unicode(path), title, sceneName, providers={provider}, media_type=media_type)
if video:
@ -195,7 +198,7 @@ def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provide
tags=None, # fixme
directory=get_target_folder(path),
chmod=chmod,
# formats=("srt", "vtt")
formats=(subtitle.format,),
path_decoder=force_unicode)
except Exception:
logging.exception('BAZARR Error saving Subtitles file to disk for this file:' + path)
@ -228,8 +231,9 @@ def _get_language_obj(profile_id):
initial_language_set = set()
language_set = set()
# where [3] is items list of dict(id, lang, forced, hi)
language_items = get_profiles_list(profile_id=int(profile_id))['items']
profile = get_profiles_list(profile_id=int(profile_id))
language_items = profile['items']
original_format = profile['originalFormat']
for language in language_items:
forced = language['forced']
@ -259,7 +263,7 @@ def _get_language_obj(profile_id):
continue
language_set.add(lang_obj_hi)
return language_set, initial_language_set
return language_set, initial_language_set, original_format
def _set_forced_providers(also_forced, pool):

@ -48,17 +48,16 @@ def movies_download_subtitles(no):
audio_language = 'None'
languages = []
providers_list = None
for i, language in enumerate(ast.literal_eval(movie['missing_subtitles'])):
for language in ast.literal_eval(movie['missing_subtitles']):
providers_list = get_providers()
if language is not None:
hi_ = "True" if language.endswith(':hi') else "False"
forced_ = "True" if language.endswith(':forced') else "False"
languages.append((language.split(":")[0], hi_, forced_))
if providers_list:
if language is not None:
hi_ = "True" if language.endswith(':hi') else "False"
forced_ = "True" if language.endswith(':forced') else "False"
languages.append((language.split(":")[0], hi_, forced_))
# confirm if language is still missing or if cutoff have been reached
confirmed_missing_subs = TableMovies.select(TableMovies.missing_subtitles) \
.where(TableMovies.radarrId == movie['radarrId']) \
@ -69,39 +68,39 @@ def movies_download_subtitles(no):
if language not in ast.literal_eval(confirmed_missing_subs['missing_subtitles']):
continue
show_progress(id='movie_search_progress_{}'.format(no),
header='Searching missing subtitles...',
name=movie['title'],
value=i,
count=count_movie)
if providers_list:
for result in generate_subtitles(path_mappings.path_replace_movie(movie['path']),
languages,
audio_language,
str(movie['sceneName']),
movie['title'],
'movie'):
if result:
message = result[0]
path = result[1]
forced = result[5]
if result[8]:
language_code = result[2] + ":hi"
elif forced:
language_code = result[2] + ":forced"
else:
language_code = result[2]
provider = result[3]
score = result[4]
subs_id = result[6]
subs_path = result[7]
store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']))
history_log_movie(1, no, message, path, language_code, provider, score, subs_id, subs_path)
send_notifications_movie(no, message)
else:
logging.info("BAZARR All providers are throttled")
else:
logging.info("BAZARR All providers are throttled")
break
show_progress(id='movie_search_progress_{}'.format(no),
header='Searching missing subtitles...',
name=movie['title'],
value=0,
count=count_movie)
for result in generate_subtitles(path_mappings.path_replace_movie(movie['path']),
languages,
audio_language,
str(movie['sceneName']),
movie['title'],
'movie'):
if result:
message = result[0]
path = result[1]
forced = result[5]
if result[8]:
language_code = result[2] + ":hi"
elif forced:
language_code = result[2] + ":forced"
else:
language_code = result[2]
provider = result[3]
score = result[4]
subs_id = result[6]
subs_path = result[7]
store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']))
history_log_movie(1, no, message, path, language_code, provider, score, subs_id, subs_path)
send_notifications_movie(no, message)
hide_progress(id='movie_search_progress_{}'.format(no))

@ -73,7 +73,9 @@ def upgrade_subtitles():
episodes_to_upgrade = []
for episode in upgradable_episodes_not_perfect:
if os.path.exists(path_mappings.path_replace(episode['subtitles_path'])) and int(episode['score']) < 357:
if os.path.exists(path_mappings.path_replace(episode['subtitles_path'])) and \
os.path.exists(path_mappings.path_replace(episode['video_path'])) and \
int(episode['score']) < 357:
episodes_to_upgrade.append(episode)
count_episode_to_upgrade = len(episodes_to_upgrade)
@ -114,7 +116,9 @@ def upgrade_subtitles():
movies_to_upgrade = []
for movie in upgradable_movies_not_perfect:
if os.path.exists(path_mappings.path_replace_movie(movie['subtitles_path'])) and int(movie['score']) < 117:
if os.path.exists(path_mappings.path_replace_movie(movie['subtitles_path'])) and \
os.path.exists(path_mappings.path_replace_movie(movie['video_path'])) and \
int(movie['score']) < 117:
movies_to_upgrade.append(movie)
count_movie_to_upgrade = len(movies_to_upgrade)

@ -63,7 +63,7 @@ def manual_upload_subtitle(path, language, forced, hi, title, scene_name, media_
tags=None, # fixme
directory=get_target_folder(path),
chmod=chmod,
# formats=("srt", "vtt")
formats=(sub.format,),
path_decoder=force_unicode)
except Exception:
logging.exception('BAZARR Error saving Subtitles file to disk for this file:' + path)

@ -1,6 +1,7 @@
# coding=utf-8
import os
import sys
import logging
import re
import platform
@ -79,8 +80,12 @@ def configure_logging(debug=False):
# File Logging
global fh
fh = TimedRotatingFileHandler(os.path.join(args.config_dir, 'log/bazarr.log'), when="midnight", interval=1,
backupCount=7, delay=True, encoding='utf-8')
if sys.version_info >= (3, 9):
fh = PatchedTimedRotatingFileHandler(os.path.join(args.config_dir, 'log/bazarr.log'), when="midnight",
interval=1, backupCount=7, delay=True, encoding='utf-8')
else:
fh = TimedRotatingFileHandler(os.path.join(args.config_dir, 'log/bazarr.log'), when="midnight", interval=1,
backupCount=7, delay=True, encoding='utf-8')
f = FileHandlerFormatter('%(asctime)s|%(levelname)-8s|%(name)-32s|%(message)s|',
'%d/%m/%Y %H:%M:%S')
fh.setFormatter(f)
@ -132,3 +137,54 @@ def configure_logging(debug=False):
def empty_log():
fh.doRollover()
logging.info('BAZARR Log file emptied')
class PatchedTimedRotatingFileHandler(TimedRotatingFileHandler):
# This super classed version of logging.TimedRotatingFileHandler is required to fix a bug in earlier version of
# Python 3.9, 3.10 and 3.11 where log rotation isn't working as expected and do not delete backup log files.
def __init__(self, filename, when='h', interval=1, backupCount=0, encoding=None, delay=False, utc=False,
atTime=None, errors=None):
super(PatchedTimedRotatingFileHandler, self).__init__(filename, when, interval, backupCount, encoding, delay, utc,
atTime, errors)
def getFilesToDelete(self):
"""
Determine the files to delete when rolling over.
More specific than the earlier method, which just used glob.glob().
"""
dirName, baseName = os.path.split(self.baseFilename)
fileNames = os.listdir(dirName)
result = []
# See bpo-44753: Don't use the extension when computing the prefix.
n, e = os.path.splitext(baseName)
prefix = n + '.'
plen = len(prefix)
for fileName in fileNames:
if self.namer is None:
# Our files will always start with baseName
if not fileName.startswith(baseName):
continue
else:
# Our files could be just about anything after custom naming, but
# likely candidates are of the form
# foo.log.DATETIME_SUFFIX or foo.DATETIME_SUFFIX.log
if (not fileName.startswith(baseName) and fileName.endswith(e) and
len(fileName) > (plen + 1) and not fileName[plen+1].isdigit()):
continue
if fileName[:plen] == prefix:
suffix = fileName[plen:]
# See bpo-45628: The date/time suffix could be anywhere in the
# filename
parts = suffix.split('.')
for part in parts:
if self.extMatch.match(part):
result.append(os.path.join(dirName, fileName))
break
if len(result) < self.backupCount:
result = []
else:
result.sort()
result = result[:len(result) - self.backupCount]
return result

@ -24,7 +24,8 @@ from notifier import update_notifier # noqa E402
from urllib.parse import unquote # noqa E402
from get_languages import load_language_in_db # noqa E402
from flask import request, redirect, abort, render_template, Response, session, send_file, stream_with_context # noqa E402
from flask import request, redirect, abort, render_template, Response, session, send_file, stream_with_context, \
send_from_directory
from threading import Thread # noqa E402
import requests # noqa E402
@ -112,6 +113,12 @@ def catch_all(path):
return render_template("index.html", BAZARR_SERVER_INJECT=inject, baseUrl=template_url)
@app.route('/assets/<path:filename>')
def web_assets(filename):
path = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'build', 'assets')
return send_from_directory(path, filename)
@check_login
@app.route('/bazarr.log')
def download_log():

@ -237,9 +237,10 @@ def dispatcher(data):
# this will happen if a season monitored status is changed.
sync_episodes(series_id=media_id, send_event=True)
elif topic == 'episode':
sync_one_episode(episode_id=media_id)
sync_one_episode(episode_id=media_id, defer_search=settings.sonarr.getboolean('defer_search_signalr'))
elif topic == 'movie':
update_one_movie(movie_id=media_id, action=action)
update_one_movie(movie_id=media_id, action=action,
defer_search=settings.radarr.getboolean('defer_search_signalr'))
except Exception as e:
logging.debug('BAZARR an exception occurred while parsing SignalR feed: {}'.format(repr(e)))
finally:

@ -24,7 +24,6 @@ from subliminal_patch.subtitle import Subtitle
from subliminal_patch.core import get_subtitle_path
from subzero.language import Language
from subliminal import region as subliminal_cache_region
from deep_translator import GoogleTranslator
from dogpile.cache import make_region
import datetime
import glob
@ -258,8 +257,12 @@ class GetSonarrInfo:
else:
raise json.decoder.JSONDecodeError
except json.decoder.JSONDecodeError:
sv = url_sonarr() + "/api/v3/system/status?apikey=" + settings.sonarr.apikey
sonarr_version = requests.get(sv, timeout=60, verify=False, headers=headers).json()['version']
try:
sv = url_sonarr() + "/api/v3/system/status?apikey=" + settings.sonarr.apikey
sonarr_version = requests.get(sv, timeout=60, verify=False, headers=headers).json()['version']
except json.decoder.JSONDecodeError:
logging.debug('BAZARR cannot get Sonarr version')
sonarr_version = 'unknown'
except Exception:
logging.debug('BAZARR cannot get Sonarr version')
sonarr_version = 'unknown'
@ -412,7 +415,7 @@ def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_pat
return True
def subtitles_apply_mods(language, subtitle_path, mods):
def subtitles_apply_mods(language, subtitle_path, mods, use_original_format):
language = alpha3_from_alpha2(language)
custom = CustomLanguage.from_value(language, "alpha3")
if custom is None:
@ -420,13 +423,16 @@ def subtitles_apply_mods(language, subtitle_path, mods):
else:
lang_obj = custom.subzero_language()
sub = Subtitle(lang_obj, mods=mods)
sub = Subtitle(lang_obj, mods=mods, original_format=use_original_format)
with open(subtitle_path, 'rb') as f:
sub.content = f.read()
if not sub.is_valid():
logging.exception('BAZARR Invalid subtitle file: ' + subtitle_path)
return
if use_original_format:
return
content = sub.get_modified_content()
if content:
@ -438,10 +444,12 @@ def subtitles_apply_mods(language, subtitle_path, mods):
def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
from deep_translator import GoogleTranslator
language_code_convert_dict = {
'he': 'iw',
'zt': 'zh-cn',
'zh': 'zh-tw',
'zt': 'zh-CN',
'zh': 'zh-TW',
}
to_lang = alpha3_from_alpha2(to_lang)
@ -461,6 +469,7 @@ def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
extension='.srt', forced_tag=forced, hi_tag=hi)
subs = pysubs2.load(source_srt_file, encoding='utf-8')
subs.remove_miscellaneous_events()
lines_list = [x.plaintext for x in subs]
joined_lines_str = '\n\n\n'.join(lines_list)
@ -480,11 +489,6 @@ def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
logging.debug('BAZARR is sending {} blocks to Google Translate'.format(len(lines_block_list)))
for block_str in lines_block_list:
empty_first_line = False
if block_str.startswith('\n\n\n'):
# This happens when the first line of text in a subtitles file is an empty string
empty_first_line = True
try:
translated_partial_srt_text = GoogleTranslator(source='auto',
target=language_code_convert_dict.get(lang_obj.alpha2,
@ -494,9 +498,6 @@ def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
logging.exception(f'BAZARR Unable to translate subtitles {source_srt_file}')
return False
else:
if empty_first_line:
# GoogleTranslate remove new lines at the beginning of the string, so we add it back.
translated_partial_srt_text = '\n\n\n' + translated_partial_srt_text
translated_partial_srt_list = translated_partial_srt_text.split('\n\n\n')
translated_lines_list += translated_partial_srt_list
@ -526,6 +527,9 @@ def check_health():
check_radarr_rootfolder()
event_stream(type='badges')
from backup import backup_rotation
backup_rotation()
def get_health_issues():
# this function must return a list of dictionaries consisting of to keys: object and issue

@ -1,8 +1,15 @@
From newest to oldest:
{{#each releases}}
{{#if @first}}
{{#each commits}}
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
{{/each}}
{{/if}}
{{#each merges}}
-
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
{{/each}}
{{#each fixes}}
-
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
{{/each}}
{{#each commits}}
-
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
{{/each}}
{{/each}}

@ -1,6 +1,15 @@
From newest to oldest:
{{#each releases}}
{{#each merges}}
-
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
{{/each}}
{{#each fixes}}
-
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
{{/each}}
{{#each commits}}
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
-
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
{{/each}}
{{/each}}

@ -1,27 +1,29 @@
# Override by duplicating me and rename to .env.local
# The following environment variables will only be used during development
# Required
# API key of your backend
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
# VITE_API_KEY="YOUR_SERVER_API_KEY"
# Address of your backend
REACT_APP_PROXY_URL=http://localhost:6767
# Optional
# VITE_PROXY_URL=http://127.0.0.1:6767
# Allow Unsecured connection to your backend
REACT_APP_PROXY_SECURE=true
# Allow websocket connection in Socket.IO
REACT_APP_ALLOW_WEBSOCKET=true
# Bazarr configuration path, must be absolute path
# Vite will use this variable to find your bazarr's configuration file
VITE_BAZARR_CONFIG_FILE="../data/config/config.ini"
# Display update section in settings
REACT_APP_CAN_UPDATE=true
VITE_CAN_UPDATE=true
# Display update notification in notification center
REACT_APP_HAS_UPDATE=false
VITE_HAS_UPDATE=false
# Display React-Query devtools
REACT_APP_QUERY_DEV=false
VITE_QUERY_DEV=false
# Proxy Settings
# Allow Unsecured connection to your backend
VITE_PROXY_SECURE=true
# Allow websocket connection in Socket.IO
VITE_ALLOW_WEBSOCKET=true

@ -1,3 +1,15 @@
{
"extends": "react-app"
"rules": {
"no-console": "error",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-empty-function": "warn",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-unused-vars": "warn"
},
"extends": [
"react-app",
"plugin:react-hooks/recommended",
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
]
}

@ -2,3 +2,5 @@ node_modules
dist
*.local
build
*.tsbuildinfo

@ -1,4 +1,4 @@
build
dist
converage
public

@ -20,26 +20,24 @@
$ npm install
```
3. Duplicate `.env.development` file and rename to `.env.local`
3. (Optional) Duplicate `.env.development` file and rename to `.env.development.local`
```
$ cp .env .env.local
$ cp .env.development .env.development.local
```
4. Update your backend server's API key in `.env.local`
4. (Optional) Update your backend server's API key in `.env.development.local`
```
# API key of your backend
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
VITE_API_KEY="YOUR_SERVER_API_KEY"
```
5. Change the address of your backend server (Optional)
> http://localhost:6767 will be used by default
5. (Optional) Change the address of your backend server
```
# Address of your backend
REACT_APP_PROXY_URL=http://localhost:6767
VITE_PROXY_URL=http://localhost:6767
```
6. Run Bazarr backend
@ -66,17 +64,11 @@ Open `http://localhost:3000` to view it in the browser.
The page will reload if you make edits.
You will also see any lint errors in the console.
### `npm test`
Run the Unit Test to validate app state.
Please ensure all tests are passed before uploading the code
### `npm run build`
Builds the app for production to the `build` folder.
Builds the app in production mode and save to the `build` folder.
### `npm run lint`
### `npm run format`
Format code for all files in `frontend` folder

@ -0,0 +1,30 @@
import { dependencies } from "../package.json";
const vendors = [
"react",
"react-redux",
"react-router-dom",
"react-dom",
"react-query",
"axios",
"socket.io-client",
];
function renderChunks() {
const chunks: Record<string, string[]> = {};
for (const key in dependencies) {
if (!vendors.includes(key)) {
chunks[key] = [key];
}
}
return chunks;
}
const chunks = {
vendors,
...renderChunks(),
};
export default chunks;

@ -0,0 +1,67 @@
/// <reference types="node" />
import { readFile } from "fs/promises";
async function read(path: string, sectionName: string, fieldName: string) {
const config = await readFile(path, "utf8");
const targetSection = config
.split("\n\n")
.filter((section) => section.includes(`[${sectionName}]`));
if (targetSection.length === 0) {
throw new Error(`Cannot find [${sectionName}] section in config`);
}
const section = targetSection[0];
for (const line of section.split("\n")) {
const matched = line.startsWith(fieldName);
if (matched) {
const results = line.split("=");
if (results.length === 2) {
const key = results[1].trim();
return key;
}
}
}
throw new Error(`Cannot find ${fieldName} in config`);
}
export default async function overrideEnv(env: Record<string, string>) {
const configPath = env["VITE_BAZARR_CONFIG_FILE"];
if (configPath === undefined) {
return;
}
if (env["VITE_API_KEY"] === undefined) {
try {
const apiKey = await read(configPath, "auth", "apikey");
env["VITE_API_KEY"] = apiKey;
process.env["VITE_API_KEY"] = apiKey;
} catch (err) {
throw new Error(
`No API key found, please run the backend first, (error: ${err.message})`
);
}
}
if (env["VITE_PROXY_URL"] === undefined) {
try {
const port = await read(configPath, "general", "port");
const baseUrl = await read(configPath, "general", "base_url");
const url = `http://localhost:${port}${baseUrl}`;
env["VITE_PROXY_URL"] = url;
process.env["VITE_PROXY_URL"] = url;
} catch (err) {
throw new Error(
`No proxy url found, please run the backend first, (error: ${err.message})`
);
}
}
}

@ -4,11 +4,7 @@
<title>Bazarr</title>
<base href="{{baseUrl}}" />
<meta charset="utf-8" />
<link
rel="icon"
type="image/x-icon"
href="%PUBLIC_URL%/static/favicon.ico"
/>
<link rel="icon" type="image/x-icon" href="./static/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
@ -17,7 +13,6 @@
name="description"
content="Bazarr is a companion application to Sonarr and Radarr. It manages and downloads subtitles based on your requirements. You define your preferences by TV show or movie and Bazarr takes care of everything for you."
/>
<link rel="manifest" href="%PUBLIC_URL%/static/manifest.json" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
@ -25,5 +20,6 @@
<script>
window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}};
</script>
<script type="module" src="./src/dom.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -12,57 +12,69 @@
"url": "https://github.com/morpheus65535/bazarr/issues"
},
"private": true,
"homepage": "./",
"dependencies": {
"@fontsource/roboto": "^4.5.1",
"@fortawesome/fontawesome-svg-core": "^1.2",
"@fortawesome/free-brands-svg-icons": "^5.15",
"@fortawesome/free-regular-svg-icons": "^5.15",
"@fortawesome/free-solid-svg-icons": "^5.15",
"@fortawesome/react-fontawesome": "^0.1.16",
"@reduxjs/toolkit": "^1.6",
"axios": "^0.24",
"bootstrap": "^4",
"lodash": "^4",
"moment": "^2.29.1",
"rc-slider": "^9.7",
"axios": "^0.26",
"react": "^17",
"react-bootstrap": "^1",
"react-dom": "^17",
"react-helmet": "^6.1",
"react-query": "^3.34",
"react-redux": "^7.2",
"react-router-dom": "^5.3",
"react-scripts": "^4",
"react-select": "^5.0.1",
"react-table": "^7",
"recharts": "^2.0.8",
"rooks": "^5.7.1",
"react-router-dom": "^6.2.1",
"socket.io-client": "^4"
},
"devDependencies": {
"@types/bootstrap": "^5",
"@types/jest": "~26.0.24",
"@fontsource/roboto": "^4.5",
"@fortawesome/fontawesome-svg-core": "^6",
"@fortawesome/free-brands-svg-icons": "^6",
"@fortawesome/free-regular-svg-icons": "^6",
"@fortawesome/free-solid-svg-icons": "^6",
"@fortawesome/react-fontawesome": "^0.1",
"@reduxjs/toolkit": "^1",
"@testing-library/jest-dom": "latest",
"@testing-library/react": "12",
"@testing-library/react-hooks": "latest",
"@testing-library/user-event": "latest",
"@types/bootstrap": "^4",
"@types/lodash": "^4",
"@types/node": "^15",
"@types/node": "^17",
"@types/react": "^17",
"@types/react-dom": "^17",
"@types/react-helmet": "^6.1",
"@types/react-router-dom": "^5",
"@types/react-table": "^7",
"http-proxy-middleware": "^2",
"@vitejs/plugin-react": "^1.3",
"bootstrap": "^4",
"clsx": "^1.1.1",
"eslint": "^8",
"eslint-config-react-app": "^7.0.0",
"eslint-plugin-react-hooks": "^4",
"husky": "^7",
"jsdom": "latest",
"lodash": "^4",
"moment": "^2.29.1",
"prettier": "^2",
"prettier-plugin-organize-imports": "^2",
"pretty-quick": "^3.1",
"rc-slider": "^9.7",
"react-helmet": "^6.1",
"react-select": "^5.0.1",
"react-table": "^7",
"recharts": "^2.0.8",
"rooks": "^5",
"sass": "^1",
"typescript": "^4"
"typescript": "^4",
"vite": "latest",
"vite-plugin-checker": "latest",
"vitest": "latest"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"lint": "prettier --write --ignore-unknown .",
"start": "vite",
"build": "vite build",
"build:ci": "vite build -m development",
"check": "eslint --ext .ts,.tsx src",
"check:ts": "tsc --noEmit --incremental false",
"check:fmt": "prettier -c .",
"test": "vitest",
"format": "prettier -w .",
"prepare": "cd .. && husky install frontend/.husky"
},
"browserslist": {

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -1,14 +0,0 @@
{
"short_name": "Bazarr",
"name": "Bazarr Frontend",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"background_color": "#ffffff"
}

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

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

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

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

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

@ -1,21 +0,0 @@
// Override bootstrap primary color
$theme-colors: (
"primary": #911f93,
"dark": #4f566f,
);
body {
font-family: "Roboto", "open sans", "Helvetica Neue", "Helvetica", "Arial",
sans-serif !important;
font-weight: 300 !important;
}
// Reduce padding of cells in datatables
.table td,
.table th {
padding: 0.4rem !important;
}
.progress-bar {
cursor: default;
}

@ -1,49 +0,0 @@
@import "./variable.scss";
:root {
.form-control {
&:focus {
outline-color: none !important;
box-shadow: none !important;
border-color: var(--primary) !important;
}
}
}
td {
vertical-align: middle !important;
}
.dropdown-hidden {
&::after {
display: none !important;
}
}
.cursor-pointer {
cursor: pointer;
}
.opacity-100 {
opacity: 100% !important;
}
.vh-100 {
height: 100vh !important;
}
.vh-75 {
height: 75vh !important;
}
.of-hidden {
overflow: hidden;
}
.of-auto {
overflow: auto;
}
.vw-1 {
width: 12rem;
}

@ -1,55 +0,0 @@
@import "./global.scss";
@import "./variable.scss";
@import "./bazarr.scss";
@import "../../node_modules/bootstrap/scss/bootstrap.scss";
@mixin sidebar-animation {
transition: {
duration: 0.2s;
timing-function: ease-in-out;
}
}
@include media-breakpoint-up(sm) {
.sidebar-container {
position: sticky;
}
.main-router {
max-width: calc(100% - #{$sidebar-width});
}
.header-icon {
min-width: $sidebar-width;
}
}
@include media-breakpoint-down(sm) {
.sidebar-container {
position: fixed !important;
transform: translateX(-100%);
@include sidebar-animation();
&.open {
transform: translateX(0) !important;
}
}
.main-router {
max-width: 100%;
}
.sidebar-overlay {
@include sidebar-animation();
&.open {
display: block !important;
opacity: 0.6;
}
}
.header-icon {
min-width: 0;
}
}

@ -1,6 +0,0 @@
$sidebar-width: 190px;
$header-height: 60px;
$theme-color-less-transparent: #911f9331;
$theme-color-transparent: #911f9313;
$theme-color-darked: #761977;

@ -1,3 +1,9 @@
import { useSystem, useSystemSettings } from "@/apis/hooks";
import { ActionButton, SearchBar } from "@/components";
import { setSidebar } from "@/modules/redux/actions";
import { useIsOffline } from "@/modules/redux/hooks";
import { useReduxAction } from "@/modules/redux/hooks/base";
import { Environment, useGotoHomepage, useIsMobile } from "@/utilities";
import {
faBars,
faHeart,
@ -5,12 +11,7 @@ import {
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { setSidebar } from "@redux/actions";
import { useIsOffline } from "@redux/hooks";
import { useReduxAction } from "@redux/hooks/base";
import logo from "@static/logo64.png";
import { ActionButton, SearchBar } from "components";
import React, { FunctionComponent, useMemo } from "react";
import { FunctionComponent, useMemo } from "react";
import {
Button,
Col,
@ -21,14 +22,9 @@ import {
Row,
} from "react-bootstrap";
import { Helmet } from "react-helmet";
import { useGotoHomepage, useIsMobile } from "utilities";
import { useSystem, useSystemSettings } from "../apis/hooks";
import "./header.scss";
import NotificationCenter from "./Notification";
interface Props {}
const Header: FunctionComponent<Props> = () => {
const Header: FunctionComponent = () => {
const { data: settings } = useSystemSettings();
const hasLogout = (settings?.auth.type ?? "none") === "form";
@ -44,7 +40,7 @@ const Header: FunctionComponent<Props> = () => {
const serverActions = useMemo(
() => (
<Dropdown alignRight>
<Dropdown.Toggle className="dropdown-hidden" as={Button}>
<Dropdown.Toggle className="hide-arrow" as={Button}>
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
</Dropdown.Toggle>
<Dropdown.Menu>
@ -87,11 +83,11 @@ const Header: FunctionComponent<Props> = () => {
<div className="header-icon px-3 m-0 d-none d-md-block">
<Image
alt="brand"
src={logo}
src={`${Environment.baseUrl}/static/logo64.png`}
width="32"
height="32"
onClick={goHome}
className="cursor-pointer"
role="button"
></Image>
</div>
<Button

@ -1,3 +1,5 @@
import { useReduxStore } from "@/modules/redux/hooks/base";
import { BuildKey, useIsArrayExtended } from "@/utilities";
import {
faBug,
faCircleNotch,
@ -10,9 +12,10 @@ import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import { useReduxStore } from "@redux/hooks/base";
import React, {
import {
Fragment,
FunctionComponent,
ReactNode,
useCallback,
useEffect,
useMemo,
@ -27,8 +30,6 @@ import {
Tooltip,
} from "react-bootstrap";
import { useDidUpdate, useTimeoutWhen } from "rooks";
import { BuildKey, useIsArrayExtended } from "utilities";
import "./notification.scss";
enum State {
Idle,
@ -63,7 +64,7 @@ function useHasErrorNotification(notifications: Server.Notification[]) {
}
const NotificationCenter: FunctionComponent = () => {
const { progress, notifications, notifier } = useReduxStore((s) => s);
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
const dropdownRef = useRef<HTMLDivElement>(null);
const [hasNew, setHasNew] = useState(false);
@ -115,7 +116,7 @@ const NotificationCenter: FunctionComponent = () => {
}
}, [btnState]);
const content = useMemo<React.ReactNode>(() => {
const content = useMemo<ReactNode>(() => {
const nodes: JSX.Element[] = [];
nodes.push(
@ -163,14 +164,14 @@ const NotificationCenter: FunctionComponent = () => {
}, [notifier.timestamp]);
return (
<React.Fragment>
<Fragment>
<Dropdown
onClick={onToggleClick}
className={`notification-btn ${hasNew ? "new-item" : ""}`}
ref={dropdownRef}
alignRight
>
<Dropdown.Toggle as={Button} className="dropdown-hidden">
<Dropdown.Toggle as={Button} className="hide-arrow">
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
</Dropdown.Toggle>
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
@ -184,7 +185,7 @@ const NotificationCenter: FunctionComponent = () => {
);
}}
</Overlay>
</React.Fragment>
</Fragment>
);
};

@ -1,26 +1,23 @@
import Socketio from "@modules/socketio";
import { useNotification } from "@redux/hooks";
import { useReduxStore } from "@redux/hooks/base";
import { LoadingIndicator, ModalProvider } from "components";
import Authentication from "pages/Authentication";
import LaunchError from "pages/LaunchError";
import React, { FunctionComponent, useEffect } from "react";
import { LoadingIndicator } from "@/components";
import ErrorBoundary from "@/components/ErrorBoundary";
import { useNotification } from "@/modules/redux/hooks";
import { useReduxStore } from "@/modules/redux/hooks/base";
import SocketIO from "@/modules/socketio";
import LaunchError from "@/pages/LaunchError";
import Sidebar from "@/Sidebar";
import { Environment } from "@/utilities";
import { FunctionComponent, useEffect } from "react";
import { Row } from "react-bootstrap";
import { Route, Switch } from "react-router";
import { BrowserRouter, Redirect } from "react-router-dom";
import { Navigate, Outlet } from "react-router-dom";
import { useEffectOnceWhen } from "rooks";
import { Environment } from "utilities";
import ErrorBoundary from "../components/ErrorBoundary";
import Router from "../Router";
import Sidebar from "../Sidebar";
import Header from "./Header";
// Sidebar Toggle
const App: FunctionComponent = () => {
const { status } = useReduxStore((s) => s.site);
interface Props {}
const App: FunctionComponent<Props> = () => {
const { status } = useReduxStore((s) => s);
useEffect(() => {
SocketIO.initialize();
}, []);
const notify = useNotification("has-update", 10 * 1000);
@ -36,7 +33,7 @@ const App: FunctionComponent<Props> = () => {
}, status === "initialized");
if (status === "unauthenticated") {
return <Redirect to="/login"></Redirect>;
return <Navigate to="/login"></Navigate>;
} else if (status === "uninitialized") {
return (
<LoadingIndicator>
@ -54,31 +51,10 @@ const App: FunctionComponent<Props> = () => {
</Row>
<Row noGutters className="flex-nowrap">
<Sidebar></Sidebar>
<ModalProvider>
<Router></Router>
</ModalProvider>
<Outlet></Outlet>
</Row>
</ErrorBoundary>
);
};
const MainRouter: FunctionComponent = () => {
useEffect(() => {
Socketio.initialize();
}, []);
return (
<BrowserRouter basename={Environment.baseUrl}>
<Switch>
<Route exact path="/login">
<Authentication></Authentication>
</Route>
<Route path="/">
<App></App>
</Route>
</Switch>
</BrowserRouter>
);
};
export default MainRouter;
export default App;

@ -1,19 +0,0 @@
import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
import { FunctionComponent } from "react";
import { Redirect } from "react-router-dom";
const RootRedirect: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
let path = "/settings";
if (sonarr) {
path = "/series";
} else if (radarr) {
path = "movies";
}
return <Redirect to={path}></Redirect>;
};
export default RootRedirect;

@ -1,251 +0,0 @@
import {
faClock,
faCogs,
faExclamationTriangle,
faFileExcel,
faFilm,
faLaptop,
faPlay,
} from "@fortawesome/free-solid-svg-icons";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
import { useBadges } from "apis/hooks";
import EmptyPage, { RouterEmptyPath } from "pages/404";
import BlacklistMoviesView from "pages/Blacklist/Movies";
import BlacklistSeriesView from "pages/Blacklist/Series";
import Episodes from "pages/Episodes";
import MoviesHistoryView from "pages/History/Movies";
import SeriesHistoryView from "pages/History/Series";
import HistoryStats from "pages/History/Statistics";
import MovieView from "pages/Movies";
import MovieDetail from "pages/Movies/Details";
import SeriesView from "pages/Series";
import SettingsGeneralView from "pages/Settings/General";
import SettingsLanguagesView from "pages/Settings/Languages";
import SettingsNotificationsView from "pages/Settings/Notifications";
import SettingsProvidersView from "pages/Settings/Providers";
import SettingsRadarrView from "pages/Settings/Radarr";
import SettingsSchedulerView from "pages/Settings/Scheduler";
import SettingsSonarrView from "pages/Settings/Sonarr";
import SettingsSubtitlesView from "pages/Settings/Subtitles";
import SettingsUIView from "pages/Settings/UI";
import SystemLogsView from "pages/System/Logs";
import SystemProvidersView from "pages/System/Providers";
import SystemReleasesView from "pages/System/Releases";
import SystemStatusView from "pages/System/Status";
import SystemTasksView from "pages/System/Tasks";
import WantedMoviesView from "pages/Wanted/Movies";
import WantedSeriesView from "pages/Wanted/Series";
import { useMemo } from "react";
import SystemBackupsView from "../pages/System/Backups";
import { Navigation } from "./nav";
import RootRedirect from "./RootRedirect";
export function useNavigationItems() {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
const { data } = useBadges();
const items = useMemo<Navigation.RouteItem[]>(
() => [
{
name: "404",
path: RouterEmptyPath,
component: EmptyPage,
routeOnly: true,
},
{
name: "Redirect",
path: "/",
component: RootRedirect,
routeOnly: true,
},
{
icon: faPlay,
name: "Series",
path: "/series",
component: SeriesView,
enabled: sonarr,
routes: [
{
name: "Episode",
path: "/:id",
component: Episodes,
routeOnly: true,
},
],
},
{
icon: faFilm,
name: "Movies",
path: "/movies",
component: MovieView,
enabled: radarr,
routes: [
{
name: "Movie Details",
path: "/:id",
component: MovieDetail,
routeOnly: true,
},
],
},
{
icon: faClock,
name: "History",
path: "/history",
routes: [
{
name: "Series",
path: "/series",
enabled: sonarr,
component: SeriesHistoryView,
},
{
name: "Movies",
path: "/movies",
enabled: radarr,
component: MoviesHistoryView,
},
{
name: "Statistics",
path: "/stats",
component: HistoryStats,
},
],
},
{
icon: faFileExcel,
name: "Blacklist",
path: "/blacklist",
routes: [
{
name: "Series",
path: "/series",
enabled: sonarr,
component: BlacklistSeriesView,
},
{
name: "Movies",
path: "/movies",
enabled: radarr,
component: BlacklistMoviesView,
},
],
},
{
icon: faExclamationTriangle,
name: "Wanted",
path: "/wanted",
routes: [
{
name: "Series",
path: "/series",
badge: data?.episodes,
enabled: sonarr,
component: WantedSeriesView,
},
{
name: "Movies",
path: "/movies",
badge: data?.movies,
enabled: radarr,
component: WantedMoviesView,
},
],
},
{
icon: faCogs,
name: "Settings",
path: "/settings",
routes: [
{
name: "General",
path: "/general",
component: SettingsGeneralView,
},
{
name: "Languages",
path: "/languages",
component: SettingsLanguagesView,
},
{
name: "Providers",
path: "/providers",
component: SettingsProvidersView,
},
{
name: "Subtitles",
path: "/subtitles",
component: SettingsSubtitlesView,
},
{
name: "Sonarr",
path: "/sonarr",
component: SettingsSonarrView,
},
{
name: "Radarr",
path: "/radarr",
component: SettingsRadarrView,
},
{
name: "Notifications",
path: "/notifications",
component: SettingsNotificationsView,
},
{
name: "Scheduler",
path: "/scheduler",
component: SettingsSchedulerView,
},
{
name: "UI",
path: "/ui",
component: SettingsUIView,
},
],
},
{
icon: faLaptop,
name: "System",
path: "/system",
routes: [
{
name: "Tasks",
path: "/tasks",
component: SystemTasksView,
},
{
name: "Logs",
path: "/logs",
component: SystemLogsView,
},
{
name: "Providers",
path: "/providers",
badge: data?.providers,
component: SystemProvidersView,
},
{
name: "Backup",
path: "/backups",
component: SystemBackupsView,
},
{
name: "Status",
path: "/status",
component: SystemStatusView,
},
{
name: "Releases",
path: "/releases",
component: SystemReleasesView,
},
],
},
],
[data, radarr, sonarr]
);
return items;
}

@ -1,26 +0,0 @@
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FunctionComponent } from "react";
export declare namespace Navigation {
type RouteWithoutChild = {
icon?: IconDefinition;
name: string;
path: string;
component: FunctionComponent;
badge?: number;
enabled?: boolean;
routeOnly?: boolean;
};
type RouteWithChild = {
icon: IconDefinition;
name: string;
path: string;
component?: FunctionComponent;
badge?: number;
enabled?: boolean;
routes: RouteWithoutChild[];
};
type RouteItem = RouteWithChild | RouteWithoutChild;
}

@ -0,0 +1,18 @@
import { useEnabledStatus } from "@/modules/redux/hooks";
import { FunctionComponent } from "react";
import { Navigate } from "react-router-dom";
const Redirector: FunctionComponent = () => {
const { sonarr, radarr } = useEnabledStatus();
let path = "/settings/general";
if (sonarr) {
path = "/series";
} else if (radarr) {
path = "/movies";
}
return <Navigate to={path}></Navigate>;
};
export default Redirector;

@ -1,83 +1,319 @@
import { FunctionComponent } from "react";
import { Redirect, Route, Switch, useHistory } from "react-router";
import { useDidMount } from "rooks";
import { BuildKey, ScrollToTop } from "utilities";
import { useNavigationItems } from "../Navigation";
import { Navigation } from "../Navigation/nav";
import { RouterEmptyPath } from "../pages/404";
import { useBadges } from "@/apis/hooks";
import App from "@/App";
import Lazy from "@/components/Lazy";
import { useEnabledStatus } from "@/modules/redux/hooks";
import BlacklistMoviesView from "@/pages/Blacklist/Movies";
import BlacklistSeriesView from "@/pages/Blacklist/Series";
import Episodes from "@/pages/Episodes";
import MoviesHistoryView from "@/pages/History/Movies";
import SeriesHistoryView from "@/pages/History/Series";
import MovieView from "@/pages/Movies";
import MovieDetailView from "@/pages/Movies/Details";
import MovieMassEditor from "@/pages/Movies/Editor";
import SeriesView from "@/pages/Series";
import SeriesMassEditor from "@/pages/Series/Editor";
import SettingsGeneralView from "@/pages/Settings/General";
import SettingsLanguagesView from "@/pages/Settings/Languages";
import SettingsNotificationsView from "@/pages/Settings/Notifications";
import SettingsProvidersView from "@/pages/Settings/Providers";
import SettingsRadarrView from "@/pages/Settings/Radarr";
import SettingsSchedulerView from "@/pages/Settings/Scheduler";
import SettingsSonarrView from "@/pages/Settings/Sonarr";
import SettingsSubtitlesView from "@/pages/Settings/Subtitles";
import SettingsUIView from "@/pages/Settings/UI";
import SystemBackupsView from "@/pages/System/Backups";
import SystemLogsView from "@/pages/System/Logs";
import SystemProvidersView from "@/pages/System/Providers";
import SystemReleasesView from "@/pages/System/Releases";
import SystemTasksView from "@/pages/System/Tasks";
import WantedMoviesView from "@/pages/Wanted/Movies";
import WantedSeriesView from "@/pages/Wanted/Series";
import { Environment } from "@/utilities";
import {
faClock,
faCogs,
faExclamationTriangle,
faFileExcel,
faFilm,
faLaptop,
faPlay,
} from "@fortawesome/free-solid-svg-icons";
import React, {
createContext,
FunctionComponent,
lazy,
useContext,
useMemo,
} from "react";
import { BrowserRouter } from "react-router-dom";
import Redirector from "./Redirector";
import { CustomRouteObject } from "./type";
const Router: FunctionComponent = () => {
const navItems = useNavigationItems();
const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
const Authentication = lazy(() => import("@/pages/Authentication"));
const NotFound = lazy(() => import("@/pages/404"));
const history = useHistory();
useDidMount(() => {
history.listen(() => {
// This is a hack to make sure ScrollToTop will be triggered in the next frame (When everything are loaded)
setTimeout(ScrollToTop);
});
});
function useRoutes(): CustomRouteObject[] {
const { data } = useBadges();
const { sonarr, radarr } = useEnabledStatus();
return (
<div className="d-flex flex-row flex-grow-1 main-router">
<Switch>
{navItems.map((v, idx) => {
if ("routes" in v) {
return (
<Route path={v.path} key={BuildKey(idx, v.name, "router")}>
<ParentRouter {...v}></ParentRouter>
</Route>
);
} else if (v.enabled !== false) {
return (
<Route
key={BuildKey(idx, v.name, "root")}
exact
path={v.path}
component={v.component}
></Route>
);
} else {
return null;
}
})}
<Route path="*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
</div>
return useMemo(
() => [
{
path: "/",
element: <App></App>,
children: [
{
index: true,
element: <Redirector></Redirector>,
},
{
icon: faPlay,
name: "Series",
path: "series",
hidden: !sonarr,
children: [
{
index: true,
element: <SeriesView></SeriesView>,
},
{
path: "edit",
hidden: true,
element: <SeriesMassEditor></SeriesMassEditor>,
},
{
path: ":id",
element: <Episodes></Episodes>,
},
],
},
{
icon: faFilm,
name: "Movies",
path: "movies",
hidden: !radarr,
children: [
{
index: true,
element: <MovieView></MovieView>,
},
{
path: "edit",
hidden: true,
element: <MovieMassEditor></MovieMassEditor>,
},
{
path: ":id",
element: <MovieDetailView></MovieDetailView>,
},
],
},
{
icon: faClock,
name: "History",
path: "history",
hidden: !sonarr && !radarr,
children: [
{
path: "series",
name: "Episodes",
hidden: !sonarr,
element: <SeriesHistoryView></SeriesHistoryView>,
},
{
path: "movies",
name: "Movies",
hidden: !radarr,
element: <MoviesHistoryView></MoviesHistoryView>,
},
{
path: "stats",
name: "Statistics",
element: (
<Lazy>
<HistoryStats></HistoryStats>
</Lazy>
),
},
],
},
{
icon: faExclamationTriangle,
name: "Wanted",
path: "wanted",
hidden: !sonarr && !radarr,
children: [
{
name: "Episodes",
path: "series",
badge: data?.episodes,
hidden: !sonarr,
element: <WantedSeriesView></WantedSeriesView>,
},
{
name: "Movies",
path: "movies",
badge: data?.movies,
hidden: !radarr,
element: <WantedMoviesView></WantedMoviesView>,
},
],
},
{
icon: faFileExcel,
name: "Blacklist",
path: "blacklist",
hidden: !sonarr && !radarr,
children: [
{
path: "series",
name: "Episodes",
hidden: !sonarr,
element: <BlacklistSeriesView></BlacklistSeriesView>,
},
{
path: "movies",
name: "Movies",
hidden: !radarr,
element: <BlacklistMoviesView></BlacklistMoviesView>,
},
],
},
{
icon: faCogs,
name: "Settings",
path: "settings",
children: [
{
path: "general",
name: "General",
element: <SettingsGeneralView></SettingsGeneralView>,
},
{
path: "languages",
name: "Languages",
element: <SettingsLanguagesView></SettingsLanguagesView>,
},
{
path: "providers",
name: "Providers",
element: <SettingsProvidersView></SettingsProvidersView>,
},
{
path: "subtitles",
name: "Subtitles",
element: <SettingsSubtitlesView></SettingsSubtitlesView>,
},
{
path: "sonarr",
name: "Sonarr",
element: <SettingsSonarrView></SettingsSonarrView>,
},
{
path: "radarr",
name: "Radarr",
element: <SettingsRadarrView></SettingsRadarrView>,
},
{
path: "notifications",
name: "Notifications",
element: (
<SettingsNotificationsView></SettingsNotificationsView>
),
},
{
path: "scheduler",
name: "Scheduler",
element: <SettingsSchedulerView></SettingsSchedulerView>,
},
{
path: "ui",
name: "UI",
element: <SettingsUIView></SettingsUIView>,
},
],
},
{
icon: faLaptop,
name: "System",
path: "system",
children: [
{
path: "tasks",
name: "Tasks",
element: <SystemTasksView></SystemTasksView>,
},
{
path: "logs",
name: "Logs",
element: <SystemLogsView></SystemLogsView>,
},
{
path: "providers",
name: "Providers",
badge: data?.providers,
element: <SystemProvidersView></SystemProvidersView>,
},
{
path: "backup",
name: "Backups",
element: <SystemBackupsView></SystemBackupsView>,
},
{
path: "status",
name: "Status",
element: (
<Lazy>
<SystemStatusView></SystemStatusView>
</Lazy>
),
},
{
path: "releases",
name: "Releases",
element: <SystemReleasesView></SystemReleasesView>,
},
],
},
],
},
{
path: "/login",
hidden: true,
element: (
<Lazy>
<Authentication></Authentication>
</Lazy>
),
},
{
path: "*",
hidden: true,
element: (
<Lazy>
<NotFound></NotFound>
</Lazy>
),
},
],
[data?.episodes, data?.movies, data?.providers, radarr, sonarr]
);
};
}
export default Router;
const RouterItemContext = createContext<CustomRouteObject[]>([]);
const ParentRouter: FunctionComponent<Navigation.RouteWithChild> = ({
path,
enabled,
component,
routes,
}) => {
if (enabled === false || (component === undefined && routes.length === 0)) {
return null;
}
const ParentComponent =
component ?? (() => <Redirect to={path + routes[0].path}></Redirect>);
export const Router: FunctionComponent = ({ children }) => {
const routes = useRoutes();
return (
<Switch>
<Route exact path={path} component={ParentComponent}></Route>
{routes
.filter((v) => v.enabled !== false)
.map((v, idx) => (
<Route
key={BuildKey(idx, v.name, "route")}
exact
path={path + v.path}
component={v.component}
></Route>
))}
<Route path="*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
<RouterItemContext.Provider value={routes}>
<BrowserRouter basename={Environment.baseUrl}>{children}</BrowserRouter>
</RouterItemContext.Provider>
);
};
export function useRouteItems() {
return useContext(RouterItemContext);
}

@ -0,0 +1,14 @@
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { RouteObject } from "react-router-dom";
declare namespace Route {
export type Item = {
icon?: IconDefinition;
name?: string;
badge?: number;
hidden?: boolean;
children?: Item[];
};
}
export type CustomRouteObject = RouteObject & Route.Item;

@ -1,12 +1,18 @@
import { setSidebar } from "@/modules/redux/actions";
import { useReduxAction, useReduxStore } from "@/modules/redux/hooks/base";
import { useRouteItems } from "@/Router";
import { CustomRouteObject, Route } from "@/Router/type";
import { BuildKey, Environment, pathJoin } from "@/utilities";
import { LOG } from "@/utilities/console";
import { useGotoHomepage } from "@/utilities/hooks";
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { setSidebar } from "@redux/actions";
import { useReduxAction, useReduxStore } from "@redux/hooks/base";
import logo from "@static/logo64.png";
import React, {
import clsx from "clsx";
import {
createContext,
FunctionComponent,
useContext,
useEffect,
useMemo,
useState,
} from "react";
@ -18,229 +24,232 @@ import {
ListGroup,
ListGroupItem,
} from "react-bootstrap";
import { NavLink, useHistory, useRouteMatch } from "react-router-dom";
import { BuildKey } from "utilities";
import { useGotoHomepage } from "utilities/hooks";
import { useNavigationItems } from "../Navigation";
import { Navigation } from "../Navigation/nav";
import "./style.scss";
const SelectionContext = createContext<{
import {
matchPath,
NavLink,
RouteObject,
useLocation,
useNavigate,
} from "react-router-dom";
const Selection = createContext<{
selection: string | null;
select: (selection: string | null) => void;
}>({ selection: null, select: () => {} });
select: (path: string | null) => void;
}>({
selection: null,
select: () => {
LOG("error", "Selection context not initialized");
},
});
const Sidebar: FunctionComponent = () => {
const open = useReduxStore((s) => s.showSidebar);
function useSelection() {
return useContext(Selection);
}
const changeSidebar = useReduxAction(setSidebar);
function useBadgeValue(route: Route.Item) {
const { badge, children } = route;
return useMemo(() => {
let value = badge ?? 0;
const cls = ["sidebar-container"];
const overlay = ["sidebar-overlay"];
if (children === undefined) {
return value;
}
if (open) {
cls.push("open");
overlay.push("open");
}
value +=
children.reduce((acc, child: Route.Item) => {
if (child.badge && child.hidden !== true) {
return acc + (child.badge ?? 0);
}
return acc;
}, 0) ?? 0;
return value === 0 ? undefined : value;
}, [badge, children]);
}
function useIsActive(parent: string, route: RouteObject) {
const { path, children } = route;
const { pathname } = useLocation();
const root = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
const paths = useMemo(
() => [root, ...(children?.map((v) => pathJoin(root, v.path ?? "")) ?? [])],
[root, children]
);
const selection = useSelection().selection;
return useMemo(
() =>
selection?.includes(root) ||
paths.some((path) => matchPath(path, pathname)),
[pathname, paths, root, selection]
);
}
// Actual sidebar
const Sidebar: FunctionComponent = () => {
const [selection, select] = useState<string | null>(null);
const isShow = useReduxStore((s) => s.site.showSidebar);
const showSidebar = useReduxAction(setSidebar);
const goHome = useGotoHomepage();
const [selection, setSelection] = useState<string | null>(null);
const routes = useRouteItems();
const { pathname } = useLocation();
useEffect(() => {
select(null);
}, [pathname]);
return (
<SelectionContext.Provider
value={{ selection: selection, select: setSelection }}
>
<aside className={cls.join(" ")}>
<Selection.Provider value={{ selection, select }}>
<nav className={clsx("sidebar-container", { open: isShow })}>
<Container className="sidebar-title d-flex align-items-center d-md-none">
<Image
alt="brand"
src={logo}
src={`${Environment.baseUrl}/static/logo64.png`}
width="32"
height="32"
onClick={goHome}
className="cursor-pointer"
></Image>
</Container>
<SidebarNavigation></SidebarNavigation>
</aside>
<ListGroup variant="flush" style={{ paddingBottom: "16rem" }}>
{routes.map((route, idx) => (
<RouteItem
key={BuildKey("nav", idx)}
parent="/"
route={route}
></RouteItem>
))}
</ListGroup>
</nav>
<div
className={overlay.join(" ")}
onClick={() => changeSidebar(false)}
className={clsx("sidebar-overlay", { open: isShow })}
onClick={() => showSidebar(false)}
></div>
</SelectionContext.Provider>
</Selection.Provider>
);
};
const SidebarNavigation: FunctionComponent = () => {
const navItems = useNavigationItems();
const RouteItem: FunctionComponent<{
route: CustomRouteObject;
parent: string;
}> = ({ route, parent }) => {
const { children, name, path, icon, hidden, element } = route;
return (
<ListGroup variant="flush">
{navItems.map((v, idx) => {
if ("routes" in v) {
return (
<SidebarParent key={BuildKey(idx, v.name)} {...v}></SidebarParent>
);
} else {
return (
<SidebarChild
parent=""
key={BuildKey(idx, v.name)}
{...v}
></SidebarChild>
);
}
})}
</ListGroup>
const isValidated = useMemo(
() =>
element !== undefined ||
children?.find((v) => v.index === true) !== undefined,
[element, children]
);
};
const SidebarParent: FunctionComponent<Navigation.RouteWithChild> = ({
icon,
badge,
name,
path,
routes,
enabled,
component,
}) => {
const computedBadge = useMemo(() => {
let computed = badge ?? 0;
const { select } = useSelection();
computed += routes.reduce((prev, curr) => {
return prev + (curr.badge ?? 0);
}, 0);
const navigate = useNavigate();
return computed !== 0 ? computed : undefined;
}, [badge, routes]);
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
const enabledRoutes = useMemo(
() => routes.filter((v) => v.enabled !== false && v.routeOnly !== true),
[routes]
);
const changeSidebar = useReduxAction(setSidebar);
const badge = useBadgeValue(route);
const { selection, select } = useContext(SelectionContext);
const isOpen = useIsActive(parent, route);
const match = useRouteMatch({ path });
const open = match !== null || selection === path;
if (hidden === true) {
return null;
}
const collapseBoxClass = useMemo(
() => `sidebar-collapse-box ${open ? "active" : ""}`,
[open]
);
// Ignore path if it is using match
if (path === undefined || path.includes(":")) {
return null;
}
const history = useHistory();
if (children !== undefined) {
const elements = children.map((child, idx) => (
<RouteItem
parent={link}
key={BuildKey(link, "nav", idx)}
route={child}
></RouteItem>
));
if (enabled === false) {
return null;
} else if (enabledRoutes.length === 0) {
if (component) {
if (name) {
return (
<NavLink
activeClassName="sb-active"
className="list-group-item list-group-item-action sidebar-button"
to={path}
onClick={() => changeSidebar(false)}
>
<SidebarContent
icon={icon}
name={name}
badge={computedBadge}
></SidebarContent>
</NavLink>
<div className={clsx("sidebar-collapse-box", { active: isOpen })}>
<ListGroupItem
action
className={clsx("button", { active: isOpen })}
onClick={() => {
LOG("info", "clicked", link);
if (isValidated) {
navigate(link);
}
if (isOpen) {
select(null);
} else {
select(link);
}
}}
>
<RouteItemContent
name={name ?? link}
icon={icon}
badge={badge}
></RouteItemContent>
</ListGroupItem>
<Collapse in={isOpen}>
<div className="indent">{elements}</div>
</Collapse>
</div>
);
} else {
return null;
return <>{elements}</>;
}
}
return (
<div className={collapseBoxClass}>
<ListGroupItem
action
className="sidebar-button"
onClick={() => {
if (open) {
select(null);
} else {
select(path);
}
if (component !== undefined) {
history.push(path);
}
}}
} else {
return (
<NavLink
to={link}
className={({ isActive }) =>
clsx("list-group-item list-group-item-action button sb-collapse", {
active: isActive,
})
}
>
<SidebarContent
<RouteItemContent
name={name ?? link}
icon={icon}
name={name}
badge={computedBadge}
></SidebarContent>
</ListGroupItem>
<Collapse in={open}>
<div className="sidebar-collapse">
{enabledRoutes.map((v, idx) => (
<SidebarChild
key={BuildKey(idx, v.name, "child")}
parent={path}
{...v}
></SidebarChild>
))}
</div>
</Collapse>
</div>
);
};
interface SidebarChildProps {
parent: string;
}
const SidebarChild: FunctionComponent<
SidebarChildProps & Navigation.RouteWithoutChild
> = ({ icon, name, path, badge, enabled, routeOnly, parent }) => {
const changeSidebar = useReduxAction(setSidebar);
const { select } = useContext(SelectionContext);
if (enabled === false || routeOnly === true) {
return null;
badge={badge}
></RouteItemContent>
</NavLink>
);
}
return (
<NavLink
activeClassName="sb-active"
className="list-group-item list-group-item-action sidebar-button sb-collapse"
to={parent + path}
onClick={() => {
select(null);
changeSidebar(false);
}}
>
<SidebarContent icon={icon} name={name} badge={badge}></SidebarContent>
</NavLink>
);
};
const SidebarContent: FunctionComponent<{
icon?: IconDefinition;
interface ItemComponentProps {
name: string;
icon?: IconDefinition;
badge?: number;
}> = ({ icon, name, badge }) => {
}
const RouteItemContent: FunctionComponent<ItemComponentProps> = ({
icon,
name,
badge,
}) => {
return (
<React.Fragment>
{icon && (
<FontAwesomeIcon
size="1x"
className="icon"
icon={icon}
></FontAwesomeIcon>
)}
<>
{icon && <FontAwesomeIcon size="1x" className="icon" icon={icon} />}
<span className="d-flex flex-grow-1 justify-content-between">
{name} <Badge variant="secondary">{badge !== 0 ? badge : null}</Badge>
{name}
<Badge variant="secondary" hidden={badge === undefined || badge === 0}>
{badge}
</Badge>
</span>
</React.Fragment>
</>
);
};

@ -1,9 +0,0 @@
import { Entrance } from "index";
import {} from "jest";
import ReactDOM from "react-dom";
it("renders", () => {
const div = document.createElement("div");
ReactDOM.render(<Entrance />, div);
ReactDOM.unmountComponentAtNode(div);
});

@ -36,7 +36,6 @@ export function useMovies() {
[QueryKeys.Movies, QueryKeys.All],
() => api.movies.movies(),
{
enabled: false,
onSuccess: (data) => {
cacheMovies(client, data);
},

@ -36,7 +36,6 @@ export function useSeries() {
[QueryKeys.Series, QueryKeys.All],
() => api.series.series(),
{
enabled: false,
onSuccess: (data) => {
cacheSeries(client, data);
},

@ -12,8 +12,16 @@ export function useSubtitleAction() {
[QueryKeys.Subtitles],
(param: Param) => api.subtitles.modify(param.action, param.form),
{
onSuccess: () => {
onSuccess: (_, param) => {
client.invalidateQueries([QueryKeys.History]);
// TODO: Query less
const { type, id } = param.form;
if (type === "episode") {
client.invalidateQueries([QueryKeys.Series, id]);
} else {
client.invalidateQueries([QueryKeys.Movies, id]);
}
},
}
);

@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { setUnauthenticated } from "../../@redux/actions";
import store from "../../@redux/store";
import { setUnauthenticated } from "../../modules/redux/actions";
import store from "../../modules/redux/store";
import { QueryKeys } from "../queries/keys";
import api from "../raw";

@ -1,6 +1,6 @@
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
import { setUnauthenticated } from "../../@redux/actions";
import { AppDispatch } from "../../@redux/store";
import { setUnauthenticated } from "../../modules/redux/actions";
import { AppDispatch } from "../../modules/redux/store";
import { Environment, isProdEnv } from "../../utilities";
class BazarrClient {
axios!: AxiosInstance;

@ -1,3 +1,5 @@
import { GetItemId } from "@/utilities";
import { usePageSize } from "@/utilities/storage";
import { useCallback, useEffect, useState } from "react";
import {
QueryKey,
@ -5,8 +7,6 @@ import {
useQueryClient,
UseQueryResult,
} from "react-query";
import { GetItemId } from "utilities";
import { usePageSize } from "utilities/storage";
import { QueryKeys } from "./keys";
export type UsePaginationQueryResult<T extends object> = UseQueryResult<

@ -10,7 +10,7 @@ class BaseApi {
private createFormdata(object?: LooseObject) {
if (object) {
let form = new FormData();
const form = new FormData();
for (const key in object) {
const data = object[key];
@ -30,7 +30,7 @@ class BaseApi {
}
}
protected async get<T = unknown>(path: string, params?: any) {
protected async get<T = unknown>(path: string, params?: LooseObject) {
const response = await client.axios.get<T>(this.prefix + path, { params });
return response.data;
}
@ -38,7 +38,7 @@ class BaseApi {
protected post<T = void>(
path: string,
formdata?: LooseObject,
params?: any
params?: LooseObject
): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata);
return client.axios.post(this.prefix + path, form, { params });
@ -47,7 +47,7 @@ class BaseApi {
protected patch<T = void>(
path: string,
formdata?: LooseObject,
params?: any
params?: LooseObject
): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata);
return client.axios.patch(this.prefix + path, form, { params });
@ -55,8 +55,8 @@ class BaseApi {
protected delete<T = void>(
path: string,
formdata?: any,
params?: any
formdata?: LooseObject,
params?: LooseObject
): Promise<AxiosResponse<T>> {
const form = this.createFormdata(formdata);
return client.axios.delete(this.prefix + path, { params, data: form });

@ -5,7 +5,7 @@ class ProviderApi extends BaseApi {
super("/providers");
}
async providers(history: boolean = false) {
async providers(history = false) {
const response = await this.get<DataWrapper<System.Provider[]>>("", {
history,
});

@ -34,7 +34,7 @@ class SystemApi extends BaseApi {
await this.post("/settings", data);
}
async languages(history: boolean = false) {
async languages(history = false) {
const response = await this.get<Language.Server[]>("/languages", {
history,
});

@ -11,7 +11,7 @@ type UrlTestResponse =
};
class RequestUtils {
async urlTest(protocol: string, url: string, params?: any) {
async urlTest(protocol: string, url: string, params?: LooseObject) {
try {
const result = await client.axios.get<UrlTestResponse>(
`../test/${protocol}/${url}api/system/status`,

@ -1,12 +1,12 @@
import UIError from "pages/UIError";
import React from "react";
import UIError from "@/pages/UIError";
import { Component } from "react";
interface State {
error: Error | null;
}
class ErrorBoundary extends React.Component<{}, State> {
constructor(props: {}) {
class ErrorBoundary extends Component<object, State> {
constructor(props: object) {
super(props);
this.state = { error: null };
}

@ -1,3 +1,8 @@
import { BuildKey, isMovie } from "@/utilities";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "@/utilities/languages";
import {
faBookmark as farBookmark,
faClone as fasClone,
@ -12,7 +17,7 @@ import {
IconDefinition,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useMemo } from "react";
import { FunctionComponent, useMemo } from "react";
import {
Badge,
Col,
@ -22,12 +27,7 @@ import {
Popover,
Row,
} from "react-bootstrap";
import { BuildKey, isMovie } from "utilities";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
} from "utilities/languages";
import { LanguageText } from ".";
import Language from "./bazarr/Language";
interface Props {
item: Item.Base;
@ -102,7 +102,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
icon={faLanguage}
desc="Language"
>
<LanguageText long text={v}></LanguageText>
<Language.Text long value={v}></Language.Text>
</DetailBadge>
))
);

@ -1,5 +1,5 @@
import { Selector, SelectorProps } from "components";
import React, { useMemo } from "react";
import { Selector, SelectorOption, SelectorProps } from "@/components";
import { useMemo } from "react";
interface Props {
options: readonly Language.Info[];

@ -0,0 +1,8 @@
import { FunctionComponent, Suspense } from "react";
import { LoadingIndicator } from ".";
const Lazy: FunctionComponent = ({ children }) => {
return <Suspense fallback={<LoadingIndicator />}>{children}</Suspense>;
};
export default Lazy;

@ -0,0 +1,121 @@
import { useIsAnyMutationRunning, useLanguageProfiles } from "@/apis/hooks";
import { GetItemId } from "@/utilities";
import { faCheck, faUndo } from "@fortawesome/free-solid-svg-icons";
import { uniqBy } from "lodash";
import { useCallback, useMemo, useState } from "react";
import { Container, Dropdown, Row } from "react-bootstrap";
import { UseMutationResult } from "react-query";
import { useNavigate } from "react-router-dom";
import { Column, useRowSelect } from "react-table";
import { ContentHeader, SimpleTable } from ".";
import { useCustomSelection } from "./tables/plugins";
interface MassEditorProps<T extends Item.Base = Item.Base> {
columns: Column<T>[];
data: T[];
mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
}
function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
const { columns, data: raw, mutation } = props;
const [selections, setSelections] = useState<T[]>([]);
const [dirties, setDirties] = useState<T[]>([]);
const hasTask = useIsAnyMutationRunning();
const { data: profiles } = useLanguageProfiles();
const navigate = useNavigate();
const onEnded = useCallback(() => navigate(".."), [navigate]);
const data = useMemo(
() => uniqBy([...dirties, ...(raw ?? [])], GetItemId),
[dirties, raw]
);
const profileOptions = useMemo(() => {
const items: JSX.Element[] = [];
if (profiles) {
items.push(
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
);
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
items.push(
...profiles.map((v) => (
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
{v.name}
</Dropdown.Item>
))
);
}
return items;
}, [profiles]);
const { mutateAsync } = mutation;
const save = useCallback(() => {
const form: FormType.ModifyItem = {
id: [],
profileid: [],
};
dirties.forEach((v) => {
const id = GetItemId(v);
if (id) {
form.id.push(id);
form.profileid.push(v.profileId);
}
});
return mutateAsync(form);
}, [dirties, mutateAsync]);
const setProfiles = useCallback(
(key: Nullable<string>) => {
const id = key ? parseInt(key) : null;
const newItems = selections.map((v) => ({ ...v, profileId: id }));
setDirties((dirty) => {
return uniqBy([...newItems, ...dirty], GetItemId);
});
},
[selections]
);
return (
<Container fluid>
<ContentHeader scroll={false}>
<ContentHeader.Group pos="start">
<Dropdown onSelect={setProfiles}>
<Dropdown.Toggle disabled={selections.length === 0} variant="light">
Change Profile
</Dropdown.Toggle>
<Dropdown.Menu>{profileOptions}</Dropdown.Menu>
</Dropdown>
</ContentHeader.Group>
<ContentHeader.Group pos="end">
<ContentHeader.Button icon={faUndo} onClick={onEnded}>
Cancel
</ContentHeader.Button>
<ContentHeader.AsyncButton
icon={faCheck}
disabled={dirties.length === 0 || hasTask}
promise={save}
onSuccess={onEnded}
>
Save
</ContentHeader.AsyncButton>
</ContentHeader.Group>
</ContentHeader>
<Row>
<SimpleTable
columns={columns}
data={data}
onSelect={setSelections}
plugins={[useRowSelect, useCustomSelection]}
></SimpleTable>
</Row>
</Container>
);
}
export default MassEditor;

@ -1,6 +1,6 @@
import { useServerSearch } from "apis/hooks";
import { useServerSearch } from "@/apis/hooks";
import { uniqueId } from "lodash";
import React, {
import {
FunctionComponent,
useCallback,
useEffect,
@ -8,7 +8,7 @@ import React, {
useState,
} from "react";
import { Dropdown, Form } from "react-bootstrap";
import { useHistory } from "react-router";
import { useNavigate } from "react-router-dom";
import { useThrottle } from "rooks";
function useSearch(query: string) {
@ -66,7 +66,7 @@ export const SearchBar: FunctionComponent<Props> = ({
const results = useSearch(query);
const history = useHistory();
const navigate = useNavigate();
const clear = useCallback(() => {
setDisplay("");
@ -100,7 +100,7 @@ export const SearchBar: FunctionComponent<Props> = ({
onSelect={(link) => {
if (link) {
clear();
history.push(link);
navigate(link);
}
}}
>

@ -4,9 +4,10 @@ import {
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, {
import {
FunctionComponent,
PropsWithChildren,
ReactElement,
useCallback,
useEffect,
useState,
@ -18,7 +19,7 @@ import { LoadingIndicator } from ".";
interface QueryOverlayProps {
result: UseQueryResult<unknown, unknown>;
children: React.ReactElement;
children: ReactElement;
}
export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
@ -43,9 +44,7 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
const [item, setItem] = useState<T | null>(null);
useEffect(() => {
promise()
.then(setItem)
.catch(() => {});
promise().then(setItem);
}, [promise]);
if (item === null) {

@ -0,0 +1,88 @@
import { useLanguages } from "@/apis/hooks";
import { Selector, SelectorOption, SelectorProps } from "@/components";
import { FunctionComponent, useMemo } from "react";
interface TextProps {
value: Language.Info;
className?: string;
long?: boolean;
}
declare type LanguageComponent = {
Text: typeof LanguageText;
Selector: typeof LanguageSelector;
};
const LanguageText: FunctionComponent<TextProps> = ({
value,
className,
long,
}) => {
const result = useMemo(() => {
let lang = value.code2;
let hi = ":HI";
let forced = ":Forced";
if (long) {
lang = value.name;
hi = " HI";
forced = " Forced";
}
let res = lang;
if (value.hi) {
res += hi;
} else if (value.forced) {
res += forced;
}
return res;
}, [value, long]);
return (
<span title={value.name} className={className}>
{result}
</span>
);
};
type LanguageSelectorProps<M extends boolean> = Omit<
SelectorProps<Language.Info, M>,
"label" | "options"
> & {
history?: boolean;
};
function getLabel(lang: Language.Info) {
return lang.name;
}
export function LanguageSelector<M extends boolean = false>(
props: LanguageSelectorProps<M>
) {
const { history, ...rest } = props;
const { data: options } = useLanguages(history);
const items = useMemo<SelectorOption<Language.Info>[]>(
() =>
options?.map((v) => ({
label: v.name,
value: v,
})) ?? [],
[options]
);
return (
<Selector
placeholder="Language..."
options={items}
label={getLabel}
{...rest}
></Selector>
);
}
const Components: LanguageComponent = {
Text: LanguageText,
Selector: LanguageSelector,
};
export default Components;

@ -0,0 +1,25 @@
import { useLanguageProfiles } from "@/apis/hooks";
import { FunctionComponent, useMemo } from "react";
interface Props {
index: number | null;
className?: string;
empty?: string;
}
const LanguageProfile: FunctionComponent<Props> = ({
index,
className,
empty = "Unknown Profile",
}) => {
const { data } = useLanguageProfiles();
const name = useMemo(
() => data?.find((v) => v.profileId === index)?.name ?? empty,
[data, empty, index]
);
return <span className={className}>{name}</span>;
};
export default LanguageProfile;

@ -1,7 +1,7 @@
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, MouseEvent } from "react";
import { FunctionComponent, MouseEvent } from "react";
import { Badge, Button, ButtonProps } from "react-bootstrap";
export const ActionBadge: FunctionComponent<{
@ -66,7 +66,7 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
}) => {
const showText = alwaysShowText === true || loading !== true;
return (
<React.Fragment>
<>
<FontAwesomeIcon
style={{ width: "1rem" }}
icon={loading ? faCircleNotch : icon}
@ -75,6 +75,6 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
{children && showText ? (
<span className="ml-2 font-weight-bold">{children}</span>
) : null}
</React.Fragment>
</>
);
};

@ -1,7 +1,7 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, {
import {
FunctionComponent,
MouseEvent,
PropsWithChildren,
@ -46,13 +46,13 @@ const ContentHeaderButton: FunctionComponent<CHButtonProps> = (props) => {
);
};
type CHAsyncButtonProps<T extends () => Promise<any>> = {
type CHAsyncButtonProps<R, T extends () => Promise<R>> = {
promise: T;
onSuccess?: (item: PromiseType<ReturnType<T>>) => void;
onSuccess?: (item: R) => void;
} & Omit<CHButtonProps, "updating" | "updatingIcon" | "onClick">;
export function ContentHeaderAsyncButton<T extends () => Promise<any>>(
props: PropsWithChildren<CHAsyncButtonProps<T>>
export function ContentHeaderAsyncButton<R, T extends () => Promise<R>>(
props: PropsWithChildren<CHAsyncButtonProps<R, T>>
): JSX.Element {
const { promise, onSuccess, ...button } = props;

@ -1,4 +1,4 @@
import React, { FunctionComponent } from "react";
import { FunctionComponent } from "react";
type GroupPosition = "start" | "end";
interface GroupProps {

@ -1,8 +1,7 @@
import React, { FunctionComponent, useMemo } from "react";
import { FunctionComponent, ReactNode, useMemo } from "react";
import { Row } from "react-bootstrap";
import ContentHeaderButton, { ContentHeaderAsyncButton } from "./Button";
import ContentHeaderGroup from "./Group";
import "./style.scss";
interface Props {
scroll?: boolean;
@ -29,7 +28,7 @@ export const ContentHeader: Header = ({ children, scroll, className }) => {
return rowCls.join(" ");
}, [scroll, className]);
let childItem: React.ReactNode;
let childItem: ReactNode;
if (scroll !== false) {
childItem = (

@ -11,7 +11,7 @@ import {
FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome";
import { isNull, isUndefined } from "lodash";
import React, { FunctionComponent, useMemo } from "react";
import { FunctionComponent, ReactElement } from "react";
import {
OverlayTrigger,
OverlayTriggerProps,
@ -97,44 +97,8 @@ export const LoadingIndicator: FunctionComponent<{
);
};
interface LanguageTextProps {
text: Language.Info;
className?: string;
long?: boolean;
}
export const LanguageText: FunctionComponent<LanguageTextProps> = ({
text,
className,
long,
}) => {
const result = useMemo(() => {
let lang = text.code2;
let hi = ":HI";
let forced = ":Forced";
if (long) {
lang = text.name;
hi = " HI";
forced = " Forced";
}
let res = lang;
if (text.hi) {
res += hi;
} else if (text.forced) {
res += forced;
}
return res;
}, [text, long]);
return (
<span title={text.name} className={className}>
{result}
</span>
);
};
interface TextPopoverProps {
children: React.ReactElement<any, any>;
children: ReactElement;
text: string | undefined | null;
placement?: OverlayTriggerProps["placement"];
delay?: number;
@ -167,6 +131,5 @@ export * from "./buttons";
export * from "./header";
export * from "./inputs";
export * from "./LanguageSelector";
export * from "./modals";
export * from "./SearchBar";
export * from "./tables";

@ -1,4 +1,4 @@
import React, {
import {
FocusEvent,
FunctionComponent,
KeyboardEvent,
@ -8,7 +8,6 @@ import React, {
useRef,
useState,
} from "react";
import "./chip.scss";
const SplitKeys = ["Tab", "Enter", " ", ",", ";"];

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

Loading…
Cancel
Save