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

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

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

View File

@ -79,6 +79,7 @@ class EpisodesHistory(Resource):
TableHistory.score, TableHistory.score,
TableShows.tags, TableShows.tags,
TableHistory.action, TableHistory.action,
TableHistory.video_path,
TableHistory.subtitles_path, TableHistory.subtitles_path,
TableHistory.sonarrEpisodeId, TableHistory.sonarrEpisodeId,
TableHistory.provider, TableHistory.provider,
@ -101,7 +102,8 @@ class EpisodesHistory(Resource):
if {"video_path": str(item['path']), "timestamp": float(item['timestamp']), "score": str(item['score']), if {"video_path": str(item['path']), "timestamp": float(item['timestamp']), "score": str(item['score']),
"tags": str(item['tags']), "monitored": str(item['monitored']), "tags": str(item['tags']), "monitored": str(item['monitored']),
"seriesType": str(item['seriesType'])} in upgradable_episodes_not_perfect: # noqa: E129 "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}) item.update({"upgradable": True})
del item['path'] del item['path']

View File

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

View File

@ -79,7 +79,8 @@ class MoviesHistory(Resource):
TableHistoryMovie.score, TableHistoryMovie.score,
TableHistoryMovie.subs_id, TableHistoryMovie.subs_id,
TableHistoryMovie.provider, TableHistoryMovie.provider,
TableHistoryMovie.subtitles_path)\ TableHistoryMovie.subtitles_path,
TableHistoryMovie.video_path)\
.join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId))\ .join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId))\
.where(query_condition)\ .where(query_condition)\
.order_by(TableHistoryMovie.timestamp.desc())\ .order_by(TableHistoryMovie.timestamp.desc())\
@ -96,7 +97,8 @@ class MoviesHistory(Resource):
item.update({"upgradable": False}) item.update({"upgradable": False})
if {"video_path": str(item['path']), "timestamp": float(item['timestamp']), "score": str(item['score']), 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 "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}) item.update({"upgradable": True})
del item['path'] del item['path']

View File

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

View File

@ -13,6 +13,7 @@ from notifier import send_notifications_movie
from list_subtitles import store_subtitles_movie from list_subtitles import store_subtitles_movie
from ..utils import authenticate from ..utils import authenticate
import logging
class ProviderMovies(Resource): class ProviderMovies(Resource):
@ -64,6 +65,8 @@ class ProviderMovies(Resource):
hi = request.form.get('hi').capitalize() hi = request.form.get('hi').capitalize()
forced = request.form.get('forced').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') selected_provider = request.form.get('provider')
subtitle = request.form.get('subtitle') subtitle = request.form.get('subtitle')
@ -75,7 +78,7 @@ class ProviderMovies(Resource):
try: try:
result = manual_download_subtitle(moviePath, audio_language, hi, forced, subtitle, selected_provider, 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: if result is not None:
message = result[0] message = result[0]
path = result[1] path = result[1]

View File

@ -73,7 +73,8 @@ class Subtitles(Resource):
else: else:
return '', 404 return '', 404
else: 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 # apply chmod if required
chmod = int(settings.general.chmod, 8) if not sys.platform.startswith( 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.items: json.dumps(item['items']),
TableLanguagesProfiles.mustContain: item['mustContain'], TableLanguagesProfiles.mustContain: item['mustContain'],
TableLanguagesProfiles.mustNotContain: item['mustNotContain'], TableLanguagesProfiles.mustNotContain: item['mustNotContain'],
TableLanguagesProfiles.originalFormat: item['originalFormat'] if item['originalFormat'] != 'null' else None,
})\ })\
.where(TableLanguagesProfiles.profileId == item['profileId'])\ .where(TableLanguagesProfiles.profileId == item['profileId'])\
.execute() .execute()
@ -72,6 +73,7 @@ class SystemSettings(Resource):
TableLanguagesProfiles.items: json.dumps(item['items']), TableLanguagesProfiles.items: json.dumps(item['items']),
TableLanguagesProfiles.mustContain: item['mustContain'], TableLanguagesProfiles.mustContain: item['mustContain'],
TableLanguagesProfiles.mustNotContain: item['mustNotContain'], TableLanguagesProfiles.mustNotContain: item['mustNotContain'],
TableLanguagesProfiles.originalFormat: item['originalFormat'] if item['originalFormat'] != 'null' else None,
}).execute() }).execute()
for profileId in existing: for profileId in existing:
# Unassign this profileId from series and movies # Unassign this profileId from series and movies

View File

@ -4,9 +4,13 @@ from flask import Blueprint
from flask_restful import Api from flask_restful import Api
from .plex import WebHooksPlex from .plex import WebHooksPlex
from .sonarr import WebHooksSonarr
from .radarr import WebHooksRadarr
api_bp_webhooks = Blueprint('api_webhooks', __name__) api_bp_webhooks = Blueprint('api_webhooks', __name__)
api = Api(api_bp_webhooks) api = Api(api_bp_webhooks)
api.add_resource(WebHooksPlex, '/webhooks/plex') 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 json
import requests import requests
import os import os
import re import logging
from flask import request from flask import request
from flask_restful import Resource from flask_restful import Resource
@ -46,8 +46,12 @@ class WebHooksPlex(Resource):
r = requests.get('https://imdb.com/title/{}'.format(episode_imdb_id), r = requests.get('https://imdb.com/title/{}'.format(episode_imdb_id),
headers={"User-Agent": os.environ["SZ_USER_AGENT"]}) headers={"User-Agent": os.environ["SZ_USER_AGENT"]})
soup = bso(r.content, "html.parser") 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: except Exception:
logging.debug('BAZARR is unable to get series IMDB id.')
return '', 404 return '', 404
else: else:
sonarrEpisodeId = TableEpisodes.select(TableEpisodes.sonarrEpisodeId) \ 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): def get_backup_files(fullpath=True):
backup_file_pattern = os.path.join(get_backup_path(), 'bazarr_backup_v*.zip') backup_file_pattern = os.path.join(get_backup_path(), 'bazarr_backup_v*.zip')
file_list = glob(backup_file_pattern) file_list = glob(backup_file_pattern)
file_list.sort(key=os.path.getmtime)
if fullpath: if fullpath:
return file_list return file_list
else: else:
return [{ return [{
'type': 'backup', 'type': 'backup',
'filename': os.path.basename(x), 'filename': os.path.basename(x),
'size': sizeof_fmt(os.path.getsize(x)),
'date': datetime.fromtimestamp(os.path.getmtime(x)).strftime("%b %d %Y") 'date': datetime.fromtimestamp(os.path.getmtime(x)).strftime("%b %d %Y")
} for x in file_list] } for x in file_list]
@ -178,7 +180,7 @@ def backup_rotation():
logging.debug(f'Cleaning up backup files older than {backup_retention} days') logging.debug(f'Cleaning up backup files older than {backup_retention} days')
for file in backup_files: 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}') logging.debug(f'Deleting old backup file {file}')
try: try:
os.remove(file) os.remove(file)
@ -195,3 +197,11 @@ def delete_backup_file(filename):
except OSError: except OSError:
logging.debug(f'Unable to delete backup file {backup_file_path}') logging.debug(f'Unable to delete backup file {backup_file_path}')
return False 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 + '$'] separator + '__pycache__' + separator + '$']
if os.path.abspath(bazarr_dir).lower() == os.path.abspath(config_dir).lower(): 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) # for users who installed Bazarr inside the config directory (ie: `%programdata%\Bazarr` on windows)
with os.scandir(config_dir) as directories: dir_to_ignore.append('^backup' + separator)
for directory in directories: dir_to_ignore.append('^cache' + separator)
if directory.is_dir(): dir_to_ignore.append('^config' + separator)
dir_to_ignore.append('^' + directory.name + os.path.sep) 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(): elif os.path.abspath(bazarr_dir).lower() in os.path.abspath(config_dir).lower():
# when config directory is a child of Bazarr installation directory # 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.append('^' + os.path.relpath(config_dir, bazarr_dir) + separator)
dir_to_ignore_regex = re.compile('(?:% s)' % '|'.join(dir_to_ignore)) dir_to_ignore_regex_string = '(?:% s)' % '|'.join(dir_to_ignore)
logging.debug(f'BAZARR upgrade leftover cleaner will ignore directories matching this ' 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'] 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))) 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_tags': '[]',
'excluded_series_types': '[]', 'excluded_series_types': '[]',
'use_ffprobe_cache': 'True', 'use_ffprobe_cache': 'True',
'exclude_season_zero': 'False' 'exclude_season_zero': 'False',
'defer_search_signalr': 'False'
}, },
'radarr': { 'radarr': {
'ip': '127.0.0.1', 'ip': '127.0.0.1',
@ -117,7 +118,8 @@ defaults = {
'only_monitored': 'False', 'only_monitored': 'False',
'movies_sync': '60', 'movies_sync': '60',
'excluded_tags': '[]', 'excluded_tags': '[]',
'use_ffprobe_cache': 'True' 'use_ffprobe_cache': 'True',
'defer_search_signalr': 'False'
}, },
'proxy': { 'proxy': {
'type': 'None', 'type': 'None',
@ -208,7 +210,8 @@ defaults = {
'include_ass': 'True', 'include_ass': 'True',
'include_srt': 'True', 'include_srt': 'True',
'hi_fallback': 'False', 'hi_fallback': 'False',
'mergerfs_mode': 'False' 'mergerfs_mode': 'False',
'timeout': '600',
}, },
'subsync': { 'subsync': {
'use_subsync': 'False', 'use_subsync': 'False',
@ -256,8 +259,7 @@ base_url = settings.general.base_url.rstrip('/')
ignore_keys = ['flask_secret_key', ignore_keys = ['flask_secret_key',
'page_size', 'page_size',
'page_size_manual_search', 'page_size_manual_search']
'throtteled_providers']
raw_keys = ['movie_default_forced', 'serie_default_forced'] 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':
if key != settings.addic7ed.username: if key != settings.addic7ed.username:
region.delete('addic7ed_data') 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':
if key != settings.legendasdivx.username: if key != settings.legendasdivx.username:
region.delete('legendasdivx_cookies2') 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':
if key != settings.opensubtitles.username: if key != settings.opensubtitles.username:
region.delete('os_token') 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':
if key != settings.opensubtitlescom.username: if key != settings.opensubtitlescom.username:
region.delete('oscom_token') 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':
if key != settings.subscene.username: if key != settings.subscene.username:
region.delete('subscene_cookies2') 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':
if key != settings.titlovi.username: if key != settings.titlovi.username:
region.delete('titlovi_token') region.delete('titlovi_token')
elif key == 'settings-titlovi-password':
if key != settings.titlovi.password:
region.delete('titlovi_token')
if settings_keys[0] == 'settings': if settings_keys[0] == 'settings':
settings[settings_keys[1]][settings_keys[2]] = str(value) settings[settings_keys[1]][settings_keys[2]] = str(value)

View File

@ -131,6 +131,7 @@ class TableHistoryMovie(BaseModel):
class TableLanguagesProfiles(BaseModel): class TableLanguagesProfiles(BaseModel):
cutoff = IntegerField(null=True) cutoff = IntegerField(null=True)
originalFormat = BooleanField(null=True)
items = TextField() items = TextField()
name = TextField() name = TextField()
profileId = AutoField() profileId = AutoField()
@ -332,6 +333,7 @@ def migrate_db():
migrator.add_column('table_history_movie', 'subtitles_path', TextField(null=True)), 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', 'mustContain', TextField(null=True)),
migrator.add_column('table_languages_profiles', 'mustNotContain', 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(): def update_profile_id_list():
global profile_id_list
profile_id_list = TableLanguagesProfiles.select(TableLanguagesProfiles.profileId, profile_id_list = TableLanguagesProfiles.select(TableLanguagesProfiles.profileId,
TableLanguagesProfiles.name, TableLanguagesProfiles.name,
TableLanguagesProfiles.cutoff, TableLanguagesProfiles.cutoff,
TableLanguagesProfiles.items, TableLanguagesProfiles.items,
TableLanguagesProfiles.mustContain, TableLanguagesProfiles.mustContain,
TableLanguagesProfiles.mustNotContain).dicts() TableLanguagesProfiles.mustNotContain,
TableLanguagesProfiles.originalFormat).dicts()
profile_id_list = list(profile_id_list) profile_id_list = list(profile_id_list)
for profile in profile_id_list: for profile in profile_id_list:
profile['items'] = json.loads(profile['items']) profile['items'] = json.loads(profile['items'])
profile['mustContain'] = ast.literal_eval(profile['mustContain']) if profile['mustContain'] else \ 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'] = ast.literal_eval(profile['mustNotContain']) if profile['mustNotContain'] else \
profile['mustNotContain'] return profile_id_list
def get_profiles_list(profile_id=None): def get_profiles_list(profile_id=None):
try: profile_id_list = update_profile_id_list()
len(profile_id_list)
except NameError:
update_profile_id_list()
if profile_id and profile_id != 'null': if profile_id and profile_id != 'null':
for profile in profile_id_list: for profile in profile_id_list:
@ -428,13 +427,11 @@ def get_profiles_list(profile_id=None):
def get_desired_languages(profile_id): def get_desired_languages(profile_id):
languages = [] languages = []
profile_id_list = update_profile_id_list()
if not len(profile_id_list):
update_profile_id_list()
if profile_id and profile_id != 'null': if profile_id and profile_id != 'null':
for profile in profile_id_list: 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): if profileId == int(profile_id):
languages = [x['language'] for x in items] languages = [x['language'] for x in items]
break break
@ -444,13 +441,11 @@ def get_desired_languages(profile_id):
def get_profile_id_name(profile_id): def get_profile_id_name(profile_id):
name_from_id = None name_from_id = None
profile_id_list = update_profile_id_list()
if not len(profile_id_list):
update_profile_id_list()
if profile_id and profile_id != 'null': if profile_id and profile_id != 'null':
for profile in profile_id_list: 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): if profileId == int(profile_id):
name_from_id = name name_from_id = name
break break
@ -460,14 +455,12 @@ def get_profile_id_name(profile_id):
def get_profile_cutoff(profile_id): def get_profile_cutoff(profile_id):
cutoff_language = None cutoff_language = None
profile_id_list = update_profile_id_list()
if not len(profile_id_list):
update_profile_id_list()
if profile_id and profile_id != 'null': if profile_id and profile_id != 'null':
cutoff_language = [] cutoff_language = []
for profile in profile_id_list: 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 cutoff:
if profileId == int(profile_id): if profileId == int(profile_id):
for item in items: 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 we have ffprobe available
if ffprobe_path: if ffprobe_path:
api.initialize({"provider": "ffmpeg", "ffmpeg": ffprobe_path}) data["ffprobe"] = api.know(video_path=file, context={"provider": "ffmpeg", "ffmpeg": ffprobe_path})
data["ffprobe"] = api.know(file)
# if not, we use enzyme for mkv files # if not, we use enzyme for mkv files
else: else:
if os.path.splitext(file)[1] == ".mkv": if os.path.splitext(file)[1] == ".mkv":

View File

@ -5,6 +5,7 @@ import argparse
from distutils.util import strtobool from distutils.util import strtobool
no_update = bool(os.environ.get("NO_UPDATE", False))
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@ -16,8 +17,9 @@ def get_args():
dest="config_dir", help="Directory containing the configuration (default: %s)" % config_dir) dest="config_dir", help="Directory containing the configuration (default: %s)" % config_dir)
parser.add_argument('-p', '--port', type=int, metavar="PORT", dest="port", parser.add_argument('-p', '--port', type=int, metavar="PORT", dest="port",
help="Port number (default: 6767)") help="Port number (default: 6767)")
parser.add_argument('--no-update', default=False, type=bool, const=True, metavar="BOOL", nargs="?", if not no_update:
help="Disable update functionality (default: False)") 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="?", parser.add_argument('--debug', default=False, type=bool, const=True, metavar="BOOL", nargs="?",
help="Enable console debugging (default: False)") help="Enable console debugging (default: False)")
parser.add_argument('--release-update', default=False, type=bool, const=True, metavar="BOOL", nargs="?", parser.add_argument('--release-update', default=False, type=bool, const=True, metavar="BOOL", nargs="?",
@ -31,3 +33,5 @@ def get_args():
args = 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.') 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)) logging.debug('BAZARR syncing this specific episode from Sonarr: {}'.format(episode_id))
url = url_sonarr() url = url_sonarr()
apikey_sonarr = settings.sonarr.apikey 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'])) store_subtitles(episode['path'], path_mappings.path_replace(episode['path']))
# Downloading missing subtitles # Downloading missing subtitles
logging.debug('BAZARR downloading missing subtitles for this episode: {}'.format(path_mappings.path_replace( if defer_search:
episode['path']))) logging.debug('BAZARR searching for missing subtitles is deferred until scheduled task execution for this '
episode_download_subtitles(episode_id) '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): 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.') 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)) logging.debug('BAZARR syncing this specific movie from Radarr: {}'.format(movie_id))
# Check if there's a row in database for this 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'])) store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']))
# Downloading missing subtitles # Downloading missing subtitles
logging.debug('BAZARR downloading missing subtitles for this movie: {}'.format(path_mappings.path_replace_movie( if defer_search:
movie['path']))) logging.debug('BAZARR searching for missing subtitles is deferred until scheduled task execution for this '
movies_download_subtitles(movie_id) '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(): def get_profile_list():

View File

@ -8,35 +8,40 @@ import pretty
import time import time
import socket import socket
import requests import requests
import tzlocal
from get_args import args from get_args import args
from config import settings, get_array_from from config import settings, get_array_from
from event_handler import event_stream from event_handler import event_stream
from utils import get_binary, blacklist_log, blacklist_log_movie 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.providers.opensubtitles import DownloadLimitReached
from subliminal.exceptions import DownloadLimitExceeded, ServiceUnavailable from subliminal.exceptions import DownloadLimitExceeded, ServiceUnavailable
from subliminal import region as subliminal_cache_region from subliminal import region as subliminal_cache_region
from subliminal_patch.extensions import provider_registry 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 # 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: now_in_tz = datetime.datetime.now(tz=timezone)
dt = datetime.datetime.now() midnight = now_in_tz.replace(hour=0, minute=0, second=0, microsecond=0) + \
tomorrow = dt + datetime.timedelta(days=1) datetime.timedelta(days=1)
return datetime.datetime.combine(tomorrow, datetime.time.min) - dt return midnight - now_in_tz
# Titulky resets its download limits at the start of a new day from its perspective - the Europe/Prague timezone # 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 # 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_timedelta = time_until_midnight(timezone=pytz.timezone('Europe/Prague'))
titulky_limit_reset_datetime = time_until_end_of_day(dt=titulky_server_local_time)
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, VALID_THROTTLE_EXCEPTIONS = (TooManyRequests, DownloadLimitExceeded, ServiceUnavailable, APIThrottled,
ParseResponseError, IPAddressBlocked) ParseResponseError, IPAddressBlocked)
@ -71,14 +76,19 @@ PROVIDER_THROTTLE_MAP = {
IPAddressBlocked: (datetime.timedelta(hours=1), "1 hours"), IPAddressBlocked: (datetime.timedelta(hours=1), "1 hours"),
}, },
"titulky": { "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": { "legendasdivx": {
TooManyRequests: (datetime.timedelta(hours=3), "3 hours"), TooManyRequests: (datetime.timedelta(hours=3), "3 hours"),
DownloadLimitExceeded: ( 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: ( 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"), 'cache_dir': os.path.join(args.config_dir, "cache"),
'ffprobe_path': _FFPROBE_BINARY, 'ffprobe_path': _FFPROBE_BINARY,
'ffmpeg_path': _FFMPEG_BINARY, 'ffmpeg_path': _FFMPEG_BINARY,
'timeout': settings.embeddedsubtitles.timeout,
} }
} }
@ -297,7 +308,7 @@ def update_throttled_provider():
for provider in list(tp): for provider in list(tp):
if provider not in providers_list: if provider not in providers_list:
del tp[provider] del tp[provider]
settings.general.throtteled_providers = str(tp) set_throttled_providers(str(tp))
reason, until, throttle_desc = tp.get(provider, (None, None, None)) 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: if not subtitles:
continue continue
subtitle_formats = set()
for s in subtitles: for s in subtitles:
s.mods = subz_mods s.mods = subz_mods
subtitle_formats.add(s.format)
try: try:
fld = get_target_folder(path) fld = get_target_folder(path)
@ -84,7 +86,7 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_
tags=None, # fixme tags=None, # fixme
directory=fld, directory=fld,
chmod=chmod, chmod=chmod,
# formats=("srt", "vtt") formats=tuple(subtitle_formats),
path_decoder=force_unicode path_decoder=force_unicode
) )
except Exception as e: 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) 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]) also_forced = any([x.forced for x in initial_language_set])
_set_forced_providers(also_forced=also_forced, pool=pool) _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, provider=s.provider_name,
subtitle=codecs.encode(pickle.dumps(s.make_picklable()), "base64").decode(), subtitle=codecs.encode(pickle.dumps(s.make_picklable()), "base64").decode(),
url=s.page_link, url=s.page_link,
original_format=original_format,
matches=list(matches), matches=list(matches),
dont_matches=list(not_matched), dont_matches=list(not_matched),
release_info=releases, release_info=releases,
@ -153,7 +154,7 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
@update_pools @update_pools
def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provider, sceneName, title, media_type, 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) logging.debug('BAZARR Manually downloading Subtitles for this file: ' + path)
if settings.general.getboolean('utf8_encode'): 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 subtitle.language.forced = True
else: else:
subtitle.language.forced = False 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) subtitle.mods = get_array_from(settings.general.subzero_mods)
video = get_video(force_unicode(path), title, sceneName, providers={provider}, media_type=media_type) video = get_video(force_unicode(path), title, sceneName, providers={provider}, media_type=media_type)
if video: if video:
@ -195,7 +198,7 @@ def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provide
tags=None, # fixme tags=None, # fixme
directory=get_target_folder(path), directory=get_target_folder(path),
chmod=chmod, chmod=chmod,
# formats=("srt", "vtt") formats=(subtitle.format,),
path_decoder=force_unicode) path_decoder=force_unicode)
except Exception: except Exception:
logging.exception('BAZARR Error saving Subtitles file to disk for this file:' + path) 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() initial_language_set = set()
language_set = set() language_set = set()
# where [3] is items list of dict(id, lang, forced, hi) profile = get_profiles_list(profile_id=int(profile_id))
language_items = get_profiles_list(profile_id=int(profile_id))['items'] language_items = profile['items']
original_format = profile['originalFormat']
for language in language_items: for language in language_items:
forced = language['forced'] forced = language['forced']
@ -259,7 +263,7 @@ def _get_language_obj(profile_id):
continue continue
language_set.add(lang_obj_hi) 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): def _set_forced_providers(also_forced, pool):

View File

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

View File

@ -73,7 +73,9 @@ def upgrade_subtitles():
episodes_to_upgrade = [] episodes_to_upgrade = []
for episode in upgradable_episodes_not_perfect: 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) episodes_to_upgrade.append(episode)
count_episode_to_upgrade = len(episodes_to_upgrade) count_episode_to_upgrade = len(episodes_to_upgrade)
@ -114,7 +116,9 @@ def upgrade_subtitles():
movies_to_upgrade = [] movies_to_upgrade = []
for movie in upgradable_movies_not_perfect: 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) movies_to_upgrade.append(movie)
count_movie_to_upgrade = len(movies_to_upgrade) 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 tags=None, # fixme
directory=get_target_folder(path), directory=get_target_folder(path),
chmod=chmod, chmod=chmod,
# formats=("srt", "vtt") formats=(sub.format,),
path_decoder=force_unicode) path_decoder=force_unicode)
except Exception: except Exception:
logging.exception('BAZARR Error saving Subtitles file to disk for this file:' + path) logging.exception('BAZARR Error saving Subtitles file to disk for this file:' + path)

View File

@ -1,6 +1,7 @@
# coding=utf-8 # coding=utf-8
import os import os
import sys
import logging import logging
import re import re
import platform import platform
@ -79,8 +80,12 @@ def configure_logging(debug=False):
# File Logging # File Logging
global fh global fh
fh = TimedRotatingFileHandler(os.path.join(args.config_dir, 'log/bazarr.log'), when="midnight", interval=1, if sys.version_info >= (3, 9):
backupCount=7, delay=True, encoding='utf-8') 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|', f = FileHandlerFormatter('%(asctime)s|%(levelname)-8s|%(name)-32s|%(message)s|',
'%d/%m/%Y %H:%M:%S') '%d/%m/%Y %H:%M:%S')
fh.setFormatter(f) fh.setFormatter(f)
@ -132,3 +137,54 @@ def configure_logging(debug=False):
def empty_log(): def empty_log():
fh.doRollover() fh.doRollover()
logging.info('BAZARR Log file emptied') 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 urllib.parse import unquote # noqa E402
from get_languages import load_language_in_db # 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 from threading import Thread # noqa E402
import requests # 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) 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 @check_login
@app.route('/bazarr.log') @app.route('/bazarr.log')
def download_log(): def download_log():

View File

@ -237,9 +237,10 @@ def dispatcher(data):
# this will happen if a season monitored status is changed. # this will happen if a season monitored status is changed.
sync_episodes(series_id=media_id, send_event=True) sync_episodes(series_id=media_id, send_event=True)
elif topic == 'episode': 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': 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: except Exception as e:
logging.debug('BAZARR an exception occurred while parsing SignalR feed: {}'.format(repr(e))) logging.debug('BAZARR an exception occurred while parsing SignalR feed: {}'.format(repr(e)))
finally: finally:

View File

@ -24,7 +24,6 @@ from subliminal_patch.subtitle import Subtitle
from subliminal_patch.core import get_subtitle_path from subliminal_patch.core import get_subtitle_path
from subzero.language import Language from subzero.language import Language
from subliminal import region as subliminal_cache_region from subliminal import region as subliminal_cache_region
from deep_translator import GoogleTranslator
from dogpile.cache import make_region from dogpile.cache import make_region
import datetime import datetime
import glob import glob
@ -258,8 +257,12 @@ class GetSonarrInfo:
else: else:
raise json.decoder.JSONDecodeError raise json.decoder.JSONDecodeError
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError:
sv = url_sonarr() + "/api/v3/system/status?apikey=" + settings.sonarr.apikey try:
sonarr_version = requests.get(sv, timeout=60, verify=False, headers=headers).json()['version'] 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: except Exception:
logging.debug('BAZARR cannot get Sonarr version') logging.debug('BAZARR cannot get Sonarr version')
sonarr_version = 'unknown' sonarr_version = 'unknown'
@ -412,7 +415,7 @@ def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_pat
return True 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) language = alpha3_from_alpha2(language)
custom = CustomLanguage.from_value(language, "alpha3") custom = CustomLanguage.from_value(language, "alpha3")
if custom is None: if custom is None:
@ -420,13 +423,16 @@ def subtitles_apply_mods(language, subtitle_path, mods):
else: else:
lang_obj = custom.subzero_language() 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: with open(subtitle_path, 'rb') as f:
sub.content = f.read() sub.content = f.read()
if not sub.is_valid(): if not sub.is_valid():
logging.exception('BAZARR Invalid subtitle file: ' + subtitle_path) logging.exception('BAZARR Invalid subtitle file: ' + subtitle_path)
return return
if use_original_format:
return
content = sub.get_modified_content() content = sub.get_modified_content()
if 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): def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
from deep_translator import GoogleTranslator
language_code_convert_dict = { language_code_convert_dict = {
'he': 'iw', 'he': 'iw',
'zt': 'zh-cn', 'zt': 'zh-CN',
'zh': 'zh-tw', 'zh': 'zh-TW',
} }
to_lang = alpha3_from_alpha2(to_lang) 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) extension='.srt', forced_tag=forced, hi_tag=hi)
subs = pysubs2.load(source_srt_file, encoding='utf-8') subs = pysubs2.load(source_srt_file, encoding='utf-8')
subs.remove_miscellaneous_events()
lines_list = [x.plaintext for x in subs] lines_list = [x.plaintext for x in subs]
joined_lines_str = '\n\n\n'.join(lines_list) 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))) logging.debug('BAZARR is sending {} blocks to Google Translate'.format(len(lines_block_list)))
for block_str in 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: try:
translated_partial_srt_text = GoogleTranslator(source='auto', translated_partial_srt_text = GoogleTranslator(source='auto',
target=language_code_convert_dict.get(lang_obj.alpha2, 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}') logging.exception(f'BAZARR Unable to translate subtitles {source_srt_file}')
return False return False
else: 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_partial_srt_list = translated_partial_srt_text.split('\n\n\n')
translated_lines_list += translated_partial_srt_list translated_lines_list += translated_partial_srt_list
@ -526,6 +527,9 @@ def check_health():
check_radarr_rootfolder() check_radarr_rootfolder()
event_stream(type='badges') event_stream(type='badges')
from backup import backup_rotation
backup_rotation()
def get_health_issues(): def get_health_issues():
# this function must return a list of dictionaries consisting of to keys: object and issue # 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: From newest to oldest:
{{#each releases}} {{#each releases}}
{{#if @first}} {{#each merges}}
{{#each commits}} -
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}} {{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
{{/each}} {{/each}}
{{/if}} {{#each fixes}}
-
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
{{/each}}
{{#each commits}}
-
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
{{/each}}
{{/each}} {{/each}}

View File

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

View File

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

View File

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

View File

@ -20,26 +20,24 @@
$ npm install $ 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 # 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) 5. (Optional) Change the address of your backend server
> http://localhost:6767 will be used by default
``` ```
# Address of your backend # Address of your backend
REACT_APP_PROXY_URL=http://localhost:6767 VITE_PROXY_URL=http://localhost:6767
``` ```
6. Run Bazarr backend 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. The page will reload if you make edits.
You will also see any lint errors in the console. 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` ### `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 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> <title>Bazarr</title>
<base href="{{baseUrl}}" /> <base href="{{baseUrl}}" />
<meta charset="utf-8" /> <meta charset="utf-8" />
<link <link rel="icon" type="image/x-icon" href="./static/favicon.ico" />
rel="icon"
type="image/x-icon"
href="%PUBLIC_URL%/static/favicon.ico"
/>
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
@ -17,7 +13,6 @@
name="description" 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." 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> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
@ -25,5 +20,6 @@
<script> <script>
window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}}; window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}};
</script> </script>
<script type="module" src="./src/dom.tsx"></script>
</body> </body>
</html> </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" "url": "https://github.com/morpheus65535/bazarr/issues"
}, },
"private": true, "private": true,
"homepage": "./",
"dependencies": { "dependencies": {
"@fontsource/roboto": "^4.5.1", "axios": "^0.26",
"@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",
"react": "^17", "react": "^17",
"react-bootstrap": "^1", "react-bootstrap": "^1",
"react-dom": "^17", "react-dom": "^17",
"react-helmet": "^6.1",
"react-query": "^3.34", "react-query": "^3.34",
"react-redux": "^7.2", "react-redux": "^7.2",
"react-router-dom": "^5.3", "react-router-dom": "^6.2.1",
"react-scripts": "^4",
"react-select": "^5.0.1",
"react-table": "^7",
"recharts": "^2.0.8",
"rooks": "^5.7.1",
"socket.io-client": "^4" "socket.io-client": "^4"
}, },
"devDependencies": { "devDependencies": {
"@types/bootstrap": "^5", "@fontsource/roboto": "^4.5",
"@types/jest": "~26.0.24", "@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/lodash": "^4",
"@types/node": "^15", "@types/node": "^17",
"@types/react": "^17", "@types/react": "^17",
"@types/react-dom": "^17", "@types/react-dom": "^17",
"@types/react-helmet": "^6.1", "@types/react-helmet": "^6.1",
"@types/react-router-dom": "^5",
"@types/react-table": "^7", "@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", "husky": "^7",
"jsdom": "latest",
"lodash": "^4",
"moment": "^2.29.1",
"prettier": "^2", "prettier": "^2",
"prettier-plugin-organize-imports": "^2", "prettier-plugin-organize-imports": "^2",
"pretty-quick": "^3.1", "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", "sass": "^1",
"typescript": "^4" "typescript": "^4",
"vite": "latest",
"vite-plugin-checker": "latest",
"vitest": "latest"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "vite",
"build": "react-scripts build", "build": "vite build",
"test": "react-scripts test", "build:ci": "vite build -m development",
"lint": "prettier --write --ignore-unknown .", "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" "prepare": "cd .. && husky install frontend/.husky"
}, },
"browserslist": { "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 { import {
faBars, faBars,
faHeart, faHeart,
@ -5,12 +11,7 @@ import {
faUser, faUser,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { setSidebar } from "@redux/actions"; import { FunctionComponent, useMemo } from "react";
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 { import {
Button, Button,
Col, Col,
@ -21,14 +22,9 @@ import {
Row, Row,
} from "react-bootstrap"; } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useGotoHomepage, useIsMobile } from "utilities";
import { useSystem, useSystemSettings } from "../apis/hooks";
import "./header.scss";
import NotificationCenter from "./Notification"; import NotificationCenter from "./Notification";
interface Props {} const Header: FunctionComponent = () => {
const Header: FunctionComponent<Props> = () => {
const { data: settings } = useSystemSettings(); const { data: settings } = useSystemSettings();
const hasLogout = (settings?.auth.type ?? "none") === "form"; const hasLogout = (settings?.auth.type ?? "none") === "form";
@ -44,7 +40,7 @@ const Header: FunctionComponent<Props> = () => {
const serverActions = useMemo( const serverActions = useMemo(
() => ( () => (
<Dropdown alignRight> <Dropdown alignRight>
<Dropdown.Toggle className="dropdown-hidden" as={Button}> <Dropdown.Toggle className="hide-arrow" as={Button}>
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon> <FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>
@ -87,11 +83,11 @@ const Header: FunctionComponent<Props> = () => {
<div className="header-icon px-3 m-0 d-none d-md-block"> <div className="header-icon px-3 m-0 d-none d-md-block">
<Image <Image
alt="brand" alt="brand"
src={logo} src={`${Environment.baseUrl}/static/logo64.png`}
width="32" width="32"
height="32" height="32"
onClick={goHome} onClick={goHome}
className="cursor-pointer" role="button"
></Image> ></Image>
</div> </div>
<Button <Button

View File

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

View File

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

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 { useBadges } from "@/apis/hooks";
import { Redirect, Route, Switch, useHistory } from "react-router"; import App from "@/App";
import { useDidMount } from "rooks"; import Lazy from "@/components/Lazy";
import { BuildKey, ScrollToTop } from "utilities"; import { useEnabledStatus } from "@/modules/redux/hooks";
import { useNavigationItems } from "../Navigation"; import BlacklistMoviesView from "@/pages/Blacklist/Movies";
import { Navigation } from "../Navigation/nav"; import BlacklistSeriesView from "@/pages/Blacklist/Series";
import { RouterEmptyPath } from "../pages/404"; 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 HistoryStats = lazy(() => import("@/pages/History/Statistics"));
const navItems = useNavigationItems(); const SystemStatusView = lazy(() => import("@/pages/System/Status"));
const Authentication = lazy(() => import("@/pages/Authentication"));
const NotFound = lazy(() => import("@/pages/404"));
const history = useHistory(); function useRoutes(): CustomRouteObject[] {
useDidMount(() => { const { data } = useBadges();
history.listen(() => { const { sonarr, radarr } = useEnabledStatus();
// This is a hack to make sure ScrollToTop will be triggered in the next frame (When everything are loaded)
setTimeout(ScrollToTop); 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 ( return (
<div className="d-flex flex-row flex-grow-1 main-router"> <RouterItemContext.Provider value={routes}>
<Switch> <BrowserRouter basename={Environment.baseUrl}>{children}</BrowserRouter>
{navItems.map((v, idx) => { </RouterItemContext.Provider>
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>
); );
}; };
export default Router; export function useRouteItems() {
return useContext(RouterItemContext);
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>
);
};

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

View File

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

View File

@ -12,8 +12,16 @@ export function useSubtitleAction() {
[QueryKeys.Subtitles], [QueryKeys.Subtitles],
(param: Param) => api.subtitles.modify(param.action, param.form), (param: Param) => api.subtitles.modify(param.action, param.form),
{ {
onSuccess: () => { onSuccess: (_, param) => {
client.invalidateQueries([QueryKeys.History]); 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 { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query"; import { useMutation, useQuery, useQueryClient } from "react-query";
import { setUnauthenticated } from "../../@redux/actions"; import { setUnauthenticated } from "../../modules/redux/actions";
import store from "../../@redux/store"; import store from "../../modules/redux/store";
import { QueryKeys } from "../queries/keys"; import { QueryKeys } from "../queries/keys";
import api from "../raw"; import api from "../raw";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { Selector, SelectorProps } from "components"; import { Selector, SelectorOption, SelectorProps } from "@/components";
import React, { useMemo } from "react"; import { useMemo } from "react";
interface Props { interface Props {
options: readonly Language.Info[]; 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 { uniqueId } from "lodash";
import React, { import {
FunctionComponent, FunctionComponent,
useCallback, useCallback,
useEffect, useEffect,
@ -8,7 +8,7 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { Dropdown, Form } from "react-bootstrap"; import { Dropdown, Form } from "react-bootstrap";
import { useHistory } from "react-router"; import { useNavigate } from "react-router-dom";
import { useThrottle } from "rooks"; import { useThrottle } from "rooks";
function useSearch(query: string) { function useSearch(query: string) {
@ -66,7 +66,7 @@ export const SearchBar: FunctionComponent<Props> = ({
const results = useSearch(query); const results = useSearch(query);
const history = useHistory(); const navigate = useNavigate();
const clear = useCallback(() => { const clear = useCallback(() => {
setDisplay(""); setDisplay("");
@ -100,7 +100,7 @@ export const SearchBar: FunctionComponent<Props> = ({
onSelect={(link) => { onSelect={(link) => {
if (link) { if (link) {
clear(); clear();
history.push(link); navigate(link);
} }
}} }}
> >

View File

@ -4,9 +4,10 @@ import {
faTimes, faTimes,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { import {
FunctionComponent, FunctionComponent,
PropsWithChildren, PropsWithChildren,
ReactElement,
useCallback, useCallback,
useEffect, useEffect,
useState, useState,
@ -18,7 +19,7 @@ import { LoadingIndicator } from ".";
interface QueryOverlayProps { interface QueryOverlayProps {
result: UseQueryResult<unknown, unknown>; result: UseQueryResult<unknown, unknown>;
children: React.ReactElement; children: ReactElement;
} }
export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({ export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
@ -43,9 +44,7 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
const [item, setItem] = useState<T | null>(null); const [item, setItem] = useState<T | null>(null);
useEffect(() => { useEffect(() => {
promise() promise().then(setItem);
.then(setItem)
.catch(() => {});
}, [promise]); }, [promise]);
if (item === null) { 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 { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons"; import { faCircleNotch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, MouseEvent } from "react"; import { FunctionComponent, MouseEvent } from "react";
import { Badge, Button, ButtonProps } from "react-bootstrap"; import { Badge, Button, ButtonProps } from "react-bootstrap";
export const ActionBadge: FunctionComponent<{ export const ActionBadge: FunctionComponent<{
@ -66,7 +66,7 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
}) => { }) => {
const showText = alwaysShowText === true || loading !== true; const showText = alwaysShowText === true || loading !== true;
return ( return (
<React.Fragment> <>
<FontAwesomeIcon <FontAwesomeIcon
style={{ width: "1rem" }} style={{ width: "1rem" }}
icon={loading ? faCircleNotch : icon} icon={loading ? faCircleNotch : icon}
@ -75,6 +75,6 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
{children && showText ? ( {children && showText ? (
<span className="ml-2 font-weight-bold">{children}</span> <span className="ml-2 font-weight-bold">{children}</span>
) : null} ) : null}
</React.Fragment> </>
); );
}; };

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import {
FontAwesomeIconProps, FontAwesomeIconProps,
} from "@fortawesome/react-fontawesome"; } from "@fortawesome/react-fontawesome";
import { isNull, isUndefined } from "lodash"; import { isNull, isUndefined } from "lodash";
import React, { FunctionComponent, useMemo } from "react"; import { FunctionComponent, ReactElement } from "react";
import { import {
OverlayTrigger, OverlayTrigger,
OverlayTriggerProps, 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 { interface TextPopoverProps {
children: React.ReactElement<any, any>; children: ReactElement;
text: string | undefined | null; text: string | undefined | null;
placement?: OverlayTriggerProps["placement"]; placement?: OverlayTriggerProps["placement"];
delay?: number; delay?: number;
@ -167,6 +131,5 @@ export * from "./buttons";
export * from "./header"; export * from "./header";
export * from "./inputs"; export * from "./inputs";
export * from "./LanguageSelector"; export * from "./LanguageSelector";
export * from "./modals";
export * from "./SearchBar"; export * from "./SearchBar";
export * from "./tables"; export * from "./tables";

View File

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

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