Merge branch 'development'
# Conflicts: # frontend/package-lock.json
|
@ -15,43 +15,55 @@ on:
|
|||
branches: [development]
|
||||
|
||||
env:
|
||||
UI_DIRECTORY: ./frontend
|
||||
UI_ARTIFACT_NAME: ui
|
||||
UI_DIRECTORY: ./frontend
|
||||
UI_ARTIFACT_NAME: ui
|
||||
|
||||
jobs:
|
||||
Frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: '${{ env.UI_DIRECTORY }}/node_modules'
|
||||
path: "${{ env.UI_DIRECTORY }}/node_modules"
|
||||
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-modules-
|
||||
|
||||
- name: Setup NodeJS
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "15.x"
|
||||
node-version: "16"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Check Types
|
||||
run: npm run check:ts
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Check Styles
|
||||
run: npm run check
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Check Format
|
||||
run: npm run check:fmt
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Unit Test
|
||||
run: npm test
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
- name: Build
|
||||
run: npm run build:ci
|
||||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.UI_ARTIFACT_NAME }}
|
||||
path: "${{ env.UI_DIRECTORY }}/build"
|
||||
|
@ -62,17 +74,17 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.8'
|
||||
python-version: "3.8"
|
||||
|
||||
- name: Install UI
|
||||
uses: actions/download-artifact@v2
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ${{ env.UI_ARTIFACT_NAME }}
|
||||
path: "${{ env.UI_DIRECTORY }}/build"
|
||||
|
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
exit 1
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: ${{ env.FETCH_DEPTH }}
|
||||
ref: development
|
||||
|
@ -29,14 +29,14 @@ jobs:
|
|||
git fetch --depth ${{ env.FETCH_DEPTH }} --tags
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: '${{ env.UI_DIRECTORY }}/node_modules'
|
||||
path: "${{ env.UI_DIRECTORY }}/node_modules"
|
||||
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-modules-
|
||||
|
||||
- name: Setup NodeJS
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "15.x"
|
||||
|
||||
|
@ -69,4 +69,4 @@ jobs:
|
|||
release-it --ci --increment prerelease --preRelease=beta
|
||||
else
|
||||
echo "**** Cannot find changes! Skipping... ****"
|
||||
fi
|
||||
fi
|
||||
|
|
|
@ -22,23 +22,23 @@ jobs:
|
|||
exit 1
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: development
|
||||
|
||||
- name: Setup Git
|
||||
run: git config --global user.name "github-actions"
|
||||
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: '${{ env.UI_DIRECTORY }}/node_modules'
|
||||
path: "${{ env.UI_DIRECTORY }}/node_modules"
|
||||
key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-modules-
|
||||
|
||||
- name: Setup NodeJS
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "15.x"
|
||||
|
||||
|
@ -62,7 +62,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Merge development -> master
|
||||
uses: devmasx/merge-branch@1.4.0
|
||||
|
|
|
@ -16,13 +16,13 @@ jobs:
|
|||
exit 1
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: ${{ env.FETCH_DEPTH }}
|
||||
ref: development
|
||||
|
||||
- name: Setup NodeJS
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "15.x"
|
||||
|
||||
|
@ -35,9 +35,9 @@ jobs:
|
|||
working-directory: ${{ env.UI_DIRECTORY }}
|
||||
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.8'
|
||||
python-version: "3.8"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
|
|
|
@ -10,6 +10,9 @@ bazarr.pid
|
|||
.idea
|
||||
.vscode
|
||||
|
||||
# LSP
|
||||
pyrightconfig.json
|
||||
|
||||
# Middleware
|
||||
VERSION
|
||||
|
||||
|
|
|
@ -79,6 +79,7 @@ class EpisodesHistory(Resource):
|
|||
TableHistory.score,
|
||||
TableShows.tags,
|
||||
TableHistory.action,
|
||||
TableHistory.video_path,
|
||||
TableHistory.subtitles_path,
|
||||
TableHistory.sonarrEpisodeId,
|
||||
TableHistory.provider,
|
||||
|
@ -101,7 +102,8 @@ class EpisodesHistory(Resource):
|
|||
if {"video_path": str(item['path']), "timestamp": float(item['timestamp']), "score": str(item['score']),
|
||||
"tags": str(item['tags']), "monitored": str(item['monitored']),
|
||||
"seriesType": str(item['seriesType'])} in upgradable_episodes_not_perfect: # noqa: E129
|
||||
if os.path.isfile(path_mappings.path_replace(item['subtitles_path'])):
|
||||
if os.path.exists(path_mappings.path_replace(item['subtitles_path'])) and \
|
||||
os.path.exists(path_mappings.path_replace(item['video_path'])):
|
||||
item.update({"upgradable": True})
|
||||
|
||||
del item['path']
|
||||
|
|
|
@ -18,7 +18,7 @@ from ..utils import authenticate
|
|||
class HistoryStats(Resource):
|
||||
@authenticate
|
||||
def get(self):
|
||||
timeframe = request.args.get('timeframe') or 'month'
|
||||
timeframe = request.args.get('timeFrame') or 'month'
|
||||
action = request.args.get('action') or 'All'
|
||||
provider = request.args.get('provider') or 'All'
|
||||
language = request.args.get('language') or 'All'
|
||||
|
|
|
@ -79,7 +79,8 @@ class MoviesHistory(Resource):
|
|||
TableHistoryMovie.score,
|
||||
TableHistoryMovie.subs_id,
|
||||
TableHistoryMovie.provider,
|
||||
TableHistoryMovie.subtitles_path)\
|
||||
TableHistoryMovie.subtitles_path,
|
||||
TableHistoryMovie.video_path)\
|
||||
.join(TableMovies, on=(TableHistoryMovie.radarrId == TableMovies.radarrId))\
|
||||
.where(query_condition)\
|
||||
.order_by(TableHistoryMovie.timestamp.desc())\
|
||||
|
@ -96,7 +97,8 @@ class MoviesHistory(Resource):
|
|||
item.update({"upgradable": False})
|
||||
if {"video_path": str(item['path']), "timestamp": float(item['timestamp']), "score": str(item['score']),
|
||||
"tags": str(item['tags']), "monitored": str(item['monitored'])} in upgradable_movies_not_perfect: # noqa: E129
|
||||
if os.path.isfile(path_mappings.path_replace_movie(item['subtitles_path'])):
|
||||
if os.path.exists(path_mappings.path_replace_movie(item['subtitles_path'])) and \
|
||||
os.path.exists(path_mappings.path_replace_movie(item['video_path'])):
|
||||
item.update({"upgradable": True})
|
||||
|
||||
del item['path']
|
||||
|
|
|
@ -66,6 +66,7 @@ class ProviderEpisodes(Resource):
|
|||
|
||||
hi = request.form.get('hi').capitalize()
|
||||
forced = request.form.get('forced').capitalize()
|
||||
use_original_format = request.form.get('original_format').capitalize()
|
||||
selected_provider = request.form.get('provider')
|
||||
subtitle = request.form.get('subtitle')
|
||||
|
||||
|
@ -77,8 +78,7 @@ class ProviderEpisodes(Resource):
|
|||
|
||||
try:
|
||||
result = manual_download_subtitle(episodePath, audio_language, hi, forced, subtitle, selected_provider,
|
||||
sceneName, title, 'series',
|
||||
profile_id=get_profile_id(episode_id=sonarrEpisodeId))
|
||||
sceneName, title, 'series', use_original_format, profile_id=get_profile_id(episode_id=sonarrEpisodeId))
|
||||
if result is not None:
|
||||
message = result[0]
|
||||
path = result[1]
|
||||
|
|
|
@ -13,6 +13,7 @@ from notifier import send_notifications_movie
|
|||
from list_subtitles import store_subtitles_movie
|
||||
|
||||
from ..utils import authenticate
|
||||
import logging
|
||||
|
||||
|
||||
class ProviderMovies(Resource):
|
||||
|
@ -64,6 +65,8 @@ class ProviderMovies(Resource):
|
|||
|
||||
hi = request.form.get('hi').capitalize()
|
||||
forced = request.form.get('forced').capitalize()
|
||||
use_original_format = request.form.get('original_format').capitalize()
|
||||
logging.debug(f"use_original_format {use_original_format}")
|
||||
selected_provider = request.form.get('provider')
|
||||
subtitle = request.form.get('subtitle')
|
||||
|
||||
|
@ -75,7 +78,7 @@ class ProviderMovies(Resource):
|
|||
|
||||
try:
|
||||
result = manual_download_subtitle(moviePath, audio_language, hi, forced, subtitle, selected_provider,
|
||||
sceneName, title, 'movie', profile_id=get_profile_id(movie_id=radarrId))
|
||||
sceneName, title, 'movie', use_original_format, profile_id=get_profile_id(movie_id=radarrId))
|
||||
if result is not None:
|
||||
message = result[0]
|
||||
path = result[1]
|
||||
|
|
|
@ -73,7 +73,8 @@ class Subtitles(Resource):
|
|||
else:
|
||||
return '', 404
|
||||
else:
|
||||
subtitles_apply_mods(language, subtitles_path, [action])
|
||||
use_original_format = True if request.form.get('original_format') == 'true' else False
|
||||
subtitles_apply_mods(language, subtitles_path, [action], use_original_format)
|
||||
|
||||
# apply chmod if required
|
||||
chmod = int(settings.general.chmod, 8) if not sys.platform.startswith(
|
||||
|
|
|
@ -59,6 +59,7 @@ class SystemSettings(Resource):
|
|||
TableLanguagesProfiles.items: json.dumps(item['items']),
|
||||
TableLanguagesProfiles.mustContain: item['mustContain'],
|
||||
TableLanguagesProfiles.mustNotContain: item['mustNotContain'],
|
||||
TableLanguagesProfiles.originalFormat: item['originalFormat'] if item['originalFormat'] != 'null' else None,
|
||||
})\
|
||||
.where(TableLanguagesProfiles.profileId == item['profileId'])\
|
||||
.execute()
|
||||
|
@ -72,6 +73,7 @@ class SystemSettings(Resource):
|
|||
TableLanguagesProfiles.items: json.dumps(item['items']),
|
||||
TableLanguagesProfiles.mustContain: item['mustContain'],
|
||||
TableLanguagesProfiles.mustNotContain: item['mustNotContain'],
|
||||
TableLanguagesProfiles.originalFormat: item['originalFormat'] if item['originalFormat'] != 'null' else None,
|
||||
}).execute()
|
||||
for profileId in existing:
|
||||
# Unassign this profileId from series and movies
|
||||
|
|
|
@ -4,9 +4,13 @@ from flask import Blueprint
|
|||
from flask_restful import Api
|
||||
|
||||
from .plex import WebHooksPlex
|
||||
from .sonarr import WebHooksSonarr
|
||||
from .radarr import WebHooksRadarr
|
||||
|
||||
|
||||
api_bp_webhooks = Blueprint('api_webhooks', __name__)
|
||||
api = Api(api_bp_webhooks)
|
||||
|
||||
api.add_resource(WebHooksPlex, '/webhooks/plex')
|
||||
api.add_resource(WebHooksSonarr, '/webhooks/sonarr')
|
||||
api.add_resource(WebHooksRadarr, '/webhooks/radarr')
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import json
|
||||
import requests
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
|
||||
from flask import request
|
||||
from flask_restful import Resource
|
||||
|
@ -46,8 +46,12 @@ class WebHooksPlex(Resource):
|
|||
r = requests.get('https://imdb.com/title/{}'.format(episode_imdb_id),
|
||||
headers={"User-Agent": os.environ["SZ_USER_AGENT"]})
|
||||
soup = bso(r.content, "html.parser")
|
||||
series_imdb_id = soup.find('a', {'class': re.compile(r'SeriesParentLink__ParentTextLink')})['href'].split('/')[2]
|
||||
script_tag = soup.find(id='__NEXT_DATA__')
|
||||
script_tag_json = script_tag.string
|
||||
show_metadata_dict = json.loads(script_tag_json)
|
||||
series_imdb_id = show_metadata_dict['props']['pageProps']['aboveTheFoldData']['series']['series']['id']
|
||||
except Exception:
|
||||
logging.debug('BAZARR is unable to get series IMDB id.')
|
||||
return '', 404
|
||||
else:
|
||||
sonarrEpisodeId = TableEpisodes.select(TableEpisodes.sonarrEpisodeId) \
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
# coding=utf-8
|
||||
|
||||
from flask import request
|
||||
from flask_restful import Resource
|
||||
|
||||
from database import TableMovies
|
||||
from get_subtitle.mass_download import movies_download_subtitles
|
||||
from list_subtitles import store_subtitles_movie
|
||||
from helper import path_mappings
|
||||
from ..utils import authenticate
|
||||
|
||||
|
||||
class WebHooksRadarr(Resource):
|
||||
@authenticate
|
||||
def post(self):
|
||||
movie_file_id = request.form.get('radarr_moviefile_id')
|
||||
|
||||
radarrMovieId = TableMovies.select(TableMovies.radarrId,
|
||||
TableMovies.path) \
|
||||
.where(TableMovies.movie_file_id == movie_file_id) \
|
||||
.dicts() \
|
||||
.get_or_none()
|
||||
|
||||
if radarrMovieId:
|
||||
store_subtitles_movie(radarrMovieId['path'], path_mappings.path_replace_movie(radarrMovieId['path']))
|
||||
movies_download_subtitles(no=radarrMovieId['radarrId'])
|
||||
|
||||
return '', 200
|
|
@ -0,0 +1,29 @@
|
|||
# coding=utf-8
|
||||
|
||||
from flask import request
|
||||
from flask_restful import Resource
|
||||
|
||||
from database import TableEpisodes, TableShows
|
||||
from get_subtitle.mass_download import episode_download_subtitles
|
||||
from list_subtitles import store_subtitles
|
||||
from helper import path_mappings
|
||||
from ..utils import authenticate
|
||||
|
||||
|
||||
class WebHooksSonarr(Resource):
|
||||
@authenticate
|
||||
def post(self):
|
||||
episode_file_id = request.form.get('sonarr_episodefile_id')
|
||||
|
||||
sonarrEpisodeId = TableEpisodes.select(TableEpisodes.sonarrEpisodeId,
|
||||
TableEpisodes.path) \
|
||||
.join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) \
|
||||
.where(TableEpisodes.episode_file_id == episode_file_id) \
|
||||
.dicts() \
|
||||
.get_or_none()
|
||||
|
||||
if sonarrEpisodeId:
|
||||
store_subtitles(sonarrEpisodeId['path'], path_mappings.path_replace(sonarrEpisodeId['path']))
|
||||
episode_download_subtitles(no=sonarrEpisodeId['sonarrEpisodeId'], send_progress=True)
|
||||
|
||||
return '', 200
|
|
@ -33,12 +33,14 @@ def get_restore_path():
|
|||
def get_backup_files(fullpath=True):
|
||||
backup_file_pattern = os.path.join(get_backup_path(), 'bazarr_backup_v*.zip')
|
||||
file_list = glob(backup_file_pattern)
|
||||
file_list.sort(key=os.path.getmtime)
|
||||
if fullpath:
|
||||
return file_list
|
||||
else:
|
||||
return [{
|
||||
'type': 'backup',
|
||||
'filename': os.path.basename(x),
|
||||
'size': sizeof_fmt(os.path.getsize(x)),
|
||||
'date': datetime.fromtimestamp(os.path.getmtime(x)).strftime("%b %d %Y")
|
||||
} for x in file_list]
|
||||
|
||||
|
@ -178,7 +180,7 @@ def backup_rotation():
|
|||
|
||||
logging.debug(f'Cleaning up backup files older than {backup_retention} days')
|
||||
for file in backup_files:
|
||||
if datetime.fromtimestamp(os.path.getmtime(file)) + timedelta(days=backup_retention) < datetime.utcnow():
|
||||
if datetime.fromtimestamp(os.path.getmtime(file)) + timedelta(days=int(backup_retention)) < datetime.utcnow():
|
||||
logging.debug(f'Deleting old backup file {file}')
|
||||
try:
|
||||
os.remove(file)
|
||||
|
@ -195,3 +197,11 @@ def delete_backup_file(filename):
|
|||
except OSError:
|
||||
logging.debug(f'Unable to delete backup file {backup_file_path}')
|
||||
return False
|
||||
|
||||
|
||||
def sizeof_fmt(num, suffix="B"):
|
||||
for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]:
|
||||
if abs(num) < 1000.0:
|
||||
return f"{num:3.1f} {unit}{suffix}"
|
||||
num /= 1000.0
|
||||
return f"{num:.1f} Y{suffix}"
|
||||
|
|
|
@ -194,16 +194,20 @@ def update_cleaner(zipfile, bazarr_dir, config_dir):
|
|||
separator + '__pycache__' + separator + '$']
|
||||
if os.path.abspath(bazarr_dir).lower() == os.path.abspath(config_dir).lower():
|
||||
# for users who installed Bazarr inside the config directory (ie: `%programdata%\Bazarr` on windows)
|
||||
with os.scandir(config_dir) as directories:
|
||||
for directory in directories:
|
||||
if directory.is_dir():
|
||||
dir_to_ignore.append('^' + directory.name + os.path.sep)
|
||||
dir_to_ignore.append('^backup' + separator)
|
||||
dir_to_ignore.append('^cache' + separator)
|
||||
dir_to_ignore.append('^config' + separator)
|
||||
dir_to_ignore.append('^db' + separator)
|
||||
dir_to_ignore.append('^log' + separator)
|
||||
dir_to_ignore.append('^restore' + separator)
|
||||
dir_to_ignore.append('^update' + separator)
|
||||
elif os.path.abspath(bazarr_dir).lower() in os.path.abspath(config_dir).lower():
|
||||
# when config directory is a child of Bazarr installation directory
|
||||
dir_to_ignore.append('^' + os.path.relpath(config_dir, bazarr_dir) + os.path.sep)
|
||||
dir_to_ignore_regex = re.compile('(?:% s)' % '|'.join(dir_to_ignore))
|
||||
dir_to_ignore.append('^' + os.path.relpath(config_dir, bazarr_dir) + separator)
|
||||
dir_to_ignore_regex_string = '(?:% s)' % '|'.join(dir_to_ignore)
|
||||
logging.debug(f'BAZARR upgrade leftover cleaner will ignore directories matching this '
|
||||
f'regex: {dir_to_ignore_regex.pattern}')
|
||||
f'regex: {dir_to_ignore_regex_string}')
|
||||
dir_to_ignore_regex = re.compile(dir_to_ignore_regex_string)
|
||||
|
||||
file_to_ignore = ['nssm.exe', '7za.exe', 'unins000.exe', 'unins000.dat']
|
||||
logging.debug('BAZARR upgrade leftover cleaner will ignore those files: {}'.format(', '.join(file_to_ignore)))
|
||||
|
|
|
@ -103,7 +103,8 @@ defaults = {
|
|||
'excluded_tags': '[]',
|
||||
'excluded_series_types': '[]',
|
||||
'use_ffprobe_cache': 'True',
|
||||
'exclude_season_zero': 'False'
|
||||
'exclude_season_zero': 'False',
|
||||
'defer_search_signalr': 'False'
|
||||
},
|
||||
'radarr': {
|
||||
'ip': '127.0.0.1',
|
||||
|
@ -117,7 +118,8 @@ defaults = {
|
|||
'only_monitored': 'False',
|
||||
'movies_sync': '60',
|
||||
'excluded_tags': '[]',
|
||||
'use_ffprobe_cache': 'True'
|
||||
'use_ffprobe_cache': 'True',
|
||||
'defer_search_signalr': 'False'
|
||||
},
|
||||
'proxy': {
|
||||
'type': 'None',
|
||||
|
@ -208,7 +210,8 @@ defaults = {
|
|||
'include_ass': 'True',
|
||||
'include_srt': 'True',
|
||||
'hi_fallback': 'False',
|
||||
'mergerfs_mode': 'False'
|
||||
'mergerfs_mode': 'False',
|
||||
'timeout': '600',
|
||||
},
|
||||
'subsync': {
|
||||
'use_subsync': 'False',
|
||||
|
@ -256,8 +259,7 @@ base_url = settings.general.base_url.rstrip('/')
|
|||
|
||||
ignore_keys = ['flask_secret_key',
|
||||
'page_size',
|
||||
'page_size_manual_search',
|
||||
'throtteled_providers']
|
||||
'page_size_manual_search']
|
||||
|
||||
raw_keys = ['movie_default_forced', 'serie_default_forced']
|
||||
|
||||
|
@ -424,26 +426,44 @@ def save_settings(settings_items):
|
|||
if key == 'settings-addic7ed-username':
|
||||
if key != settings.addic7ed.username:
|
||||
region.delete('addic7ed_data')
|
||||
elif key == 'settings-addic7ed-password':
|
||||
if key != settings.addic7ed.password:
|
||||
region.delete('addic7ed_data')
|
||||
|
||||
if key == 'settings-legendasdivx-username':
|
||||
if key != settings.legendasdivx.username:
|
||||
region.delete('legendasdivx_cookies2')
|
||||
elif key == 'settings-legendasdivx-password':
|
||||
if key != settings.legendasdivx.password:
|
||||
region.delete('legendasdivx_cookies2')
|
||||
|
||||
if key == 'settings-opensubtitles-username':
|
||||
if key != settings.opensubtitles.username:
|
||||
region.delete('os_token')
|
||||
elif key == 'settings-opensubtitles-password':
|
||||
if key != settings.opensubtitles.password:
|
||||
region.delete('os_token')
|
||||
|
||||
if key == 'settings-opensubtitlescom-username':
|
||||
if key != settings.opensubtitlescom.username:
|
||||
region.delete('oscom_token')
|
||||
elif key == 'settings-opensubtitlescom-password':
|
||||
if key != settings.opensubtitlescom.password:
|
||||
region.delete('oscom_token')
|
||||
|
||||
if key == 'settings-subscene-username':
|
||||
if key != settings.subscene.username:
|
||||
region.delete('subscene_cookies2')
|
||||
elif key == 'settings-subscene-password':
|
||||
if key != settings.subscene.password:
|
||||
region.delete('subscene_cookies2')
|
||||
|
||||
if key == 'settings-titlovi-username':
|
||||
if key != settings.titlovi.username:
|
||||
region.delete('titlovi_token')
|
||||
elif key == 'settings-titlovi-password':
|
||||
if key != settings.titlovi.password:
|
||||
region.delete('titlovi_token')
|
||||
|
||||
if settings_keys[0] == 'settings':
|
||||
settings[settings_keys[1]][settings_keys[2]] = str(value)
|
||||
|
|
|
@ -131,6 +131,7 @@ class TableHistoryMovie(BaseModel):
|
|||
|
||||
class TableLanguagesProfiles(BaseModel):
|
||||
cutoff = IntegerField(null=True)
|
||||
originalFormat = BooleanField(null=True)
|
||||
items = TextField()
|
||||
name = TextField()
|
||||
profileId = AutoField()
|
||||
|
@ -332,6 +333,7 @@ def migrate_db():
|
|||
migrator.add_column('table_history_movie', 'subtitles_path', TextField(null=True)),
|
||||
migrator.add_column('table_languages_profiles', 'mustContain', TextField(null=True)),
|
||||
migrator.add_column('table_languages_profiles', 'mustNotContain', TextField(null=True)),
|
||||
migrator.add_column('table_languages_profiles', 'originalFormat', BooleanField(null=True)),
|
||||
)
|
||||
|
||||
|
||||
|
@ -396,27 +398,24 @@ def get_exclusion_clause(exclusion_type):
|
|||
|
||||
|
||||
def update_profile_id_list():
|
||||
global profile_id_list
|
||||
profile_id_list = TableLanguagesProfiles.select(TableLanguagesProfiles.profileId,
|
||||
TableLanguagesProfiles.name,
|
||||
TableLanguagesProfiles.cutoff,
|
||||
TableLanguagesProfiles.items,
|
||||
TableLanguagesProfiles.mustContain,
|
||||
TableLanguagesProfiles.mustNotContain).dicts()
|
||||
TableLanguagesProfiles.mustNotContain,
|
||||
TableLanguagesProfiles.originalFormat).dicts()
|
||||
profile_id_list = list(profile_id_list)
|
||||
for profile in profile_id_list:
|
||||
profile['items'] = json.loads(profile['items'])
|
||||
profile['mustContain'] = ast.literal_eval(profile['mustContain']) if profile['mustContain'] else \
|
||||
profile['mustContain']
|
||||
profile['mustNotContain'] = ast.literal_eval(profile['mustNotContain']) if profile['mustNotContain'] else \
|
||||
profile['mustNotContain']
|
||||
profile['mustContain'] = ast.literal_eval(profile['mustContain']) if profile['mustContain'] else []
|
||||
profile['mustNotContain'] = ast.literal_eval(profile['mustNotContain']) if profile['mustNotContain'] else []
|
||||
|
||||
return profile_id_list
|
||||
|
||||
|
||||
def get_profiles_list(profile_id=None):
|
||||
try:
|
||||
len(profile_id_list)
|
||||
except NameError:
|
||||
update_profile_id_list()
|
||||
profile_id_list = update_profile_id_list()
|
||||
|
||||
if profile_id and profile_id != 'null':
|
||||
for profile in profile_id_list:
|
||||
|
@ -428,13 +427,11 @@ def get_profiles_list(profile_id=None):
|
|||
|
||||
def get_desired_languages(profile_id):
|
||||
languages = []
|
||||
|
||||
if not len(profile_id_list):
|
||||
update_profile_id_list()
|
||||
profile_id_list = update_profile_id_list()
|
||||
|
||||
if profile_id and profile_id != 'null':
|
||||
for profile in profile_id_list:
|
||||
profileId, name, cutoff, items, mustContain, mustNotContain = profile.values()
|
||||
profileId, name, cutoff, items, mustContain, mustNotContain, originalFormat = profile.values()
|
||||
if profileId == int(profile_id):
|
||||
languages = [x['language'] for x in items]
|
||||
break
|
||||
|
@ -444,13 +441,11 @@ def get_desired_languages(profile_id):
|
|||
|
||||
def get_profile_id_name(profile_id):
|
||||
name_from_id = None
|
||||
|
||||
if not len(profile_id_list):
|
||||
update_profile_id_list()
|
||||
profile_id_list = update_profile_id_list()
|
||||
|
||||
if profile_id and profile_id != 'null':
|
||||
for profile in profile_id_list:
|
||||
profileId, name, cutoff, items, mustContain, mustNotContain = profile.values()
|
||||
profileId, name, cutoff, items, mustContain, mustNotContain, originalFormat = profile.values()
|
||||
if profileId == int(profile_id):
|
||||
name_from_id = name
|
||||
break
|
||||
|
@ -460,14 +455,12 @@ def get_profile_id_name(profile_id):
|
|||
|
||||
def get_profile_cutoff(profile_id):
|
||||
cutoff_language = None
|
||||
|
||||
if not len(profile_id_list):
|
||||
update_profile_id_list()
|
||||
profile_id_list = update_profile_id_list()
|
||||
|
||||
if profile_id and profile_id != 'null':
|
||||
cutoff_language = []
|
||||
for profile in profile_id_list:
|
||||
profileId, name, cutoff, items, mustContain, mustNotContain = profile.values()
|
||||
profileId, name, cutoff, items, mustContain, mustNotContain, originalFormat = profile.values()
|
||||
if cutoff:
|
||||
if profileId == int(profile_id):
|
||||
for item in items:
|
||||
|
|
|
@ -106,8 +106,7 @@ def parse_video_metadata(file, file_size, episode_file_id=None, movie_file_id=No
|
|||
|
||||
# if we have ffprobe available
|
||||
if ffprobe_path:
|
||||
api.initialize({"provider": "ffmpeg", "ffmpeg": ffprobe_path})
|
||||
data["ffprobe"] = api.know(file)
|
||||
data["ffprobe"] = api.know(video_path=file, context={"provider": "ffmpeg", "ffmpeg": ffprobe_path})
|
||||
# if not, we use enzyme for mkv files
|
||||
else:
|
||||
if os.path.splitext(file)[1] == ".mkv":
|
||||
|
|
|
@ -5,6 +5,7 @@ import argparse
|
|||
|
||||
from distutils.util import strtobool
|
||||
|
||||
no_update = bool(os.environ.get("NO_UPDATE", False))
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
|
||||
|
@ -16,8 +17,9 @@ def get_args():
|
|||
dest="config_dir", help="Directory containing the configuration (default: %s)" % config_dir)
|
||||
parser.add_argument('-p', '--port', type=int, metavar="PORT", dest="port",
|
||||
help="Port number (default: 6767)")
|
||||
parser.add_argument('--no-update', default=False, type=bool, const=True, metavar="BOOL", nargs="?",
|
||||
help="Disable update functionality (default: False)")
|
||||
if not no_update:
|
||||
parser.add_argument('--no-update', default=False, type=bool, const=True, metavar="BOOL", nargs="?",
|
||||
help="Disable update functionality (default: False)")
|
||||
parser.add_argument('--debug', default=False, type=bool, const=True, metavar="BOOL", nargs="?",
|
||||
help="Enable console debugging (default: False)")
|
||||
parser.add_argument('--release-update', default=False, type=bool, const=True, metavar="BOOL", nargs="?",
|
||||
|
@ -31,3 +33,5 @@ def get_args():
|
|||
|
||||
|
||||
args = get_args()
|
||||
if no_update:
|
||||
args.no_update = True
|
||||
|
|
|
@ -165,7 +165,7 @@ def sync_episodes(series_id=None, send_event=True):
|
|||
logging.debug('BAZARR All episodes synced from Sonarr into database.')
|
||||
|
||||
|
||||
def sync_one_episode(episode_id):
|
||||
def sync_one_episode(episode_id, defer_search=False):
|
||||
logging.debug('BAZARR syncing this specific episode from Sonarr: {}'.format(episode_id))
|
||||
url = url_sonarr()
|
||||
apikey_sonarr = settings.sonarr.apikey
|
||||
|
@ -239,9 +239,13 @@ def sync_one_episode(episode_id):
|
|||
store_subtitles(episode['path'], path_mappings.path_replace(episode['path']))
|
||||
|
||||
# Downloading missing subtitles
|
||||
logging.debug('BAZARR downloading missing subtitles for this episode: {}'.format(path_mappings.path_replace(
|
||||
episode['path'])))
|
||||
episode_download_subtitles(episode_id)
|
||||
if defer_search:
|
||||
logging.debug('BAZARR searching for missing subtitles is deferred until scheduled task execution for this '
|
||||
'episode: {}'.format(path_mappings.path_replace(episode['path'])))
|
||||
else:
|
||||
logging.debug('BAZARR downloading missing subtitles for this episode: {}'.format(path_mappings.path_replace(
|
||||
episode['path'])))
|
||||
episode_download_subtitles(episode_id)
|
||||
|
||||
|
||||
def SonarrFormatAudioCodec(audio_codec):
|
||||
|
|
|
@ -166,7 +166,7 @@ def update_movies(send_event=True):
|
|||
logging.debug('BAZARR All movies synced from Radarr into database.')
|
||||
|
||||
|
||||
def update_one_movie(movie_id, action):
|
||||
def update_one_movie(movie_id, action, defer_search=False):
|
||||
logging.debug('BAZARR syncing this specific movie from Radarr: {}'.format(movie_id))
|
||||
|
||||
# Check if there's a row in database for this movie ID
|
||||
|
@ -262,9 +262,13 @@ def update_one_movie(movie_id, action):
|
|||
store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']))
|
||||
|
||||
# Downloading missing subtitles
|
||||
logging.debug('BAZARR downloading missing subtitles for this movie: {}'.format(path_mappings.path_replace_movie(
|
||||
movie['path'])))
|
||||
movies_download_subtitles(movie_id)
|
||||
if defer_search:
|
||||
logging.debug('BAZARR searching for missing subtitles is deferred until scheduled task execution for this '
|
||||
'movie: {}'.format(path_mappings.path_replace_movie(movie['path'])))
|
||||
else:
|
||||
logging.debug('BAZARR downloading missing subtitles for this movie: {}'.format(path_mappings.path_replace_movie(
|
||||
movie['path'])))
|
||||
movies_download_subtitles(movie_id)
|
||||
|
||||
|
||||
def get_profile_list():
|
||||
|
|
|
@ -8,35 +8,40 @@ import pretty
|
|||
import time
|
||||
import socket
|
||||
import requests
|
||||
import tzlocal
|
||||
|
||||
from get_args import args
|
||||
from config import settings, get_array_from
|
||||
from event_handler import event_stream
|
||||
from utils import get_binary, blacklist_log, blacklist_log_movie
|
||||
from subliminal_patch.exceptions import TooManyRequests, APIThrottled, ParseResponseError, IPAddressBlocked, MustGetBlacklisted
|
||||
from subliminal_patch.exceptions import TooManyRequests, APIThrottled, ParseResponseError, IPAddressBlocked, \
|
||||
MustGetBlacklisted, SearchLimitReached
|
||||
from subliminal.providers.opensubtitles import DownloadLimitReached
|
||||
from subliminal.exceptions import DownloadLimitExceeded, ServiceUnavailable
|
||||
from subliminal import region as subliminal_cache_region
|
||||
from subliminal_patch.extensions import provider_registry
|
||||
|
||||
|
||||
def time_until_end_of_day(dt=None):
|
||||
def time_until_midnight(timezone):
|
||||
# type: (datetime.datetime) -> datetime.timedelta
|
||||
"""
|
||||
Get timedelta until end of day on the datetime passed, or current time.
|
||||
Get timedelta until midnight.
|
||||
"""
|
||||
if dt is None:
|
||||
dt = datetime.datetime.now()
|
||||
tomorrow = dt + datetime.timedelta(days=1)
|
||||
return datetime.datetime.combine(tomorrow, datetime.time.min) - dt
|
||||
now_in_tz = datetime.datetime.now(tz=timezone)
|
||||
midnight = now_in_tz.replace(hour=0, minute=0, second=0, microsecond=0) + \
|
||||
datetime.timedelta(days=1)
|
||||
return midnight - now_in_tz
|
||||
|
||||
|
||||
# Titulky resets its download limits at the start of a new day from its perspective - the Europe/Prague timezone
|
||||
# Needs to convert to offset-naive dt
|
||||
titulky_server_local_time = datetime.datetime.now(tz=pytz.timezone('Europe/Prague')).replace(tzinfo=None)
|
||||
titulky_limit_reset_datetime = time_until_end_of_day(dt=titulky_server_local_time)
|
||||
titulky_limit_reset_timedelta = time_until_midnight(timezone=pytz.timezone('Europe/Prague'))
|
||||
|
||||
hours_until_end_of_day = time_until_end_of_day().seconds // 3600 + 1
|
||||
# LegendasDivx reset its searches limit at approximately midnight, Lisbon time, everyday.
|
||||
legendasdivx_limit_reset_timedelta = time_until_midnight(timezone=pytz.timezone('Europe/Lisbon')) + \
|
||||
datetime.timedelta(minutes=15)
|
||||
|
||||
hours_until_end_of_day = time_until_midnight(timezone=tzlocal.get_localzone()).days + 1
|
||||
|
||||
VALID_THROTTLE_EXCEPTIONS = (TooManyRequests, DownloadLimitExceeded, ServiceUnavailable, APIThrottled,
|
||||
ParseResponseError, IPAddressBlocked)
|
||||
|
@ -71,14 +76,19 @@ PROVIDER_THROTTLE_MAP = {
|
|||
IPAddressBlocked: (datetime.timedelta(hours=1), "1 hours"),
|
||||
},
|
||||
"titulky": {
|
||||
DownloadLimitExceeded: (titulky_limit_reset_datetime, f"{titulky_limit_reset_datetime.seconds // 3600 + 1} hours")
|
||||
DownloadLimitExceeded: (titulky_limit_reset_timedelta, f"{titulky_limit_reset_timedelta.seconds // 3600 + 1} hours")
|
||||
},
|
||||
"legendasdivx": {
|
||||
TooManyRequests: (datetime.timedelta(hours=3), "3 hours"),
|
||||
DownloadLimitExceeded: (
|
||||
datetime.timedelta(hours=hours_until_end_of_day), "{} hours".format(str(hours_until_end_of_day))),
|
||||
legendasdivx_limit_reset_timedelta,
|
||||
f"{legendasdivx_limit_reset_timedelta.seconds // 3600 + 1} hours"),
|
||||
IPAddressBlocked: (
|
||||
datetime.timedelta(hours=hours_until_end_of_day), "{} hours".format(str(hours_until_end_of_day))),
|
||||
legendasdivx_limit_reset_timedelta,
|
||||
f"{legendasdivx_limit_reset_timedelta.seconds // 3600 + 1} hours"),
|
||||
SearchLimitReached: (
|
||||
legendasdivx_limit_reset_timedelta,
|
||||
f"{legendasdivx_limit_reset_timedelta.seconds // 3600 + 1} hours"),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -215,6 +225,7 @@ def get_providers_auth():
|
|||
'cache_dir': os.path.join(args.config_dir, "cache"),
|
||||
'ffprobe_path': _FFPROBE_BINARY,
|
||||
'ffmpeg_path': _FFMPEG_BINARY,
|
||||
'timeout': settings.embeddedsubtitles.timeout,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -297,7 +308,7 @@ def update_throttled_provider():
|
|||
for provider in list(tp):
|
||||
if provider not in providers_list:
|
||||
del tp[provider]
|
||||
settings.general.throtteled_providers = str(tp)
|
||||
set_throttled_providers(str(tp))
|
||||
|
||||
reason, until, throttle_desc = tp.get(provider, (None, None, None))
|
||||
|
||||
|
|
|
@ -72,8 +72,10 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_
|
|||
if not subtitles:
|
||||
continue
|
||||
|
||||
subtitle_formats = set()
|
||||
for s in subtitles:
|
||||
s.mods = subz_mods
|
||||
subtitle_formats.add(s.format)
|
||||
|
||||
try:
|
||||
fld = get_target_folder(path)
|
||||
|
@ -84,7 +86,7 @@ def generate_subtitles(path, languages, audio_language, sceneName, title, media_
|
|||
tags=None, # fixme
|
||||
directory=fld,
|
||||
chmod=chmod,
|
||||
# formats=("srt", "vtt")
|
||||
formats=tuple(subtitle_formats),
|
||||
path_decoder=force_unicode
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
@ -31,7 +31,7 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
|
|||
|
||||
pool = _get_pool(media_type, profile_id)
|
||||
|
||||
language_set, initial_language_set = _get_language_obj(profile_id=profile_id)
|
||||
language_set, initial_language_set, original_format = _get_language_obj(profile_id=profile_id)
|
||||
also_forced = any([x.forced for x in initial_language_set])
|
||||
_set_forced_providers(also_forced=also_forced, pool=pool)
|
||||
|
||||
|
@ -136,6 +136,7 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
|
|||
provider=s.provider_name,
|
||||
subtitle=codecs.encode(pickle.dumps(s.make_picklable()), "base64").decode(),
|
||||
url=s.page_link,
|
||||
original_format=original_format,
|
||||
matches=list(matches),
|
||||
dont_matches=list(not_matched),
|
||||
release_info=releases,
|
||||
|
@ -153,7 +154,7 @@ def manual_search(path, profile_id, providers, sceneName, title, media_type):
|
|||
|
||||
@update_pools
|
||||
def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provider, sceneName, title, media_type,
|
||||
profile_id):
|
||||
use_original_format, profile_id):
|
||||
logging.debug('BAZARR Manually downloading Subtitles for this file: ' + path)
|
||||
|
||||
if settings.general.getboolean('utf8_encode'):
|
||||
|
@ -170,6 +171,8 @@ def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provide
|
|||
subtitle.language.forced = True
|
||||
else:
|
||||
subtitle.language.forced = False
|
||||
if use_original_format == 'True':
|
||||
subtitle.use_original_format = use_original_format
|
||||
subtitle.mods = get_array_from(settings.general.subzero_mods)
|
||||
video = get_video(force_unicode(path), title, sceneName, providers={provider}, media_type=media_type)
|
||||
if video:
|
||||
|
@ -195,7 +198,7 @@ def manual_download_subtitle(path, audio_language, hi, forced, subtitle, provide
|
|||
tags=None, # fixme
|
||||
directory=get_target_folder(path),
|
||||
chmod=chmod,
|
||||
# formats=("srt", "vtt")
|
||||
formats=(subtitle.format,),
|
||||
path_decoder=force_unicode)
|
||||
except Exception:
|
||||
logging.exception('BAZARR Error saving Subtitles file to disk for this file:' + path)
|
||||
|
@ -228,8 +231,9 @@ def _get_language_obj(profile_id):
|
|||
initial_language_set = set()
|
||||
language_set = set()
|
||||
|
||||
# where [3] is items list of dict(id, lang, forced, hi)
|
||||
language_items = get_profiles_list(profile_id=int(profile_id))['items']
|
||||
profile = get_profiles_list(profile_id=int(profile_id))
|
||||
language_items = profile['items']
|
||||
original_format = profile['originalFormat']
|
||||
|
||||
for language in language_items:
|
||||
forced = language['forced']
|
||||
|
@ -259,7 +263,7 @@ def _get_language_obj(profile_id):
|
|||
continue
|
||||
language_set.add(lang_obj_hi)
|
||||
|
||||
return language_set, initial_language_set
|
||||
return language_set, initial_language_set, original_format
|
||||
|
||||
|
||||
def _set_forced_providers(also_forced, pool):
|
||||
|
|
|
@ -48,17 +48,16 @@ def movies_download_subtitles(no):
|
|||
audio_language = 'None'
|
||||
|
||||
languages = []
|
||||
providers_list = None
|
||||
|
||||
for i, language in enumerate(ast.literal_eval(movie['missing_subtitles'])):
|
||||
for language in ast.literal_eval(movie['missing_subtitles']):
|
||||
providers_list = get_providers()
|
||||
|
||||
if language is not None:
|
||||
hi_ = "True" if language.endswith(':hi') else "False"
|
||||
forced_ = "True" if language.endswith(':forced') else "False"
|
||||
languages.append((language.split(":")[0], hi_, forced_))
|
||||
|
||||
if providers_list:
|
||||
if language is not None:
|
||||
hi_ = "True" if language.endswith(':hi') else "False"
|
||||
forced_ = "True" if language.endswith(':forced') else "False"
|
||||
languages.append((language.split(":")[0], hi_, forced_))
|
||||
|
||||
# confirm if language is still missing or if cutoff have been reached
|
||||
confirmed_missing_subs = TableMovies.select(TableMovies.missing_subtitles) \
|
||||
.where(TableMovies.radarrId == movie['radarrId']) \
|
||||
|
@ -69,39 +68,39 @@ def movies_download_subtitles(no):
|
|||
|
||||
if language not in ast.literal_eval(confirmed_missing_subs['missing_subtitles']):
|
||||
continue
|
||||
else:
|
||||
logging.info("BAZARR All providers are throttled")
|
||||
break
|
||||
|
||||
show_progress(id='movie_search_progress_{}'.format(no),
|
||||
header='Searching missing subtitles...',
|
||||
name=movie['title'],
|
||||
value=i,
|
||||
count=count_movie)
|
||||
show_progress(id='movie_search_progress_{}'.format(no),
|
||||
header='Searching missing subtitles...',
|
||||
name=movie['title'],
|
||||
value=0,
|
||||
count=count_movie)
|
||||
|
||||
if providers_list:
|
||||
for result in generate_subtitles(path_mappings.path_replace_movie(movie['path']),
|
||||
languages,
|
||||
audio_language,
|
||||
str(movie['sceneName']),
|
||||
movie['title'],
|
||||
'movie'):
|
||||
for result in generate_subtitles(path_mappings.path_replace_movie(movie['path']),
|
||||
languages,
|
||||
audio_language,
|
||||
str(movie['sceneName']),
|
||||
movie['title'],
|
||||
'movie'):
|
||||
|
||||
if result:
|
||||
message = result[0]
|
||||
path = result[1]
|
||||
forced = result[5]
|
||||
if result[8]:
|
||||
language_code = result[2] + ":hi"
|
||||
elif forced:
|
||||
language_code = result[2] + ":forced"
|
||||
else:
|
||||
language_code = result[2]
|
||||
provider = result[3]
|
||||
score = result[4]
|
||||
subs_id = result[6]
|
||||
subs_path = result[7]
|
||||
store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']))
|
||||
history_log_movie(1, no, message, path, language_code, provider, score, subs_id, subs_path)
|
||||
send_notifications_movie(no, message)
|
||||
else:
|
||||
logging.info("BAZARR All providers are throttled")
|
||||
if result:
|
||||
message = result[0]
|
||||
path = result[1]
|
||||
forced = result[5]
|
||||
if result[8]:
|
||||
language_code = result[2] + ":hi"
|
||||
elif forced:
|
||||
language_code = result[2] + ":forced"
|
||||
else:
|
||||
language_code = result[2]
|
||||
provider = result[3]
|
||||
score = result[4]
|
||||
subs_id = result[6]
|
||||
subs_path = result[7]
|
||||
store_subtitles_movie(movie['path'], path_mappings.path_replace_movie(movie['path']))
|
||||
history_log_movie(1, no, message, path, language_code, provider, score, subs_id, subs_path)
|
||||
send_notifications_movie(no, message)
|
||||
|
||||
hide_progress(id='movie_search_progress_{}'.format(no))
|
||||
|
|
|
@ -73,7 +73,9 @@ def upgrade_subtitles():
|
|||
|
||||
episodes_to_upgrade = []
|
||||
for episode in upgradable_episodes_not_perfect:
|
||||
if os.path.exists(path_mappings.path_replace(episode['subtitles_path'])) and int(episode['score']) < 357:
|
||||
if os.path.exists(path_mappings.path_replace(episode['subtitles_path'])) and \
|
||||
os.path.exists(path_mappings.path_replace(episode['video_path'])) and \
|
||||
int(episode['score']) < 357:
|
||||
episodes_to_upgrade.append(episode)
|
||||
|
||||
count_episode_to_upgrade = len(episodes_to_upgrade)
|
||||
|
@ -114,7 +116,9 @@ def upgrade_subtitles():
|
|||
|
||||
movies_to_upgrade = []
|
||||
for movie in upgradable_movies_not_perfect:
|
||||
if os.path.exists(path_mappings.path_replace_movie(movie['subtitles_path'])) and int(movie['score']) < 117:
|
||||
if os.path.exists(path_mappings.path_replace_movie(movie['subtitles_path'])) and \
|
||||
os.path.exists(path_mappings.path_replace_movie(movie['video_path'])) and \
|
||||
int(movie['score']) < 117:
|
||||
movies_to_upgrade.append(movie)
|
||||
|
||||
count_movie_to_upgrade = len(movies_to_upgrade)
|
||||
|
|
|
@ -63,7 +63,7 @@ def manual_upload_subtitle(path, language, forced, hi, title, scene_name, media_
|
|||
tags=None, # fixme
|
||||
directory=get_target_folder(path),
|
||||
chmod=chmod,
|
||||
# formats=("srt", "vtt")
|
||||
formats=(sub.format,),
|
||||
path_decoder=force_unicode)
|
||||
except Exception:
|
||||
logging.exception('BAZARR Error saving Subtitles file to disk for this file:' + path)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# coding=utf-8
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
import re
|
||||
import platform
|
||||
|
@ -79,8 +80,12 @@ def configure_logging(debug=False):
|
|||
|
||||
# File Logging
|
||||
global fh
|
||||
fh = TimedRotatingFileHandler(os.path.join(args.config_dir, 'log/bazarr.log'), when="midnight", interval=1,
|
||||
backupCount=7, delay=True, encoding='utf-8')
|
||||
if sys.version_info >= (3, 9):
|
||||
fh = PatchedTimedRotatingFileHandler(os.path.join(args.config_dir, 'log/bazarr.log'), when="midnight",
|
||||
interval=1, backupCount=7, delay=True, encoding='utf-8')
|
||||
else:
|
||||
fh = TimedRotatingFileHandler(os.path.join(args.config_dir, 'log/bazarr.log'), when="midnight", interval=1,
|
||||
backupCount=7, delay=True, encoding='utf-8')
|
||||
f = FileHandlerFormatter('%(asctime)s|%(levelname)-8s|%(name)-32s|%(message)s|',
|
||||
'%d/%m/%Y %H:%M:%S')
|
||||
fh.setFormatter(f)
|
||||
|
@ -132,3 +137,54 @@ def configure_logging(debug=False):
|
|||
def empty_log():
|
||||
fh.doRollover()
|
||||
logging.info('BAZARR Log file emptied')
|
||||
|
||||
|
||||
class PatchedTimedRotatingFileHandler(TimedRotatingFileHandler):
|
||||
# This super classed version of logging.TimedRotatingFileHandler is required to fix a bug in earlier version of
|
||||
# Python 3.9, 3.10 and 3.11 where log rotation isn't working as expected and do not delete backup log files.
|
||||
|
||||
def __init__(self, filename, when='h', interval=1, backupCount=0, encoding=None, delay=False, utc=False,
|
||||
atTime=None, errors=None):
|
||||
super(PatchedTimedRotatingFileHandler, self).__init__(filename, when, interval, backupCount, encoding, delay, utc,
|
||||
atTime, errors)
|
||||
|
||||
def getFilesToDelete(self):
|
||||
"""
|
||||
Determine the files to delete when rolling over.
|
||||
More specific than the earlier method, which just used glob.glob().
|
||||
"""
|
||||
dirName, baseName = os.path.split(self.baseFilename)
|
||||
fileNames = os.listdir(dirName)
|
||||
result = []
|
||||
# See bpo-44753: Don't use the extension when computing the prefix.
|
||||
n, e = os.path.splitext(baseName)
|
||||
prefix = n + '.'
|
||||
plen = len(prefix)
|
||||
for fileName in fileNames:
|
||||
if self.namer is None:
|
||||
# Our files will always start with baseName
|
||||
if not fileName.startswith(baseName):
|
||||
continue
|
||||
else:
|
||||
# Our files could be just about anything after custom naming, but
|
||||
# likely candidates are of the form
|
||||
# foo.log.DATETIME_SUFFIX or foo.DATETIME_SUFFIX.log
|
||||
if (not fileName.startswith(baseName) and fileName.endswith(e) and
|
||||
len(fileName) > (plen + 1) and not fileName[plen+1].isdigit()):
|
||||
continue
|
||||
|
||||
if fileName[:plen] == prefix:
|
||||
suffix = fileName[plen:]
|
||||
# See bpo-45628: The date/time suffix could be anywhere in the
|
||||
# filename
|
||||
parts = suffix.split('.')
|
||||
for part in parts:
|
||||
if self.extMatch.match(part):
|
||||
result.append(os.path.join(dirName, fileName))
|
||||
break
|
||||
if len(result) < self.backupCount:
|
||||
result = []
|
||||
else:
|
||||
result.sort()
|
||||
result = result[:len(result) - self.backupCount]
|
||||
return result
|
||||
|
|
|
@ -24,7 +24,8 @@ from notifier import update_notifier # noqa E402
|
|||
|
||||
from urllib.parse import unquote # noqa E402
|
||||
from get_languages import load_language_in_db # noqa E402
|
||||
from flask import request, redirect, abort, render_template, Response, session, send_file, stream_with_context # noqa E402
|
||||
from flask import request, redirect, abort, render_template, Response, session, send_file, stream_with_context, \
|
||||
send_from_directory
|
||||
from threading import Thread # noqa E402
|
||||
import requests # noqa E402
|
||||
|
||||
|
@ -112,6 +113,12 @@ def catch_all(path):
|
|||
return render_template("index.html", BAZARR_SERVER_INJECT=inject, baseUrl=template_url)
|
||||
|
||||
|
||||
@app.route('/assets/<path:filename>')
|
||||
def web_assets(filename):
|
||||
path = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'build', 'assets')
|
||||
return send_from_directory(path, filename)
|
||||
|
||||
|
||||
@check_login
|
||||
@app.route('/bazarr.log')
|
||||
def download_log():
|
||||
|
|
|
@ -237,9 +237,10 @@ def dispatcher(data):
|
|||
# this will happen if a season monitored status is changed.
|
||||
sync_episodes(series_id=media_id, send_event=True)
|
||||
elif topic == 'episode':
|
||||
sync_one_episode(episode_id=media_id)
|
||||
sync_one_episode(episode_id=media_id, defer_search=settings.sonarr.getboolean('defer_search_signalr'))
|
||||
elif topic == 'movie':
|
||||
update_one_movie(movie_id=media_id, action=action)
|
||||
update_one_movie(movie_id=media_id, action=action,
|
||||
defer_search=settings.radarr.getboolean('defer_search_signalr'))
|
||||
except Exception as e:
|
||||
logging.debug('BAZARR an exception occurred while parsing SignalR feed: {}'.format(repr(e)))
|
||||
finally:
|
||||
|
|
|
@ -24,7 +24,6 @@ from subliminal_patch.subtitle import Subtitle
|
|||
from subliminal_patch.core import get_subtitle_path
|
||||
from subzero.language import Language
|
||||
from subliminal import region as subliminal_cache_region
|
||||
from deep_translator import GoogleTranslator
|
||||
from dogpile.cache import make_region
|
||||
import datetime
|
||||
import glob
|
||||
|
@ -258,8 +257,12 @@ class GetSonarrInfo:
|
|||
else:
|
||||
raise json.decoder.JSONDecodeError
|
||||
except json.decoder.JSONDecodeError:
|
||||
sv = url_sonarr() + "/api/v3/system/status?apikey=" + settings.sonarr.apikey
|
||||
sonarr_version = requests.get(sv, timeout=60, verify=False, headers=headers).json()['version']
|
||||
try:
|
||||
sv = url_sonarr() + "/api/v3/system/status?apikey=" + settings.sonarr.apikey
|
||||
sonarr_version = requests.get(sv, timeout=60, verify=False, headers=headers).json()['version']
|
||||
except json.decoder.JSONDecodeError:
|
||||
logging.debug('BAZARR cannot get Sonarr version')
|
||||
sonarr_version = 'unknown'
|
||||
except Exception:
|
||||
logging.debug('BAZARR cannot get Sonarr version')
|
||||
sonarr_version = 'unknown'
|
||||
|
@ -412,7 +415,7 @@ def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_pat
|
|||
return True
|
||||
|
||||
|
||||
def subtitles_apply_mods(language, subtitle_path, mods):
|
||||
def subtitles_apply_mods(language, subtitle_path, mods, use_original_format):
|
||||
language = alpha3_from_alpha2(language)
|
||||
custom = CustomLanguage.from_value(language, "alpha3")
|
||||
if custom is None:
|
||||
|
@ -420,13 +423,16 @@ def subtitles_apply_mods(language, subtitle_path, mods):
|
|||
else:
|
||||
lang_obj = custom.subzero_language()
|
||||
|
||||
sub = Subtitle(lang_obj, mods=mods)
|
||||
sub = Subtitle(lang_obj, mods=mods, original_format=use_original_format)
|
||||
with open(subtitle_path, 'rb') as f:
|
||||
sub.content = f.read()
|
||||
|
||||
if not sub.is_valid():
|
||||
logging.exception('BAZARR Invalid subtitle file: ' + subtitle_path)
|
||||
return
|
||||
|
||||
if use_original_format:
|
||||
return
|
||||
|
||||
content = sub.get_modified_content()
|
||||
if content:
|
||||
|
@ -438,10 +444,12 @@ def subtitles_apply_mods(language, subtitle_path, mods):
|
|||
|
||||
|
||||
def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
|
||||
from deep_translator import GoogleTranslator
|
||||
|
||||
language_code_convert_dict = {
|
||||
'he': 'iw',
|
||||
'zt': 'zh-cn',
|
||||
'zh': 'zh-tw',
|
||||
'zt': 'zh-CN',
|
||||
'zh': 'zh-TW',
|
||||
}
|
||||
|
||||
to_lang = alpha3_from_alpha2(to_lang)
|
||||
|
@ -461,6 +469,7 @@ def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
|
|||
extension='.srt', forced_tag=forced, hi_tag=hi)
|
||||
|
||||
subs = pysubs2.load(source_srt_file, encoding='utf-8')
|
||||
subs.remove_miscellaneous_events()
|
||||
lines_list = [x.plaintext for x in subs]
|
||||
joined_lines_str = '\n\n\n'.join(lines_list)
|
||||
|
||||
|
@ -480,11 +489,6 @@ def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
|
|||
|
||||
logging.debug('BAZARR is sending {} blocks to Google Translate'.format(len(lines_block_list)))
|
||||
for block_str in lines_block_list:
|
||||
empty_first_line = False
|
||||
if block_str.startswith('\n\n\n'):
|
||||
# This happens when the first line of text in a subtitles file is an empty string
|
||||
empty_first_line = True
|
||||
|
||||
try:
|
||||
translated_partial_srt_text = GoogleTranslator(source='auto',
|
||||
target=language_code_convert_dict.get(lang_obj.alpha2,
|
||||
|
@ -494,9 +498,6 @@ def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
|
|||
logging.exception(f'BAZARR Unable to translate subtitles {source_srt_file}')
|
||||
return False
|
||||
else:
|
||||
if empty_first_line:
|
||||
# GoogleTranslate remove new lines at the beginning of the string, so we add it back.
|
||||
translated_partial_srt_text = '\n\n\n' + translated_partial_srt_text
|
||||
translated_partial_srt_list = translated_partial_srt_text.split('\n\n\n')
|
||||
translated_lines_list += translated_partial_srt_list
|
||||
|
||||
|
@ -526,6 +527,9 @@ def check_health():
|
|||
check_radarr_rootfolder()
|
||||
event_stream(type='badges')
|
||||
|
||||
from backup import backup_rotation
|
||||
backup_rotation()
|
||||
|
||||
|
||||
def get_health_issues():
|
||||
# this function must return a list of dictionaries consisting of to keys: object and issue
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
From newest to oldest:
|
||||
{{#each releases}}
|
||||
{{#if @first}}
|
||||
{{#each commits}}
|
||||
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{#each merges}}
|
||||
-
|
||||
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{#each fixes}}
|
||||
-
|
||||
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{#each commits}}
|
||||
-
|
||||
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{/each}}
|
|
@ -1,6 +1,15 @@
|
|||
From newest to oldest:
|
||||
{{#each releases}}
|
||||
{{#each merges}}
|
||||
-
|
||||
{{message}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{#each fixes}}
|
||||
-
|
||||
{{commit.subject}}{{#if href}} [#{{id}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{#each commits}}
|
||||
- {{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||
-
|
||||
{{subject}}{{#if href}} [{{shorthash}}]({{href}}){{/if}}
|
||||
{{/each}}
|
||||
{{/each}}
|
|
@ -1,27 +1,29 @@
|
|||
# Override by duplicating me and rename to .env.local
|
||||
# The following environment variables will only be used during development
|
||||
|
||||
# Required
|
||||
|
||||
# API key of your backend
|
||||
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
|
||||
# VITE_API_KEY="YOUR_SERVER_API_KEY"
|
||||
|
||||
# Address of your backend
|
||||
REACT_APP_PROXY_URL=http://localhost:6767
|
||||
# VITE_PROXY_URL=http://127.0.0.1:6767
|
||||
|
||||
# Optional
|
||||
|
||||
# Allow Unsecured connection to your backend
|
||||
REACT_APP_PROXY_SECURE=true
|
||||
|
||||
# Allow websocket connection in Socket.IO
|
||||
REACT_APP_ALLOW_WEBSOCKET=true
|
||||
# Bazarr configuration path, must be absolute path
|
||||
# Vite will use this variable to find your bazarr's configuration file
|
||||
VITE_BAZARR_CONFIG_FILE="../data/config/config.ini"
|
||||
|
||||
# Display update section in settings
|
||||
REACT_APP_CAN_UPDATE=true
|
||||
VITE_CAN_UPDATE=true
|
||||
|
||||
# Display update notification in notification center
|
||||
REACT_APP_HAS_UPDATE=false
|
||||
VITE_HAS_UPDATE=false
|
||||
|
||||
# Display React-Query devtools
|
||||
REACT_APP_QUERY_DEV=false
|
||||
VITE_QUERY_DEV=false
|
||||
|
||||
# Proxy Settings
|
||||
|
||||
# Allow Unsecured connection to your backend
|
||||
VITE_PROXY_SECURE=true
|
||||
|
||||
# Allow websocket connection in Socket.IO
|
||||
VITE_ALLOW_WEBSOCKET=true
|
||||
|
|
|
@ -1,3 +1,15 @@
|
|||
{
|
||||
"extends": "react-app"
|
||||
"rules": {
|
||||
"no-console": "error",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-empty-function": "warn",
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
"@typescript-eslint/no-unused-vars": "warn"
|
||||
},
|
||||
"extends": [
|
||||
"react-app",
|
||||
"plugin:react-hooks/recommended",
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -2,3 +2,5 @@ node_modules
|
|||
dist
|
||||
*.local
|
||||
build
|
||||
|
||||
*.tsbuildinfo
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
|
||||
build
|
||||
dist
|
||||
converage
|
||||
public
|
||||
|
|
|
@ -20,26 +20,24 @@
|
|||
$ npm install
|
||||
```
|
||||
|
||||
3. Duplicate `.env.development` file and rename to `.env.local`
|
||||
3. (Optional) Duplicate `.env.development` file and rename to `.env.development.local`
|
||||
|
||||
```
|
||||
$ cp .env .env.local
|
||||
$ cp .env.development .env.development.local
|
||||
```
|
||||
|
||||
4. Update your backend server's API key in `.env.local`
|
||||
4. (Optional) Update your backend server's API key in `.env.development.local`
|
||||
|
||||
```
|
||||
# API key of your backend
|
||||
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
|
||||
VITE_API_KEY="YOUR_SERVER_API_KEY"
|
||||
```
|
||||
|
||||
5. Change the address of your backend server (Optional)
|
||||
|
||||
> http://localhost:6767 will be used by default
|
||||
5. (Optional) Change the address of your backend server
|
||||
|
||||
```
|
||||
# Address of your backend
|
||||
REACT_APP_PROXY_URL=http://localhost:6767
|
||||
VITE_PROXY_URL=http://localhost:6767
|
||||
```
|
||||
|
||||
6. Run Bazarr backend
|
||||
|
@ -66,17 +64,11 @@ Open `http://localhost:3000` to view it in the browser.
|
|||
The page will reload if you make edits.
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Run the Unit Test to validate app state.
|
||||
|
||||
Please ensure all tests are passed before uploading the code
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.
|
||||
Builds the app in production mode and save to the `build` folder.
|
||||
|
||||
### `npm run lint`
|
||||
### `npm run format`
|
||||
|
||||
Format code for all files in `frontend` folder
|
||||
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import { dependencies } from "../package.json";
|
||||
|
||||
const vendors = [
|
||||
"react",
|
||||
"react-redux",
|
||||
"react-router-dom",
|
||||
"react-dom",
|
||||
"react-query",
|
||||
"axios",
|
||||
"socket.io-client",
|
||||
];
|
||||
|
||||
function renderChunks() {
|
||||
const chunks: Record<string, string[]> = {};
|
||||
|
||||
for (const key in dependencies) {
|
||||
if (!vendors.includes(key)) {
|
||||
chunks[key] = [key];
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const chunks = {
|
||||
vendors,
|
||||
...renderChunks(),
|
||||
};
|
||||
|
||||
export default chunks;
|
|
@ -0,0 +1,67 @@
|
|||
/// <reference types="node" />
|
||||
|
||||
import { readFile } from "fs/promises";
|
||||
|
||||
async function read(path: string, sectionName: string, fieldName: string) {
|
||||
const config = await readFile(path, "utf8");
|
||||
|
||||
const targetSection = config
|
||||
.split("\n\n")
|
||||
.filter((section) => section.includes(`[${sectionName}]`));
|
||||
|
||||
if (targetSection.length === 0) {
|
||||
throw new Error(`Cannot find [${sectionName}] section in config`);
|
||||
}
|
||||
|
||||
const section = targetSection[0];
|
||||
|
||||
for (const line of section.split("\n")) {
|
||||
const matched = line.startsWith(fieldName);
|
||||
if (matched) {
|
||||
const results = line.split("=");
|
||||
if (results.length === 2) {
|
||||
const key = results[1].trim();
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Cannot find ${fieldName} in config`);
|
||||
}
|
||||
|
||||
export default async function overrideEnv(env: Record<string, string>) {
|
||||
const configPath = env["VITE_BAZARR_CONFIG_FILE"];
|
||||
|
||||
if (configPath === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (env["VITE_API_KEY"] === undefined) {
|
||||
try {
|
||||
const apiKey = await read(configPath, "auth", "apikey");
|
||||
|
||||
env["VITE_API_KEY"] = apiKey;
|
||||
process.env["VITE_API_KEY"] = apiKey;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`No API key found, please run the backend first, (error: ${err.message})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (env["VITE_PROXY_URL"] === undefined) {
|
||||
try {
|
||||
const port = await read(configPath, "general", "port");
|
||||
const baseUrl = await read(configPath, "general", "base_url");
|
||||
|
||||
const url = `http://localhost:${port}${baseUrl}`;
|
||||
|
||||
env["VITE_PROXY_URL"] = url;
|
||||
process.env["VITE_PROXY_URL"] = url;
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`No proxy url found, please run the backend first, (error: ${err.message})`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,11 +4,7 @@
|
|||
<title>Bazarr</title>
|
||||
<base href="{{baseUrl}}" />
|
||||
<meta charset="utf-8" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="%PUBLIC_URL%/static/favicon.ico"
|
||||
/>
|
||||
<link rel="icon" type="image/x-icon" href="./static/favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"
|
||||
|
@ -17,7 +13,6 @@
|
|||
name="description"
|
||||
content="Bazarr is a companion application to Sonarr and Radarr. It manages and downloads subtitles based on your requirements. You define your preferences by TV show or movie and Bazarr takes care of everything for you."
|
||||
/>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/static/manifest.json" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
@ -25,5 +20,6 @@
|
|||
<script>
|
||||
window.Bazarr = {{BAZARR_SERVER_INJECT | tojson | safe}};
|
||||
</script>
|
||||
<script type="module" src="./src/dom.tsx"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -12,57 +12,69 @@
|
|||
"url": "https://github.com/morpheus65535/bazarr/issues"
|
||||
},
|
||||
"private": true,
|
||||
"homepage": "./",
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^4.5.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.15",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.15",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15",
|
||||
"@fortawesome/react-fontawesome": "^0.1.16",
|
||||
"@reduxjs/toolkit": "^1.6",
|
||||
"axios": "^0.24",
|
||||
"bootstrap": "^4",
|
||||
"lodash": "^4",
|
||||
"moment": "^2.29.1",
|
||||
"rc-slider": "^9.7",
|
||||
"axios": "^0.26",
|
||||
"react": "^17",
|
||||
"react-bootstrap": "^1",
|
||||
"react-dom": "^17",
|
||||
"react-helmet": "^6.1",
|
||||
"react-query": "^3.34",
|
||||
"react-redux": "^7.2",
|
||||
"react-router-dom": "^5.3",
|
||||
"react-scripts": "^4",
|
||||
"react-select": "^5.0.1",
|
||||
"react-table": "^7",
|
||||
"recharts": "^2.0.8",
|
||||
"rooks": "^5.7.1",
|
||||
"react-router-dom": "^6.2.1",
|
||||
"socket.io-client": "^4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bootstrap": "^5",
|
||||
"@types/jest": "~26.0.24",
|
||||
"@fontsource/roboto": "^4.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6",
|
||||
"@fortawesome/free-brands-svg-icons": "^6",
|
||||
"@fortawesome/free-regular-svg-icons": "^6",
|
||||
"@fortawesome/free-solid-svg-icons": "^6",
|
||||
"@fortawesome/react-fontawesome": "^0.1",
|
||||
"@reduxjs/toolkit": "^1",
|
||||
"@testing-library/jest-dom": "latest",
|
||||
"@testing-library/react": "12",
|
||||
"@testing-library/react-hooks": "latest",
|
||||
"@testing-library/user-event": "latest",
|
||||
"@types/bootstrap": "^4",
|
||||
"@types/lodash": "^4",
|
||||
"@types/node": "^15",
|
||||
"@types/node": "^17",
|
||||
"@types/react": "^17",
|
||||
"@types/react-dom": "^17",
|
||||
"@types/react-helmet": "^6.1",
|
||||
"@types/react-router-dom": "^5",
|
||||
"@types/react-table": "^7",
|
||||
"http-proxy-middleware": "^2",
|
||||
"@vitejs/plugin-react": "^1.3",
|
||||
"bootstrap": "^4",
|
||||
"clsx": "^1.1.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-react-app": "^7.0.0",
|
||||
"eslint-plugin-react-hooks": "^4",
|
||||
"husky": "^7",
|
||||
"jsdom": "latest",
|
||||
"lodash": "^4",
|
||||
"moment": "^2.29.1",
|
||||
"prettier": "^2",
|
||||
"prettier-plugin-organize-imports": "^2",
|
||||
"pretty-quick": "^3.1",
|
||||
"rc-slider": "^9.7",
|
||||
"react-helmet": "^6.1",
|
||||
"react-select": "^5.0.1",
|
||||
"react-table": "^7",
|
||||
"recharts": "^2.0.8",
|
||||
"rooks": "^5",
|
||||
"sass": "^1",
|
||||
"typescript": "^4"
|
||||
"typescript": "^4",
|
||||
"vite": "latest",
|
||||
"vite-plugin-checker": "latest",
|
||||
"vitest": "latest"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"lint": "prettier --write --ignore-unknown .",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"build:ci": "vite build -m development",
|
||||
"check": "eslint --ext .ts,.tsx src",
|
||||
"check:ts": "tsc --noEmit --incremental false",
|
||||
"check:fmt": "prettier -c .",
|
||||
"test": "vitest",
|
||||
"format": "prettier -w .",
|
||||
"prepare": "cd .. && husky install frontend/.husky"
|
||||
},
|
||||
"browserslist": {
|
||||
|
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"short_name": "Bazarr",
|
||||
"name": "Bazarr Frontend",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff"
|
||||
}
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
@ -1,109 +0,0 @@
|
|||
import { keys } from "lodash";
|
||||
import {
|
||||
siteAddProgress,
|
||||
siteRemoveProgress,
|
||||
siteUpdateNotifier,
|
||||
siteUpdateProgressCount,
|
||||
} from "../../@redux/actions";
|
||||
import store from "../../@redux/store";
|
||||
|
||||
// A background task manager, use for dispatching task one by one
|
||||
class BackgroundTask {
|
||||
private groups: Task.Group;
|
||||
constructor() {
|
||||
this.groups = {};
|
||||
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this));
|
||||
}
|
||||
|
||||
private onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
const message = "Background tasks are still running";
|
||||
if (Object.keys(this.groups).length !== 0) {
|
||||
e.preventDefault();
|
||||
e.returnValue = message;
|
||||
return;
|
||||
}
|
||||
delete e["returnValue"];
|
||||
}
|
||||
|
||||
dispatch<T extends Task.Callable>(groupName: string, tasks: Task.Task<T>[]) {
|
||||
if (groupName in this.groups) {
|
||||
this.groups[groupName].push(...tasks);
|
||||
store.dispatch(
|
||||
siteUpdateProgressCount({
|
||||
id: groupName,
|
||||
count: this.groups[groupName].length,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.groups[groupName] = tasks;
|
||||
setTimeout(async () => {
|
||||
for (let index = 0; index < tasks.length; index++) {
|
||||
const task = tasks[index];
|
||||
|
||||
store.dispatch(
|
||||
siteAddProgress([
|
||||
{
|
||||
id: groupName,
|
||||
header: groupName,
|
||||
name: task.name,
|
||||
value: index,
|
||||
count: tasks.length,
|
||||
},
|
||||
])
|
||||
);
|
||||
try {
|
||||
await task.callable(...task.parameters);
|
||||
} catch (error) {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
delete this.groups[groupName];
|
||||
store.dispatch(siteRemoveProgress([groupName]));
|
||||
});
|
||||
}
|
||||
|
||||
find(groupName: string, id: number) {
|
||||
if (groupName in this.groups) {
|
||||
return this.groups[groupName].find((v) => v.id === id) !== undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
has(groupName: string) {
|
||||
return groupName in this.groups;
|
||||
}
|
||||
|
||||
hasId(ids: number[]) {
|
||||
for (const id of ids) {
|
||||
for (const key in this.groups) {
|
||||
const tasks = this.groups[key];
|
||||
if (tasks.find((v) => v.id === id) !== undefined) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return keys(this.groups).length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
const BGT = new BackgroundTask();
|
||||
|
||||
export default BGT;
|
||||
|
||||
export function dispatchTask<T extends Task.Callable>(
|
||||
groupName: string,
|
||||
tasks: Task.Task<T>[],
|
||||
comment?: string
|
||||
) {
|
||||
BGT.dispatch(groupName, tasks);
|
||||
|
||||
if (comment) {
|
||||
store.dispatch(siteUpdateNotifier(comment));
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
declare namespace Task {
|
||||
type Callable = (...args: any[]) => Promise<void>;
|
||||
|
||||
interface Task<FN extends Callable> {
|
||||
name: string;
|
||||
id?: number;
|
||||
callable: FN;
|
||||
parameters: Parameters<FN>;
|
||||
}
|
||||
|
||||
type Group = {
|
||||
[category: string]: Task.Task<Callable>[];
|
||||
};
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
export function createTask<T extends Task.Callable>(
|
||||
name: string,
|
||||
id: number | undefined,
|
||||
callable: T,
|
||||
...parameters: Parameters<T>
|
||||
): Task.Task<T> {
|
||||
return {
|
||||
name,
|
||||
id,
|
||||
callable,
|
||||
parameters,
|
||||
};
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
import { ActionCreator } from "@reduxjs/toolkit";
|
||||
import { useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { AppDispatch, RootState } from "../store";
|
||||
|
||||
// function use
|
||||
export function useReduxStore<T extends (store: RootState) => any>(
|
||||
selector: T
|
||||
) {
|
||||
return useSelector<RootState, ReturnType<T>>(selector);
|
||||
}
|
||||
|
||||
export function useAppDispatch() {
|
||||
return useDispatch<AppDispatch>();
|
||||
}
|
||||
|
||||
// TODO: Fix type
|
||||
export function useReduxAction<T extends ActionCreator<any>>(action: T) {
|
||||
const dispatch = useAppDispatch();
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => dispatch(action(...args)),
|
||||
[action, dispatch]
|
||||
);
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
// Override bootstrap primary color
|
||||
$theme-colors: (
|
||||
"primary": #911f93,
|
||||
"dark": #4f566f,
|
||||
);
|
||||
|
||||
body {
|
||||
font-family: "Roboto", "open sans", "Helvetica Neue", "Helvetica", "Arial",
|
||||
sans-serif !important;
|
||||
font-weight: 300 !important;
|
||||
}
|
||||
|
||||
// Reduce padding of cells in datatables
|
||||
.table td,
|
||||
.table th {
|
||||
padding: 0.4rem !important;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
cursor: default;
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
@import "./variable.scss";
|
||||
|
||||
:root {
|
||||
.form-control {
|
||||
&:focus {
|
||||
outline-color: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
|
||||
.dropdown-hidden {
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opacity-100 {
|
||||
opacity: 100% !important;
|
||||
}
|
||||
|
||||
.vh-100 {
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
.vh-75 {
|
||||
height: 75vh !important;
|
||||
}
|
||||
|
||||
.of-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.of-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.vw-1 {
|
||||
width: 12rem;
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
@import "./global.scss";
|
||||
@import "./variable.scss";
|
||||
@import "./bazarr.scss";
|
||||
|
||||
@import "../../node_modules/bootstrap/scss/bootstrap.scss";
|
||||
|
||||
@mixin sidebar-animation {
|
||||
transition: {
|
||||
duration: 0.2s;
|
||||
timing-function: ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.sidebar-container {
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.main-router {
|
||||
max-width: calc(100% - #{$sidebar-width});
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
min-width: $sidebar-width;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
.sidebar-container {
|
||||
position: fixed !important;
|
||||
transform: translateX(-100%);
|
||||
|
||||
@include sidebar-animation();
|
||||
|
||||
&.open {
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.main-router {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
@include sidebar-animation();
|
||||
&.open {
|
||||
display: block !important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
$sidebar-width: 190px;
|
||||
$header-height: 60px;
|
||||
|
||||
$theme-color-less-transparent: #911f9331;
|
||||
$theme-color-transparent: #911f9313;
|
||||
$theme-color-darked: #761977;
|
|
@ -1,3 +1,9 @@
|
|||
import { useSystem, useSystemSettings } from "@/apis/hooks";
|
||||
import { ActionButton, SearchBar } from "@/components";
|
||||
import { setSidebar } from "@/modules/redux/actions";
|
||||
import { useIsOffline } from "@/modules/redux/hooks";
|
||||
import { useReduxAction } from "@/modules/redux/hooks/base";
|
||||
import { Environment, useGotoHomepage, useIsMobile } from "@/utilities";
|
||||
import {
|
||||
faBars,
|
||||
faHeart,
|
||||
|
@ -5,12 +11,7 @@ import {
|
|||
faUser,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { setSidebar } from "@redux/actions";
|
||||
import { useIsOffline } from "@redux/hooks";
|
||||
import { useReduxAction } from "@redux/hooks/base";
|
||||
import logo from "@static/logo64.png";
|
||||
import { ActionButton, SearchBar } from "components";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
|
@ -21,14 +22,9 @@ import {
|
|||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useGotoHomepage, useIsMobile } from "utilities";
|
||||
import { useSystem, useSystemSettings } from "../apis/hooks";
|
||||
import "./header.scss";
|
||||
import NotificationCenter from "./Notification";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Header: FunctionComponent<Props> = () => {
|
||||
const Header: FunctionComponent = () => {
|
||||
const { data: settings } = useSystemSettings();
|
||||
|
||||
const hasLogout = (settings?.auth.type ?? "none") === "form";
|
||||
|
@ -44,7 +40,7 @@ const Header: FunctionComponent<Props> = () => {
|
|||
const serverActions = useMemo(
|
||||
() => (
|
||||
<Dropdown alignRight>
|
||||
<Dropdown.Toggle className="dropdown-hidden" as={Button}>
|
||||
<Dropdown.Toggle className="hide-arrow" as={Button}>
|
||||
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
|
@ -87,11 +83,11 @@ const Header: FunctionComponent<Props> = () => {
|
|||
<div className="header-icon px-3 m-0 d-none d-md-block">
|
||||
<Image
|
||||
alt="brand"
|
||||
src={logo}
|
||||
src={`${Environment.baseUrl}/static/logo64.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
onClick={goHome}
|
||||
className="cursor-pointer"
|
||||
role="button"
|
||||
></Image>
|
||||
</div>
|
||||
<Button
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import { BuildKey, useIsArrayExtended } from "@/utilities";
|
||||
import {
|
||||
faBug,
|
||||
faCircleNotch,
|
||||
|
@ -10,9 +12,10 @@ import {
|
|||
FontAwesomeIcon,
|
||||
FontAwesomeIconProps,
|
||||
} from "@fortawesome/react-fontawesome";
|
||||
import { useReduxStore } from "@redux/hooks/base";
|
||||
import React, {
|
||||
import {
|
||||
Fragment,
|
||||
FunctionComponent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
|
@ -27,8 +30,6 @@ import {
|
|||
Tooltip,
|
||||
} from "react-bootstrap";
|
||||
import { useDidUpdate, useTimeoutWhen } from "rooks";
|
||||
import { BuildKey, useIsArrayExtended } from "utilities";
|
||||
import "./notification.scss";
|
||||
|
||||
enum State {
|
||||
Idle,
|
||||
|
@ -63,7 +64,7 @@ function useHasErrorNotification(notifications: Server.Notification[]) {
|
|||
}
|
||||
|
||||
const NotificationCenter: FunctionComponent = () => {
|
||||
const { progress, notifications, notifier } = useReduxStore((s) => s);
|
||||
const { progress, notifications, notifier } = useReduxStore((s) => s.site);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [hasNew, setHasNew] = useState(false);
|
||||
|
@ -115,7 +116,7 @@ const NotificationCenter: FunctionComponent = () => {
|
|||
}
|
||||
}, [btnState]);
|
||||
|
||||
const content = useMemo<React.ReactNode>(() => {
|
||||
const content = useMemo<ReactNode>(() => {
|
||||
const nodes: JSX.Element[] = [];
|
||||
|
||||
nodes.push(
|
||||
|
@ -163,14 +164,14 @@ const NotificationCenter: FunctionComponent = () => {
|
|||
}, [notifier.timestamp]);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Fragment>
|
||||
<Dropdown
|
||||
onClick={onToggleClick}
|
||||
className={`notification-btn ${hasNew ? "new-item" : ""}`}
|
||||
ref={dropdownRef}
|
||||
alignRight
|
||||
>
|
||||
<Dropdown.Toggle as={Button} className="dropdown-hidden">
|
||||
<Dropdown.Toggle as={Button} className="hide-arrow">
|
||||
<FontAwesomeIcon {...iconProps}></FontAwesomeIcon>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="pb-3">{content}</Dropdown.Menu>
|
||||
|
@ -184,7 +185,7 @@ const NotificationCenter: FunctionComponent = () => {
|
|||
);
|
||||
}}
|
||||
</Overlay>
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,26 +1,23 @@
|
|||
import Socketio from "@modules/socketio";
|
||||
import { useNotification } from "@redux/hooks";
|
||||
import { useReduxStore } from "@redux/hooks/base";
|
||||
import { LoadingIndicator, ModalProvider } from "components";
|
||||
import Authentication from "pages/Authentication";
|
||||
import LaunchError from "pages/LaunchError";
|
||||
import React, { FunctionComponent, useEffect } from "react";
|
||||
import { LoadingIndicator } from "@/components";
|
||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
import { useNotification } from "@/modules/redux/hooks";
|
||||
import { useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import SocketIO from "@/modules/socketio";
|
||||
import LaunchError from "@/pages/LaunchError";
|
||||
import Sidebar from "@/Sidebar";
|
||||
import { Environment } from "@/utilities";
|
||||
import { FunctionComponent, useEffect } from "react";
|
||||
import { Row } from "react-bootstrap";
|
||||
import { Route, Switch } from "react-router";
|
||||
import { BrowserRouter, Redirect } from "react-router-dom";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { useEffectOnceWhen } from "rooks";
|
||||
import { Environment } from "utilities";
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import Router from "../Router";
|
||||
import Sidebar from "../Sidebar";
|
||||
import Header from "./Header";
|
||||
|
||||
// Sidebar Toggle
|
||||
const App: FunctionComponent = () => {
|
||||
const { status } = useReduxStore((s) => s.site);
|
||||
|
||||
interface Props {}
|
||||
|
||||
const App: FunctionComponent<Props> = () => {
|
||||
const { status } = useReduxStore((s) => s);
|
||||
useEffect(() => {
|
||||
SocketIO.initialize();
|
||||
}, []);
|
||||
|
||||
const notify = useNotification("has-update", 10 * 1000);
|
||||
|
||||
|
@ -36,7 +33,7 @@ const App: FunctionComponent<Props> = () => {
|
|||
}, status === "initialized");
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
return <Redirect to="/login"></Redirect>;
|
||||
return <Navigate to="/login"></Navigate>;
|
||||
} else if (status === "uninitialized") {
|
||||
return (
|
||||
<LoadingIndicator>
|
||||
|
@ -54,31 +51,10 @@ const App: FunctionComponent<Props> = () => {
|
|||
</Row>
|
||||
<Row noGutters className="flex-nowrap">
|
||||
<Sidebar></Sidebar>
|
||||
<ModalProvider>
|
||||
<Router></Router>
|
||||
</ModalProvider>
|
||||
<Outlet></Outlet>
|
||||
</Row>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const MainRouter: FunctionComponent = () => {
|
||||
useEffect(() => {
|
||||
Socketio.initialize();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter basename={Environment.baseUrl}>
|
||||
<Switch>
|
||||
<Route exact path="/login">
|
||||
<Authentication></Authentication>
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<App></App>
|
||||
</Route>
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainRouter;
|
||||
export default App;
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
|
||||
import { FunctionComponent } from "react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
const RootRedirect: FunctionComponent = () => {
|
||||
const sonarr = useIsSonarrEnabled();
|
||||
const radarr = useIsRadarrEnabled();
|
||||
|
||||
let path = "/settings";
|
||||
if (sonarr) {
|
||||
path = "/series";
|
||||
} else if (radarr) {
|
||||
path = "movies";
|
||||
}
|
||||
|
||||
return <Redirect to={path}></Redirect>;
|
||||
};
|
||||
|
||||
export default RootRedirect;
|
|
@ -1,251 +0,0 @@
|
|||
import {
|
||||
faClock,
|
||||
faCogs,
|
||||
faExclamationTriangle,
|
||||
faFileExcel,
|
||||
faFilm,
|
||||
faLaptop,
|
||||
faPlay,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useIsRadarrEnabled, useIsSonarrEnabled } from "@redux/hooks";
|
||||
import { useBadges } from "apis/hooks";
|
||||
import EmptyPage, { RouterEmptyPath } from "pages/404";
|
||||
import BlacklistMoviesView from "pages/Blacklist/Movies";
|
||||
import BlacklistSeriesView from "pages/Blacklist/Series";
|
||||
import Episodes from "pages/Episodes";
|
||||
import MoviesHistoryView from "pages/History/Movies";
|
||||
import SeriesHistoryView from "pages/History/Series";
|
||||
import HistoryStats from "pages/History/Statistics";
|
||||
import MovieView from "pages/Movies";
|
||||
import MovieDetail from "pages/Movies/Details";
|
||||
import SeriesView from "pages/Series";
|
||||
import SettingsGeneralView from "pages/Settings/General";
|
||||
import SettingsLanguagesView from "pages/Settings/Languages";
|
||||
import SettingsNotificationsView from "pages/Settings/Notifications";
|
||||
import SettingsProvidersView from "pages/Settings/Providers";
|
||||
import SettingsRadarrView from "pages/Settings/Radarr";
|
||||
import SettingsSchedulerView from "pages/Settings/Scheduler";
|
||||
import SettingsSonarrView from "pages/Settings/Sonarr";
|
||||
import SettingsSubtitlesView from "pages/Settings/Subtitles";
|
||||
import SettingsUIView from "pages/Settings/UI";
|
||||
import SystemLogsView from "pages/System/Logs";
|
||||
import SystemProvidersView from "pages/System/Providers";
|
||||
import SystemReleasesView from "pages/System/Releases";
|
||||
import SystemStatusView from "pages/System/Status";
|
||||
import SystemTasksView from "pages/System/Tasks";
|
||||
import WantedMoviesView from "pages/Wanted/Movies";
|
||||
import WantedSeriesView from "pages/Wanted/Series";
|
||||
import { useMemo } from "react";
|
||||
import SystemBackupsView from "../pages/System/Backups";
|
||||
import { Navigation } from "./nav";
|
||||
import RootRedirect from "./RootRedirect";
|
||||
|
||||
export function useNavigationItems() {
|
||||
const sonarr = useIsSonarrEnabled();
|
||||
const radarr = useIsRadarrEnabled();
|
||||
const { data } = useBadges();
|
||||
|
||||
const items = useMemo<Navigation.RouteItem[]>(
|
||||
() => [
|
||||
{
|
||||
name: "404",
|
||||
path: RouterEmptyPath,
|
||||
component: EmptyPage,
|
||||
routeOnly: true,
|
||||
},
|
||||
{
|
||||
name: "Redirect",
|
||||
path: "/",
|
||||
component: RootRedirect,
|
||||
routeOnly: true,
|
||||
},
|
||||
{
|
||||
icon: faPlay,
|
||||
name: "Series",
|
||||
path: "/series",
|
||||
component: SeriesView,
|
||||
enabled: sonarr,
|
||||
routes: [
|
||||
{
|
||||
name: "Episode",
|
||||
path: "/:id",
|
||||
component: Episodes,
|
||||
routeOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faFilm,
|
||||
name: "Movies",
|
||||
path: "/movies",
|
||||
component: MovieView,
|
||||
enabled: radarr,
|
||||
routes: [
|
||||
{
|
||||
name: "Movie Details",
|
||||
path: "/:id",
|
||||
component: MovieDetail,
|
||||
routeOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faClock,
|
||||
name: "History",
|
||||
path: "/history",
|
||||
routes: [
|
||||
{
|
||||
name: "Series",
|
||||
path: "/series",
|
||||
enabled: sonarr,
|
||||
component: SeriesHistoryView,
|
||||
},
|
||||
{
|
||||
name: "Movies",
|
||||
path: "/movies",
|
||||
enabled: radarr,
|
||||
component: MoviesHistoryView,
|
||||
},
|
||||
{
|
||||
name: "Statistics",
|
||||
path: "/stats",
|
||||
component: HistoryStats,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faFileExcel,
|
||||
name: "Blacklist",
|
||||
path: "/blacklist",
|
||||
routes: [
|
||||
{
|
||||
name: "Series",
|
||||
path: "/series",
|
||||
enabled: sonarr,
|
||||
component: BlacklistSeriesView,
|
||||
},
|
||||
{
|
||||
name: "Movies",
|
||||
path: "/movies",
|
||||
enabled: radarr,
|
||||
component: BlacklistMoviesView,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faExclamationTriangle,
|
||||
name: "Wanted",
|
||||
path: "/wanted",
|
||||
routes: [
|
||||
{
|
||||
name: "Series",
|
||||
path: "/series",
|
||||
badge: data?.episodes,
|
||||
enabled: sonarr,
|
||||
component: WantedSeriesView,
|
||||
},
|
||||
{
|
||||
name: "Movies",
|
||||
path: "/movies",
|
||||
badge: data?.movies,
|
||||
enabled: radarr,
|
||||
component: WantedMoviesView,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faCogs,
|
||||
name: "Settings",
|
||||
path: "/settings",
|
||||
routes: [
|
||||
{
|
||||
name: "General",
|
||||
path: "/general",
|
||||
component: SettingsGeneralView,
|
||||
},
|
||||
{
|
||||
name: "Languages",
|
||||
path: "/languages",
|
||||
component: SettingsLanguagesView,
|
||||
},
|
||||
{
|
||||
name: "Providers",
|
||||
path: "/providers",
|
||||
component: SettingsProvidersView,
|
||||
},
|
||||
{
|
||||
name: "Subtitles",
|
||||
path: "/subtitles",
|
||||
component: SettingsSubtitlesView,
|
||||
},
|
||||
{
|
||||
name: "Sonarr",
|
||||
path: "/sonarr",
|
||||
component: SettingsSonarrView,
|
||||
},
|
||||
{
|
||||
name: "Radarr",
|
||||
path: "/radarr",
|
||||
component: SettingsRadarrView,
|
||||
},
|
||||
{
|
||||
name: "Notifications",
|
||||
path: "/notifications",
|
||||
component: SettingsNotificationsView,
|
||||
},
|
||||
{
|
||||
name: "Scheduler",
|
||||
path: "/scheduler",
|
||||
component: SettingsSchedulerView,
|
||||
},
|
||||
{
|
||||
name: "UI",
|
||||
path: "/ui",
|
||||
component: SettingsUIView,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faLaptop,
|
||||
name: "System",
|
||||
path: "/system",
|
||||
routes: [
|
||||
{
|
||||
name: "Tasks",
|
||||
path: "/tasks",
|
||||
component: SystemTasksView,
|
||||
},
|
||||
{
|
||||
name: "Logs",
|
||||
path: "/logs",
|
||||
component: SystemLogsView,
|
||||
},
|
||||
{
|
||||
name: "Providers",
|
||||
path: "/providers",
|
||||
badge: data?.providers,
|
||||
component: SystemProvidersView,
|
||||
},
|
||||
{
|
||||
name: "Backup",
|
||||
path: "/backups",
|
||||
component: SystemBackupsView,
|
||||
},
|
||||
{
|
||||
name: "Status",
|
||||
path: "/status",
|
||||
component: SystemStatusView,
|
||||
},
|
||||
{
|
||||
name: "Releases",
|
||||
path: "/releases",
|
||||
component: SystemReleasesView,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[data, radarr, sonarr]
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
export declare namespace Navigation {
|
||||
type RouteWithoutChild = {
|
||||
icon?: IconDefinition;
|
||||
name: string;
|
||||
path: string;
|
||||
component: FunctionComponent;
|
||||
badge?: number;
|
||||
enabled?: boolean;
|
||||
routeOnly?: boolean;
|
||||
};
|
||||
|
||||
type RouteWithChild = {
|
||||
icon: IconDefinition;
|
||||
name: string;
|
||||
path: string;
|
||||
component?: FunctionComponent;
|
||||
badge?: number;
|
||||
enabled?: boolean;
|
||||
routes: RouteWithoutChild[];
|
||||
};
|
||||
|
||||
type RouteItem = RouteWithChild | RouteWithoutChild;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { useEnabledStatus } from "@/modules/redux/hooks";
|
||||
import { FunctionComponent } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
const Redirector: FunctionComponent = () => {
|
||||
const { sonarr, radarr } = useEnabledStatus();
|
||||
|
||||
let path = "/settings/general";
|
||||
if (sonarr) {
|
||||
path = "/series";
|
||||
} else if (radarr) {
|
||||
path = "/movies";
|
||||
}
|
||||
|
||||
return <Navigate to={path}></Navigate>;
|
||||
};
|
||||
|
||||
export default Redirector;
|
|
@ -1,83 +1,319 @@
|
|||
import { FunctionComponent } from "react";
|
||||
import { Redirect, Route, Switch, useHistory } from "react-router";
|
||||
import { useDidMount } from "rooks";
|
||||
import { BuildKey, ScrollToTop } from "utilities";
|
||||
import { useNavigationItems } from "../Navigation";
|
||||
import { Navigation } from "../Navigation/nav";
|
||||
import { RouterEmptyPath } from "../pages/404";
|
||||
import { useBadges } from "@/apis/hooks";
|
||||
import App from "@/App";
|
||||
import Lazy from "@/components/Lazy";
|
||||
import { useEnabledStatus } from "@/modules/redux/hooks";
|
||||
import BlacklistMoviesView from "@/pages/Blacklist/Movies";
|
||||
import BlacklistSeriesView from "@/pages/Blacklist/Series";
|
||||
import Episodes from "@/pages/Episodes";
|
||||
import MoviesHistoryView from "@/pages/History/Movies";
|
||||
import SeriesHistoryView from "@/pages/History/Series";
|
||||
import MovieView from "@/pages/Movies";
|
||||
import MovieDetailView from "@/pages/Movies/Details";
|
||||
import MovieMassEditor from "@/pages/Movies/Editor";
|
||||
import SeriesView from "@/pages/Series";
|
||||
import SeriesMassEditor from "@/pages/Series/Editor";
|
||||
import SettingsGeneralView from "@/pages/Settings/General";
|
||||
import SettingsLanguagesView from "@/pages/Settings/Languages";
|
||||
import SettingsNotificationsView from "@/pages/Settings/Notifications";
|
||||
import SettingsProvidersView from "@/pages/Settings/Providers";
|
||||
import SettingsRadarrView from "@/pages/Settings/Radarr";
|
||||
import SettingsSchedulerView from "@/pages/Settings/Scheduler";
|
||||
import SettingsSonarrView from "@/pages/Settings/Sonarr";
|
||||
import SettingsSubtitlesView from "@/pages/Settings/Subtitles";
|
||||
import SettingsUIView from "@/pages/Settings/UI";
|
||||
import SystemBackupsView from "@/pages/System/Backups";
|
||||
import SystemLogsView from "@/pages/System/Logs";
|
||||
import SystemProvidersView from "@/pages/System/Providers";
|
||||
import SystemReleasesView from "@/pages/System/Releases";
|
||||
import SystemTasksView from "@/pages/System/Tasks";
|
||||
import WantedMoviesView from "@/pages/Wanted/Movies";
|
||||
import WantedSeriesView from "@/pages/Wanted/Series";
|
||||
import { Environment } from "@/utilities";
|
||||
import {
|
||||
faClock,
|
||||
faCogs,
|
||||
faExclamationTriangle,
|
||||
faFileExcel,
|
||||
faFilm,
|
||||
faLaptop,
|
||||
faPlay,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import React, {
|
||||
createContext,
|
||||
FunctionComponent,
|
||||
lazy,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import Redirector from "./Redirector";
|
||||
import { CustomRouteObject } from "./type";
|
||||
|
||||
const Router: FunctionComponent = () => {
|
||||
const navItems = useNavigationItems();
|
||||
const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
|
||||
const SystemStatusView = lazy(() => import("@/pages/System/Status"));
|
||||
const Authentication = lazy(() => import("@/pages/Authentication"));
|
||||
const NotFound = lazy(() => import("@/pages/404"));
|
||||
|
||||
const history = useHistory();
|
||||
useDidMount(() => {
|
||||
history.listen(() => {
|
||||
// This is a hack to make sure ScrollToTop will be triggered in the next frame (When everything are loaded)
|
||||
setTimeout(ScrollToTop);
|
||||
});
|
||||
});
|
||||
function useRoutes(): CustomRouteObject[] {
|
||||
const { data } = useBadges();
|
||||
const { sonarr, radarr } = useEnabledStatus();
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
path: "/",
|
||||
element: <App></App>,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Redirector></Redirector>,
|
||||
},
|
||||
{
|
||||
icon: faPlay,
|
||||
name: "Series",
|
||||
path: "series",
|
||||
hidden: !sonarr,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SeriesView></SeriesView>,
|
||||
},
|
||||
{
|
||||
path: "edit",
|
||||
hidden: true,
|
||||
element: <SeriesMassEditor></SeriesMassEditor>,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
element: <Episodes></Episodes>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faFilm,
|
||||
name: "Movies",
|
||||
path: "movies",
|
||||
hidden: !radarr,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <MovieView></MovieView>,
|
||||
},
|
||||
{
|
||||
path: "edit",
|
||||
hidden: true,
|
||||
element: <MovieMassEditor></MovieMassEditor>,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
element: <MovieDetailView></MovieDetailView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faClock,
|
||||
name: "History",
|
||||
path: "history",
|
||||
hidden: !sonarr && !radarr,
|
||||
children: [
|
||||
{
|
||||
path: "series",
|
||||
name: "Episodes",
|
||||
hidden: !sonarr,
|
||||
element: <SeriesHistoryView></SeriesHistoryView>,
|
||||
},
|
||||
{
|
||||
path: "movies",
|
||||
name: "Movies",
|
||||
hidden: !radarr,
|
||||
element: <MoviesHistoryView></MoviesHistoryView>,
|
||||
},
|
||||
{
|
||||
path: "stats",
|
||||
name: "Statistics",
|
||||
element: (
|
||||
<Lazy>
|
||||
<HistoryStats></HistoryStats>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faExclamationTriangle,
|
||||
name: "Wanted",
|
||||
path: "wanted",
|
||||
hidden: !sonarr && !radarr,
|
||||
children: [
|
||||
{
|
||||
name: "Episodes",
|
||||
path: "series",
|
||||
badge: data?.episodes,
|
||||
hidden: !sonarr,
|
||||
element: <WantedSeriesView></WantedSeriesView>,
|
||||
},
|
||||
{
|
||||
name: "Movies",
|
||||
path: "movies",
|
||||
badge: data?.movies,
|
||||
hidden: !radarr,
|
||||
element: <WantedMoviesView></WantedMoviesView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faFileExcel,
|
||||
name: "Blacklist",
|
||||
path: "blacklist",
|
||||
hidden: !sonarr && !radarr,
|
||||
children: [
|
||||
{
|
||||
path: "series",
|
||||
name: "Episodes",
|
||||
hidden: !sonarr,
|
||||
element: <BlacklistSeriesView></BlacklistSeriesView>,
|
||||
},
|
||||
{
|
||||
path: "movies",
|
||||
name: "Movies",
|
||||
hidden: !radarr,
|
||||
element: <BlacklistMoviesView></BlacklistMoviesView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faCogs,
|
||||
name: "Settings",
|
||||
path: "settings",
|
||||
children: [
|
||||
{
|
||||
path: "general",
|
||||
name: "General",
|
||||
element: <SettingsGeneralView></SettingsGeneralView>,
|
||||
},
|
||||
{
|
||||
path: "languages",
|
||||
name: "Languages",
|
||||
element: <SettingsLanguagesView></SettingsLanguagesView>,
|
||||
},
|
||||
{
|
||||
path: "providers",
|
||||
name: "Providers",
|
||||
element: <SettingsProvidersView></SettingsProvidersView>,
|
||||
},
|
||||
{
|
||||
path: "subtitles",
|
||||
name: "Subtitles",
|
||||
element: <SettingsSubtitlesView></SettingsSubtitlesView>,
|
||||
},
|
||||
{
|
||||
path: "sonarr",
|
||||
name: "Sonarr",
|
||||
element: <SettingsSonarrView></SettingsSonarrView>,
|
||||
},
|
||||
{
|
||||
path: "radarr",
|
||||
name: "Radarr",
|
||||
element: <SettingsRadarrView></SettingsRadarrView>,
|
||||
},
|
||||
{
|
||||
path: "notifications",
|
||||
name: "Notifications",
|
||||
element: (
|
||||
<SettingsNotificationsView></SettingsNotificationsView>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "scheduler",
|
||||
name: "Scheduler",
|
||||
element: <SettingsSchedulerView></SettingsSchedulerView>,
|
||||
},
|
||||
{
|
||||
path: "ui",
|
||||
name: "UI",
|
||||
element: <SettingsUIView></SettingsUIView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: faLaptop,
|
||||
name: "System",
|
||||
path: "system",
|
||||
children: [
|
||||
{
|
||||
path: "tasks",
|
||||
name: "Tasks",
|
||||
element: <SystemTasksView></SystemTasksView>,
|
||||
},
|
||||
{
|
||||
path: "logs",
|
||||
name: "Logs",
|
||||
element: <SystemLogsView></SystemLogsView>,
|
||||
},
|
||||
{
|
||||
path: "providers",
|
||||
name: "Providers",
|
||||
badge: data?.providers,
|
||||
element: <SystemProvidersView></SystemProvidersView>,
|
||||
},
|
||||
{
|
||||
path: "backup",
|
||||
name: "Backups",
|
||||
element: <SystemBackupsView></SystemBackupsView>,
|
||||
},
|
||||
{
|
||||
path: "status",
|
||||
name: "Status",
|
||||
element: (
|
||||
<Lazy>
|
||||
<SystemStatusView></SystemStatusView>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "releases",
|
||||
name: "Releases",
|
||||
element: <SystemReleasesView></SystemReleasesView>,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
hidden: true,
|
||||
element: (
|
||||
<Lazy>
|
||||
<Authentication></Authentication>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
hidden: true,
|
||||
element: (
|
||||
<Lazy>
|
||||
<NotFound></NotFound>
|
||||
</Lazy>
|
||||
),
|
||||
},
|
||||
],
|
||||
[data?.episodes, data?.movies, data?.providers, radarr, sonarr]
|
||||
);
|
||||
}
|
||||
|
||||
const RouterItemContext = createContext<CustomRouteObject[]>([]);
|
||||
|
||||
export const Router: FunctionComponent = ({ children }) => {
|
||||
const routes = useRoutes();
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-row flex-grow-1 main-router">
|
||||
<Switch>
|
||||
{navItems.map((v, idx) => {
|
||||
if ("routes" in v) {
|
||||
return (
|
||||
<Route path={v.path} key={BuildKey(idx, v.name, "router")}>
|
||||
<ParentRouter {...v}></ParentRouter>
|
||||
</Route>
|
||||
);
|
||||
} else if (v.enabled !== false) {
|
||||
return (
|
||||
<Route
|
||||
key={BuildKey(idx, v.name, "root")}
|
||||
exact
|
||||
path={v.path}
|
||||
component={v.component}
|
||||
></Route>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
<Route path="*">
|
||||
<Redirect to={RouterEmptyPath}></Redirect>
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
<RouterItemContext.Provider value={routes}>
|
||||
<BrowserRouter basename={Environment.baseUrl}>{children}</BrowserRouter>
|
||||
</RouterItemContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Router;
|
||||
|
||||
const ParentRouter: FunctionComponent<Navigation.RouteWithChild> = ({
|
||||
path,
|
||||
enabled,
|
||||
component,
|
||||
routes,
|
||||
}) => {
|
||||
if (enabled === false || (component === undefined && routes.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
const ParentComponent =
|
||||
component ?? (() => <Redirect to={path + routes[0].path}></Redirect>);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path={path} component={ParentComponent}></Route>
|
||||
{routes
|
||||
.filter((v) => v.enabled !== false)
|
||||
.map((v, idx) => (
|
||||
<Route
|
||||
key={BuildKey(idx, v.name, "route")}
|
||||
exact
|
||||
path={path + v.path}
|
||||
component={v.component}
|
||||
></Route>
|
||||
))}
|
||||
<Route path="*">
|
||||
<Redirect to={RouterEmptyPath}></Redirect>
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
export function useRouteItems() {
|
||||
return useContext(RouterItemContext);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { RouteObject } from "react-router-dom";
|
||||
|
||||
declare namespace Route {
|
||||
export type Item = {
|
||||
icon?: IconDefinition;
|
||||
name?: string;
|
||||
badge?: number;
|
||||
hidden?: boolean;
|
||||
children?: Item[];
|
||||
};
|
||||
}
|
||||
|
||||
export type CustomRouteObject = RouteObject & Route.Item;
|
|
@ -1,12 +1,18 @@
|
|||
import { setSidebar } from "@/modules/redux/actions";
|
||||
import { useReduxAction, useReduxStore } from "@/modules/redux/hooks/base";
|
||||
import { useRouteItems } from "@/Router";
|
||||
import { CustomRouteObject, Route } from "@/Router/type";
|
||||
import { BuildKey, Environment, pathJoin } from "@/utilities";
|
||||
import { LOG } from "@/utilities/console";
|
||||
import { useGotoHomepage } from "@/utilities/hooks";
|
||||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { setSidebar } from "@redux/actions";
|
||||
import { useReduxAction, useReduxStore } from "@redux/hooks/base";
|
||||
import logo from "@static/logo64.png";
|
||||
import React, {
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
createContext,
|
||||
FunctionComponent,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
@ -18,229 +24,232 @@ import {
|
|||
ListGroup,
|
||||
ListGroupItem,
|
||||
} from "react-bootstrap";
|
||||
import { NavLink, useHistory, useRouteMatch } from "react-router-dom";
|
||||
import { BuildKey } from "utilities";
|
||||
import { useGotoHomepage } from "utilities/hooks";
|
||||
import { useNavigationItems } from "../Navigation";
|
||||
import { Navigation } from "../Navigation/nav";
|
||||
import "./style.scss";
|
||||
import {
|
||||
matchPath,
|
||||
NavLink,
|
||||
RouteObject,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
|
||||
const SelectionContext = createContext<{
|
||||
const Selection = createContext<{
|
||||
selection: string | null;
|
||||
select: (selection: string | null) => void;
|
||||
}>({ selection: null, select: () => {} });
|
||||
select: (path: string | null) => void;
|
||||
}>({
|
||||
selection: null,
|
||||
select: () => {
|
||||
LOG("error", "Selection context not initialized");
|
||||
},
|
||||
});
|
||||
|
||||
function useSelection() {
|
||||
return useContext(Selection);
|
||||
}
|
||||
|
||||
function useBadgeValue(route: Route.Item) {
|
||||
const { badge, children } = route;
|
||||
return useMemo(() => {
|
||||
let value = badge ?? 0;
|
||||
|
||||
if (children === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
value +=
|
||||
children.reduce((acc, child: Route.Item) => {
|
||||
if (child.badge && child.hidden !== true) {
|
||||
return acc + (child.badge ?? 0);
|
||||
}
|
||||
return acc;
|
||||
}, 0) ?? 0;
|
||||
|
||||
return value === 0 ? undefined : value;
|
||||
}, [badge, children]);
|
||||
}
|
||||
|
||||
function useIsActive(parent: string, route: RouteObject) {
|
||||
const { path, children } = route;
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const root = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||
|
||||
const paths = useMemo(
|
||||
() => [root, ...(children?.map((v) => pathJoin(root, v.path ?? "")) ?? [])],
|
||||
[root, children]
|
||||
);
|
||||
|
||||
const selection = useSelection().selection;
|
||||
return useMemo(
|
||||
() =>
|
||||
selection?.includes(root) ||
|
||||
paths.some((path) => matchPath(path, pathname)),
|
||||
[pathname, paths, root, selection]
|
||||
);
|
||||
}
|
||||
|
||||
// Actual sidebar
|
||||
const Sidebar: FunctionComponent = () => {
|
||||
const open = useReduxStore((s) => s.showSidebar);
|
||||
const [selection, select] = useState<string | null>(null);
|
||||
const isShow = useReduxStore((s) => s.site.showSidebar);
|
||||
|
||||
const changeSidebar = useReduxAction(setSidebar);
|
||||
|
||||
const cls = ["sidebar-container"];
|
||||
const overlay = ["sidebar-overlay"];
|
||||
|
||||
if (open) {
|
||||
cls.push("open");
|
||||
overlay.push("open");
|
||||
}
|
||||
const showSidebar = useReduxAction(setSidebar);
|
||||
|
||||
const goHome = useGotoHomepage();
|
||||
|
||||
const [selection, setSelection] = useState<string | null>(null);
|
||||
const routes = useRouteItems();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
useEffect(() => {
|
||||
select(null);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<SelectionContext.Provider
|
||||
value={{ selection: selection, select: setSelection }}
|
||||
>
|
||||
<aside className={cls.join(" ")}>
|
||||
<Selection.Provider value={{ selection, select }}>
|
||||
<nav className={clsx("sidebar-container", { open: isShow })}>
|
||||
<Container className="sidebar-title d-flex align-items-center d-md-none">
|
||||
<Image
|
||||
alt="brand"
|
||||
src={logo}
|
||||
src={`${Environment.baseUrl}/static/logo64.png`}
|
||||
width="32"
|
||||
height="32"
|
||||
onClick={goHome}
|
||||
className="cursor-pointer"
|
||||
></Image>
|
||||
</Container>
|
||||
<SidebarNavigation></SidebarNavigation>
|
||||
</aside>
|
||||
<ListGroup variant="flush" style={{ paddingBottom: "16rem" }}>
|
||||
{routes.map((route, idx) => (
|
||||
<RouteItem
|
||||
key={BuildKey("nav", idx)}
|
||||
parent="/"
|
||||
route={route}
|
||||
></RouteItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
</nav>
|
||||
<div
|
||||
className={overlay.join(" ")}
|
||||
onClick={() => changeSidebar(false)}
|
||||
className={clsx("sidebar-overlay", { open: isShow })}
|
||||
onClick={() => showSidebar(false)}
|
||||
></div>
|
||||
</SelectionContext.Provider>
|
||||
</Selection.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarNavigation: FunctionComponent = () => {
|
||||
const navItems = useNavigationItems();
|
||||
const RouteItem: FunctionComponent<{
|
||||
route: CustomRouteObject;
|
||||
parent: string;
|
||||
}> = ({ route, parent }) => {
|
||||
const { children, name, path, icon, hidden, element } = route;
|
||||
|
||||
return (
|
||||
<ListGroup variant="flush">
|
||||
{navItems.map((v, idx) => {
|
||||
if ("routes" in v) {
|
||||
return (
|
||||
<SidebarParent key={BuildKey(idx, v.name)} {...v}></SidebarParent>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<SidebarChild
|
||||
parent=""
|
||||
key={BuildKey(idx, v.name)}
|
||||
{...v}
|
||||
></SidebarChild>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarParent: FunctionComponent<Navigation.RouteWithChild> = ({
|
||||
icon,
|
||||
badge,
|
||||
name,
|
||||
path,
|
||||
routes,
|
||||
enabled,
|
||||
component,
|
||||
}) => {
|
||||
const computedBadge = useMemo(() => {
|
||||
let computed = badge ?? 0;
|
||||
|
||||
computed += routes.reduce((prev, curr) => {
|
||||
return prev + (curr.badge ?? 0);
|
||||
}, 0);
|
||||
|
||||
return computed !== 0 ? computed : undefined;
|
||||
}, [badge, routes]);
|
||||
|
||||
const enabledRoutes = useMemo(
|
||||
() => routes.filter((v) => v.enabled !== false && v.routeOnly !== true),
|
||||
[routes]
|
||||
const isValidated = useMemo(
|
||||
() =>
|
||||
element !== undefined ||
|
||||
children?.find((v) => v.index === true) !== undefined,
|
||||
[element, children]
|
||||
);
|
||||
|
||||
const changeSidebar = useReduxAction(setSidebar);
|
||||
const { select } = useSelection();
|
||||
|
||||
const { selection, select } = useContext(SelectionContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const match = useRouteMatch({ path });
|
||||
const open = match !== null || selection === path;
|
||||
const link = useMemo(() => pathJoin(parent, path ?? ""), [parent, path]);
|
||||
|
||||
const collapseBoxClass = useMemo(
|
||||
() => `sidebar-collapse-box ${open ? "active" : ""}`,
|
||||
[open]
|
||||
);
|
||||
const badge = useBadgeValue(route);
|
||||
|
||||
const history = useHistory();
|
||||
const isOpen = useIsActive(parent, route);
|
||||
|
||||
if (enabled === false) {
|
||||
if (hidden === true) {
|
||||
return null;
|
||||
} else if (enabledRoutes.length === 0) {
|
||||
if (component) {
|
||||
}
|
||||
|
||||
// Ignore path if it is using match
|
||||
if (path === undefined || path.includes(":")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (children !== undefined) {
|
||||
const elements = children.map((child, idx) => (
|
||||
<RouteItem
|
||||
parent={link}
|
||||
key={BuildKey(link, "nav", idx)}
|
||||
route={child}
|
||||
></RouteItem>
|
||||
));
|
||||
|
||||
if (name) {
|
||||
return (
|
||||
<NavLink
|
||||
activeClassName="sb-active"
|
||||
className="list-group-item list-group-item-action sidebar-button"
|
||||
to={path}
|
||||
onClick={() => changeSidebar(false)}
|
||||
>
|
||||
<SidebarContent
|
||||
icon={icon}
|
||||
name={name}
|
||||
badge={computedBadge}
|
||||
></SidebarContent>
|
||||
</NavLink>
|
||||
<div className={clsx("sidebar-collapse-box", { active: isOpen })}>
|
||||
<ListGroupItem
|
||||
action
|
||||
className={clsx("button", { active: isOpen })}
|
||||
onClick={() => {
|
||||
LOG("info", "clicked", link);
|
||||
|
||||
if (isValidated) {
|
||||
navigate(link);
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
select(null);
|
||||
} else {
|
||||
select(link);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RouteItemContent
|
||||
name={name ?? link}
|
||||
icon={icon}
|
||||
badge={badge}
|
||||
></RouteItemContent>
|
||||
</ListGroupItem>
|
||||
<Collapse in={isOpen}>
|
||||
<div className="indent">{elements}</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
return <>{elements}</>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={collapseBoxClass}>
|
||||
<ListGroupItem
|
||||
action
|
||||
className="sidebar-button"
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
select(null);
|
||||
} else {
|
||||
select(path);
|
||||
}
|
||||
if (component !== undefined) {
|
||||
history.push(path);
|
||||
}
|
||||
}}
|
||||
} else {
|
||||
return (
|
||||
<NavLink
|
||||
to={link}
|
||||
className={({ isActive }) =>
|
||||
clsx("list-group-item list-group-item-action button sb-collapse", {
|
||||
active: isActive,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SidebarContent
|
||||
<RouteItemContent
|
||||
name={name ?? link}
|
||||
icon={icon}
|
||||
name={name}
|
||||
badge={computedBadge}
|
||||
></SidebarContent>
|
||||
</ListGroupItem>
|
||||
<Collapse in={open}>
|
||||
<div className="sidebar-collapse">
|
||||
{enabledRoutes.map((v, idx) => (
|
||||
<SidebarChild
|
||||
key={BuildKey(idx, v.name, "child")}
|
||||
parent={path}
|
||||
{...v}
|
||||
></SidebarChild>
|
||||
))}
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
badge={badge}
|
||||
></RouteItemContent>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface SidebarChildProps {
|
||||
parent: string;
|
||||
interface ItemComponentProps {
|
||||
name: string;
|
||||
icon?: IconDefinition;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const SidebarChild: FunctionComponent<
|
||||
SidebarChildProps & Navigation.RouteWithoutChild
|
||||
> = ({ icon, name, path, badge, enabled, routeOnly, parent }) => {
|
||||
const changeSidebar = useReduxAction(setSidebar);
|
||||
const { select } = useContext(SelectionContext);
|
||||
|
||||
if (enabled === false || routeOnly === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const RouteItemContent: FunctionComponent<ItemComponentProps> = ({
|
||||
icon,
|
||||
name,
|
||||
badge,
|
||||
}) => {
|
||||
return (
|
||||
<NavLink
|
||||
activeClassName="sb-active"
|
||||
className="list-group-item list-group-item-action sidebar-button sb-collapse"
|
||||
to={parent + path}
|
||||
onClick={() => {
|
||||
select(null);
|
||||
changeSidebar(false);
|
||||
}}
|
||||
>
|
||||
<SidebarContent icon={icon} name={name} badge={badge}></SidebarContent>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarContent: FunctionComponent<{
|
||||
icon?: IconDefinition;
|
||||
name: string;
|
||||
badge?: number;
|
||||
}> = ({ icon, name, badge }) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{icon && (
|
||||
<FontAwesomeIcon
|
||||
size="1x"
|
||||
className="icon"
|
||||
icon={icon}
|
||||
></FontAwesomeIcon>
|
||||
)}
|
||||
<>
|
||||
{icon && <FontAwesomeIcon size="1x" className="icon" icon={icon} />}
|
||||
<span className="d-flex flex-grow-1 justify-content-between">
|
||||
{name} <Badge variant="secondary">{badge !== 0 ? badge : null}</Badge>
|
||||
{name}
|
||||
<Badge variant="secondary" hidden={badge === undefined || badge === 0}>
|
||||
{badge}
|
||||
</Badge>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import { Entrance } from "index";
|
||||
import {} from "jest";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
it("renders", () => {
|
||||
const div = document.createElement("div");
|
||||
ReactDOM.render(<Entrance />, div);
|
||||
ReactDOM.unmountComponentAtNode(div);
|
||||
});
|
|
@ -36,7 +36,6 @@ export function useMovies() {
|
|||
[QueryKeys.Movies, QueryKeys.All],
|
||||
() => api.movies.movies(),
|
||||
{
|
||||
enabled: false,
|
||||
onSuccess: (data) => {
|
||||
cacheMovies(client, data);
|
||||
},
|
||||
|
|
|
@ -36,7 +36,6 @@ export function useSeries() {
|
|||
[QueryKeys.Series, QueryKeys.All],
|
||||
() => api.series.series(),
|
||||
{
|
||||
enabled: false,
|
||||
onSuccess: (data) => {
|
||||
cacheSeries(client, data);
|
||||
},
|
||||
|
|
|
@ -12,8 +12,16 @@ export function useSubtitleAction() {
|
|||
[QueryKeys.Subtitles],
|
||||
(param: Param) => api.subtitles.modify(param.action, param.form),
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: (_, param) => {
|
||||
client.invalidateQueries([QueryKeys.History]);
|
||||
|
||||
// TODO: Query less
|
||||
const { type, id } = param.form;
|
||||
if (type === "episode") {
|
||||
client.invalidateQueries([QueryKeys.Series, id]);
|
||||
} else {
|
||||
client.invalidateQueries([QueryKeys.Movies, id]);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { setUnauthenticated } from "../../@redux/actions";
|
||||
import store from "../../@redux/store";
|
||||
import { setUnauthenticated } from "../../modules/redux/actions";
|
||||
import store from "../../modules/redux/store";
|
||||
import { QueryKeys } from "../queries/keys";
|
||||
import api from "../raw";
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
|
||||
import { setUnauthenticated } from "../../@redux/actions";
|
||||
import { AppDispatch } from "../../@redux/store";
|
||||
import { setUnauthenticated } from "../../modules/redux/actions";
|
||||
import { AppDispatch } from "../../modules/redux/store";
|
||||
import { Environment, isProdEnv } from "../../utilities";
|
||||
class BazarrClient {
|
||||
axios!: AxiosInstance;
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { GetItemId } from "@/utilities";
|
||||
import { usePageSize } from "@/utilities/storage";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
QueryKey,
|
||||
|
@ -5,8 +7,6 @@ import {
|
|||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "react-query";
|
||||
import { GetItemId } from "utilities";
|
||||
import { usePageSize } from "utilities/storage";
|
||||
import { QueryKeys } from "./keys";
|
||||
|
||||
export type UsePaginationQueryResult<T extends object> = UseQueryResult<
|
||||
|
|
|
@ -10,7 +10,7 @@ class BaseApi {
|
|||
|
||||
private createFormdata(object?: LooseObject) {
|
||||
if (object) {
|
||||
let form = new FormData();
|
||||
const form = new FormData();
|
||||
|
||||
for (const key in object) {
|
||||
const data = object[key];
|
||||
|
@ -30,7 +30,7 @@ class BaseApi {
|
|||
}
|
||||
}
|
||||
|
||||
protected async get<T = unknown>(path: string, params?: any) {
|
||||
protected async get<T = unknown>(path: string, params?: LooseObject) {
|
||||
const response = await client.axios.get<T>(this.prefix + path, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ class BaseApi {
|
|||
protected post<T = void>(
|
||||
path: string,
|
||||
formdata?: LooseObject,
|
||||
params?: any
|
||||
params?: LooseObject
|
||||
): Promise<AxiosResponse<T>> {
|
||||
const form = this.createFormdata(formdata);
|
||||
return client.axios.post(this.prefix + path, form, { params });
|
||||
|
@ -47,7 +47,7 @@ class BaseApi {
|
|||
protected patch<T = void>(
|
||||
path: string,
|
||||
formdata?: LooseObject,
|
||||
params?: any
|
||||
params?: LooseObject
|
||||
): Promise<AxiosResponse<T>> {
|
||||
const form = this.createFormdata(formdata);
|
||||
return client.axios.patch(this.prefix + path, form, { params });
|
||||
|
@ -55,8 +55,8 @@ class BaseApi {
|
|||
|
||||
protected delete<T = void>(
|
||||
path: string,
|
||||
formdata?: any,
|
||||
params?: any
|
||||
formdata?: LooseObject,
|
||||
params?: LooseObject
|
||||
): Promise<AxiosResponse<T>> {
|
||||
const form = this.createFormdata(formdata);
|
||||
return client.axios.delete(this.prefix + path, { params, data: form });
|
||||
|
|
|
@ -5,7 +5,7 @@ class ProviderApi extends BaseApi {
|
|||
super("/providers");
|
||||
}
|
||||
|
||||
async providers(history: boolean = false) {
|
||||
async providers(history = false) {
|
||||
const response = await this.get<DataWrapper<System.Provider[]>>("", {
|
||||
history,
|
||||
});
|
||||
|
|
|
@ -34,7 +34,7 @@ class SystemApi extends BaseApi {
|
|||
await this.post("/settings", data);
|
||||
}
|
||||
|
||||
async languages(history: boolean = false) {
|
||||
async languages(history = false) {
|
||||
const response = await this.get<Language.Server[]>("/languages", {
|
||||
history,
|
||||
});
|
||||
|
|
|
@ -11,7 +11,7 @@ type UrlTestResponse =
|
|||
};
|
||||
|
||||
class RequestUtils {
|
||||
async urlTest(protocol: string, url: string, params?: any) {
|
||||
async urlTest(protocol: string, url: string, params?: LooseObject) {
|
||||
try {
|
||||
const result = await client.axios.get<UrlTestResponse>(
|
||||
`../test/${protocol}/${url}api/system/status`,
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import UIError from "pages/UIError";
|
||||
import React from "react";
|
||||
import UIError from "@/pages/UIError";
|
||||
import { Component } from "react";
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<{}, State> {
|
||||
constructor(props: {}) {
|
||||
class ErrorBoundary extends Component<object, State> {
|
||||
constructor(props: object) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
import { BuildKey, isMovie } from "@/utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "@/utilities/languages";
|
||||
import {
|
||||
faBookmark as farBookmark,
|
||||
faClone as fasClone,
|
||||
|
@ -12,7 +17,7 @@ import {
|
|||
IconDefinition,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Col,
|
||||
|
@ -22,12 +27,7 @@ import {
|
|||
Popover,
|
||||
Row,
|
||||
} from "react-bootstrap";
|
||||
import { BuildKey, isMovie } from "utilities";
|
||||
import {
|
||||
useLanguageProfileBy,
|
||||
useProfileItemsToLanguages,
|
||||
} from "utilities/languages";
|
||||
import { LanguageText } from ".";
|
||||
import Language from "./bazarr/Language";
|
||||
|
||||
interface Props {
|
||||
item: Item.Base;
|
||||
|
@ -102,7 +102,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => {
|
|||
icon={faLanguage}
|
||||
desc="Language"
|
||||
>
|
||||
<LanguageText long text={v}></LanguageText>
|
||||
<Language.Text long value={v}></Language.Text>
|
||||
</DetailBadge>
|
||||
))
|
||||
);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Selector, SelectorProps } from "components";
|
||||
import React, { useMemo } from "react";
|
||||
import { Selector, SelectorOption, SelectorProps } from "@/components";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Props {
|
||||
options: readonly Language.Info[];
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import { FunctionComponent, Suspense } from "react";
|
||||
import { LoadingIndicator } from ".";
|
||||
|
||||
const Lazy: FunctionComponent = ({ children }) => {
|
||||
return <Suspense fallback={<LoadingIndicator />}>{children}</Suspense>;
|
||||
};
|
||||
|
||||
export default Lazy;
|
|
@ -0,0 +1,121 @@
|
|||
import { useIsAnyMutationRunning, useLanguageProfiles } from "@/apis/hooks";
|
||||
import { GetItemId } from "@/utilities";
|
||||
import { faCheck, faUndo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Container, Dropdown, Row } from "react-bootstrap";
|
||||
import { UseMutationResult } from "react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Column, useRowSelect } from "react-table";
|
||||
import { ContentHeader, SimpleTable } from ".";
|
||||
import { useCustomSelection } from "./tables/plugins";
|
||||
|
||||
interface MassEditorProps<T extends Item.Base = Item.Base> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
mutation: UseMutationResult<void, unknown, FormType.ModifyItem>;
|
||||
}
|
||||
|
||||
function MassEditor<T extends Item.Base>(props: MassEditorProps<T>) {
|
||||
const { columns, data: raw, mutation } = props;
|
||||
|
||||
const [selections, setSelections] = useState<T[]>([]);
|
||||
const [dirties, setDirties] = useState<T[]>([]);
|
||||
const hasTask = useIsAnyMutationRunning();
|
||||
const { data: profiles } = useLanguageProfiles();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onEnded = useCallback(() => navigate(".."), [navigate]);
|
||||
|
||||
const data = useMemo(
|
||||
() => uniqBy([...dirties, ...(raw ?? [])], GetItemId),
|
||||
[dirties, raw]
|
||||
);
|
||||
|
||||
const profileOptions = useMemo(() => {
|
||||
const items: JSX.Element[] = [];
|
||||
if (profiles) {
|
||||
items.push(
|
||||
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
|
||||
);
|
||||
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
|
||||
items.push(
|
||||
...profiles.map((v) => (
|
||||
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
|
||||
{v.name}
|
||||
</Dropdown.Item>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [profiles]);
|
||||
|
||||
const { mutateAsync } = mutation;
|
||||
|
||||
const save = useCallback(() => {
|
||||
const form: FormType.ModifyItem = {
|
||||
id: [],
|
||||
profileid: [],
|
||||
};
|
||||
dirties.forEach((v) => {
|
||||
const id = GetItemId(v);
|
||||
if (id) {
|
||||
form.id.push(id);
|
||||
form.profileid.push(v.profileId);
|
||||
}
|
||||
});
|
||||
return mutateAsync(form);
|
||||
}, [dirties, mutateAsync]);
|
||||
|
||||
const setProfiles = useCallback(
|
||||
(key: Nullable<string>) => {
|
||||
const id = key ? parseInt(key) : null;
|
||||
|
||||
const newItems = selections.map((v) => ({ ...v, profileId: id }));
|
||||
|
||||
setDirties((dirty) => {
|
||||
return uniqBy([...newItems, ...dirty], GetItemId);
|
||||
});
|
||||
},
|
||||
[selections]
|
||||
);
|
||||
return (
|
||||
<Container fluid>
|
||||
<ContentHeader scroll={false}>
|
||||
<ContentHeader.Group pos="start">
|
||||
<Dropdown onSelect={setProfiles}>
|
||||
<Dropdown.Toggle disabled={selections.length === 0} variant="light">
|
||||
Change Profile
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>{profileOptions}</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</ContentHeader.Group>
|
||||
<ContentHeader.Group pos="end">
|
||||
<ContentHeader.Button icon={faUndo} onClick={onEnded}>
|
||||
Cancel
|
||||
</ContentHeader.Button>
|
||||
<ContentHeader.AsyncButton
|
||||
icon={faCheck}
|
||||
disabled={dirties.length === 0 || hasTask}
|
||||
promise={save}
|
||||
onSuccess={onEnded}
|
||||
>
|
||||
Save
|
||||
</ContentHeader.AsyncButton>
|
||||
</ContentHeader.Group>
|
||||
</ContentHeader>
|
||||
<Row>
|
||||
<SimpleTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
onSelect={setSelections}
|
||||
plugins={[useRowSelect, useCustomSelection]}
|
||||
></SimpleTable>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default MassEditor;
|
|
@ -1,6 +1,6 @@
|
|||
import { useServerSearch } from "apis/hooks";
|
||||
import { useServerSearch } from "@/apis/hooks";
|
||||
import { uniqueId } from "lodash";
|
||||
import React, {
|
||||
import {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
@ -8,7 +8,7 @@ import React, {
|
|||
useState,
|
||||
} from "react";
|
||||
import { Dropdown, Form } from "react-bootstrap";
|
||||
import { useHistory } from "react-router";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useThrottle } from "rooks";
|
||||
|
||||
function useSearch(query: string) {
|
||||
|
@ -66,7 +66,7 @@ export const SearchBar: FunctionComponent<Props> = ({
|
|||
|
||||
const results = useSearch(query);
|
||||
|
||||
const history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setDisplay("");
|
||||
|
@ -100,7 +100,7 @@ export const SearchBar: FunctionComponent<Props> = ({
|
|||
onSelect={(link) => {
|
||||
if (link) {
|
||||
clear();
|
||||
history.push(link);
|
||||
navigate(link);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -4,9 +4,10 @@ import {
|
|||
faTimes,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, {
|
||||
import {
|
||||
FunctionComponent,
|
||||
PropsWithChildren,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
|
@ -18,7 +19,7 @@ import { LoadingIndicator } from ".";
|
|||
|
||||
interface QueryOverlayProps {
|
||||
result: UseQueryResult<unknown, unknown>;
|
||||
children: React.ReactElement;
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
|
||||
|
@ -43,9 +44,7 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
|
|||
const [item, setItem] = useState<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
promise()
|
||||
.then(setItem)
|
||||
.catch(() => {});
|
||||
promise().then(setItem);
|
||||
}, [promise]);
|
||||
|
||||
if (item === null) {
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
import { useLanguages } from "@/apis/hooks";
|
||||
import { Selector, SelectorOption, SelectorProps } from "@/components";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
|
||||
interface TextProps {
|
||||
value: Language.Info;
|
||||
className?: string;
|
||||
long?: boolean;
|
||||
}
|
||||
|
||||
declare type LanguageComponent = {
|
||||
Text: typeof LanguageText;
|
||||
Selector: typeof LanguageSelector;
|
||||
};
|
||||
|
||||
const LanguageText: FunctionComponent<TextProps> = ({
|
||||
value,
|
||||
className,
|
||||
long,
|
||||
}) => {
|
||||
const result = useMemo(() => {
|
||||
let lang = value.code2;
|
||||
let hi = ":HI";
|
||||
let forced = ":Forced";
|
||||
if (long) {
|
||||
lang = value.name;
|
||||
hi = " HI";
|
||||
forced = " Forced";
|
||||
}
|
||||
|
||||
let res = lang;
|
||||
if (value.hi) {
|
||||
res += hi;
|
||||
} else if (value.forced) {
|
||||
res += forced;
|
||||
}
|
||||
return res;
|
||||
}, [value, long]);
|
||||
|
||||
return (
|
||||
<span title={value.name} className={className}>
|
||||
{result}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
type LanguageSelectorProps<M extends boolean> = Omit<
|
||||
SelectorProps<Language.Info, M>,
|
||||
"label" | "options"
|
||||
> & {
|
||||
history?: boolean;
|
||||
};
|
||||
|
||||
function getLabel(lang: Language.Info) {
|
||||
return lang.name;
|
||||
}
|
||||
|
||||
export function LanguageSelector<M extends boolean = false>(
|
||||
props: LanguageSelectorProps<M>
|
||||
) {
|
||||
const { history, ...rest } = props;
|
||||
const { data: options } = useLanguages(history);
|
||||
|
||||
const items = useMemo<SelectorOption<Language.Info>[]>(
|
||||
() =>
|
||||
options?.map((v) => ({
|
||||
label: v.name,
|
||||
value: v,
|
||||
})) ?? [],
|
||||
[options]
|
||||
);
|
||||
|
||||
return (
|
||||
<Selector
|
||||
placeholder="Language..."
|
||||
options={items}
|
||||
label={getLabel}
|
||||
{...rest}
|
||||
></Selector>
|
||||
);
|
||||
}
|
||||
|
||||
const Components: LanguageComponent = {
|
||||
Text: LanguageText,
|
||||
Selector: LanguageSelector,
|
||||
};
|
||||
|
||||
export default Components;
|
|
@ -0,0 +1,25 @@
|
|||
import { useLanguageProfiles } from "@/apis/hooks";
|
||||
import { FunctionComponent, useMemo } from "react";
|
||||
|
||||
interface Props {
|
||||
index: number | null;
|
||||
className?: string;
|
||||
empty?: string;
|
||||
}
|
||||
|
||||
const LanguageProfile: FunctionComponent<Props> = ({
|
||||
index,
|
||||
className,
|
||||
empty = "Unknown Profile",
|
||||
}) => {
|
||||
const { data } = useLanguageProfiles();
|
||||
|
||||
const name = useMemo(
|
||||
() => data?.find((v) => v.profileId === index)?.name ?? empty,
|
||||
[data, empty, index]
|
||||
);
|
||||
|
||||
return <span className={className}>{name}</span>;
|
||||
};
|
||||
|
||||
export default LanguageProfile;
|
|
@ -1,7 +1,7 @@
|
|||
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
|
||||
import { faCircleNotch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, { FunctionComponent, MouseEvent } from "react";
|
||||
import { FunctionComponent, MouseEvent } from "react";
|
||||
import { Badge, Button, ButtonProps } from "react-bootstrap";
|
||||
|
||||
export const ActionBadge: FunctionComponent<{
|
||||
|
@ -66,7 +66,7 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
|
|||
}) => {
|
||||
const showText = alwaysShowText === true || loading !== true;
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
style={{ width: "1rem" }}
|
||||
icon={loading ? faCircleNotch : icon}
|
||||
|
@ -75,6 +75,6 @@ export const ActionButtonItem: FunctionComponent<ActionButtonItemProps> = ({
|
|||
{children && showText ? (
|
||||
<span className="ml-2 font-weight-bold">{children}</span>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import React, {
|
||||
import {
|
||||
FunctionComponent,
|
||||
MouseEvent,
|
||||
PropsWithChildren,
|
||||
|
@ -46,13 +46,13 @@ const ContentHeaderButton: FunctionComponent<CHButtonProps> = (props) => {
|
|||
);
|
||||
};
|
||||
|
||||
type CHAsyncButtonProps<T extends () => Promise<any>> = {
|
||||
type CHAsyncButtonProps<R, T extends () => Promise<R>> = {
|
||||
promise: T;
|
||||
onSuccess?: (item: PromiseType<ReturnType<T>>) => void;
|
||||
onSuccess?: (item: R) => void;
|
||||
} & Omit<CHButtonProps, "updating" | "updatingIcon" | "onClick">;
|
||||
|
||||
export function ContentHeaderAsyncButton<T extends () => Promise<any>>(
|
||||
props: PropsWithChildren<CHAsyncButtonProps<T>>
|
||||
export function ContentHeaderAsyncButton<R, T extends () => Promise<R>>(
|
||||
props: PropsWithChildren<CHAsyncButtonProps<R, T>>
|
||||
): JSX.Element {
|
||||
const { promise, onSuccess, ...button } = props;
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { FunctionComponent } from "react";
|
||||
import { FunctionComponent } from "react";
|
||||
|
||||
type GroupPosition = "start" | "end";
|
||||
interface GroupProps {
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { FunctionComponent, ReactNode, useMemo } from "react";
|
||||
import { Row } from "react-bootstrap";
|
||||
import ContentHeaderButton, { ContentHeaderAsyncButton } from "./Button";
|
||||
import ContentHeaderGroup from "./Group";
|
||||
import "./style.scss";
|
||||
|
||||
interface Props {
|
||||
scroll?: boolean;
|
||||
|
@ -29,7 +28,7 @@ export const ContentHeader: Header = ({ children, scroll, className }) => {
|
|||
return rowCls.join(" ");
|
||||
}, [scroll, className]);
|
||||
|
||||
let childItem: React.ReactNode;
|
||||
let childItem: ReactNode;
|
||||
|
||||
if (scroll !== false) {
|
||||
childItem = (
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
FontAwesomeIconProps,
|
||||
} from "@fortawesome/react-fontawesome";
|
||||
import { isNull, isUndefined } from "lodash";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { FunctionComponent, ReactElement } from "react";
|
||||
import {
|
||||
OverlayTrigger,
|
||||
OverlayTriggerProps,
|
||||
|
@ -97,44 +97,8 @@ export const LoadingIndicator: FunctionComponent<{
|
|||
);
|
||||
};
|
||||
|
||||
interface LanguageTextProps {
|
||||
text: Language.Info;
|
||||
className?: string;
|
||||
long?: boolean;
|
||||
}
|
||||
|
||||
export const LanguageText: FunctionComponent<LanguageTextProps> = ({
|
||||
text,
|
||||
className,
|
||||
long,
|
||||
}) => {
|
||||
const result = useMemo(() => {
|
||||
let lang = text.code2;
|
||||
let hi = ":HI";
|
||||
let forced = ":Forced";
|
||||
if (long) {
|
||||
lang = text.name;
|
||||
hi = " HI";
|
||||
forced = " Forced";
|
||||
}
|
||||
|
||||
let res = lang;
|
||||
if (text.hi) {
|
||||
res += hi;
|
||||
} else if (text.forced) {
|
||||
res += forced;
|
||||
}
|
||||
return res;
|
||||
}, [text, long]);
|
||||
return (
|
||||
<span title={text.name} className={className}>
|
||||
{result}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface TextPopoverProps {
|
||||
children: React.ReactElement<any, any>;
|
||||
children: ReactElement;
|
||||
text: string | undefined | null;
|
||||
placement?: OverlayTriggerProps["placement"];
|
||||
delay?: number;
|
||||
|
@ -167,6 +131,5 @@ export * from "./buttons";
|
|||
export * from "./header";
|
||||
export * from "./inputs";
|
||||
export * from "./LanguageSelector";
|
||||
export * from "./modals";
|
||||
export * from "./SearchBar";
|
||||
export * from "./tables";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, {
|
||||
import {
|
||||
FocusEvent,
|
||||
FunctionComponent,
|
||||
KeyboardEvent,
|
||||
|
@ -8,7 +8,6 @@ import React, {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import "./chip.scss";
|
||||
|
||||
const SplitKeys = ["Tab", "Enter", " ", ",", ";"];
|
||||
|
||||
|
|