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