Merge branch 'development'

# Conflicts:
#	frontend/package-lock.json
This commit is contained in:
morpheus65535 2022-04-30 09:09:50 -04:00
commit 135bdf2d45
296 changed files with 13377 additions and 35979 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
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=i,
count=count_movie)
show_progress(id='movie_search_progress_{}'.format(no),
header='Searching missing subtitles...',
name=movie['title'],
value=0,
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'):
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")
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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
# VITE_PROXY_URL=http://127.0.0.1:6767
# Optional
# 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

View File

@ -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
frontend/.gitignore vendored
View File

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

View File

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

View File

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

30
frontend/config/chunks.ts Normal file
View File

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

View File

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

View File

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

37179
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

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

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 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]
);
}
const RouterItemContext = createContext<CustomRouteObject[]>([]);
export const Router: FunctionComponent = ({ children }) => {
const routes = useRoutes();
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>
<RouterItemContext.Provider value={routes}>
<BrowserRouter basename={Environment.baseUrl}>{children}</BrowserRouter>
</RouterItemContext.Provider>
);
};
export default Router;
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>);
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>
);
};
export function useRouteItems() {
return useContext(RouterItemContext);
}

14
frontend/src/Router/type.d.ts vendored Normal file
View File

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

View File

@ -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";
import {
matchPath,
NavLink,
RouteObject,
useLocation,
useNavigate,
} from "react-router-dom";
const SelectionContext = createContext<{
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");
},
});
function useSelection() {
return useContext(Selection);
}
function useBadgeValue(route: Route.Item) {
const { badge, children } = route;
return useMemo(() => {
let value = badge ?? 0;
if (children === undefined) {
return value;
}
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 open = useReduxStore((s) => s.showSidebar);
const [selection, select] = useState<string | null>(null);
const isShow = useReduxStore((s) => s.site.showSidebar);
const changeSidebar = useReduxAction(setSidebar);
const cls = ["sidebar-container"];
const overlay = ["sidebar-overlay"];
if (open) {
cls.push("open");
overlay.push("open");
}
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 SidebarParent: FunctionComponent<Navigation.RouteWithChild> = ({
icon,
badge,
name,
path,
routes,
enabled,
component,
}) => {
const computedBadge = useMemo(() => {
let computed = badge ?? 0;
computed += routes.reduce((prev, curr) => {
return prev + (curr.badge ?? 0);
}, 0);
return computed !== 0 ? computed : undefined;
}, [badge, routes]);
const enabledRoutes = useMemo(
() => routes.filter((v) => v.enabled !== false && v.routeOnly !== true),
[routes]
const isValidated = useMemo(
() =>
element !== undefined ||
children?.find((v) => v.index === true) !== undefined,
[element, children]
);
const changeSidebar = useReduxAction(setSidebar);
const { select } = useSelection();
const { selection, select } = useContext(SelectionContext);
const navigate = useNavigate();
const match = useRouteMatch({ path });
const open = match !== null || selection === path;
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
const collapseBoxClass = useMemo(
() => `sidebar-collapse-box ${open ? "active" : ""}`,
[open]
);
const badge = useBadgeValue(route);
const history = useHistory();
const isOpen = useIsActive(parent, route);
if (enabled === false) {
if (hidden === true) {
return null;
} else if (enabledRoutes.length === 0) {
if (component) {
}
// Ignore path if it is using match
if (path === undefined || path.includes(":")) {
return null;
}
if (children !== undefined) {
const elements = children.map((child, idx) => (
<RouteItem
parent={link}
key={BuildKey(link, "nav", idx)}
route={child}
></RouteItem>
));
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>
);
badge={badge}
></RouteItemContent>
</NavLink>
);
}
};
interface SidebarChildProps {
parent: string;
interface ItemComponentProps {
name: string;
icon?: IconDefinition;
badge?: number;
}
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;
}
const RouteItemContent: FunctionComponent<ItemComponentProps> = ({
icon,
name,
badge,
}) => {
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;
name: string;
badge?: number;
}> = ({ 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>
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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