Refactored Web UI using React

pull/1334/head
Liang Yi 3 years ago committed by GitHub
parent fc9f7772b3
commit f64719e4ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [frontend-upgrade]
pull_request:
branches: [frontend-upgrade]
jobs:
Frontend:
runs-on: ubuntu-latest
env:
working-directory: ./frontend
strategy:
matrix:
node-version: [14.x]
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm install
working-directory: ${{ env.working-directory }}
- name: Build
run: npm run build
working-directory: ${{ env.working-directory }}

@ -1,7 +1,7 @@
name: release_beta_to_dev
on:
push:
branches: [ development ]
branches: [development]
jobs:
Release:
@ -9,6 +9,7 @@ jobs:
env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: true
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
working-directory: ./frontend
steps:
- name: Checkout source code
uses: actions/checkout@v2
@ -19,10 +20,33 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v2
with:
node-version: '15.x'
node-version: "15.x"
- run: npm install -D release-it
- run: npm install -D @release-it/bumper
- name: Remove previous node_modules directory
uses: JesseTG/rm@v1.0.2
with:
path: ${{ env.working-directory }}/node_modules
- name: Install dependencies
run: npm install
working-directory: ${{ env.working-directory }}
- name: Remove previous build directory
uses: JesseTG/rm@v1.0.2
with:
path: ${{ env.working-directory }}/build
- name: Build
run: npm run build
working-directory: ${{ env.working-directory }}
- name: Remove generated node_modules directory
uses: JesseTG/rm@v1.0.2
with:
path: ${{ env.working-directory }}/node_modules
- id: latest_release
uses: pozetroninc/github-action-get-latest-release@master
with:
@ -31,9 +55,9 @@ jobs:
- name: Define LAST_VERSION environment variable
run: |
echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV
echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV
- name: Update version and create release
uses: TheRealWaldo/release-it@v0.2.1
with:
json-opts: '{"preRelease": true, "increment": "prepatch", "preReleaseId": "beta"}'
json-opts: '{"preRelease": true, "increment": "prepatch", "preReleaseId": "beta"}'

@ -1,6 +1,5 @@
name: release_major_and_merge
on:
workflow_dispatch
on: workflow_dispatch
jobs:
Release:
@ -14,7 +13,7 @@ jobs:
run: |
echo This action can only be run on development branch, not ${{ github.ref }}
exit 1
- name: Checkout source code
uses: actions/checkout@v2
with:
@ -24,11 +23,11 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v2
with:
node-version: '15.x'
node-version: "15.x"
- run: npm install -D release-it
- run: npm install -D @release-it/bumper
- run: npm install -D auto-changelog
- id: latest_release
uses: pozetroninc/github-action-get-latest-release@master
with:
@ -37,8 +36,8 @@ jobs:
- name: Define LAST_VERSION environment variable
run: |
echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV
echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV
- name: Update version and create release
uses: TheRealWaldo/release-it@v0.2.1
with:
@ -56,4 +55,4 @@ jobs:
type: now
from_branch: development
target_branch: master
github_token: ${{ secrets.GITHUB_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}

@ -1,6 +1,5 @@
name: release_minor_and_merge
on:
workflow_dispatch
on: workflow_dispatch
jobs:
Release:
@ -14,7 +13,7 @@ jobs:
run: |
echo This action can only be run on development branch, not ${{ github.ref }}
exit 1
- name: Checkout source code
uses: actions/checkout@v2
with:
@ -24,11 +23,11 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v2
with:
node-version: '15.x'
node-version: "15.x"
- run: npm install -D release-it
- run: npm install -D @release-it/bumper
- run: npm install -D auto-changelog
- id: latest_release
uses: pozetroninc/github-action-get-latest-release@master
with:
@ -37,8 +36,8 @@ jobs:
- name: Define LAST_VERSION environment variable
run: |
echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV
echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV
- name: Update version and create release
uses: TheRealWaldo/release-it@v0.2.1
with:
@ -56,4 +55,4 @@ jobs:
type: now
from_branch: development
target_branch: master
github_token: ${{ secrets.GITHUB_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}

@ -1,6 +1,5 @@
name: release_patch_and_merge
on:
workflow_dispatch
on: workflow_dispatch
jobs:
Release:
@ -14,7 +13,7 @@ jobs:
run: |
echo This action can only be run on development branch, not ${{ github.ref }}
exit 1
- name: Checkout source code
uses: actions/checkout@v2
with:
@ -24,11 +23,11 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v2
with:
node-version: '15.x'
node-version: "15.x"
- run: npm install -D release-it
- run: npm install -D @release-it/bumper
- run: npm install -D auto-changelog
- id: latest_release
uses: pozetroninc/github-action-get-latest-release@master
with:
@ -37,8 +36,8 @@ jobs:
- name: Define LAST_VERSION environment variable
run: |
echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV
echo "LAST_VERSION=${{steps.latest_release.outputs.release}}" >> $GITHUB_ENV
- name: Update version and create release
uses: TheRealWaldo/release-it@v0.2.1
with:
@ -49,11 +48,11 @@ jobs:
steps:
- name: Checkout source code
uses: actions/checkout@v2
- name: Merge development -> master
uses: devmasx/merge-branch@v1.3.1
with:
type: now
from_branch: development
target_branch: master
github_token: ${{ secrets.GITHUB_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}

13
.gitignore vendored

@ -9,10 +9,15 @@ bazarr.pid
/venv
/data
/bin
static/scss/.sass-cache/*
static/scss/.sass-cache
*.scssc
/.vscode
# Allow
!*.dll
!*.dll
# Frontend
node_modules
frontend/build
frontend/dist
frontend/*.local
frontend/.eslintcache
frontend/.idea/*

@ -1,6 +1,6 @@
{
"github": {
"release": true,
"release": true,
"releaseName": "v${version}",
"releaseNotes": "echo \"From newest to oldest:\" && git log --pretty=format:\"- %s [%h](${repo.protocol}://${repo.host}/${repo.owner}/${repo.project}/commit/%H)\" --no-merges --grep \"^Release\" --invert-grep $LAST_VERSION..HEAD"
},
@ -18,4 +18,4 @@
}
}
}
}
}

@ -1,37 +1,44 @@
# How to Contribute #
# How to Contribute
## Tools required
## Tools required ##
- Python 3.7.x or 3.8.x (3.8.x is highly recommended and 3.9 is proscribed).
- Pycharm or Visual Studio code IDE are recommanded but if you're happy with VIM, enjoy it!
- Git.
- UI testing must be done using Chrome latest version.
## Warning ##
## Warning
As we're using Git in the development process, you better disable automatic update of Bazarr in UI or you may get your changes overwritten. Alternatively, you can completely disable the update module by running Bazarr with `--no-update` command line argument.
## Branching ##
### Basic rules ###
## Branching
### Basic rules
- `master` contains only stable releases (which have been merged to `master`) and is intended for end-users.
- `development` is the target for integration and is not intended for end-users.
- `feature` is a temporary feature branch based on `development`.
### Conditions ###
### Conditions
- `master` is not merged back to `development`.
- `development` is not re-based on `master`.
- all `feature` branches branch from `development` only.
- Bugfixes created specifically for a feature branch are done there (because they are specific, they're not cherry-picked to `development`).
- We will not release a patch (0.0.x) if a newer minor (0.x.0) has already been released.
## Typical contribution workflow
### Community devs
## Typical contribution workflow ##
### Community devs ###
- Fork the repository or pull latest changes if you already have forked it.
- Checkout `development` branch.
- Make the desired changes.
- Submit a PR to Bazarr `development` branch.
- Once reviewed, your PR will be merged using Squash and Merge with a meaningful message.
### Official devs team ###
### Official devs team
- All commits must have a meaningful commit message (ex.: Fixed issue with this, Improved process abc, Added input field to UI, etc.).
- Fixes can be made directly to `development` branch but keep in mind that a pre-release with a beta versioning will be created for every push you make.
- Features must be developed in dedicated feature branch and merged back to `development` branch using PR.

File diff suppressed because it is too large Load Diff

@ -14,8 +14,8 @@ socketio = SocketIO()
def create_app():
# Flask Setup
app = Flask(__name__,
template_folder=os.path.join(os.path.dirname(__file__), '..', 'views'),
static_folder=os.path.join(os.path.dirname(__file__), '..', 'static'),
template_folder=os.path.join(os.path.dirname(__file__), '..', 'frontend', 'build'),
static_folder=os.path.join(os.path.dirname(__file__), '..', 'frontend', 'build', 'static'),
static_url_path=base_url.rstrip('/') + '/static')
app.wsgi_app = ReverseProxied(app.wsgi_app)
app.route = prefix_route(app.route, base_url.rstrip('/'))

@ -5,6 +5,7 @@ import logging
import json
import requests
import semver
from shutil import rmtree
from zipfile import ZipFile
from get_args import args
@ -107,12 +108,22 @@ def apply_update():
update_dir = os.path.join(args.config_dir, 'update')
bazarr_zip = os.path.join(update_dir, 'bazarr.zip')
bazarr_dir = os.path.dirname(os.path.dirname(__file__))
build_dir = os.path.join(os.path.dirname(__file__), 'frontend', 'build')
if os.path.isdir(update_dir):
if os.path.isfile(bazarr_zip):
logging.debug('BAZARR is trying to unzip this release to {0}: {1}'.format(bazarr_dir, bazarr_zip))
try:
with ZipFile(bazarr_zip, 'r') as archive:
zip_root_directory = archive.namelist()[0]
if os.path.isdir(build_dir):
try:
rmtree(build_dir, ignore_errors=True)
except Exception as e:
logging.exception(
'BAZARR was unable to delete the previous build directory during upgrade process.')
for file in archive.namelist():
if file.startswith(zip_root_directory) and file != zip_root_directory and not \
file.endswith('bazarr.py'):

@ -2,6 +2,7 @@
import hashlib
import os
import ast
from urllib.parse import quote_plus
@ -45,7 +46,7 @@ defaults = {
'ignore_pgs_subs': 'False',
'ignore_vobsub_subs': 'False',
'adaptive_searching': 'False',
'enabled_providers': '',
'enabled_providers': '[]',
'multithreading': 'True',
'chmod_enabled': 'False',
'chmod': '0640',
@ -58,7 +59,7 @@ defaults = {
'anti_captcha_provider': 'None',
'wanted_search_frequency': '3',
'wanted_search_frequency_movie': '3',
'subzero_mods': '',
'subzero_mods': '[]',
'dont_notify_manual_actions': 'False'
},
'auth': {
@ -100,7 +101,7 @@ defaults = {
'port': '',
'username': '',
'password': '',
'exclude': 'localhost,127.0.0.1'
'exclude': '["localhost","127.0.0.1"]'
},
'opensubtitles': {
'username': '',
@ -175,8 +176,70 @@ settings = simpleconfigparser(defaults=defaults, interpolation=None)
settings.read(os.path.join(args.config_dir, 'config', 'config.ini'))
settings.general.base_url = settings.general.base_url if settings.general.base_url else '/'
base_url = settings.general.base_url
base_url = settings.general.base_url.rstrip('/')
ignore_keys = ['flask_secret_key',
'page_size',
'page_size_manual_search',
'throtteled_providers']
raw_keys = ['movie_default_forced', 'serie_default_forced']
array_keys = ['excluded_tags',
'exclude',
'subzero_mods',
'excluded_series_types',
'enabled_providers',
'path_mappings',
'path_mappings_movie']
str_keys = ['chmod']
empty_values = ['', 'None', 'null', 'undefined', None, []]
def get_settings():
result = dict()
sections = settings.sections()
for sec in sections:
sec_values = settings.items(sec, False)
values_dict = dict()
for sec_val in sec_values:
key = sec_val[0]
value = sec_val[1]
if key in ignore_keys:
continue
if key not in raw_keys:
# Do some postprocessings
if value in empty_values:
if key in array_keys:
value = []
else:
continue
elif value == 'True':
value = True
elif value == 'False':
value = False
elif (value[0] == '[' and value[-1] == ']'):
value = ast.literal_eval(value)
elif value.find(',') != -1:
value = value.split(',')
pass
else:
if key not in str_keys:
try:
value = int(value)
except ValueError:
pass
values_dict[key] = value
result[sec] = values_dict
return result
def save_settings(settings_items):
from database import database
@ -188,24 +251,30 @@ def save_settings(settings_items):
configure_proxy = False
exclusion_updated = False
# Subzero Mods
update_subzero = False
subzero_mods = get_array_from(settings.general.subzero_mods)
if len(subzero_mods) == 1 and subzero_mods[0] == '':
subzero_mods = []
for key, value in settings_items:
# Intercept database stored settings
if key == 'enabled_languages':
database.execute("UPDATE table_settings_languages SET enabled=0")
for item in value:
database.execute("UPDATE table_settings_languages SET enabled=1 WHERE code2=?", (item,))
continue
# Make sure that text based form values aren't pass as list unless they are language list
if isinstance(value, list) and len(value) == 1 and key not in ['settings-general-serie_default_language',
'settings-general-movie_default_language']:
settings_keys = key.split('-')
# Make sure that text based form values aren't pass as list
if isinstance(value, list) and len(value) == 1 and settings_keys[-1] not in array_keys:
value = value[0]
if value in empty_values:
value = None
# Make sure empty language list are stored correctly due to bug in bootstrap-select
if key in ['settings-general-serie_default_language', 'settings-general-movie_default_language'] and value == ['null']:
# Make sure empty language list are stored correctly
if settings_keys[-1] in array_keys and value[0] in empty_values :
value = []
settings_keys = key.split('-')
# Handle path mappings settings since they are array in array
if settings_keys[-1] in ['path_mappings', 'path_mappings_movie']:
value = [v.split(',') for v in value]
if value == 'true':
value = 'True'
@ -213,7 +282,7 @@ def save_settings(settings_items):
value = 'False'
if key == 'settings-auth-password':
if value != settings.auth.password:
if value != settings.auth.password and value != None:
value = hashlib.md5(value.encode('utf-8')).hexdigest()
if key == 'settings-general-debug':
@ -266,6 +335,31 @@ def save_settings(settings_items):
if settings_keys[0] == 'settings':
settings[settings_keys[1]][settings_keys[2]] = str(value)
if settings_keys[0] == 'subzero':
mod = settings_keys[1]
enabled = value == 'True'
if mod in subzero_mods and not enabled:
subzero_mods.remove(mod)
elif enabled:
subzero_mods.append(mod)
# Handle color
if mod == 'color':
previous = None
for exist_mod in subzero_mods:
if exist_mod.startswith('color'):
previous = exist_mod
break
if previous is not None:
subzero_mods.remove(previous)
if value not in empty_values:
subzero_mods.append(value)
update_subzero = True
if update_subzero:
settings.set('general', 'subzero_mods', ','.join(subzero_mods))
with open(os.path.join(args.config_dir, 'config', 'config.ini'), 'w+') as handle:
settings.write(handle)
@ -307,8 +401,12 @@ def url_sonarr():
if settings.sonarr.base_url.endswith("/"):
settings.sonarr.base_url = settings.sonarr.base_url[:-1]
return protocol_sonarr + "://" + settings.sonarr.ip + ":" + settings.sonarr.port + settings.sonarr.base_url
if settings.sonarr.port in empty_values:
port = ""
else:
port = f":{settings.sonarr.port}"
return f"{protocol_sonarr}://{settings.sonarr.ip}{port}{settings.sonarr.base_url}"
def url_sonarr_short():
if settings.sonarr.getboolean('ssl'):
@ -316,14 +414,12 @@ def url_sonarr_short():
else:
protocol_sonarr = "http"
if settings.sonarr.base_url == '':
settings.sonarr.base_url = "/"
if not settings.sonarr.base_url.startswith("/"):
settings.sonarr.base_url = "/" + settings.sonarr.base_url
if settings.sonarr.base_url.endswith("/"):
settings.sonarr.base_url = settings.sonarr.base_url[:-1]
return protocol_sonarr + "://" + settings.sonarr.ip + ":" + settings.sonarr.port
if settings.sonarr.port in empty_values:
port = ""
else:
port = f":{settings.sonarr.port}"
return f"{protocol_sonarr}://{settings.sonarr.ip}{port}"
def url_radarr():
if settings.radarr.getboolean('ssl'):
@ -338,8 +434,12 @@ def url_radarr():
if settings.radarr.base_url.endswith("/"):
settings.radarr.base_url = settings.radarr.base_url[:-1]
return protocol_radarr + "://" + settings.radarr.ip + ":" + settings.radarr.port + settings.radarr.base_url
if settings.radarr.port in empty_values:
port = ""
else:
port = f":{settings.radarr.port}"
return f"{protocol_radarr}://{settings.radarr.ip}{port}{settings.radarr.base_url}"
def url_radarr_short():
if settings.radarr.getboolean('ssl'):
@ -347,15 +447,21 @@ def url_radarr_short():
else:
protocol_radarr = "http"
if settings.radarr.base_url == '':
settings.radarr.base_url = "/"
if not settings.radarr.base_url.startswith("/"):
settings.radarr.base_url = "/" + settings.radarr.base_url
if settings.radarr.base_url.endswith("/"):
settings.radarr.base_url = settings.radarr.base_url[:-1]
if settings.radarr.port in empty_values:
port = ""
else:
port = f":{settings.radarr.port}"
return protocol_radarr + "://" + settings.radarr.ip + ":" + settings.radarr.port
return f"{protocol_radarr}://{settings.radarr.ip}{port}"
def get_array_from(property):
if property:
if '[' in property:
return ast.literal_eval(property)
else:
return property.split(',')
else:
return []
def configure_captcha_func():
# set anti-captcha provider and key
@ -380,4 +486,5 @@ def configure_proxy_func():
proxy = settings.proxy.type + '://' + settings.proxy.url + ':' + settings.proxy.port
os.environ['HTTP_PROXY'] = str(proxy)
os.environ['HTTPS_PROXY'] = str(proxy)
os.environ['NO_PROXY'] = str(settings.proxy.exclude)
exclude = ','.join(get_array_from(settings.proxy.exclude))
os.environ['NO_PROXY'] = exclude

@ -10,7 +10,7 @@ from sqlite3worker import Sqlite3Worker
from get_args import args
from helper import path_mappings
from config import settings
from config import settings, get_array_from
global profile_id_list
profile_id_list = []
@ -270,7 +270,7 @@ def get_exclusion_clause(type):
where_clause += ' AND table_movies.monitored = "True"'
if type == 'series':
typesList = ast.literal_eval(settings.sonarr.excluded_series_types)
typesList = get_array_from(settings.sonarr.excluded_series_types)
for type in typesList:
where_clause += ' AND table_shows.seriesType != "' + type + '"'
@ -281,6 +281,9 @@ def update_profile_id_list():
global profile_id_list
profile_id_list = database.execute("SELECT profileId, name, cutoff, items FROM table_languages_profiles")
for profile in profile_id_list:
profile['items'] = json.loads(profile['items'])
def get_profiles_list(profile_id=None):
if not len(profile_id_list):
@ -304,8 +307,7 @@ def get_desired_languages(profile_id):
for profile in profile_id_list:
profileId, name, cutoff, items = profile.values()
if profileId == int(profile_id):
items_list = ast.literal_eval(items)
languages = [x['language'] for x in items_list]
languages = [x['language'] for x in items]
break
return languages
@ -339,7 +341,7 @@ def get_profile_cutoff(profile_id):
profileId, name, cutoff, items = profile.values()
if cutoff:
if profileId == int(profile_id):
for item in ast.literal_eval(items):
for item in items:
if item['id'] == cutoff:
return [item]
elif cutoff == 65535:

@ -7,9 +7,10 @@ import pretty
import time
import socket
import requests
import ast
from get_args import args
from config import settings
from config import settings, get_array_from
from event_handler import event_stream
from subliminal_patch.exceptions import TooManyRequests, APIThrottled, ParseResponseError, IPAddressBlocked
from subliminal.providers.opensubtitles import DownloadLimitReached
@ -90,22 +91,21 @@ def provider_pool():
def get_providers():
providers_list = []
if settings.general.enabled_providers:
for provider in settings.general.enabled_providers.lower().split(','):
reason, until, throttle_desc = tp.get(provider, (None, None, None))
providers_list.append(provider)
if reason:
now = datetime.datetime.now()
if now < until:
logging.debug("Not using %s until %s, because of: %s", provider,
until.strftime("%y/%m/%d %H:%M"), reason)
providers_list.remove(provider)
else:
logging.info("Using %s again after %s, (disabled because: %s)", provider, throttle_desc, reason)
del tp[provider]
set_throttled_providers(str(tp))
providers = get_array_from(settings.general.enabled_providers)
for provider in providers:
reason, until, throttle_desc = tp.get(provider, (None, None, None))
providers_list.append(provider)
if reason:
now = datetime.datetime.now()
if now < until:
logging.debug("Not using %s until %s, because of: %s", provider,
until.strftime("%y/%m/%d %H:%M"), reason)
providers_list.remove(provider)
else:
logging.info("Using %s again after %s, (disabled because: %s)", provider, throttle_desc, reason)
del tp[provider]
set_throttled_providers(str(tp))
# if forced only is enabled: # fixme: Prepared for forced only implementation to remove providers with don't support forced only subtitles
# for provider in providers_list:
# if provider in PROVIDERS_FORCED_OFF:
@ -247,9 +247,22 @@ def throttled_count(name):
def update_throttled_provider():
changed = False
if settings.general.enabled_providers:
for provider in list(tp):
if provider not in settings.general.enabled_providers:
providers_list = get_array_from(settings.general.enabled_providers)
for provider in list(tp):
if provider not in providers_list:
del tp[provider]
settings.general.throtteled_providers = str(tp)
changed = True
reason, until, throttle_desc = tp.get(provider, (None, None, None))
if reason:
now = datetime.datetime.now()
if now < until:
pass
else:
logging.info("Using %s again after %s, (disabled because: %s)", provider, throttle_desc, reason)
del tp[provider]
set_throttled_providers(str(tp))
@ -268,10 +281,10 @@ def update_throttled_provider():
def list_throttled_providers():
update_throttled_provider()
throttled_providers = []
if settings.general.enabled_providers:
for provider in settings.general.enabled_providers.lower().split(','):
reason, until, throttle_desc = tp.get(provider, (None, None, None))
throttled_providers.append([provider, reason, pretty.date(until)])
providers = get_array_from(settings.general.enabled_providers)
for provider in providers:
reason, until, throttle_desc = tp.get(provider, (None, None, None))
throttled_providers.append([provider, reason, pretty.date(until)])
return throttled_providers

@ -22,7 +22,7 @@ from subliminal_patch.score import compute_score
from subliminal_patch.subtitle import Subtitle
from get_languages import language_from_alpha3, alpha2_from_alpha3, alpha3_from_alpha2, language_from_alpha2, \
alpha2_from_language, alpha3_from_language
from config import settings
from config import settings, get_array_from
from helper import path_mappings, pp_replace, get_target_folder, force_unicode
from list_subtitles import store_subtitles, list_missing_subtitles, store_subtitles_movie, list_missing_subtitles_movies
from utils import history_log, history_log_movie, get_binary, get_blacklist, notify_sonarr, notify_radarr
@ -115,10 +115,6 @@ def download_subtitle(path, language, audio_language, hi, forced, providers, pro
hi = "force HI"
else:
hi = "force non-HI"
language_set = set()
if not isinstance(language, list):
language = [language]
if forced == "True":
providers_auth['podnapisi']['only_foreign'] = True ## fixme: This is also in get_providers_auth()
@ -129,7 +125,15 @@ def download_subtitle(path, language, audio_language, hi, forced, providers, pro
providers_auth['subscene']['only_foreign'] = False
providers_auth['opensubtitles']['only_foreign'] = False
language_set = set()
if not isinstance(language, list):
language = [language]
for l in language:
# Always use alpha2 in API Request
l = alpha3_from_alpha2(l)
if l == 'pob':
lang_obj = Language('por', 'BR')
if forced == "True":
@ -190,7 +194,7 @@ def download_subtitle(path, language, audio_language, hi, forced, providers, pro
logging.info("BAZARR All providers are throttled")
return None
subz_mods = settings.general.subzero_mods.strip().split(',') if settings.general.subzero_mods.strip() else None
subz_mods = get_array_from(settings.general.subzero_mods)
saved_any = False
if downloaded_subtitles:
for video, subtitles in downloaded_subtitles.items():
@ -323,12 +327,15 @@ def manual_search(path, profileId, providers, providers_auth, sceneName, title,
language_set = set()
# where [3] is items list of dict(id, lang, forced, hi)
language_items = ast.literal_eval(get_profiles_list(profile_id=int(profileId))['items'])
language_items = get_profiles_list(profile_id=int(profileId))['items']
for language in language_items:
lang_id, lang, forced, hi, audio_exclude = language.values()
forced = language['forced']
hi = language['hi']
audio_exclude = language['audio_exclude']
language = language['language']
lang = alpha3_from_alpha2(lang)
lang = alpha3_from_alpha2(language)
if lang == 'pob':
lang_obj = Language('por', 'BR')
@ -433,9 +440,6 @@ def manual_search(path, profileId, providers, providers_auth, sceneName, title,
if not initial_hi_match:
initial_hi = None
if initial_hi_match:
matches.add('hearing_impaired')
score, score_without_hash = compute_score(matches, s, video, hearing_impaired=initial_hi)
if 'hash' not in matches:
not_matched = scores - matches
@ -455,23 +459,25 @@ def manual_search(path, profileId, providers, providers_auth, sceneName, title,
if s_item.strip():
releases.append(s_item)
if len(releases) == 0:
releases = ['n/a']
if s.uploader and s.uploader.strip():
s_uploader = s.uploader.strip()
else:
s_uploader = 'n/a'
s_uploader = None
subtitles_list.append(
dict(score=round((score / max_score * 100), 2),
orig_score=score,
score_without_hash=score_without_hash, forced=str(s.language.forced),
language=str(s.language.basename), hearing_impaired=str(s.hearing_impaired),
score_without_hash=score_without_hash,
forced=str(s.language.forced),
language=str(s.language.basename),
hearing_impaired=str(s.hearing_impaired),
provider=s.provider_name,
subtitle=codecs.encode(pickle.dumps(s.make_picklable()), "base64").decode(),
url=s.page_link, matches=list(matches), dont_matches=list(not_matched),
release_info=releases, uploader=s_uploader))
url=s.page_link,
matches=list(matches),
dont_matches=list(not_matched),
release_info=releases,
uploader=s_uploader))
final_subtitles = sorted(subtitles_list, key=lambda x: (x['orig_score'], x['score_without_hash']),
reverse=True)
@ -493,7 +499,15 @@ def manual_download_subtitle(path, language, audio_language, hi, forced, subtitl
os.environ["SZ_KEEP_ENCODING"] = "True"
subtitle = pickle.loads(codecs.decode(subtitle.encode(), "base64"))
subtitle.mods = settings.general.subzero_mods.strip().split(',') if settings.general.subzero_mods.strip() else None
if hi == 'True':
subtitle.language.hi = True
else:
subtitle.language.hi = False
if forced == 'True':
subtitle.language.forced = True
else:
subtitle.language.forced = False
subtitle.mods = get_array_from(settings.general.subzero_mods)
use_postprocessing = settings.general.getboolean('use_postprocessing')
postprocessing_cmd = settings.general.postprocessing_cmd
single = settings.general.getboolean('single_language')
@ -654,7 +668,7 @@ def manual_upload_subtitle(path, language, forced, title, scene_name, media_type
sub = Subtitle(
lang_obj,
mods=settings.general.subzero_mods.strip().split(',') if settings.general.subzero_mods.strip() else None
mods = get_array_from(settings.general.subzero_mods)
)
sub.content = subtitle.read()

@ -8,7 +8,7 @@ import logging
from charamel import Detector
from bs4 import UnicodeDammit
from config import settings
from config import settings, get_array_from
class PathMappings:
@ -17,8 +17,8 @@ class PathMappings:
self.path_mapping_movies = []
def update(self):
self.path_mapping_series = [x for x in ast.literal_eval(settings.general.path_mappings) if x[0] != x[1]]
self.path_mapping_movies = [x for x in ast.literal_eval(settings.general.path_mappings_movie) if x[0] != x[1]]
self.path_mapping_series = [x for x in get_array_from(settings.general.path_mappings) if x[0] != x[1]]
self.path_mapping_movies = [x for x in get_array_from(settings.general.path_mappings_movie) if x[0] != x[1]]
def path_replace(self, path):
if path is None:

@ -85,10 +85,15 @@ if not settings.general.flask_secret_key:
settings.write(handle)
# change default base_url to ''
if settings.general.base_url == '/':
settings.general.base_url = ''
with open(os.path.join(args.config_dir, 'config', 'config.ini'), 'w+') as handle:
settings.write(handle)
settings.general.base_url = settings.general.base_url.rstrip('/')
with open(os.path.join(args.config_dir, 'config', 'config.ini'), 'w+') as handle:
settings.write(handle)
# migrate enabled_providers from comma separated string to list
if isinstance(settings.general.enabled_providers, str) and not settings.general.enabled_providers.startswith('['):
settings.general.enabled_providers = str(settings.general.enabled_providers.split(","))
with open(os.path.join(args.config_dir, 'config', 'config.ini'), 'w+') as handle:
settings.write(handle)
# create database file
if not os.path.exists(os.path.join(args.config_dir, 'db', 'bazarr.db')):

@ -268,7 +268,7 @@ def list_missing_subtitles(no=None, epno=None, send_event=True):
desired_subtitles_temp = get_profiles_list(profile_id=episode_subtitles['profileId'])
desired_subtitles_list = []
if desired_subtitles_temp:
for language in ast.literal_eval(desired_subtitles_temp['items']):
for language in desired_subtitles_temp['items']:
if language['audio_exclude'] == "True":
cutoff_lang_temp = get_profile_cutoff(profile_id=episode_subtitles['profileId'])
if cutoff_lang_temp:
@ -380,7 +380,7 @@ def list_missing_subtitles_movies(no=None, epno=None, send_event=True):
desired_subtitles_temp = get_profiles_list(profile_id=movie_subtitles['profileId'])
desired_subtitles_list = []
if desired_subtitles_temp:
for language in ast.literal_eval(desired_subtitles_temp['items']):
for language in desired_subtitles_temp['items']:
if language['audio_exclude'] == "True":
cutoff_lang_temp = get_profile_cutoff(profile_id=movie_subtitles['profileId'])
if cutoff_lang_temp:

@ -15,12 +15,11 @@ import gc
import libs
import hashlib
import apprise
import calendar
from get_args import args
from logger import empty_log
from config import settings, url_sonarr, url_radarr, configure_proxy_func
from config import settings, url_sonarr, url_radarr, configure_proxy_func, base_url
from init import *
from database import database
@ -58,431 +57,96 @@ login_auth = settings.auth.type
update_notifier()
def check_credentials(user, pw):
username = settings.auth.username
password = settings.auth.password
if hashlib.md5(pw.encode('utf-8')).hexdigest() == password and user == username:
return True
return False
def login_required(f):
@wraps(f)
def wrap(*args, **kwargs):
if settings.auth.type == 'basic':
auth = request.authorization
if not (auth and check_credentials(request.authorization.username, request.authorization.password)):
return ('Unauthorized', 401, {
'WWW-Authenticate': 'Basic realm="Login Required"'
})
return f(*args, **kwargs)
elif settings.auth.type == 'form':
if 'logged_in' in session:
return f(*args, **kwargs)
else:
flash("You need to login first")
return redirect(url_for('login_page'))
else:
return f(*args, **kwargs)
return wrap
@app.errorhandler(404)
@login_required
def page_not_found(e):
if request.path == '/':
return redirect(url_for('series'), code=302)
return render_template('404.html'), 404
@app.route('/login/', methods=["GET", "POST"])
def login_page():
error = ''
password_reset = False
if settings.auth.password == hashlib.md5(settings.auth.username.encode('utf-8')).hexdigest():
password_reset = True
try:
if request.method == "POST":
if check_credentials(request.form['username'], request.form['password']):
session['logged_in'] = True
session['username'] = request.form['username']
flash("You are now logged in")
return redirect(url_for("redirect_root"))
else:
error = "Invalid credentials, try again."
gc.collect()
if settings.auth.type == 'form' and not 'logged_in' in session:
return render_template("login.html", error=error, password_reset=password_reset)
else:
return redirect(url_for("redirect_root"))
except Exception as e:
# flash(e)
error = "Invalid credentials, try again."
return render_template("login.html", error=error)
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def catch_all(path):
return render_template("index.html")
@app.context_processor
def template_variable_processor():
updated = None
updated = False
try:
updated = database.execute("SELECT updated FROM system", only_one=True)['updated']
except:
pass
finally:
return dict(settings=settings, args=args, updated=updated)
inject = dict()
inject["apiKey"] = settings.auth.apikey
inject["baseUrl"] = base_url
inject["canUpdate"] = not args.no_update
inject["hasUpdate"] = updated != '0'
def api_authorize():
if 'apikey' in request.GET.dict:
if request.GET.dict['apikey'][0] == settings.auth.apikey:
return
else:
abort(401, 'Unauthorized')
else:
abort(401, 'Unauthorized')
template_url = base_url
if not template_url.endswith("/"):
template_url += "/"
return dict(BAZARR_SERVER_INJECT=inject, baseUrl=template_url)
def post_get(name, default=''):
return request.POST.get(name, default).strip()
@app.route("/logout/")
@login_required
def logout():
if settings.auth.type == 'basic':
return abort(401)
elif settings.auth.type == 'form':
session.clear()
flash("You have been logged out!")
gc.collect()
return redirect(url_for('redirect_root'))
@app.route('/emptylog')
@login_required
def emptylog():
empty_log()
return '', 200
@app.route('/bazarr.log')
@login_required
def download_log():
r = Response()
r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
r.headers["Pragma"] = "no-cache"
r.headers["Expires"] = "0"
r.headers['Cache-Control'] = 'public, max-age=0'
return send_file(os.path.join(args.config_dir, 'log', 'bazarr.log'), cache_timeout=0)
return send_file(os.path.join(args.config_dir, 'log', 'bazarr.log'), cache_timeout=0, as_attachment=True)
@app.route('/image_proxy/<path:url>', methods=['GET'])
@login_required
def image_proxy(url):
@app.route('/images/series/<path:url>', methods=['GET'])
def series_images(url):
url = url.strip("/")
apikey = settings.sonarr.apikey
url_image = (url_sonarr() + '/api/' + url + '?apikey=' + apikey).replace('poster-250', 'poster-500')
baseUrl = settings.sonarr.base_url.strip("/")
url_image = (url_sonarr() + '/api' + url.lstrip(baseUrl) + '?apikey=' + apikey).replace('poster-250', 'poster-500')
try:
req = requests.get(url_image, stream=True, timeout=15, verify=False)
except:
return None
return '', 404
else:
return Response(stream_with_context(req.iter_content(2048)), content_type=req.headers['content-type'])
@app.route('/image_proxy_movies/<path:url>', methods=['GET'])
@login_required
def image_proxy_movies(url):
@app.route('/images/movies/<path:url>', methods=['GET'])
def movies_images(url):
apikey = settings.radarr.apikey
url_image = url_radarr() + '/api/' + url + '?apikey=' + apikey
baseUrl = settings.radarr.base_url
url_image = url_radarr() + '/api/' + url.lstrip(baseUrl) + '?apikey=' + apikey
try:
req = requests.get(url_image, stream=True, timeout=15, verify=False)
except:
return None
return '', 404
else:
return Response(stream_with_context(req.iter_content(2048)), content_type=req.headers['content-type'])
@app.route("/")
@login_required
def redirect_root():
if settings.general.getboolean('use_sonarr'):
return redirect(url_for('series'))
elif settings.general.getboolean('use_radarr'):
return redirect(url_for('movies'))
else:
return redirect(url_for('settingsgeneral'))
@app.route('/series/')
@login_required
def series():
return render_template('series.html')
@app.route('/serieseditor/')
@login_required
def serieseditor():
return render_template('serieseditor.html')
@app.route('/episodes/<no>')
@login_required
def episodes(no):
return render_template('episodes.html', id=str(no))
@app.route('/movies')
@login_required
def movies():
return render_template('movies.html')
@app.route('/movieseditor')
@login_required
def movieseditor():
return render_template('movieseditor.html')
@app.route('/movie/<no>')
@login_required
def movie(no):
return render_template('movie.html', id=str(no))
@app.route('/history/series/')
@login_required
def historyseries():
return render_template('historyseries.html')
@app.route('/history/movies/')
@login_required
def historymovies():
return render_template('historymovies.html')
@app.route('/history/stats/')
@login_required
def historystats():
data_providers = database.execute("SELECT DISTINCT provider FROM table_history WHERE provider IS NOT null "
"UNION SELECT DISTINCT provider FROM table_history_movie WHERE provider "
"IS NOT null")
data_providers_list = []
for item in data_providers:
data_providers_list.append(item['provider'])
data_languages = database.execute("SELECT DISTINCT language FROM table_history WHERE language IS NOT null "
"AND language != '' UNION SELECT DISTINCT language FROM table_history_movie "
"WHERE language IS NOT null AND language != ''")
data_languages_list = []
for item in data_languages:
splitted_lang = item['language'].split(':')
item = {"name" : language_from_alpha2(splitted_lang[0]),
"code2" : splitted_lang[0],
"code3" : alpha3_from_alpha2(splitted_lang[0]),
"forced": True if len(splitted_lang) > 1 else False}
data_languages_list.append(item)
return render_template('historystats.html', data_providers=data_providers_list,
data_languages=sorted(data_languages_list, key=lambda i: i['name']))
@app.route('/blacklist/series/')
@login_required
def blacklistseries():
return render_template('blacklistseries.html')
@app.route('/blacklist/movies/')
@login_required
def blacklistmovies():
return render_template('blacklistmovies.html')
@app.route('/wanted/series/')
@login_required
def wantedseries():
return render_template('wantedseries.html')
@app.route('/wanted/movies/')
@login_required
def wantedmovies():
return render_template('wantedmovies.html')
@app.route('/settings/general/')
@login_required
def settingsgeneral():
return render_template('settingsgeneral.html')
# @app.route('/check_update')
# @authenticate
# def check_update():
# if not args.no_update:
# check_and_apply_update()
@app.route('/settings/sonarr/')
@login_required
def settingssonarr():
return render_template('settingssonarr.html')
@app.route('/settings/radarr/')
@login_required
def settingsradarr():
return render_template('settingsradarr.html')
@app.route('/settings/subtitles/')
@login_required
def settingssubtitles():
return render_template('settingssubtitles.html', os=sys.platform)
@app.route('/settings/languages/')
@login_required
def settingslanguages():
return render_template('settingslanguages.html')
@app.route('/settings/providers/')
@login_required
def settingsproviders():
return render_template('settingsproviders.html')
@app.route('/settings/notifications/')
@login_required
def settingsnotifications():
return render_template('settingsnotifications.html')
@app.route('/settings/scheduler/')
@login_required
def settingsscheduler():
days_of_the_week = list(enumerate(calendar.day_name))
return render_template('settingsscheduler.html', days=days_of_the_week)
@app.route('/check_update')
@login_required
def check_update():
if not args.no_update and bazarr_version != '':
check_if_new_update()
return '', 200
@app.route('/system/tasks')
@login_required
def systemtasks():
return render_template('systemtasks.html')
@app.route('/system/logs')
@login_required
def systemlogs():
return render_template('systemlogs.html')
@app.route('/system/providers')
@login_required
def systemproviders():
return render_template('systemproviders.html')
@app.route('/system/status')
@login_required
def systemstatus():
return render_template('systemstatus.html')
@app.route('/system/releases')
@login_required
def systemreleases():
return render_template('systemreleases.html')
# return '', 200
def configured():
database.execute("UPDATE system SET configured = 1")
@app.route('/api/series/wanted')
def api_wanted():
data = database.execute("SELECT table_shows.title as seriesTitle, table_episodes.season || 'x' || "
"table_episodes.episode as episode_number, table_episodes.title as episodeTitle, "
"table_episodes.missing_subtitles FROM table_episodes INNER JOIN table_shows on "
"table_shows.sonarrSeriesId = table_episodes.sonarrSeriesId WHERE "
"table_episodes.missing_subtitles != '[]' ORDER BY table_episodes._rowid_ DESC LIMIT 10")
wanted_subs = []
for item in data:
wanted_subs.append([item['seriesTitle'], item['episode_number'], item['episodeTitle'],
item['missing_subtitles']])
return dict(subtitles=wanted_subs)
@app.route('/api/series/history')
def api_history():
data = database.execute("SELECT table_shows.title as seriesTitle, "
"table_episodes.season || 'x' || table_episodes.episode as episode_number, "
"table_episodes.title as episodeTitle, "
"strftime('%Y-%m-%d', datetime(table_history.timestamp, 'unixepoch')) as date, "
"table_history.description FROM table_history "
"INNER JOIN table_shows on table_shows.sonarrSeriesId = table_history.sonarrSeriesId "
"INNER JOIN table_episodes on table_episodes.sonarrEpisodeId = "
"table_history.sonarrEpisodeId WHERE table_history.action != '0' ORDER BY id DESC LIMIT 10")
history_subs = []
for item in data:
history_subs.append([item['seriesTitle'], item['episode_number'], item['episodeTitle'], item['date'],
item['description']])
return dict(subtitles=history_subs)
@app.route('/api/movies/wanted/')
def api_movies_wanted():
data = database.execute("SELECT table_movies.title, table_movies.missing_subtitles FROM table_movies "
"WHERE table_movies.missing_subtitles != '[]' ORDER BY table_movies._rowid_ DESC LIMIT 10")
wanted_subs = []
for item in data:
wanted_subs.append([item['title'], item['missing_subtitles']])
return dict(subtitles=wanted_subs)
@app.route('/api/movies/history/')
def api_movies_history():
data = database.execute("SELECT table_movies.title, strftime('%Y-%m-%d', "
"datetime(table_history_movie.timestamp, 'unixepoch')) as date, "
"table_history_movie.description FROM table_history_movie "
"INNER JOIN table_movies on table_movies.radarrId = table_history_movie.radarrId "
"WHERE table_history_movie.action != '0' ORDER BY id DESC LIMIT 10")
history_subs = []
for item in data:
history_subs.append([item['title'], item['date'], item['description']])
return dict(subtitles=history_subs)
@app.route('/test_url', methods=['GET'])
@app.route('/test_url/<protocol>/<path:url>', methods=['GET'])
@login_required
def test_url(protocol, url):
@app.route('/test', methods=['GET'])
@app.route('/test/<protocol>/<path:url>', methods=['GET'])
def proxy(protocol, url):
url = protocol + '://' + unquote(url)
params = request.args
try:
result = requests.get(url, allow_redirects=False, verify=False, timeout=5)
result = requests.get(url, params, allow_redirects=False, verify=False, timeout=5)
except Exception as e:
return dict(status=False, error=repr(e))
else:
if result.status_code == 200:
return dict(status=True, version=result.json()['version'])
try:
version = result.json()['version']
return dict(status=True, version=version)
except Exception:
return dict(status=False, error='Error Occured. Check your settings.')
elif result.status_code == 401:
return dict(status=False, error='Access Denied. Check API key.')
elif 300 <= result.status_code <= 399:
@ -491,25 +155,5 @@ def test_url(protocol, url):
return dict(status=False, error=result.raise_for_status())
@app.route('/test_notification', methods=['GET'])
@app.route('/test_notification/<protocol>/<path:provider>', methods=['GET'])
@login_required
def test_notification(protocol, provider):
provider = unquote(provider)
asset = apprise.AppriseAsset(async_mode=False)
apobj = apprise.Apprise(asset=asset)
apobj.add(protocol + "://" + provider)
apobj.notify(
title='Bazarr test notification',
body='Test notification'
)
return '', 200
if __name__ == "__main__":
webserver.start()

@ -15,7 +15,7 @@ from get_args import args
from config import settings, url_sonarr, url_radarr
from database import database
from event_handler import event_stream
from get_languages import alpha2_from_alpha3, language_from_alpha3, alpha3_from_alpha2
from get_languages import alpha2_from_alpha3, language_from_alpha3, language_from_alpha2, alpha3_from_alpha2
from helper import path_mappings
from list_subtitles import store_subtitles, store_subtitles_movie
from subliminal_patch.subtitle import Subtitle
@ -282,15 +282,16 @@ def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_pat
if not subtitles_path.endswith('.srt'):
logging.error('BAZARR can only delete .srt files.')
return False
language_log = language
language_string = language_from_alpha2(language)
if hi in [True, 'true', 'True']:
language_log = alpha2_from_alpha3(language) + ':hi'
language_string = language_from_alpha3(language) + ' HI'
language_log += ':hi'
language_string += ' HI'
elif forced in [True, 'true', 'True']:
language_log = alpha2_from_alpha3(language) + ':forced'
language_string = language_from_alpha3(language) + ' forced'
else:
language_log = alpha2_from_alpha3(language)
language_string = language_from_alpha3(language)
language_log += ':forced'
language_string += ' forced'
result = language_string + " subtitles deleted from disk."
if media_type == 'series':
@ -323,7 +324,7 @@ def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_pat
def subtitles_apply_mods(language, subtitle_path, mods):
language = alpha3_from_alpha2(language)
if language == 'pob':
lang_obj = Language('por', 'BR')
elif language == 'zht':
@ -349,6 +350,7 @@ def subtitles_apply_mods(language, subtitle_path, mods):
def translate_subtitles_file(video_path, source_srt_file, to_lang, forced, hi):
to_lang = alpha3_from_alpha2(to_lang)
lang_obj = Language(to_lang)
if forced:
lang_obj = Language.rebuild(lang_obj, forced=True)

@ -0,0 +1,8 @@
# Please override by creating a .env.local file at the same directory
# Required
REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
# Optional
REACT_APP_CAN_UPDATE=true
REACT_APP_HAS_UPDATE=false
REACT_APP_LOG_REDUX_EVENT=false

@ -0,0 +1,6 @@
/*
!/frontend
build
dist
converage

@ -0,0 +1,4 @@
{
"tabWidth": 2,
"useTabs": false
}

@ -0,0 +1,30 @@
# Bazarr Frontend
## How to Run
1. Duplicate `.env` file and rename to `.env.local`
2. Fill any variable that defined in `.env.local`
3. Run Bazarr backend (Backend must listening on `http://localhost:6767`)
4. Start frontend by running `npm start`
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.
Open `http://localhost:3000` to view it in the browser.
The page will reload if you make edits.
You will also see any lint errors in the console.
### `npm run build`
Builds the app for production to the `build` folder.
### `npm run lint`
Format code for all files in `frontend` folder
This command will automatic trigger when you commit codes to git. Run manually if you modify `.prettierignore` or `.prettierrc`

File diff suppressed because it is too large Load Diff

@ -0,0 +1,91 @@
{
"name": "bazarr",
"version": "1.0.0",
"description": "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.",
"repository": {
"type": "git",
"url": "git+https://github.com/morpheus65535/bazarr.git"
},
"author": "morpheus65535",
"license": "GPL-3.0",
"bugs": {
"url": "https://github.com/morpheus65535/bazarr/issues"
},
"homepage": "./",
"proxy": "http://localhost:6767",
"dependencies": {
"@fontsource/roboto": "^4.2.2",
"@fortawesome/fontawesome-svg-core": "^1.2.0",
"@fortawesome/free-brands-svg-icons": "^5.15.0",
"@fortawesome/free-regular-svg-icons": "^5.15.0",
"@fortawesome/free-solid-svg-icons": "^5.15.0",
"@fortawesome/react-fontawesome": "^0.1.11",
"@types/bootstrap": "^5.0.0",
"@types/lodash": "^4.0.0",
"@types/node": "^14.0.0",
"@types/rc-slider": "^8.6.6",
"@types/react": "^16.0.0",
"@types/react-dom": "^16.0.0",
"@types/react-helmet": "^6.1.0",
"@types/react-redux": "^7.0.0",
"@types/react-router-dom": "^5.0.0",
"@types/react-select": "^4.0.3",
"@types/react-table": "^7.0.0",
"@types/redux-actions": "^2.0.0",
"@types/redux-logger": "^3.0.0",
"@types/redux-promise": "^0.5.0",
"axios": "^0.21.0",
"bootstrap": "^4.0.0",
"lodash": "^4.0.0",
"moment": "^2.29.1",
"rc-slider": "^9.7.1",
"react": "^16.0.0",
"react-bootstrap": "^1.0.0",
"react-dom": "^16.0.0",
"react-helmet": "^6.1.0",
"react-redux": "^7.0.0",
"react-router-dom": "^5.2.0",
"react-scripts": "^4.0.0",
"react-select": "^4.0.0",
"react-table": "^7.0.0",
"recharts": "^2.0.8",
"redux-actions": "^2.0.0",
"redux-logger": "^3.0.6",
"redux-promise": "^0.6.0",
"redux-thunk": "^2.3.0",
"sass": "^1.0.0",
"typescript": "^4.0.0"
},
"devDependencies": {
"husky": "^4.0.0",
"prettier": "^2.1.2",
"prettier-plugin-organize-imports": "^1.1.1",
"pretty-quick": "^3.1.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"lint": "prettier --write --ignore-unknown ."
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"husky": {
"hooks": {
"pre-commit": "pretty-quick --staged"
}
}
}

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

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@ -0,0 +1,14 @@
{
"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"
}

@ -0,0 +1,20 @@
import { faEyeSlash as fasEyeSlash } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent } from "react";
import { Container } from "react-bootstrap";
export const RouterEmptyPath = "/empty-page";
const EmptyPage: FunctionComponent = () => {
return (
<Container className="d-flex flex-column align-items-center my-5">
<h1>
<FontAwesomeIcon className="mr-2" icon={fasEyeSlash}></FontAwesomeIcon>
404
</h1>
<p>The Request URL No Found</p>
</Container>
);
};
export default EmptyPage;

@ -0,0 +1,155 @@
import { isEqual } from "lodash";
import { log } from "../../utilites/logger";
import {
ActionCallback,
ActionDispatcher,
AsyncActionCreator,
AsyncActionDispatcher,
AvailableCreator,
AvailableType,
PromiseCreator,
} from "../types";
// Limiter the call to API
const gLimiter: Map<PromiseCreator, Date> = new Map();
const gArgs: Map<PromiseCreator, any[]> = new Map();
const LIMIT_CALL_MS = 200;
function asyncActionFactory<T extends PromiseCreator>(
type: string,
promise: T,
args: Parameters<T>
): AsyncActionDispatcher<PromiseType<ReturnType<T>>> {
return (dispatch) => {
const previousArgs = gArgs.get(promise);
const date = new Date();
if (isEqual(previousArgs, args)) {
// Get last execute date
const previousExec = gLimiter.get(promise);
if (previousExec) {
const distInMs = date.getTime() - previousExec.getTime();
if (distInMs < LIMIT_CALL_MS) {
log(
"warning",
"Multiple calls to API within the range",
promise,
args
);
return Promise.resolve();
}
}
} else {
gArgs.set(promise, args);
}
gLimiter.set(promise, date);
dispatch({
type,
payload: {
loading: true,
parameters: args,
},
});
return new Promise((resolve, reject) => {
promise(...args)
.then((val) => {
dispatch({
type,
payload: {
loading: false,
item: val,
parameters: args,
},
});
resolve();
})
.catch((err) => {
dispatch({
type,
error: true,
payload: {
loading: false,
item: err,
parameters: args,
},
});
reject(err);
});
});
};
}
export function createAsyncAction<T extends PromiseCreator>(
type: string,
promise: T
) {
return (...args: Parameters<T>) => asyncActionFactory(type, promise, args);
}
// Create a action which combine multiple ActionDispatcher and execute them at once
function combineActionFactory(
dispatchers: AvailableType<any>[]
): ActionDispatcher {
return (dispatch) => {
dispatchers.forEach((fn) => {
if (typeof fn === "function") {
fn(dispatch);
} else {
dispatch(fn);
}
});
};
}
export function createCombineAction<T extends AvailableCreator>(fn: T) {
return (...args: Parameters<T>) => combineActionFactory(fn(...args));
}
function combineAsyncActionFactory(
dispatchers: AsyncActionDispatcher<any>[]
): AsyncActionDispatcher<any> {
return (dispatch) => {
const promises = dispatchers.map((v) => v(dispatch));
return Promise.all(promises) as Promise<any>;
};
}
export function createAsyncCombineAction<T extends AsyncActionCreator>(fn: T) {
return (...args: Parameters<T>) => combineAsyncActionFactory(fn(...args));
}
export function callbackActionFactory(
dispatchers: AsyncActionDispatcher<any>[],
success: ActionCallback,
error?: ActionCallback
): ActionDispatcher<any> {
return (dispatch) => {
const promises = dispatchers.map((v) => v(dispatch));
Promise.all(promises)
.then(() => {
const action = success();
if (action !== undefined) {
dispatch(action);
}
})
.catch(() => {
const action = error && error();
if (action !== undefined) {
dispatch(action);
}
});
};
}
export function createCallbackAction<T extends AsyncActionCreator>(
fn: T,
success: ActionCallback,
error?: ActionCallback
) {
return (...args: Parameters<T>) =>
callbackActionFactory(fn(args), success, error);
}

@ -0,0 +1,5 @@
export * from "./movie";
export * from "./providers";
export * from "./series";
export * from "./site";
export * from "./system";

@ -0,0 +1,59 @@
import { MoviesApi } from "../../apis";
import {
MOVIES_UPDATE_BLACKLIST,
MOVIES_UPDATE_HISTORY_LIST,
MOVIES_UPDATE_INFO,
MOVIES_UPDATE_LIST,
MOVIES_UPDATE_RANGE,
MOVIES_UPDATE_WANTED_LIST,
MOVIES_UPDATE_WANTED_RANGE,
} from "../constants";
import {
createAsyncAction,
createAsyncCombineAction,
createCombineAction,
} from "./factory";
import { badgeUpdateAll } from "./site";
export const movieUpdateList = createAsyncAction(MOVIES_UPDATE_LIST, () =>
MoviesApi.movies()
);
const movieUpdateWantedList = createAsyncAction(
MOVIES_UPDATE_WANTED_LIST,
(radarrid?: number) => MoviesApi.wantedBy(radarrid)
);
export const movieUpdateWantedByRange = createAsyncAction(
MOVIES_UPDATE_WANTED_RANGE,
(start: number, length: number) => MoviesApi.wanted(start, length)
);
export const movieUpdateWantedBy = createCombineAction((radarrid?: number) => [
movieUpdateWantedList(radarrid),
badgeUpdateAll(),
]);
export const movieUpdateHistoryList = createAsyncAction(
MOVIES_UPDATE_HISTORY_LIST,
() => MoviesApi.history()
);
export const movieUpdateByRange = createAsyncAction(
MOVIES_UPDATE_RANGE,
(start: number, length: number) => MoviesApi.moviesBy(start, length)
);
const movieUpdateInfo = createAsyncAction(MOVIES_UPDATE_INFO, (id?: number[]) =>
MoviesApi.movies(id)
);
export const movieUpdateInfoAll = createAsyncCombineAction((id?: number[]) => [
movieUpdateInfo(id),
badgeUpdateAll(),
]);
export const movieUpdateBlacklist = createAsyncAction(
MOVIES_UPDATE_BLACKLIST,
() => MoviesApi.blacklist()
);

@ -0,0 +1,13 @@
import { ProvidersApi } from "../../apis";
import { PROVIDER_UPDATE_LIST } from "../constants";
import { createAsyncAction, createCombineAction } from "./factory";
import { badgeUpdateAll } from "./site";
const providerUpdateList = createAsyncAction(PROVIDER_UPDATE_LIST, () =>
ProvidersApi.providers()
);
export const providerUpdateAll = createCombineAction(() => [
providerUpdateList(),
badgeUpdateAll(),
]);

@ -0,0 +1,62 @@
import { EpisodesApi, SeriesApi } from "../../apis";
import {
SERIES_UPDATE_BLACKLIST,
SERIES_UPDATE_EPISODE_LIST,
SERIES_UPDATE_HISTORY_LIST,
SERIES_UPDATE_INFO,
SERIES_UPDATE_RANGE,
SERIES_UPDATE_WANTED_LIST,
SERIES_UPDATE_WANTED_RANGE,
} from "../constants";
import {
createAsyncAction,
createAsyncCombineAction,
createCombineAction,
} from "./factory";
import { badgeUpdateAll } from "./site";
const seriesUpdateWantedList = createAsyncAction(
SERIES_UPDATE_WANTED_LIST,
(episodeid?: number) => EpisodesApi.wantedBy(episodeid)
);
const seriesUpdateBy = createAsyncAction(SERIES_UPDATE_INFO, (id?: number[]) =>
SeriesApi.series(id)
);
const episodeUpdateBy = createAsyncAction(
SERIES_UPDATE_EPISODE_LIST,
(seriesid: number) => EpisodesApi.bySeriesId(seriesid)
);
export const seriesUpdateByRange = createAsyncAction(
SERIES_UPDATE_RANGE,
(start: number, length: number) => SeriesApi.seriesBy(start, length)
);
export const seriesUpdateWantedByRange = createAsyncAction(
SERIES_UPDATE_WANTED_RANGE,
(start: number, length: number) => EpisodesApi.wanted(start, length)
);
export const seriesUpdateWantedBy = createCombineAction(
(episodeid?: number) => [seriesUpdateWantedList(episodeid), badgeUpdateAll()]
);
export const episodeUpdateBySeriesId = createCombineAction(
(seriesid: number) => [episodeUpdateBy(seriesid), badgeUpdateAll()]
);
export const seriesUpdateHistoryList = createAsyncAction(
SERIES_UPDATE_HISTORY_LIST,
() => EpisodesApi.history()
);
export const seriesUpdateInfoAll = createAsyncCombineAction(
(seriesid?: number[]) => [seriesUpdateBy(seriesid), badgeUpdateAll()]
);
export const seriesUpdateBlacklist = createAsyncAction(
SERIES_UPDATE_BLACKLIST,
() => EpisodesApi.blacklist()
);

@ -0,0 +1,65 @@
import { createAction } from "redux-actions";
import { BadgesApi } from "../../apis";
import {
SITE_AUTH_SUCCESS,
SITE_BADGE_UPDATE,
SITE_INITIALIZED,
SITE_INITIALIZE_FAILED,
SITE_NEED_AUTH,
SITE_NOTIFICATIONS_ADD,
SITE_NOTIFICATIONS_REMOVE,
SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP,
SITE_OFFLINE_UPDATE,
SITE_SAVE_LOCALSTORAGE,
SITE_SIDEBAR_UPDATE,
} from "../constants";
import { createAsyncAction, createCallbackAction } from "./factory";
import { systemUpdateLanguagesAll, systemUpdateSettings } from "./system";
export const bootstrap = createCallbackAction(
() => [systemUpdateLanguagesAll(), systemUpdateSettings()],
() => siteInitialized(),
() => siteInitializeFailed()
);
const siteInitializeFailed = createAction(SITE_INITIALIZE_FAILED);
const siteInitialized = createAction(SITE_INITIALIZED);
export const siteRedirectToAuth = createAction(SITE_NEED_AUTH);
export const siteAuthSuccess = createAction(SITE_AUTH_SUCCESS);
export const badgeUpdateAll = createAsyncAction(SITE_BADGE_UPDATE, () =>
BadgesApi.all()
);
export const siteSaveLocalstorage = createAction(
SITE_SAVE_LOCALSTORAGE,
(settings: LooseObject) => settings
);
export const siteAddError = createAction(
SITE_NOTIFICATIONS_ADD,
(err: ReduxStore.Notification) => err
);
export const siteRemoveError = createAction(
SITE_NOTIFICATIONS_REMOVE,
(id: string) => id
);
export const siteRemoveErrorByTimestamp = createAction(
SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP,
(date: Date) => date
);
export const siteChangeSidebar = createAction(
SITE_SIDEBAR_UPDATE,
(id: string) => id
);
export const siteUpdateOffline = createAction(
SITE_OFFLINE_UPDATE,
(state: boolean) => state
);

@ -0,0 +1,62 @@
import { Action } from "redux-actions";
import { SystemApi } from "../../apis";
import {
SYSTEM_RUN_TASK,
SYSTEM_UPDATE_LANGUAGES_LIST,
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
SYSTEM_UPDATE_LOGS,
SYSTEM_UPDATE_RELEASES,
SYSTEM_UPDATE_SETTINGS,
SYSTEM_UPDATE_STATUS,
SYSTEM_UPDATE_TASKS,
} from "../constants";
import { createAsyncAction, createAsyncCombineAction } from "./factory";
export const systemUpdateLanguagesAll = createAsyncCombineAction(() => [
systemUpdateLanguages(),
systemUpdateLanguagesProfiles(),
]);
export const systemUpdateLanguages = createAsyncAction(
SYSTEM_UPDATE_LANGUAGES_LIST,
() => SystemApi.languages()
);
export const systemUpdateLanguagesProfiles = createAsyncAction(
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
() => SystemApi.languagesProfileList()
);
export const systemUpdateStatus = createAsyncAction(SYSTEM_UPDATE_STATUS, () =>
SystemApi.status()
);
export const systemUpdateTasks = createAsyncAction(SYSTEM_UPDATE_TASKS, () =>
SystemApi.getTasks()
);
export function systemRunTasks(id: string): Action<string> {
return {
type: SYSTEM_RUN_TASK,
payload: id,
};
}
export const systemUpdateLogs = createAsyncAction(SYSTEM_UPDATE_LOGS, () =>
SystemApi.logs()
);
export const systemUpdateReleases = createAsyncAction(
SYSTEM_UPDATE_RELEASES,
() => SystemApi.releases()
);
export const systemUpdateSettings = createAsyncAction(
SYSTEM_UPDATE_SETTINGS,
() => SystemApi.settings()
);
export const systemUpdateSettingsAll = createAsyncCombineAction(() => [
systemUpdateSettings(),
systemUpdateLanguagesAll(),
]);

@ -0,0 +1,45 @@
// Provider action
export const PROVIDER_UPDATE_LIST = "UPDATE_PROVIDER_LIST";
// System action
export const SYSTEM_UPDATE_LANGUAGES_LIST = "UPDATE_ALL_LANGUAGES_LIST";
export const SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST =
"UPDATE_LANGUAGES_PROFILE_LIST";
export const SYSTEM_UPDATE_STATUS = "UPDATE_SYSTEM_STATUS";
export const SYSTEM_UPDATE_TASKS = "UPDATE_SYSTEM_TASKS";
export const SYSTEM_UPDATE_LOGS = "UPDATE_SYSTEM_LOGS";
export const SYSTEM_UPDATE_RELEASES = "SYSTEM_UPDATE_RELEASES";
export const SYSTEM_UPDATE_SETTINGS = "UPDATE_SYSTEM_SETTINGS";
export const SYSTEM_RUN_TASK = "SYSTEM_RUN_TASK";
// Series action
export const SERIES_UPDATE_WANTED_RANGE = "SERIES_UPDATE_WANTED_RANGE";
export const SERIES_UPDATE_WANTED_LIST = "UPDATE_SERIES_WANTED_LIST";
export const SERIES_UPDATE_EPISODE_LIST = "UPDATE_SERIES_EPISODE_LIST";
export const SERIES_UPDATE_HISTORY_LIST = "UPDATE_SERIES_HISTORY_LIST";
export const SERIES_UPDATE_INFO = "UPDATE_SEIRES_INFO";
export const SERIES_UPDATE_RANGE = "SERIES_UPDATE_RANGE";
export const SERIES_UPDATE_BLACKLIST = "UPDATE_SERIES_BLACKLIST";
// Movie action
export const MOVIES_UPDATE_LIST = "UPDATE_MOVIE_LIST";
export const MOVIES_UPDATE_WANTED_RANGE = "MOVIES_UPDATE_WANTED_RANGE";
export const MOVIES_UPDATE_WANTED_LIST = "UPDATE_MOVIE_WANTED_LIST";
export const MOVIES_UPDATE_HISTORY_LIST = "UPDATE_MOVIE_HISTORY_LIST";
export const MOVIES_UPDATE_INFO = "UPDATE_MOVIE_INFO";
export const MOVIES_UPDATE_RANGE = "MOVIES_UPDATE_RANGE";
export const MOVIES_UPDATE_BLACKLIST = "UPDATE_MOVIES_BLACKLIST";
// Site Action
export const SITE_AUTH_SUCCESS = "SITE_AUTH_SUCCESS";
export const SITE_NEED_AUTH = "SITE_NEED_AUTH";
export const SITE_INITIALIZED = "SITE_SYSTEM_INITIALIZED";
export const SITE_INITIALIZE_FAILED = "SITE_INITIALIZE_FAILED";
export const SITE_SAVE_LOCALSTORAGE = "SITE_SAVE_LOCALSTORAGE";
export const SITE_NOTIFICATIONS_ADD = "SITE_NOTIFICATIONS_ADD";
export const SITE_NOTIFICATIONS_REMOVE = "SITE_NOTIFICATIONS_REMOVE";
export const SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP =
"SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP";
export const SITE_SIDEBAR_UPDATE = "SITE_SIDEBAR_UPDATE";
export const SITE_BADGE_UPDATE = "SITE_BADGE_UPDATE";
export const SITE_OFFLINE_UPDATE = "SITE_OFFLINE_UPDATE";

@ -0,0 +1,36 @@
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { createCallbackAction } from "../actions/factory";
import { ActionCallback, AsyncActionDispatcher } from "../types";
// function use
export function useReduxStore<T extends (store: ReduxStore) => any>(
selector: T
) {
return useSelector<ReduxStore, ReturnType<T>>(selector);
}
export function useReduxAction<T extends (...args: any[]) => void>(action: T) {
const dispatch = useDispatch();
return useCallback((...args: Parameters<T>) => dispatch(action(...args)), [
action,
dispatch,
]);
}
export function useReduxActionWith<
T extends (...args: any[]) => AsyncActionDispatcher<any>
>(action: T, success: ActionCallback) {
const dispatch = useDispatch();
return useCallback(
(...args: Parameters<T>) => {
const callbackAction = createCallbackAction(
() => [action(...args)],
success
);
dispatch(callbackAction());
},
[dispatch, action, success]
);
}

@ -0,0 +1,265 @@
import { useCallback, useMemo } from "react";
import { buildOrderList } from "../../utilites";
import {
episodeUpdateBySeriesId,
movieUpdateBlacklist,
movieUpdateHistoryList,
movieUpdateInfoAll,
movieUpdateWantedBy,
providerUpdateAll,
seriesUpdateBlacklist,
seriesUpdateHistoryList,
seriesUpdateInfoAll,
seriesUpdateWantedBy,
systemUpdateLanguages,
systemUpdateLanguagesProfiles,
systemUpdateSettingsAll,
} from "../actions";
import { useReduxAction, useReduxStore } from "./base";
function stateBuilder<T, D extends (...args: any[]) => any>(
t: T,
d: D
): [Readonly<T>, D] {
return [t, d];
}
export function useSystemSettings() {
const action = useReduxAction(systemUpdateSettingsAll);
const items = useReduxStore((s) => s.system.settings);
return stateBuilder(items, action);
}
export function useLanguageProfiles() {
const action = useReduxAction(systemUpdateLanguagesProfiles);
const items = useReduxStore((s) => s.system.languagesProfiles.data);
return stateBuilder(items, action);
}
export function useProfileBy(id: number | null | undefined) {
const [profiles] = useLanguageProfiles();
return useMemo(() => profiles.find((v) => v.profileId === id), [
id,
profiles,
]);
}
export function useLanguages(enabled: boolean = false) {
const action = useReduxAction(systemUpdateLanguages);
const items = useReduxStore((s) =>
enabled ? s.system.enabledLanguage.data : s.system.languages.data
);
return stateBuilder(items, action);
}
function useLanguageGetter(enabled: boolean = false) {
const [languages] = useLanguages(enabled);
return useCallback(
(code?: string) => {
if (code === undefined) {
return undefined;
} else {
return languages.find((v) => v.code2 === code);
}
},
[languages]
);
}
export function useLanguageBy(code?: string) {
const getter = useLanguageGetter();
return useMemo(() => getter(code), [code, getter]);
}
// Convert languageprofile items to language
export function useProfileItems(profile?: Profile.Languages) {
const getter = useLanguageGetter(true);
return useMemo(
() =>
profile?.items.map<Language>(({ language, hi, forced }) => {
const name = getter(language)?.name ?? "";
return {
hi: hi === "True",
forced: forced === "True",
code2: language,
name,
};
}) ?? [],
[getter, profile?.items]
);
}
export function useRawSeries() {
const action = useReduxAction(seriesUpdateInfoAll);
const items = useReduxStore((d) => d.series.seriesList);
return stateBuilder(items, action);
}
export function useSeries(order = true) {
const [rawSeries, action] = useRawSeries();
const series = useMemo<AsyncState<Item.Series[]>>(() => {
const state = rawSeries.data;
if (order) {
return {
...rawSeries,
data: buildOrderList(state),
};
} else {
return {
...rawSeries,
data: Object.values(state.items),
};
}
}, [rawSeries, order]);
return stateBuilder(series, action);
}
export function useSerieBy(id?: number) {
const [series, updateSerie] = useRawSeries();
const updateEpisodes = useReduxAction(episodeUpdateBySeriesId);
const serie = useMemo<AsyncState<Item.Series | null>>(() => {
const items = series.data.items;
let item: Item.Series | null = null;
if (id && !isNaN(id) && id in items) {
item = items[id];
}
return {
...series,
data: item,
};
}, [id, series]);
const update = useCallback(() => {
if (id && !isNaN(id)) {
updateSerie([id]);
updateEpisodes(id);
}
}, [id, updateSerie, updateEpisodes]);
return stateBuilder(serie, update);
}
export function useEpisodesBy(seriesId?: number) {
const action = useReduxAction(episodeUpdateBySeriesId);
const callback = useCallback(() => {
if (seriesId !== undefined && !isNaN(seriesId)) {
action(seriesId);
}
}, [action, seriesId]);
const list = useReduxStore((d) => d.series.episodeList);
const items = useMemo(() => {
if (seriesId !== undefined && !isNaN(seriesId)) {
return list.data[seriesId] ?? [];
} else {
return [];
}
}, [seriesId, list.data]);
const state: AsyncState<Item.Episode[]> = {
...list,
data: items,
};
return stateBuilder(state, callback);
}
export function useRawMovies() {
const action = useReduxAction(movieUpdateInfoAll);
const items = useReduxStore((d) => d.movie.movieList);
return stateBuilder(items, action);
}
export function useMovies(order = true) {
const [rawMovies, action] = useRawMovies();
const movies = useMemo<AsyncState<Item.Movie[]>>(() => {
const state = rawMovies.data;
if (order) {
return {
...rawMovies,
data: buildOrderList(state),
};
} else {
return {
...rawMovies,
data: Object.values(state.items),
};
}
}, [rawMovies, order]);
return stateBuilder(movies, action);
}
export function useMovieBy(id?: number) {
const [movies, updateMovies] = useRawMovies();
const movie = useMemo<AsyncState<Item.Movie | null>>(() => {
const items = movies.data.items;
let item: Item.Movie | null = null;
if (id && !isNaN(id) && id in items) {
item = items[id];
}
return {
...movies,
data: item,
};
}, [id, movies]);
const update = useCallback(() => {
if (id && !isNaN(id)) {
updateMovies([id]);
}
}, [id, updateMovies]);
return stateBuilder(movie, update);
}
export function useWantedSeries() {
const action = useReduxAction(seriesUpdateWantedBy);
const items = useReduxStore((d) => d.series.wantedEpisodesList);
return stateBuilder(items, action);
}
export function useWantedMovies() {
const action = useReduxAction(movieUpdateWantedBy);
const items = useReduxStore((d) => d.movie.wantedMovieList);
return stateBuilder(items, action);
}
export function useProviders() {
const action = useReduxAction(providerUpdateAll);
const items = useReduxStore((d) => d.system.providers);
return stateBuilder(items, action);
}
export function useBlacklistMovies() {
const action = useReduxAction(movieUpdateBlacklist);
const items = useReduxStore((d) => d.movie.blacklist);
return stateBuilder(items, action);
}
export function useBlacklistSeries() {
const action = useReduxAction(seriesUpdateBlacklist);
const items = useReduxStore((d) => d.series.blacklist);
return stateBuilder(items, action);
}
export function useMoviesHistory() {
const action = useReduxAction(movieUpdateHistoryList);
const items = useReduxStore((s) => s.movie.historyList);
return stateBuilder(items, action);
}
export function useSeriesHistory() {
const action = useReduxAction(seriesUpdateHistoryList);
const items = useReduxStore((s) => s.series.historyList);
return stateBuilder(items, action);
}

@ -0,0 +1,36 @@
import { useCallback } from "react";
import { useSystemSettings } from ".";
import { siteAddError, siteRemoveErrorByTimestamp } from "../actions";
import { useReduxAction, useReduxStore } from "./base";
export function useNotification(id: string, sec: number = 5) {
const add = useReduxAction(siteAddError);
const remove = useReduxAction(siteRemoveErrorByTimestamp);
return useCallback(
(msg: Omit<ReduxStore.Notification, "id" | "timestamp">) => {
const error: ReduxStore.Notification = {
...msg,
id,
timestamp: new Date(),
};
add(error);
setTimeout(() => remove(error.timestamp), sec * 1000);
},
[add, remove, sec, id]
);
}
export function useIsOffline() {
return useReduxStore((s) => s.site.offline);
}
export function useIsSonarrEnabled() {
const [settings] = useSystemSettings();
return settings.data?.general.use_sonarr ?? true;
}
export function useIsRadarrEnabled() {
const [settings] = useSystemSettings();
return settings.data?.general.use_radarr ?? true;
}

@ -0,0 +1,12 @@
import { combineReducers } from "redux";
import movie from "./movie";
import series from "./series";
import site from "./site";
import system from "./system";
export default combineReducers({
system,
series,
movie,
site,
});

@ -0,0 +1,112 @@
import { mergeArray } from "../../utilites";
import { AsyncAction } from "../types";
export function updateAsyncState<Payload>(
action: AsyncAction<Payload>,
defVal: Readonly<Payload>
): AsyncState<Payload> {
if (action.payload.loading) {
return {
updating: true,
data: defVal,
};
} else if (action.error !== undefined) {
return {
updating: false,
error: action.payload.item as Error,
data: defVal,
};
} else {
return {
updating: false,
error: undefined,
data: action.payload.item as Payload,
};
}
}
export function updateOrderIdState<T extends LooseObject>(
action: AsyncAction<AsyncDataWrapper<T>>,
state: AsyncState<OrderIdState<T>>,
id: ItemIdType<T>
): AsyncState<OrderIdState<T>> {
if (action.payload.loading) {
return {
...state,
updating: true,
};
} else if (action.error !== undefined) {
return {
...state,
updating: false,
error: action.payload.item as Error,
};
} else {
const { data, total } = action.payload.item as AsyncDataWrapper<T>;
const [start, length] = action.payload.parameters;
// Convert item list to object
const idState: IdState<T> = data.reduce<IdState<T>>((prev, curr) => {
const tid = curr[id];
prev[tid] = curr;
return prev;
}, {});
const dataOrder: number[] = data.map((v) => v[id]);
let newItems = { ...state.data.items, ...idState };
let newOrder = state.data.order;
const countDist = total - newOrder.length;
if (countDist > 0) {
newOrder.push(...Array(countDist).fill(null));
} else if (countDist < 0) {
// Completely drop old data if list has shrinked
newOrder = Array(total).fill(null);
newItems = { ...idState };
}
if (typeof start === "number" && typeof length === "number") {
newOrder.splice(start, length, ...dataOrder);
} else if (start === undefined) {
// Full Update
newOrder = dataOrder;
}
return {
updating: false,
data: {
items: newItems,
order: newOrder,
},
};
}
}
export function updateAsyncList<T, ID extends keyof T>(
action: AsyncAction<T[]>,
state: AsyncState<T[]>,
match: ID
): AsyncState<T[]> {
if (action.payload.loading) {
return {
...state,
updating: true,
};
} else if (action.error !== undefined) {
return {
...state,
updating: false,
error: action.payload.item as Error,
};
} else {
const list = state.data as T[];
const payload = action.payload.item as T[];
const result = mergeArray(list, payload, (l, r) => l[match] === r[match]);
return {
updating: false,
data: result,
};
}
}

@ -0,0 +1,86 @@
import { handleActions } from "redux-actions";
import {
MOVIES_UPDATE_BLACKLIST,
MOVIES_UPDATE_HISTORY_LIST,
MOVIES_UPDATE_INFO,
MOVIES_UPDATE_RANGE,
MOVIES_UPDATE_WANTED_LIST,
MOVIES_UPDATE_WANTED_RANGE,
} from "../constants";
import { AsyncAction } from "../types";
import { updateAsyncState, updateOrderIdState } from "./mapper";
const reducer = handleActions<ReduxStore.Movie, any>(
{
[MOVIES_UPDATE_WANTED_LIST]: (
state,
action: AsyncAction<AsyncDataWrapper<Wanted.Movie>>
) => {
return {
...state,
wantedMovieList: updateOrderIdState(
action,
state.wantedMovieList,
"radarrId"
),
};
},
[MOVIES_UPDATE_WANTED_RANGE]: (
state,
action: AsyncAction<AsyncDataWrapper<Wanted.Movie>>
) => {
return {
...state,
wantedMovieList: updateOrderIdState(
action,
state.wantedMovieList,
"radarrId"
),
};
},
[MOVIES_UPDATE_HISTORY_LIST]: (
state,
action: AsyncAction<History.Movie[]>
) => {
return {
...state,
historyList: updateAsyncState(action, state.historyList.data),
};
},
[MOVIES_UPDATE_INFO]: (
state,
action: AsyncAction<AsyncDataWrapper<Item.Movie>>
) => {
return {
...state,
movieList: updateOrderIdState(action, state.movieList, "radarrId"),
};
},
[MOVIES_UPDATE_RANGE]: (
state,
action: AsyncAction<AsyncDataWrapper<Item.Movie>>
) => {
return {
...state,
movieList: updateOrderIdState(action, state.movieList, "radarrId"),
};
},
[MOVIES_UPDATE_BLACKLIST]: (
state,
action: AsyncAction<Blacklist.Movie[]>
) => {
return {
...state,
blacklist: updateAsyncState(action, state.blacklist.data),
};
},
},
{
movieList: { updating: true, data: { items: {}, order: [] } },
wantedMovieList: { updating: true, data: { items: {}, order: [] } },
historyList: { updating: true, data: [] },
blacklist: { updating: true, data: [] },
}
);
export default reducer;

@ -0,0 +1,118 @@
import { handleActions } from "redux-actions";
import {
SERIES_UPDATE_BLACKLIST,
SERIES_UPDATE_EPISODE_LIST,
SERIES_UPDATE_HISTORY_LIST,
SERIES_UPDATE_INFO,
SERIES_UPDATE_RANGE,
SERIES_UPDATE_WANTED_LIST,
SERIES_UPDATE_WANTED_RANGE,
} from "../constants";
import { AsyncAction } from "../types";
import { updateAsyncState, updateOrderIdState } from "./mapper";
const reducer = handleActions<ReduxStore.Series, any>(
{
[SERIES_UPDATE_WANTED_LIST]: (
state,
action: AsyncAction<AsyncDataWrapper<Wanted.Episode>>
) => {
return {
...state,
wantedEpisodesList: updateOrderIdState(
action,
state.wantedEpisodesList,
"sonarrEpisodeId"
),
};
},
[SERIES_UPDATE_WANTED_RANGE]: (
state,
action: AsyncAction<AsyncDataWrapper<Wanted.Episode>>
) => {
return {
...state,
wantedEpisodesList: updateOrderIdState(
action,
state.wantedEpisodesList,
"sonarrEpisodeId"
),
};
},
[SERIES_UPDATE_EPISODE_LIST]: (
state,
action: AsyncAction<Item.Episode[]>
) => {
const { updating, error, data: items } = updateAsyncState(action, []);
const stateItems = { ...state.episodeList.data };
if (items.length > 0) {
const id = items[0].sonarrSeriesId;
stateItems[id] = items;
}
return {
...state,
episodeList: {
updating,
error,
data: stateItems,
},
};
},
[SERIES_UPDATE_HISTORY_LIST]: (
state,
action: AsyncAction<History.Episode[]>
) => {
return {
...state,
historyList: updateAsyncState(action, state.historyList.data),
};
},
[SERIES_UPDATE_INFO]: (
state,
action: AsyncAction<AsyncDataWrapper<Item.Series>>
) => {
return {
...state,
seriesList: updateOrderIdState(
action,
state.seriesList,
"sonarrSeriesId"
),
};
},
[SERIES_UPDATE_RANGE]: (
state,
action: AsyncAction<AsyncDataWrapper<Item.Series>>
) => {
return {
...state,
seriesList: updateOrderIdState(
action,
state.seriesList,
"sonarrSeriesId"
),
};
},
[SERIES_UPDATE_BLACKLIST]: (
state,
action: AsyncAction<Blacklist.Episode[]>
) => {
return {
...state,
blacklist: updateAsyncState(action, state.blacklist.data),
};
},
},
{
seriesList: { updating: true, data: { items: {}, order: [] } },
wantedEpisodesList: { updating: true, data: { items: {}, order: [] } },
episodeList: { updating: true, data: {} },
historyList: { updating: true, data: [] },
blacklist: { updating: true, data: [] },
}
);
export default reducer;

@ -0,0 +1,109 @@
import { Action, handleActions } from "redux-actions";
import { storage } from "../../@storage/local";
import {
SITE_AUTH_SUCCESS,
SITE_BADGE_UPDATE,
SITE_INITIALIZED,
SITE_INITIALIZE_FAILED,
SITE_NEED_AUTH,
SITE_NOTIFICATIONS_ADD,
SITE_NOTIFICATIONS_REMOVE,
SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP,
SITE_OFFLINE_UPDATE,
SITE_SAVE_LOCALSTORAGE,
SITE_SIDEBAR_UPDATE,
} from "../constants";
import { AsyncAction } from "../types";
function updateLocalStorage(): Partial<ReduxStore.Site> {
return {
pageSize: storage.pageSize,
};
}
const reducer = handleActions<ReduxStore.Site, any>(
{
[SITE_NEED_AUTH]: (state) => ({
...state,
auth: false,
}),
[SITE_AUTH_SUCCESS]: (state) => ({
...state,
auth: true,
}),
[SITE_INITIALIZED]: (state) => ({
...state,
initialized: true,
}),
[SITE_INITIALIZE_FAILED]: (state) => ({
...state,
initialized: "An Error Occurred When Initializing Bazarr UI",
}),
[SITE_SAVE_LOCALSTORAGE]: (state, action: Action<LooseObject>) => {
const settings = action.payload;
for (const key in settings) {
const value = settings[key];
localStorage.setItem(key, value);
}
return {
...state,
...updateLocalStorage(),
};
},
[SITE_NOTIFICATIONS_ADD]: (
state,
action: Action<ReduxStore.Notification>
) => {
const alerts = [
...state.notifications.filter((v) => v.id !== action.payload.id),
action.payload,
];
return { ...state, notifications: alerts };
},
[SITE_NOTIFICATIONS_REMOVE]: (state, action: Action<string>) => {
const alerts = state.notifications.filter((v) => v.id !== action.payload);
return { ...state, notifications: alerts };
},
[SITE_NOTIFICATIONS_REMOVE_BY_TIMESTAMP]: (state, action: Action<Date>) => {
const alerts = state.notifications.filter(
(v) => v.timestamp !== action.payload
);
return { ...state, notifications: alerts };
},
[SITE_SIDEBAR_UPDATE]: (state, action: Action<string>) => {
return {
...state,
sidebar: action.payload,
};
},
[SITE_BADGE_UPDATE]: {
next: (state, action: AsyncAction<Badge>) => {
const badges = action.payload.item;
if (badges && action.error !== true) {
return { ...state, badges: badges as Badge };
}
return state;
},
throw: (state) => state,
},
[SITE_OFFLINE_UPDATE]: (state, action: Action<boolean>) => {
return { ...state, offline: action.payload };
},
},
{
initialized: false,
auth: true,
pageSize: 50,
notifications: [],
sidebar: "",
badges: {
movies: 0,
episodes: 0,
providers: 0,
},
offline: false,
...updateLocalStorage(),
}
);
export default reducer;

@ -0,0 +1,130 @@
import { Action, handleActions } from "redux-actions";
import {
PROVIDER_UPDATE_LIST,
SYSTEM_RUN_TASK,
SYSTEM_UPDATE_LANGUAGES_LIST,
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
SYSTEM_UPDATE_LOGS,
SYSTEM_UPDATE_RELEASES,
SYSTEM_UPDATE_SETTINGS,
SYSTEM_UPDATE_STATUS,
SYSTEM_UPDATE_TASKS,
} from "../constants";
import { updateAsyncState } from "./mapper";
const reducer = handleActions<ReduxStore.System, any>(
{
[SYSTEM_UPDATE_LANGUAGES_LIST]: (state, action) => {
const languages = updateAsyncState<Array<ApiLanguage>>(action, []);
const enabledLanguage: AsyncState<ApiLanguage[]> = {
...languages,
data: languages.data.filter((v) => v.enabled),
};
const newState = {
...state,
languages,
enabledLanguage,
};
return newState;
},
[SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST]: (state, action) => {
const newState = {
...state,
languagesProfiles: updateAsyncState<Array<Profile.Languages>>(
action,
[]
),
};
return newState;
},
[SYSTEM_UPDATE_STATUS]: (state, action) => {
return {
...state,
status: updateAsyncState<System.Status | undefined>(
action,
state.status.data
),
};
},
[SYSTEM_UPDATE_TASKS]: (state, action) => {
return {
...state,
tasks: updateAsyncState<Array<System.Task>>(action, state.tasks.data),
};
},
[SYSTEM_RUN_TASK]: (state, action: Action<string>) => {
const id = action.payload;
const tasks = state.tasks;
const newItems = [...tasks.data];
const idx = newItems.findIndex((v) => v.job_id === id);
if (idx !== -1) {
newItems[idx].job_running = true;
}
return {
...state,
tasks: {
...tasks,
data: newItems,
},
};
},
[PROVIDER_UPDATE_LIST]: (state, action) => {
return {
...state,
providers: updateAsyncState(action, state.providers.data),
};
},
[SYSTEM_UPDATE_LOGS]: (state, action) => {
return {
...state,
logs: updateAsyncState(action, state.logs.data),
};
},
[SYSTEM_UPDATE_RELEASES]: (state, action) => {
return {
...state,
releases: updateAsyncState(action, state.releases.data),
};
},
[SYSTEM_UPDATE_SETTINGS]: (state, action) => {
return {
...state,
settings: updateAsyncState(action, state.settings.data),
};
},
},
{
languages: { updating: true, data: [] },
enabledLanguage: { updating: true, data: [] },
languagesProfiles: { updating: true, data: [] },
status: {
updating: true,
data: undefined,
},
tasks: {
updating: true,
data: [],
},
providers: {
updating: true,
data: [],
},
logs: {
updating: true,
data: [],
},
releases: {
updating: true,
data: [],
},
settings: {
updating: true,
data: undefined,
},
}
);
export default reducer;

@ -0,0 +1,62 @@
interface IdState<T> {
[key: number]: Readonly<T>;
}
interface OrderIdState<T> {
items: IdState<T>;
order: (number | null)[];
}
interface ReduxStore {
system: ReduxStore.System;
series: ReduxStore.Series;
movie: ReduxStore.Movie;
site: ReduxStore.Site;
}
namespace ReduxStore {
interface Notification {
type: "error" | "warning" | "info";
message: string;
timestamp: Date;
id: string;
}
interface Site {
// Initialization state or error message
initialized: boolean | string;
auth: boolean;
pageSize: number;
notifications: Notification[];
sidebar: string;
badges: Badge;
offline: boolean;
}
interface System {
languages: AsyncState<Array<Language>>;
enabledLanguage: AsyncState<Array<Language>>;
languagesProfiles: AsyncState<Array<Profile.Languages>>;
status: AsyncState<System.Status | undefined>;
tasks: AsyncState<Array<System.Task>>;
providers: AsyncState<Array<System.Provider>>;
logs: AsyncState<Array<System.Log>>;
releases: AsyncState<Array<ReleaseInfo>>;
settings: AsyncState<Settings | undefined>;
}
interface Series {
seriesList: AsyncState<OrderIdState<Item.Series>>;
wantedEpisodesList: AsyncState<OrderIdState<Wanted.Episode>>;
episodeList: AsyncState<IdState<Item.Episode[]>>;
historyList: AsyncState<Array<History.Episode>>;
blacklist: AsyncState<Array<Blacklist.Episode>>;
}
interface Movie {
movieList: AsyncState<OrderIdState<Item.Movie>>;
wantedMovieList: AsyncState<OrderIdState<Wanted.Movie>>;
historyList: AsyncState<Array<History.Movie>>;
blacklist: AsyncState<Array<Blacklist.Movie>>;
}
}

@ -0,0 +1,17 @@
import { applyMiddleware, createStore } from "redux";
import logger from "redux-logger";
import promise from "redux-promise";
import trunk from "redux-thunk";
import rootReducer from "../reducers";
const plugins = [promise, trunk];
if (
process.env.NODE_ENV === "development" &&
process.env["REACT_APP_LOG_REDUX_EVENT"] !== "false"
) {
plugins.push(logger);
}
const store = createStore(rootReducer, applyMiddleware(...plugins));
export default store;

@ -0,0 +1,22 @@
import { Dispatch } from "redux";
import { Action } from "redux-actions";
interface AsyncPayload<Payload> {
loading: boolean;
item?: Payload | Error;
parameters: any[];
}
type AvailableType<T> = Action<T> | ActionDispatcher<T>;
type AsyncAction<Payload> = Action<AsyncPayload<Payload>>;
type ActionDispatcher<T = any> = (dispatch: Dispatch<Action<T>>) => void;
type AsyncActionDispatcher<T> = (
dispatch: Dispatch<AsyncAction<T>>
) => Promise<void>;
type PromiseCreator = (...args: any[]) => Promise<any>;
type AvailableCreator = (...args: any[]) => AvailableType<any>[];
type AsyncActionCreator = (...args: any[]) => AsyncActionDispatcher<any>[];
type ActionCallback = () => Action<any> | void;

@ -0,0 +1,21 @@
// 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;
}

@ -0,0 +1,45 @@
@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;
}
}
.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;
}

@ -0,0 +1,55 @@
@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;
}
}

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

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

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,10 @@
export const uiPageSizeKey = "storage-ui-pageSize";
export const storage: LocalStorageType = {
get pageSize(): number {
return parseInt(localStorage.getItem(uiPageSizeKey) ?? "50");
},
set pageSize(v: number) {
localStorage.setItem(uiPageSizeKey, v.toString());
},
};

@ -0,0 +1,258 @@
type LanguageCodeType = string;
interface Badge {
episodes: number;
movies: number;
providers: number;
}
interface ApiLanguage {
code2: LanguageCodeType;
name: string;
hi?: boolean;
forced?: boolean;
enabled: boolean;
}
type Language = Omit<ApiLanguage, "enabled">;
namespace Profile {
interface Item {
id: number;
audio_exclude: PythonBoolean;
forced: PythonBoolean;
hi: PythonBoolean;
language: LanguageCodeType;
}
interface Languages {
name: string;
profileId: number;
cutoff: number | null;
items: Item[];
}
}
interface Subtitle extends Language {
forced: boolean;
hi: boolean;
path: string | null;
}
interface PathType {
path: string;
exist: boolean;
}
interface SubtitlePathType {
subtitles_path: string;
}
interface MonitoredType {
monitored: boolean;
}
interface SubtitleType {
subtitles: Subtitle[];
}
interface MissingSubtitleType {
missing_subtitles: Subtitle[];
}
interface SceneNameType {
sceneName?: string;
}
interface TagType {
tags: string[];
}
interface SeriesIdType {
sonarrSeriesId: number;
}
type EpisodeIdType = SeriesIdType & {
sonarrEpisodeId: number;
};
interface EpisodeTitleType {
seriesTitle: string;
episodeTitle: string;
}
interface MovieIdType {
radarrId: number;
}
interface TitleType {
title: string;
}
interface AudioLanguageType {
audio_language: Language[];
}
interface ItemHistoryType {
language: Language;
provider: string;
}
namespace Item {
type Base = PathType &
TitleType &
TagType &
AudioLanguageType & {
profileId: number | null;
fanart: string;
overview: string;
imdbId: string;
alternativeTitles: string[];
poster: string;
year: string;
};
type Series = Base &
SeriesIdType & {
hearing_impaired: boolean;
episodeFileCount: number;
episodeMissingCount: number;
seriesType: SonarrSeriesType;
tvdbId: number;
};
type Movie = Base &
MovieIdType &
MonitoredType &
SubtitleType &
MissingSubtitleType &
SceneNameType & {
hearing_impaired: boolean;
audio_codec: string;
// movie_file_id: number;
tmdbId: number;
};
type Episode = PathType &
TitleType &
MonitoredType &
EpisodeIdType &
SubtitleType &
MissingSubtitleType &
SceneNameType &
AudioLanguageType & {
audio_codec: string;
video_codec: string;
season: number;
episode: number;
resolution: string;
format: string;
// episode_file_id: number;
};
}
namespace Wanted {
type Base = MonitoredType &
TagType &
SceneNameType & {
// failedAttempts?: any;
hearing_impaired: boolean;
missing_subtitles: Subtitle[];
};
type Episode = Base &
EpisodeIdType &
EpisodeTitleType & {
episode_number: string;
seriesType: SonarrSeriesType;
};
type Movie = Base & MovieIdType & TitleType;
}
namespace Blacklist {
type Base = ItemHistoryType & {
timestamp: string;
subs_id: string;
};
type Movie = Base & MovieIdType & TitleType;
type Episode = Base &
EpisodeTitleType &
SeriesIdType & {
episode_number: string;
};
}
namespace History {
type Base = SubtitlePathType &
TagType &
MonitoredType &
Partial<ItemHistoryType> & {
action: number;
blacklisted: boolean;
score?: string;
subs_id?: string;
raw_timestamp: int;
timestamp: string;
description: string;
upgradable: boolean;
};
type Movie = History.Base & MovieIdType & TitleType;
type Episode = History.Base &
EpisodeIdType &
EpisodeTitleType & {
episode_number: string;
};
type StatItem = {
count: number;
date: string;
};
type Stat = {
movies: StatItem[];
series: StatItem[];
};
type TimeframeOptions = "week" | "month" | "trimester" | "year";
type ActionOptions = 0 | 1 | 2;
}
interface SearchResultType {
matches: string[];
dont_matches: string[];
language: string;
forced: PythonBoolean;
hearing_impaired: PythonBoolean;
orig_score: number;
provider: string;
release_info: string[];
score: number;
score_without_hash: number;
subtitle: any;
uploader?: string;
url?: string;
}
interface ReleaseInfo {
current: boolean;
date: string;
name: string;
prerelease: boolean;
body: string[];
}
interface SubtitleInfo {
filename: string;
episode: number;
season: number;
}
type ItemSearchResult = Partial<SeriesIdType> &
Partial<MovieIdType> & {
title: string;
year: string;
};

@ -0,0 +1,34 @@
// Sonarr
type SonarrSeriesType = "Standard" | "Daily" | "Anime";
type PythonBoolean = "True" | "False";
type FileTree = {
children: boolean;
path: string;
name: string;
};
type StorageType = string | null;
interface AsyncState<T> {
updating: boolean;
error?: Error;
data: Readonly<T>;
}
type AsyncPayload<T> = T extends AsyncState<infer D> ? D : never;
type SelectorOption<PAYLOAD> = {
label: string;
value: PAYLOAD;
};
type SelectorValueType<T, M extends boolean> = M extends true
? ReadonlyArray<T>
: Nullable<T>;
type SimpleStateType<T> = [
T,
((item: T) => void) | ((fn: (item: T) => T) => void)
];

@ -0,0 +1,76 @@
namespace FormType {
interface ModifyItem {
id: number[];
profileid: (number | null)[];
}
type SeriesAction = OneSerieAction | SearchWantedAction;
type MoviesAction = OneMovieAction | SearchWantedAction;
interface OneMovieAction {
action: "search-missing" | "scan-disk";
radarrid: number;
}
interface OneSerieAction {
action: "search-missing" | "scan-disk";
seriesid: number;
}
interface SearchWantedAction {
action: "search-wanted";
}
interface Subtitle {
language: string;
hi: boolean;
forced: boolean;
}
interface UploadSubtitle extends Subtitle {
file: File;
}
interface DeleteSubtitle extends Subtitle {
path: string;
}
interface ModifySubtitle {
id: number;
type: "episode" | "movie";
language: string;
path: string;
}
interface DownloadSeries {
episodePath: string;
sceneName?: string;
language: string;
hi: boolean;
forced: boolean;
sonarrSeriesId: number;
sonarrEpisodeId: number;
title: string;
}
interface AddBlacklist {
provider: string;
subs_id: string;
language: LanguageCodeType;
subtitles_path: string;
}
interface DeleteBlacklist {
provider: string;
subs_id: string;
}
interface ManualDownload {
language: string;
hi: PythonBoolean;
forced: PythonBoolean;
provider: string;
subtitle: any;
}
}

@ -0,0 +1,149 @@
import {
UseColumnOrderInstanceProps,
UseColumnOrderState,
UseExpandedHooks,
UseExpandedInstanceProps,
UseExpandedOptions,
UseExpandedRowProps,
UseExpandedState,
UseFiltersColumnOptions,
UseFiltersColumnProps,
UseGroupByCellProps,
UseGroupByColumnOptions,
UseGroupByColumnProps,
UseGroupByHooks,
UseGroupByInstanceProps,
UseGroupByOptions,
UseGroupByRowProps,
UseGroupByState,
UsePaginationInstanceProps,
UsePaginationOptions,
UsePaginationState,
UseRowSelectHooks,
UseRowSelectInstanceProps,
UseRowSelectOptions,
UseRowSelectRowProps,
UseRowSelectState,
UseSortByColumnOptions,
UseSortByColumnProps,
UseSortByHooks,
UseSortByInstanceProps,
UseSortByOptions,
UseSortByState,
} from "react-table";
import {} from "../components/tables/plugins";
import { PageControlAction } from "../components/tables/types";
declare module "react-table" {
// take this file as-is, or comment out the sections that don't apply to your plugin configuration
// Customize of React Table
type TableUpdater<D extends object> = (row: Row<D>, ...others: any[]) => void;
interface useAsyncPaginationProps<D extends Record<string, unknown>> {
asyncLoader?: (start: number, length: number) => void;
asyncState?: AsyncState<OrderIdState<D>>;
asyncId?: (item: D) => number;
}
interface useAsyncPaginationState<D extends Record<string, unknown>> {
pageToLoad?: PageControlAction;
needLoadingScreen?: boolean;
}
interface useSelectionProps<D extends Record<string, unknown>> {
isSelecting?: boolean;
onSelect?: (items: D[]) => void;
}
interface useSelectionState<D extends Record<string, unknown>> {}
interface CustomTableProps<D extends Record<string, unknown>>
extends useSelectionProps<D>,
useAsyncPaginationProps<D> {
externalUpdate?: TableUpdater<D>;
loose?: any[];
}
interface CustomTableState<D extends Record<string, unknown>>
extends useSelectionState<D>,
useAsyncPaginationState<D> {}
export interface TableOptions<
D extends Record<string, unknown>
> extends UseExpandedOptions<D>,
// UseFiltersOptions<D>,
// UseGlobalFiltersOptions<D>,
UseGroupByOptions<D>,
UsePaginationOptions<D>,
// UseResizeColumnsOptions<D>,
UseRowSelectOptions<D>,
// UseRowStateOptions<D>,
UseSortByOptions<D>,
CustomTableProps<D> {
data: readonly D[];
}
export interface Hooks<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseExpandedHooks<D>,
UseGroupByHooks<D>,
UseRowSelectHooks<D>,
UseSortByHooks<D> {}
export interface TableInstance<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseColumnOrderInstanceProps<D>,
UseExpandedInstanceProps<D>,
// UseFiltersInstanceProps<D>,
// UseGlobalFiltersInstanceProps<D>,
UseGroupByInstanceProps<D>,
UsePaginationInstanceProps<D>,
UseRowSelectInstanceProps<D>,
// UseRowStateInstanceProps<D>,
UseSortByInstanceProps<D>,
CustomTableProps<D> {}
export interface TableState<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseColumnOrderState<D>,
UseExpandedState<D>,
// UseFiltersState<D>,
// UseGlobalFiltersState<D>,
UseGroupByState<D>,
UsePaginationState<D>,
// UseResizeColumnsState<D>,
UseRowSelectState<D>,
// UseRowStateState<D>,
UseSortByState<D>,
CustomTableState<D> {}
export interface ColumnInterface<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseFiltersColumnOptions<D>,
// UseGlobalFiltersColumnOptions<D>,
UseGroupByColumnOptions<D>,
// UseResizeColumnsColumnOptions<D>,
UseSortByColumnOptions<D> {
selectHide?: boolean;
className?: string;
}
export interface ColumnInstance<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseFiltersColumnProps<D>,
UseGroupByColumnProps<D>,
// UseResizeColumnsColumnProps<D>,
UseSortByColumnProps<D> {}
export interface Cell<
D extends Record<string, unknown> = Record<string, unknown>,
V = any
> extends UseGroupByCellProps<D> {}
export interface Row<
D extends Record<string, unknown> = Record<string, unknown>
> extends UseExpandedRowProps<D>,
UseGroupByRowProps<D>,
UseRowSelectRowProps<D> {}
}

@ -0,0 +1,201 @@
interface Settings {
general: Settings.General;
proxy: Settings.Proxy;
auth: Settings.Auth;
subsync: Settings.Subsync;
analytics: Settings.Analytic;
sonarr: Settings.Sonarr;
radarr: Settings.Radarr;
// Anitcaptcha
anticaptcha: Settings.Anticaptcha;
deathbycaptcha: Settings.DeathByCaptche;
// Providers
opensubtitles: Settings.OpenSubtitles;
opensubtitlescom: Settings.OpenSubtitlesCom;
addic7ed: Settings.Addic7ed;
legendasdivx: Settings.Legandasdivx;
legendastv: Settings.Legendastv;
xsubs: Settings.XSubs;
assrt: Settings.Assrt;
napisy24: Settings.Napisy24;
subscene: Settings.Subscene;
betaseries: Settings.Betaseries;
titlovi: Settings.titlovi;
notifications: Settings.Notifications;
}
namespace Settings {
interface General {
adaptive_searching: boolean;
anti_captcha_provider?: string;
auto_update: boolean;
base_url?: string;
branch: string;
chmod?: string;
chmod_enabled: boolean;
days_to_upgrade_subs: number;
debug: boolean;
dont_notify_manual_actions: boolean;
embedded_subs_show_desired: boolean;
enabled_providers: string[];
ignore_pgs_subs: boolean;
ignore_vobsub_subs: boolean;
ip: string;
multithreading: boolean;
minimum_score: number;
minimum_score_movie: number;
movie_default_enabled: boolean;
movie_default_profile?: number;
serie_default_enabled: boolean;
serie_default_profile?: number;
path_mappings: [string, string][];
path_mappings_movie: [string, string][];
port: number;
upgrade_subs: boolean;
postprocessing_cmd?: string;
postprocessing_threshold: number;
postprocessing_threshold_movie: number;
single_language: boolean;
subfolder: string;
subfolder_custom?: string;
subzero_mods?: string[];
subzero_color_selection?: string;
update_restart: boolean;
upgrade_frequency: number;
upgrade_manual: boolean;
use_embedded_subs: boolean;
use_postprocessing: boolean;
use_postprocessing_threshold: boolean;
use_postprocessing_threshold_movie: boolean;
use_radarr: boolean;
use_scenename: boolean;
use_sonarr: boolean;
utf8_encode: boolean;
wanted_search_frequency: number;
wanted_search_frequency_movie: number;
}
interface Proxy {
exclude: string[];
type?: string;
url?: string;
port?: number;
username?: string;
password?: string;
}
interface Auth {
type?: string;
username?: string;
password?: string;
apikey: string;
}
interface Subsync {
use_subsync: boolean;
use_subsync_threshold: boolean;
subsync_threshold: number;
use_subsync_movie_threshold: boolean;
subsync_movie_threshold: number;
debug: boolean;
}
interface Analytic {
enabled: boolean;
}
interface Notifications {
providers: NotificationInfo[];
}
interface NotificationInfo {
enabled: boolean;
name: string;
url: string | null;
}
// Sonarr / Radarr
type FullUpdateOptions = "Manually" | "Daily" | "Weekly";
interface Sonarr {
ip: string;
port: number;
base_url?: string;
ssl: boolean;
apikey?: string;
full_update: FullUpdateOptions;
full_update_day: number;
full_update_hour: number;
only_monitored: boolean;
series_sync: number;
episodes_sync: number;
excluded_tags: string[];
excluded_series_types: SonarrSeriesType[];
}
interface Radarr {
ip: string;
port: number;
base_url?: string;
ssl: boolean;
apikey?: string;
full_update: FullUpdateOptions;
full_update_day: number;
full_update_hour: number;
only_monitored: boolean;
movies_sync: number;
excluded_tags: string[];
}
interface Anticaptcha {
anti_captcha_key?: string;
}
interface DeathByCaptche {
username?: string;
password?: string;
}
// Providers
interface BaseProvider {
username?: string;
password?: string;
}
interface OpenSubtitles extends BaseProvider {
use_tag_search: boolean;
vip: boolean;
ssl: boolean;
timeout: number;
skip_wrong_fps: boolean;
}
interface OpenSubtitlesCom extends BaseProvider {
use_hash: boolean;
}
interface Addic7ed extends BaseProvider {}
interface Legandasdivx extends BaseProvider {
skip_wrong_fps: boolean;
}
interface Legendastv extends BaseProvider {}
interface XSubs extends BaseProvider {}
interface Napisy24 extends BaseProvider {}
interface Subscene extends BaseProvider {}
interface Titlovi extends BaseProvider {}
interface Betaseries {
token?: string;
}
interface Assrt {
token?: string;
}
}

@ -0,0 +1,3 @@
interface LocalStorageType {
pageSize: number;
}

@ -0,0 +1,35 @@
namespace System {
interface Task {
interval: string;
job_id: string;
job_running: boolean;
name: string;
next_run_in: string;
next_run_time: string;
}
interface Status {
bazarr_config_directory: string;
bazarr_directory: string;
bazarr_version: string;
operating_system: string;
python_version: string;
radarr_version: string;
sonarr_version: string;
}
interface Provider {
name: string;
status: string;
retry: string;
}
type LogType = "INFO" | "WARNING" | "ERROR" | "DEBUG";
interface Log {
type: System.LogType;
timestamp: string;
message: string;
exception?: string;
}
}

@ -0,0 +1,39 @@
type ValueOf<D> = D[keyof D];
type Unpacked<D> = D extends any[] | readonly any[] ? D[number] : D;
type Nullable<D> = D | null;
type LooseObject = {
[key: string]: any;
};
type StrictObject<T> = {
[key: string]: T;
};
type Pair<T = string> = {
key: string;
value: T;
};
interface DataWrapper<T> {
data: T;
}
interface AsyncDataWrapper<T> {
data: T[];
total: number;
}
type PromiseType<T> = T extends Promise<infer D> ? D : never;
type Override<T, U> = T & Omit<U, keyof T>;
type Comparer<T> = (lhs: T, rhs: T) => boolean;
type KeysOfType<D, T> = NonNullable<
ValueOf<{ [P in keyof D]: D[P] extends T ? P : never }>
>;
type ItemIdType<T> = KeysOfType<T, number>;

@ -0,0 +1,12 @@
declare global {
interface Window {
Bazarr: BazarrServer;
}
}
export interface BazarrServer {
baseUrl: string;
apiKey: string;
canUpdate: boolean;
hasUpdate: boolean;
}

@ -0,0 +1,153 @@
import {
faBars,
faHeart,
faNetworkWired,
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, {
FunctionComponent,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import {
Button,
Col,
Container,
Dropdown,
Image,
Navbar,
Row,
} from "react-bootstrap";
import { SidebarToggleContext } from ".";
import { siteRedirectToAuth } from "../@redux/actions";
import { useSystemSettings } from "../@redux/hooks";
import { useReduxAction } from "../@redux/hooks/base";
import { useIsOffline } from "../@redux/hooks/site";
import logo from "../@static/logo64.png";
import { SystemApi } from "../apis";
import { ActionButton, SearchBar, SearchResult } from "../components";
import { useBaseUrl } from "../utilites";
import "./header.scss";
async function SearchItem(text: string) {
const results = await SystemApi.search(text);
return results.map<SearchResult>((v) => {
let link: string;
if (v.sonarrSeriesId) {
link = `/series/${v.sonarrSeriesId}`;
} else if (v.radarrId) {
link = `/movies/${v.radarrId}`;
} else {
link = "";
}
return {
name: `${v.title} (${v.year})`,
link,
};
});
}
interface Props {}
const Header: FunctionComponent<Props> = () => {
const setNeedAuth = useReduxAction(siteRedirectToAuth);
const [settings] = useSystemSettings();
const canLogout = (settings.data?.auth.type ?? "none") !== "none";
const toggleSidebar = useContext(SidebarToggleContext);
const offline = useIsOffline();
const dropdown = useMemo(
() => (
<Dropdown alignRight>
<Dropdown.Toggle className="dropdown-hidden" as={Button}>
<FontAwesomeIcon icon={faUser}></FontAwesomeIcon>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
onClick={() => {
SystemApi.restart();
}}
>
Restart
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
SystemApi.shutdown();
}}
>
Shutdown
</Dropdown.Item>
<Dropdown.Divider hidden={!canLogout}></Dropdown.Divider>
<Dropdown.Item
hidden={!canLogout}
onClick={() => {
SystemApi.logout().then(() => setNeedAuth());
}}
>
Logout
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
),
[canLogout, setNeedAuth]
);
const [reconnecting, setReconnect] = useState(false);
const reconnect = useCallback(() => {
setReconnect(true);
SystemApi.status().finally(() => setReconnect(false));
}, []);
const baseUrl = useBaseUrl();
return (
<Navbar bg="primary" className="flex-grow-1 px-0">
<div className="header-icon px-3 m-0 d-none d-md-block">
<Navbar.Brand href={baseUrl} className="">
<Image alt="brand" src={logo} width="32" height="32"></Image>
</Navbar.Brand>
</div>
<Button className="mx-2 m-0 d-md-none" onClick={toggleSidebar}>
<FontAwesomeIcon icon={faBars}></FontAwesomeIcon>
</Button>
<Container fluid>
<Row noGutters className="flex-grow-1">
<Col xs={6} sm={4} className="d-flex align-items-center">
<SearchBar onSearch={SearchItem}></SearchBar>
</Col>
<Col className="d-flex flex-row align-items-center justify-content-end pr-2">
<Button
href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=XHHRWXT9YB7WE&source=url"
target="_blank"
>
<FontAwesomeIcon icon={faHeart}></FontAwesomeIcon>
</Button>
{offline ? (
<ActionButton
loading={reconnecting}
className="ml-2"
variant="warning"
icon={faNetworkWired}
onClick={reconnect}
>
Reconnect
</ActionButton>
) : (
dropdown
)}
</Col>
</Row>
</Container>
</Navbar>
);
};
export default Header;

@ -0,0 +1,75 @@
import React, { FunctionComponent, useEffect, useMemo } from "react";
import { Redirect, Route, Switch, useHistory } from "react-router-dom";
import EmptyPage, { RouterEmptyPath } from "../404";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
import BlacklistRouter from "../Blacklist/Router";
import HistoryRouter from "../History/Router";
import MovieRouter from "../Movies/Router";
import SeriesRouter from "../Series/Router";
import SettingRouter from "../Settings/Router";
import SystemRouter from "../System/Router";
import { ScrollToTop } from "../utilites";
import WantedRouter from "../Wanted/Router";
const Router: FunctionComponent<{ className?: string }> = ({ className }) => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
const redirectPath = useMemo(() => {
if (sonarr) {
return "/series";
} else if (radarr) {
return "/movies";
} else {
return "/settings";
}
}, [sonarr, radarr]);
const history = useHistory();
useEffect(() => {
ScrollToTop();
}, [history.location]);
return (
<div className={className}>
<Switch>
<Route exact path="/">
<Redirect exact to={redirectPath}></Redirect>
</Route>
{sonarr && (
<Route path="/series">
<SeriesRouter></SeriesRouter>
</Route>
)}
{radarr && (
<Route path="/movies">
<MovieRouter></MovieRouter>
</Route>
)}
<Route path="/wanted">
<WantedRouter></WantedRouter>
</Route>
<Route path="/history">
<HistoryRouter></HistoryRouter>
</Route>
<Route path="/blacklist">
<BlacklistRouter></BlacklistRouter>
</Route>
<Route path="/settings">
<SettingRouter></SettingRouter>
</Route>
<Route path="/system">
<SystemRouter></SystemRouter>
</Route>
<Route exact path={RouterEmptyPath}>
<EmptyPage></EmptyPage>
</Route>
<Route path="*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
</div>
);
};
export default Router;

@ -0,0 +1,30 @@
@import "../@scss/variable.scss";
.header-container {
height: $header-height;
input {
&[type="text"] {
// Fake Material Design Style
padding: 0;
transition: none;
color: white;
border-radius: 0;
border: none;
border-bottom: {
color: white !important;
width: 1px !important;
style: solid !important;
}
background-color: transparent;
&::placeholder {
color: lightgray;
}
&:focus {
box-shadow: none;
}
}
}
}

@ -0,0 +1,108 @@
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, {
FunctionComponent,
useCallback,
useEffect,
useState,
} from "react";
import { Alert, Button, Container, Row } from "react-bootstrap";
import { Redirect } from "react-router-dom";
import { bootstrap as ReduxBootstrap } from "../@redux/actions";
import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
import { useNotification } from "../@redux/hooks/site";
import { LoadingIndicator, ModalProvider } from "../components";
import Sidebar from "../Sidebar";
import { Reload, useHasUpdateInject } from "../utilites";
import Header from "./Header";
import NotificationContainer from "./notifications";
import Router from "./Router";
// Sidebar Toggle
export const SidebarToggleContext = React.createContext<() => void>(() => {});
interface Props {}
const App: FunctionComponent<Props> = () => {
const bootstrap = useReduxAction(ReduxBootstrap);
const { initialized, auth } = useReduxStore((s) => s.site);
const notify = useNotification("has-update", 10);
// Has any update?
const hasUpdate = useHasUpdateInject();
useEffect(() => {
if (initialized) {
if (hasUpdate) {
notify({
type: "info",
message: "A new version of Bazarr is ready, restart is required",
// TODO: Restart action
});
}
}
}, [initialized, hasUpdate, notify]);
useEffect(() => {
bootstrap();
}, [bootstrap]);
const [sidebar, setSidebar] = useState(false);
const toggleSidebar = useCallback(() => setSidebar(!sidebar), [sidebar]);
if (!auth) {
return <Redirect to="/login"></Redirect>;
}
if (typeof initialized === "boolean" && initialized === false) {
return (
<LoadingIndicator>
<span>Please wait</span>
</LoadingIndicator>
);
} else if (typeof initialized === "string") {
return <InitializationErrorView>{initialized}</InitializationErrorView>;
}
return (
<SidebarToggleContext.Provider value={toggleSidebar}>
<Row noGutters className="header-container">
<Header></Header>
</Row>
<Row noGutters className="flex-nowrap">
<Sidebar open={sidebar}></Sidebar>
<ModalProvider>
<Router className="d-flex flex-row flex-grow-1 main-router"></Router>
</ModalProvider>
</Row>
<NotificationContainer></NotificationContainer>
</SidebarToggleContext.Provider>
);
};
const InitializationErrorView: FunctionComponent<{
children: string;
}> = ({ children }) => {
return (
<Container className="my-3">
<Alert
className="d-flex flex-nowrap justify-content-between align-items-center"
variant="danger"
>
<div>
<FontAwesomeIcon
className="mr-2"
icon={faExclamationTriangle}
></FontAwesomeIcon>
<span>{children}</span>
</div>
<Button variant="outline-danger" onClick={Reload}>
Reload
</Button>
</Alert>
</Container>
);
};
export default App;

@ -0,0 +1,62 @@
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { capitalize } from "lodash";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Toast } from "react-bootstrap";
import { siteRemoveError } from "../../@redux/actions";
import { useReduxAction, useReduxStore } from "../../@redux/hooks/base";
import "./style.scss";
function useNotificationList() {
return useReduxStore((s) => s.site.notifications);
}
function useRemoveNotification() {
return useReduxAction(siteRemoveError);
}
export interface NotificationContainerProps {}
const NotificationContainer: FunctionComponent<NotificationContainerProps> = () => {
const list = useNotificationList();
const items = useMemo(
() =>
list.map((v, idx) => (
<NotificationToast key={v.id} {...v}></NotificationToast>
)),
[list]
);
return (
<div className="alert-container">
<div className="toast-container">{items}</div>
</div>
);
};
type MessageHolderProps = ReduxStore.Notification & {};
const NotificationToast: FunctionComponent<MessageHolderProps> = (props) => {
const { message, id, type } = props;
const removeNotification = useRemoveNotification();
const remove = useCallback(() => removeNotification(id), [
removeNotification,
id,
]);
return (
<Toast onClose={remove} animation={false}>
<Toast.Header>
<FontAwesomeIcon
className="mr-1"
icon={faExclamationTriangle}
></FontAwesomeIcon>
<strong className="mr-auto">{capitalize(type)}</strong>
</Toast.Header>
<Toast.Body>{message}</Toast.Body>
</Toast>
);
};
export default NotificationContainer;

@ -0,0 +1,23 @@
@import "../../@scss/variable.scss";
.alert-container {
position: fixed;
display: block;
top: 0;
right: 0;
margin-top: $header-height;
z-index: 9999;
.toast-container {
padding: 1rem;
.toast {
max-width: 260px;
min-width: 200px;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
}
}

@ -0,0 +1,106 @@
import React, { FunctionComponent, useCallback, useState } from "react";
import {
Alert,
Button,
Card,
Collapse,
Form,
Image,
Spinner,
} from "react-bootstrap";
import { Redirect } from "react-router-dom";
import { siteAuthSuccess } from "../@redux/actions";
import { useReduxAction, useReduxStore } from "../@redux/hooks/base";
import logo from "../@static/logo128.png";
import { SystemApi } from "../apis";
import "./style.scss";
interface Props {}
const AuthPage: FunctionComponent<Props> = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [updating, setUpdate] = useState(false);
const updateError = useCallback((msg: string) => {
setError(msg);
setTimeout(() => setError(""), 2000);
}, []);
const onSuccess = useReduxAction(siteAuthSuccess);
const authState = useReduxStore((s) => s.site.auth);
const onError = useCallback(() => {
setUpdate(false);
updateError("Login Failed");
}, [updateError]);
if (authState) {
return <Redirect to="/"></Redirect>;
}
return (
<div className="d-flex bg-light vh-100 justify-content-center align-items-center">
<Card className="auth-card shadow">
<Form
onSubmit={(e) => {
e.preventDefault();
if (!updating) {
setUpdate(true);
SystemApi.login(username, password)
.then(onSuccess)
.catch(onError);
}
}}
>
<Card.Body>
<Form.Group className="mb-5 d-flex justify-content-center">
<Image width="64" height="64" src={logo}></Image>
</Form.Group>
<Form.Group>
<Form.Control
disabled={updating}
name="username"
type="text"
placeholder="Username"
required
onChange={(e) => setUsername(e.currentTarget.value)}
></Form.Control>
</Form.Group>
<Form.Group>
<Form.Control
disabled={updating}
name="password"
type="password"
placeholder="Password"
required
onChange={(e) => setPassword(e.currentTarget.value)}
></Form.Control>
</Form.Group>
<Collapse in={error.length !== 0}>
<div>
<Alert variant="danger" className="m-0">
{error}
</Alert>
</div>
</Collapse>
</Card.Body>
<Card.Footer>
<Button type="submit" disabled={updating} block>
{updating ? (
<Spinner size="sm" animation="border"></Spinner>
) : (
"LOGIN"
)}
</Button>
</Card.Footer>
</Form>
</Card>
</div>
);
};
export default AuthPage;

@ -0,0 +1,3 @@
.auth-card {
width: 24rem;
}

@ -0,0 +1,42 @@
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { useBlacklistMovies } from "../../@redux/hooks";
import { MoviesApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components";
import { useAutoUpdate } from "../../utilites/hooks";
import Table from "./table";
interface Props {}
const BlacklistMoviesView: FunctionComponent<Props> = () => {
const [blacklist, update] = useBlacklistMovies();
useAutoUpdate(update);
return (
<AsyncStateOverlay state={blacklist}>
{(data) => (
<Container fluid>
<Helmet>
<title>Movies Blacklist - Bazarr</title>
</Helmet>
<ContentHeader>
<ContentHeader.AsyncButton
icon={faTrash}
disabled={data.length === 0}
promise={() => MoviesApi.deleteBlacklist(true)}
onSuccess={update}
>
Remove All
</ContentHeader.AsyncButton>
</ContentHeader>
<Row>
<Table blacklist={data} update={update}></Table>
</Row>
</Container>
)}
</AsyncStateOverlay>
);
};
export default BlacklistMoviesView;

@ -0,0 +1,84 @@
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useMemo } from "react";
import { Link } from "react-router-dom";
import { Column } from "react-table";
import { MoviesApi } from "../../apis";
import { AsyncButton, LanguageText, PageTable } from "../../components";
interface Props {
blacklist: readonly Blacklist.Movie[];
update: () => void;
}
const Table: FunctionComponent<Props> = ({ blacklist, update }) => {
const columns = useMemo<Column<Blacklist.Movie>[]>(
() => [
{
Header: "Name",
accessor: "title",
className: "text-nowrap",
Cell: (row) => {
const target = `/movies/${row.row.original.radarrId}`;
return (
<Link to={target}>
<span>{row.value}</span>
</Link>
);
},
},
{
Header: "Language",
accessor: "language",
Cell: ({ value }) => {
if (value) {
return <LanguageText text={value} long></LanguageText>;
} else {
return null;
}
},
},
{
Header: "Provider",
accessor: "provider",
},
{
Header: "Date",
accessor: "timestamp",
},
{
accessor: "subs_id",
Cell: (row) => {
const subs_id = row.value;
return (
<AsyncButton
size="sm"
variant="light"
noReset
promise={() =>
MoviesApi.deleteBlacklist(false, {
provider: row.row.original.provider,
subs_id,
})
}
onSuccess={update}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</AsyncButton>
);
},
},
],
[update]
);
return (
<PageTable
emptyText="No Blacklisted Movies Subtitles"
columns={columns}
data={blacklist}
></PageTable>
);
};
export default Table;

@ -0,0 +1,30 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { RouterEmptyPath } from "../404";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
import BlacklistMovies from "./Movies";
import BlacklistSeries from "./Series";
const Router: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
return (
<Switch>
{sonarr && (
<Route exact path="/blacklist/series">
<BlacklistSeries></BlacklistSeries>
</Route>
)}
{radarr && (
<Route path="/blacklist/movies">
<BlacklistMovies></BlacklistMovies>
</Route>
)}
<Route path="/blacklist/*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};
export default Router;

@ -0,0 +1,42 @@
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { useBlacklistSeries } from "../../@redux/hooks";
import { EpisodesApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components";
import { useAutoUpdate } from "../../utilites";
import Table from "./table";
interface Props {}
const BlacklistSeriesView: FunctionComponent<Props> = () => {
const [blacklist, update] = useBlacklistSeries();
useAutoUpdate(update);
return (
<AsyncStateOverlay state={blacklist}>
{(data) => (
<Container fluid>
<Helmet>
<title>Series Blacklist - Bazarr</title>
</Helmet>
<ContentHeader>
<ContentHeader.AsyncButton
icon={faTrash}
disabled={data.length === 0}
promise={() => EpisodesApi.deleteBlacklist(true)}
onSuccess={update}
>
Remove All
</ContentHeader.AsyncButton>
</ContentHeader>
<Row>
<Table blacklist={data} update={update}></Table>
</Row>
</Container>
)}
</AsyncStateOverlay>
);
};
export default BlacklistSeriesView;

@ -0,0 +1,90 @@
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useMemo } from "react";
import { Link } from "react-router-dom";
import { Column } from "react-table";
import { EpisodesApi } from "../../apis";
import { AsyncButton, LanguageText, PageTable } from "../../components";
interface Props {
blacklist: readonly Blacklist.Episode[];
update: () => void;
}
const Table: FunctionComponent<Props> = ({ blacklist, update }) => {
const columns = useMemo<Column<Blacklist.Episode>[]>(
() => [
{
Header: "Series",
accessor: "seriesTitle",
className: "text-nowrap",
Cell: (row) => {
const target = `/series/${row.row.original.sonarrSeriesId}`;
return (
<Link to={target}>
<span>{row.value}</span>
</Link>
);
},
},
{
Header: "Episode",
accessor: "episode_number",
},
{
accessor: "episodeTitle",
},
{
Header: "Language",
accessor: "language",
Cell: ({ value }) => {
if (value) {
return <LanguageText text={value} long></LanguageText>;
} else {
return null;
}
},
},
{
Header: "Provider",
accessor: "provider",
},
{
Header: "Date",
accessor: "timestamp",
},
{
accessor: "subs_id",
Cell: (row) => {
const subs_id = row.value;
return (
<AsyncButton
size="sm"
variant="light"
noReset
promise={() =>
EpisodesApi.deleteBlacklist(false, {
provider: row.row.original.provider,
subs_id,
})
}
onSuccess={update}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</AsyncButton>
);
},
},
],
[update]
);
return (
<PageTable
emptyText="No Blacklisted Series Subtitles"
columns={columns}
data={blacklist}
></PageTable>
);
};
export default Table;

@ -0,0 +1,113 @@
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column, Row } from "react-table";
import { useMoviesHistory } from "../../@redux/hooks";
import { MoviesApi } from "../../apis";
import { HistoryIcon, LanguageText } from "../../components";
import { BlacklistButton } from "../../generic/blacklist";
import { useAutoUpdate } from "../../utilites/hooks";
import HistoryGenericView from "../generic";
interface Props {}
const MoviesHistoryView: FunctionComponent<Props> = () => {
const [movies, update] = useMoviesHistory();
useAutoUpdate(update);
const tableUpdate = useCallback((row: Row<History.Base>) => update(), [
update,
]);
const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>(
() => [
{
accessor: "action",
className: "text-center",
Cell: (row) => <HistoryIcon action={row.value}></HistoryIcon>,
},
{
Header: "Name",
accessor: "title",
className: "text-nowrap",
Cell: (row) => {
const target = `/movies/${row.row.original.radarrId}`;
return (
<Link to={target}>
<span>{row.value}</span>
</Link>
);
},
},
{
Header: "Language",
accessor: "language",
Cell: ({ value }) => {
if (value) {
return (
<Badge variant="secondary">
<LanguageText text={value} long></LanguageText>
</Badge>
);
} else {
return null;
}
},
},
{
Header: "Score",
accessor: "score",
},
{
Header: "Date",
accessor: "timestamp",
className: "text-nowrap",
},
{
accessor: "description",
Cell: ({ row, value }) => {
const overlay = (
<Popover id={`description-${row.id}`}>
<Popover.Content>{value}</Popover.Content>
</Popover>
);
return (
<OverlayTrigger overlay={overlay}>
<FontAwesomeIcon size="sm" icon={faInfoCircle}></FontAwesomeIcon>
</OverlayTrigger>
);
},
},
{
accessor: "blacklisted",
Cell: ({ row, externalUpdate }) => {
const original = row.original;
return (
<BlacklistButton
history={original}
update={() => externalUpdate && externalUpdate(row)}
promise={(form) =>
MoviesApi.addBlacklist(original.radarrId, form)
}
></BlacklistButton>
);
},
},
],
[]
);
return (
<HistoryGenericView
type="movies"
state={movies}
columns={columns as Column<History.Base>[]}
tableUpdater={tableUpdate}
></HistoryGenericView>
);
};
export default MoviesHistoryView;

@ -0,0 +1,34 @@
import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { RouterEmptyPath } from "../404";
import { useIsRadarrEnabled, useIsSonarrEnabled } from "../@redux/hooks/site";
import MoviesHistory from "./Movies";
import SeriesHistory from "./Series";
import HistoryStats from "./Statistics";
const Router: FunctionComponent = () => {
const sonarr = useIsSonarrEnabled();
const radarr = useIsRadarrEnabled();
return (
<Switch>
{sonarr && (
<Route exact path="/history/series">
<SeriesHistory></SeriesHistory>
</Route>
)}
{radarr && (
<Route exact path="/history/movies">
<MoviesHistory></MoviesHistory>
</Route>
)}
<Route exact path="/history/stats">
<HistoryStats></HistoryStats>
</Route>
<Route path="/history/*">
<Redirect to={RouterEmptyPath}></Redirect>
</Route>
</Switch>
);
};
export default Router;

@ -0,0 +1,122 @@
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column, Row } from "react-table";
import { useSeriesHistory } from "../../@redux/hooks";
import { EpisodesApi } from "../../apis";
import { HistoryIcon, LanguageText } from "../../components";
import { BlacklistButton } from "../../generic/blacklist";
import { useAutoUpdate } from "../../utilites/hooks";
import HistoryGenericView from "../generic";
interface Props {}
const SeriesHistoryView: FunctionComponent<Props> = () => {
const [series, update] = useSeriesHistory();
useAutoUpdate(update);
const tableUpdate = useCallback((row: Row<History.Base>) => update(), [
update,
]);
const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>(
() => [
{
accessor: "action",
className: "text-center",
Cell: ({ value }) => <HistoryIcon action={value}></HistoryIcon>,
},
{
Header: "Series",
accessor: "seriesTitle",
Cell: (row) => {
const target = `/series/${row.row.original.sonarrSeriesId}`;
return (
<Link to={target}>
<span>{row.value}</span>
</Link>
);
},
},
{
Header: "Episode",
accessor: "episode_number",
},
{
Header: "Title",
accessor: "episodeTitle",
},
{
Header: "Language",
accessor: "language",
Cell: ({ value }) => {
if (value) {
return (
<Badge variant="secondary">
<LanguageText text={value} long></LanguageText>
</Badge>
);
} else {
return null;
}
},
},
{
Header: "Score",
accessor: "score",
},
{
Header: "Date",
accessor: "timestamp",
className: "text-nowrap",
},
{
accessor: "description",
Cell: ({ row, value }) => {
const overlay = (
<Popover id={`description-${row.id}`}>
<Popover.Content>{value}</Popover.Content>
</Popover>
);
return (
<OverlayTrigger overlay={overlay}>
<FontAwesomeIcon size="sm" icon={faInfoCircle}></FontAwesomeIcon>
</OverlayTrigger>
);
},
},
{
accessor: "blacklisted",
Cell: ({ row, externalUpdate }) => {
const original = row.original;
const { sonarrEpisodeId, sonarrSeriesId } = original;
return (
<BlacklistButton
history={original}
update={() => externalUpdate && externalUpdate(row)}
promise={(form) =>
EpisodesApi.addBlacklist(sonarrSeriesId, sonarrEpisodeId, form)
}
></BlacklistButton>
);
},
},
],
[]
);
return (
<HistoryGenericView
type="series"
state={series}
columns={columns as Column<History.Base>[]}
tableUpdater={tableUpdate}
></HistoryGenericView>
);
};
export default SeriesHistoryView;

@ -0,0 +1,131 @@
import { merge } from "lodash";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Col, Container } from "react-bootstrap";
import { Helmet } from "react-helmet";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useLanguages, useProviders } from "../../@redux/hooks";
import { HistoryApi } from "../../apis";
import {
AsyncSelector,
ContentHeader,
LanguageSelector,
PromiseOverlay,
Selector,
} from "../../components";
import { useAutoUpdate } from "../../utilites/hooks";
import { actionOptions, timeframeOptions } from "./options";
function converter(item: History.Stat) {
const movies = item.movies.map((v) => ({
date: v.date,
movies: v.count,
}));
const series = item.series.map((v) => ({
date: v.date,
series: v.count,
}));
const result = merge(movies, series);
return result;
}
const providerLabel = (item: System.Provider) => item.name;
const SelectorContainer: FunctionComponent = ({ children }) => (
<Col xs={6} lg={3} className="p-1">
{children}
</Col>
);
const HistoryStats: FunctionComponent = () => {
const [languages] = useLanguages(true);
const [providerList, update] = useProviders();
useAutoUpdate(update);
const [timeframe, setTimeframe] = useState<History.TimeframeOptions>("month");
const [action, setAction] = useState<Nullable<History.ActionOptions>>(null);
const [lang, setLanguage] = useState<Nullable<Language>>(null);
const [provider, setProvider] = useState<Nullable<System.Provider>>(null);
const promise = useCallback(() => {
return HistoryApi.stats(
timeframe,
action ?? undefined,
provider?.name,
lang?.code2
);
}, [timeframe, lang?.code2, action, provider]);
return (
// TODO: Responsive
<Container fluid className="vh-75">
<Helmet>
<title>History Statistics - Bazarr</title>
</Helmet>
<PromiseOverlay promise={promise}>
{(data) => (
<React.Fragment>
<ContentHeader scroll={false}>
<SelectorContainer>
<Selector
placeholder="Time..."
options={timeframeOptions}
value={timeframe}
onChange={(v) => setTimeframe(v ?? "month")}
></Selector>
</SelectorContainer>
<SelectorContainer>
<Selector
placeholder="Action..."
clearable
options={actionOptions}
value={action}
onChange={setAction}
></Selector>
</SelectorContainer>
<SelectorContainer>
<AsyncSelector
placeholder="Provider..."
clearable
state={providerList}
label={providerLabel}
onChange={setProvider}
></AsyncSelector>
</SelectorContainer>
<SelectorContainer>
<LanguageSelector
clearable
options={languages}
value={lang}
onChange={setLanguage}
></LanguageSelector>
</SelectorContainer>
</ContentHeader>
<ResponsiveContainer height="100%">
<BarChart data={converter(data)}>
<CartesianGrid strokeDasharray="4 2"></CartesianGrid>
<XAxis dataKey="date"></XAxis>
<YAxis allowDecimals={false}></YAxis>
<Tooltip></Tooltip>
<Legend verticalAlign="top"></Legend>
<Bar name="Series" dataKey="series" fill="#2493B6"></Bar>
<Bar name="Movies" dataKey="movies" fill="#FFC22F"></Bar>
</BarChart>
</ResponsiveContainer>
</React.Fragment>
)}
</PromiseOverlay>
</Container>
);
};
export default HistoryStats;

@ -0,0 +1,33 @@
export const actionOptions: SelectorOption<History.ActionOptions>[] = [
{
label: "Automatically Downloaded",
value: 0,
},
{
label: "Manually Downloaded",
value: 1,
},
{
label: "Upgraded",
value: 2,
},
];
export const timeframeOptions: SelectorOption<History.TimeframeOptions>[] = [
{
label: "Last Week",
value: "week",
},
{
label: "Last Month",
value: "month",
},
{
label: "Last Trimester",
value: "trimester",
},
{
label: "Last Year",
value: "year",
},
];

@ -0,0 +1,43 @@
import { capitalize } from "lodash";
import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Column, TableUpdater } from "react-table";
import { AsyncStateOverlay, PageTable } from "../../components";
interface Props {
type: "movies" | "series";
state: Readonly<AsyncState<History.Base[]>>;
columns: Column<History.Base>[];
tableUpdater?: TableUpdater<History.Base>;
}
const HistoryGenericView: FunctionComponent<Props> = ({
state,
columns,
type,
tableUpdater,
}) => {
const typeName = capitalize(type);
return (
<Container fluid>
<Helmet>
<title>{typeName} History - Bazarr</title>
</Helmet>
<Row>
<AsyncStateOverlay state={state}>
{(data) => (
<PageTable
emptyText={`Nothing Found in ${typeName} History`}
columns={columns}
data={data}
externalUpdate={tableUpdater}
></PageTable>
)}
</AsyncStateOverlay>
</Row>
</Container>
);
};
export default HistoryGenericView;

@ -0,0 +1,170 @@
import {
faCloudUploadAlt,
faHistory,
faSearch,
faSync,
faToolbox,
faUser,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
import { RouterEmptyPath } from "../../404";
import { useMovieBy } from "../../@redux/hooks";
import { MoviesApi, ProvidersApi } from "../../apis";
import {
ContentHeader,
ItemEditorModal,
LoadingIndicator,
MovieHistoryModal,
MovieUploadModal,
SubtitleToolModal,
useShowModal,
} from "../../components";
import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
import ItemOverview from "../../generic/ItemOverview";
import { useAutoUpdate, useWhenLoadingFinish } from "../../utilites";
import Table from "./table";
const download = (item: any, result: SearchResultType) => {
item = item as Item.Movie;
const { language, hearing_impaired, forced, provider, subtitle } = result;
return ProvidersApi.downloadMovieSubtitle(item.radarrId, {
language,
hi: hearing_impaired,
forced,
provider,
subtitle,
});
};
interface Params {
id: string;
}
interface Props extends RouteComponentProps<Params> {}
const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
const id = Number.parseInt(match.params.id);
const [movie, update] = useMovieBy(id);
useAutoUpdate(update);
const item = movie.data;
const showModal = useShowModal();
const [valid, setValid] = useState(true);
const validator = useCallback(() => {
if (movie.data === null) {
setValid(false);
}
}, [movie.data]);
useWhenLoadingFinish(movie, validator);
if (isNaN(id) || !valid) {
return <Redirect to={RouterEmptyPath}></Redirect>;
}
if (!item) {
return <LoadingIndicator></LoadingIndicator>;
}
const allowEdit = item.profileId !== undefined;
return (
<Container fluid>
<Helmet>
<title>{item.title} - Bazarr (Movies)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Group pos="start">
<ContentHeader.AsyncButton
icon={faSync}
promise={() =>
MoviesApi.action({ action: "scan-disk", radarrid: item.radarrId })
}
onSuccess={update}
>
Scan Disk
</ContentHeader.AsyncButton>
<ContentHeader.AsyncButton
icon={faSearch}
disabled={item.profileId === null}
promise={() =>
MoviesApi.action({
action: "search-missing",
radarrid: item.radarrId,
})
}
onSuccess={update}
>
Search
</ContentHeader.AsyncButton>
<ContentHeader.Button
icon={faUser}
disabled={item.profileId === null}
onClick={() => showModal<Item.Movie>("manual-search", item)}
>
Manual
</ContentHeader.Button>
<ContentHeader.Button
icon={faHistory}
onClick={() => showModal("history", item)}
>
History
</ContentHeader.Button>
<ContentHeader.Button
icon={faToolbox}
onClick={() => showModal("tools", [item])}
>
Tools
</ContentHeader.Button>
</ContentHeader.Group>
<ContentHeader.Group pos="end">
<ContentHeader.Button
disabled={!allowEdit || item.profileId === null}
icon={faCloudUploadAlt}
onClick={() => showModal("upload", item)}
>
Upload
</ContentHeader.Button>
<ContentHeader.Button
icon={faWrench}
onClick={() => showModal("edit", item)}
>
Edit Movie
</ContentHeader.Button>
</ContentHeader.Group>
</ContentHeader>
<Row>
<ItemOverview item={item} details={[]}></ItemOverview>
</Row>
<Row>
<Table movie={item} update={update}></Table>
</Row>
<ItemEditorModal
modalKey="edit"
submit={(form) => MoviesApi.modify(form)}
onSuccess={update}
></ItemEditorModal>
<SubtitleToolModal
modalKey="tools"
size="lg"
update={update}
></SubtitleToolModal>
<MovieHistoryModal modalKey="history" size="lg"></MovieHistoryModal>
<MovieUploadModal modalKey="upload" size="lg"></MovieUploadModal>
<ManualSearchModal
modalKey="manual-search"
onDownload={update}
onSelect={download}
></ManualSearchModal>
</Container>
);
};
export default withRouter(MovieDetailView);

@ -0,0 +1,119 @@
import { faSearch, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap";
import { Column } from "react-table";
import { MoviesApi } from "../../apis";
import { AsyncButton, LanguageText, SimpleTable } from "../../components";
const missingText = "Missing Subtitles";
interface Props {
movie: Item.Movie;
update: (id: number) => void;
}
const Table: FunctionComponent<Props> = (props) => {
const { movie, update } = props;
const columns: Column<Subtitle>[] = useMemo<Column<Subtitle>[]>(
() => [
{
Header: "Subtitle Path",
accessor: "path",
Cell: (row) => {
if (row.value === null || row.value.length === 0) {
return "Video File Subtitle Track";
} else if (row.value === missingText) {
return <span className="text-muted">{row.value}</span>;
} else {
return row.value;
}
},
},
{
Header: "Language",
accessor: "name",
Cell: ({ row }) => {
if (row.original.path === missingText) {
return (
<Badge variant="primary">
<LanguageText text={row.original} long></LanguageText>
</Badge>
);
} else {
return (
<Badge variant="secondary">
<LanguageText text={row.original} long></LanguageText>
</Badge>
);
}
},
},
{
accessor: "code2",
Cell: (row) => {
const { original } = row.row;
if (original.path === null || original.path.length === 0) {
return null;
} else if (original.path === missingText) {
return (
<AsyncButton
promise={() =>
MoviesApi.downloadSubtitles(movie.radarrId, {
language: original.code2,
hi: original.hi,
forced: original.forced,
})
}
onSuccess={() => update(movie.radarrId)}
variant="light"
size="sm"
>
<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>
</AsyncButton>
);
} else {
return (
<AsyncButton
variant="light"
size="sm"
promise={() =>
MoviesApi.deleteSubtitles(movie.radarrId, {
language: original.code2,
hi: original.hi,
forced: original.forced,
path: original.path ?? "",
})
}
onSuccess={() => update(movie.radarrId)}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</AsyncButton>
);
}
},
},
],
[movie, update]
);
const data: Subtitle[] = useMemo(() => {
const missing = movie.missing_subtitles.map((item) => {
item.path = missingText;
return item;
});
return movie.subtitles.concat(missing);
}, [movie.missing_subtitles, movie.subtitles]);
return (
<SimpleTable
columns={columns}
data={data}
emptyText="No Subtitles Found For This Movie"
></SimpleTable>
);
};
export default Table;

@ -0,0 +1,21 @@
import React, { FunctionComponent } from "react";
import { Route, Switch } from "react-router-dom";
import Movie from ".";
import MovieDetail from "./Detail";
interface Props {}
const Router: FunctionComponent<Props> = () => {
return (
<Switch>
<Route exact path="/movies">
<Movie></Movie>
</Route>
<Route path="/movies/:id">
<MovieDetail></MovieDetail>
</Route>
</Switch>
);
};
export default Router;

@ -0,0 +1,131 @@
import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
import {
faBookmark,
faCheck,
faExclamationTriangle,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap";
import { Link } from "react-router-dom";
import { Column } from "react-table";
import { movieUpdateByRange, movieUpdateInfoAll } from "../@redux/actions";
import { useRawMovies } from "../@redux/hooks";
import { useReduxAction } from "../@redux/hooks/base";
import { MoviesApi } from "../apis";
import { ActionBadge } from "../components";
import BaseItemView from "../generic/BaseItemView";
interface Props {}
const MovieView: FunctionComponent<Props> = () => {
const [movies] = useRawMovies();
const load = useReduxAction(movieUpdateByRange);
const columns: Column<Item.Movie>[] = useMemo<Column<Item.Movie>[]>(
() => [
{
accessor: "monitored",
selectHide: true,
Cell: ({ value }) => (
<FontAwesomeIcon
title={value ? "monitored" : "unmonitored"}
icon={value ? faBookmark : farBookmark}
></FontAwesomeIcon>
),
},
{
Header: "Name",
accessor: "title",
className: "text-nowrap",
Cell: ({ row, value, isSelecting: select }) => {
if (select) {
return value;
} else {
const target = `/movies/${row.original.radarrId}`;
return (
<Link to={target} title={row.original.sceneName ?? value}>
<span>{value}</span>
</Link>
);
}
},
},
{
Header: "Exist",
accessor: "exist",
selectHide: true,
Cell: ({ row, value }) => {
const exist = value;
const { path } = row.original;
return (
<FontAwesomeIcon
title={path}
icon={exist ? faCheck : faExclamationTriangle}
></FontAwesomeIcon>
);
},
},
{
Header: "Audio",
accessor: "audio_language",
Cell: (row) => {
return row.value.map((v) => (
<Badge variant="secondary" className="mr-2" key={v.code2}>
{v.name}
</Badge>
));
},
},
{
Header: "Languages Profile",
accessor: "profileId",
Cell: ({ value, loose }) => {
if (loose) {
// Define in generic/BaseItemView/table.tsx
const profiles = loose[0] as Profile.Languages[];
return profiles.find((v) => v.profileId === value)?.name ?? null;
} else {
return null;
}
},
},
{
accessor: "missing_subtitles",
selectHide: true,
Cell: (row) => {
const missing = row.value;
return missing.map((v) => (
<Badge className="mx-2" variant="warning" key={v.code2}>
{v.code2}
</Badge>
));
},
},
{
accessor: "radarrId",
selectHide: true,
Cell: ({ row, externalUpdate }) => (
<ActionBadge
icon={faWrench}
onClick={() => externalUpdate && externalUpdate(row, "edit")}
></ActionBadge>
),
},
],
[]
);
return (
<BaseItemView
state={movies}
name="Movies"
loader={load}
updateAction={movieUpdateInfoAll}
columns={columns as Column<Item.Base>[]}
modify={(form) => MoviesApi.modify(form)}
></BaseItemView>
);
};
export default MovieView;

@ -0,0 +1,68 @@
import { faSearch, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent } from "react";
import { Badge } from "react-bootstrap";
import { useSerieBy } from "../../@redux/hooks";
import { EpisodesApi } from "../../apis";
import { AsyncButton, LanguageText } from "../../components";
interface Props {
seriesid: number;
episodeid: number;
missing?: boolean;
subtitle: Subtitle;
}
export const SubtitleAction: FunctionComponent<Props> = ({
seriesid,
episodeid,
missing,
subtitle,
}) => {
const { hi, forced } = subtitle;
const [, update] = useSerieBy(seriesid);
const path = subtitle.path;
if (missing || path) {
return (
<AsyncButton
promise={() => {
if (missing) {
return EpisodesApi.downloadSubtitles(seriesid, episodeid, {
hi,
forced,
language: subtitle.code2,
});
} else if (path) {
return EpisodesApi.deleteSubtitles(seriesid, episodeid, {
hi,
forced,
path: path,
language: subtitle.code2,
});
} else {
return null;
}
}}
onSuccess={update}
as={Badge}
className="mr-1"
variant={missing ? "primary" : "secondary"}
>
<LanguageText className="pr-1" text={subtitle}></LanguageText>
<FontAwesomeIcon
size="sm"
icon={missing ? faSearch : faTrash}
></FontAwesomeIcon>
</AsyncButton>
);
} else {
return (
<Badge className="mr-1" variant="secondary">
<LanguageText text={subtitle} long={false}></LanguageText>
</Badge>
);
}
};

@ -0,0 +1,160 @@
import {
faAdjust,
faBriefcase,
faCloudUploadAlt,
faHdd,
faSearch,
faSync,
faWrench,
} from "@fortawesome/free-solid-svg-icons";
import React, {
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
import { RouterEmptyPath } from "../../404";
import { useEpisodesBy, useSerieBy } from "../../@redux/hooks";
import { SeriesApi } from "../../apis";
import {
ContentHeader,
ItemEditorModal,
LoadingIndicator,
SeriesUploadModal,
useShowModal,
} from "../../components";
import ItemOverview from "../../generic/ItemOverview";
import { useAutoUpdate, useWhenLoadingFinish } from "../../utilites";
import Table from "./table";
interface Params {
id: string;
}
interface Props extends RouteComponentProps<Params> {}
const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
const { match } = props;
const id = Number.parseInt(match.params.id);
const [serie, update] = useSerieBy(id);
const item = serie.data;
const [episodes] = useEpisodesBy(serie.data?.sonarrSeriesId);
useAutoUpdate(update);
const available = episodes.data.length !== 0;
const details = useMemo(
() => [
{
icon: faHdd,
text: `${item?.episodeFileCount} files`,
},
{
icon: faAdjust,
text: item?.seriesType ?? "",
},
],
[item]
);
const showModal = useShowModal();
const [valid, setValid] = useState(true);
const validator = useCallback(() => {
if (serie.data === null) {
setValid(false);
}
}, [serie.data]);
useWhenLoadingFinish(serie, validator);
if (isNaN(id) || !valid) {
return <Redirect to={RouterEmptyPath}></Redirect>;
}
if (!item) {
return <LoadingIndicator></LoadingIndicator>;
}
return (
<Container fluid>
<Helmet>
<title>{item.title} - Bazarr (Series)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Group pos="start">
<ContentHeader.AsyncButton
icon={faSync}
disabled={!available}
promise={() =>
SeriesApi.action({ action: "scan-disk", seriesid: id })
}
onSuccess={update}
>
Scan Disk
</ContentHeader.AsyncButton>
<ContentHeader.AsyncButton
icon={faSearch}
promise={() =>
SeriesApi.action({ action: "search-missing", seriesid: id })
}
onSuccess={update}
disabled={
item.episodeFileCount === 0 ||
item.profileId === null ||
!available
}
>
Search
</ContentHeader.AsyncButton>
</ContentHeader.Group>
<ContentHeader.Group pos="end">
<ContentHeader.Button
disabled={item.episodeFileCount === 0 || !available}
icon={faBriefcase}
onClick={() => showModal("tools", episodes.data)}
>
Tools
</ContentHeader.Button>
<ContentHeader.Button
disabled={
item.episodeFileCount === 0 ||
item.profileId === null ||
!available
}
icon={faCloudUploadAlt}
onClick={() => showModal("upload", item)}
>
Upload
</ContentHeader.Button>
<ContentHeader.Button
icon={faWrench}
onClick={() => showModal("edit", item)}
>
Edit Series
</ContentHeader.Button>
</ContentHeader.Group>
</ContentHeader>
<Row>
<ItemOverview item={item} details={details}></ItemOverview>
</Row>
<Row>
<Table episodes={episodes} update={update}></Table>
</Row>
<ItemEditorModal
modalKey="edit"
submit={(form) => SeriesApi.modify(form)}
onSuccess={update}
></ItemEditorModal>
<SeriesUploadModal modalKey="upload"></SeriesUploadModal>
</Container>
);
};
export default withRouter(SeriesEpisodesView);

@ -0,0 +1,226 @@
import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons";
import {
faBookmark,
faBriefcase,
faHistory,
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Badge, ButtonGroup } from "react-bootstrap";
import { Column, TableOptions, TableUpdater } from "react-table";
import { ProvidersApi } from "../../apis";
import {
ActionButton,
AsyncStateOverlay,
EpisodeHistoryModal,
GroupTable,
SubtitleToolModal,
useShowModal,
} from "../../components";
import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
import { BuildKey } from "../../utilites";
import { SubtitleAction } from "./components";
interface Props {
episodes: AsyncState<Item.Episode[]>;
update: () => void;
}
const download = (item: any, result: SearchResultType) => {
item = item as Item.Episode;
const { language, hearing_impaired, forced, provider, subtitle } = result;
return ProvidersApi.downloadEpisodeSubtitle(
item.sonarrSeriesId,
item.sonarrEpisodeId,
{
language,
hi: hearing_impaired,
forced,
provider,
subtitle,
}
);
};
const Table: FunctionComponent<Props> = ({ episodes, update }) => {
const showModal = useShowModal();
const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>(
() => [
{
accessor: "monitored",
Cell: (row) => {
return (
<FontAwesomeIcon
title={row.value ? "monitored" : "unmonitored"}
icon={row.value ? faBookmark : farBookmark}
></FontAwesomeIcon>
);
},
},
{
accessor: "season",
Cell: (row) => {
return `Season ${row.value}`;
},
},
{
Header: "Episode",
accessor: "episode",
},
{
Header: "Title",
accessor: "title",
className: "text-nowrap",
},
{
Header: "Audio",
accessor: "audio_language",
Cell: (row) => {
return row.value.map((v) => (
<Badge variant="secondary" key={v.code2}>
{v.name}
</Badge>
));
},
},
{
Header: "Subtitles",
accessor: "missing_subtitles",
Cell: ({ row }) => {
const episode = row.original;
const seriesid = episode.sonarrSeriesId;
const elements = useMemo(() => {
const episodeid = episode.sonarrEpisodeId;
const missing = episode.missing_subtitles.map((val, idx) => (
<SubtitleAction
missing
key={BuildKey(idx, val.code2, "missing")}
seriesid={seriesid}
episodeid={episodeid}
subtitle={val}
></SubtitleAction>
));
const existing = episode.subtitles.filter(
(val) =>
episode.missing_subtitles.findIndex(
(v) => v.code2 === val.code2
) === -1
);
const subtitles = existing.map((val, idx) => (
<SubtitleAction
key={BuildKey(idx, val.code2, "valid")}
seriesid={seriesid}
episodeid={episodeid}
subtitle={val}
></SubtitleAction>
));
return [...missing, ...subtitles];
}, [episode, seriesid]);
return elements;
},
},
{
Header: "Actions",
accessor: "sonarrEpisodeId",
Cell: ({ row, externalUpdate }) => {
return (
<ButtonGroup>
<ActionButton
icon={faUser}
onClick={() => {
externalUpdate && externalUpdate(row, "manual-search");
}}
></ActionButton>
<ActionButton
icon={faHistory}
onClick={() => {
externalUpdate && externalUpdate(row, "history");
}}
></ActionButton>
<ActionButton
icon={faBriefcase}
onClick={() => {
externalUpdate && externalUpdate(row, "tools");
}}
></ActionButton>
</ButtonGroup>
);
},
},
],
[]
);
const updateRow = useCallback<TableUpdater<Item.Episode>>(
(row, modalKey: string) => {
if (modalKey === "tools") {
showModal(modalKey, [row.original]);
} else {
showModal(modalKey, row.original);
}
},
[showModal]
);
const maxSeason = useMemo(
() =>
episodes.data.reduce<number>(
(prev, curr) => Math.max(prev, curr.season),
0
),
[episodes]
);
const options: TableOptions<Item.Episode> = useMemo(() => {
return {
columns,
data: episodes.data,
externalUpdate: updateRow,
initialState: {
sortBy: [
{ id: "season", desc: true },
{ id: "episode", desc: true },
],
groupBy: ["season"],
expanded: {
[`season:${maxSeason}`]: true,
},
},
};
}, [episodes, columns, maxSeason, updateRow]);
return (
<React.Fragment>
<AsyncStateOverlay state={episodes}>
{() => (
<GroupTable
emptyText="No Episode Found For This Series"
{...options}
></GroupTable>
)}
</AsyncStateOverlay>
<SubtitleToolModal
modalKey="tools"
size="lg"
update={update}
></SubtitleToolModal>
<EpisodeHistoryModal modalKey="history" size="lg"></EpisodeHistoryModal>
<ManualSearchModal
modalKey="manual-search"
onDownload={update}
onSelect={download}
></ManualSearchModal>
</React.Fragment>
);
};
export default Table;

@ -0,0 +1,21 @@
import React, { FunctionComponent } from "react";
import { Route, Switch } from "react-router-dom";
import Series from ".";
import Episodes from "./Episodes";
interface Props {}
const Router: FunctionComponent<Props> = () => {
return (
<Switch>
<Route exact path="/series">
<Series></Series>
</Route>
<Route path="/series/:id">
<Episodes></Episodes>
</Route>
</Switch>
);
};
export default Router;

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

Loading…
Cancel
Save