Refactored Web UI using React
34
.github/workflows/ci.yml
vendored
Normal file
|
@ -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 }}
|
36
.github/workflows/release_beta_to_dev.yaml
vendored
|
@ -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"}'
|
||||
|
|
15
.github/workflows/release_major_and_merge.yaml
vendored
|
@ -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 }}
|
||||
|
|
15
.github/workflows/release_minor_and_merge.yaml
vendored
|
@ -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 }}
|
||||
|
|
17
.github/workflows/release_patch_and_merge.yaml
vendored
|
@ -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.
|
||||
|
|
2280
bazarr/api.py
|
@ -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'):
|
||||
|
|
181
bazarr/config.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']:
|
||||
value = value[0]
|
||||
|
||||
# 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']:
|
||||
value = []
|
||||
|
||||
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
|
||||
if settings_keys[-1] in array_keys and value[0] in empty_values :
|
||||
value = []
|
||||
|
||||
# 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:
|
||||
|
|
444
bazarr/main.py
|
@ -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('/check_update')
|
||||
# @authenticate
|
||||
# def check_update():
|
||||
# if not args.no_update:
|
||||
# check_and_apply_update()
|
||||
|
||||
|
||||
@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('/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)
|
||||
|
|
8
frontend/.env
Normal file
|
@ -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
|
6
frontend/.prettierignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
/*
|
||||
!/frontend
|
||||
|
||||
build
|
||||
dist
|
||||
converage
|
4
frontend/.prettierrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
30
frontend/README.md
Normal file
|
@ -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`
|
39464
frontend/package-lock.json
generated
Normal file
91
frontend/package.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
29
frontend/public/index.html
Normal file
|
@ -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 |
14
frontend/public/static/manifest.json
Normal file
|
@ -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"
|
||||
}
|
20
frontend/src/404/index.tsx
Normal file
|
@ -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;
|
155
frontend/src/@redux/actions/factory.ts
Normal file
|
@ -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);
|
||||
}
|
5
frontend/src/@redux/actions/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export * from "./movie";
|
||||
export * from "./providers";
|
||||
export * from "./series";
|
||||
export * from "./site";
|
||||
export * from "./system";
|
59
frontend/src/@redux/actions/movie.ts
Normal file
|
@ -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()
|
||||
);
|
13
frontend/src/@redux/actions/providers.ts
Normal file
|
@ -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(),
|
||||
]);
|
62
frontend/src/@redux/actions/series.ts
Normal file
|
@ -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()
|
||||
);
|
65
frontend/src/@redux/actions/site.ts
Normal file
|
@ -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
|
||||
);
|
62
frontend/src/@redux/actions/system.ts
Normal file
|
@ -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(),
|
||||
]);
|
45
frontend/src/@redux/constants/index.ts
Normal file
|
@ -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";
|
36
frontend/src/@redux/hooks/base.ts
Normal file
|
@ -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]
|
||||
);
|
||||
}
|
265
frontend/src/@redux/hooks/index.ts
Normal file
|
@ -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);
|
||||
}
|
36
frontend/src/@redux/hooks/site.ts
Normal file
|
@ -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;
|
||||
}
|
12
frontend/src/@redux/reducers/index.ts
Normal file
|
@ -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,
|
||||
});
|
112
frontend/src/@redux/reducers/mapper.ts
Normal file
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
86
frontend/src/@redux/reducers/movie.ts
Normal file
|
@ -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;
|
118
frontend/src/@redux/reducers/series.ts
Normal file
|
@ -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;
|
109
frontend/src/@redux/reducers/site.ts
Normal file
|
@ -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;
|
130
frontend/src/@redux/reducers/system.ts
Normal file
|
@ -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;
|
62
frontend/src/@redux/redux.d.ts
vendored
Normal file
|
@ -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>>;
|
||||
}
|
||||
}
|
17
frontend/src/@redux/store/index.ts
Normal file
|
@ -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;
|
22
frontend/src/@redux/types.d.ts
vendored
Normal file
|
@ -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;
|
21
frontend/src/@scss/bazarr.scss
Normal file
|
@ -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;
|
||||
}
|
45
frontend/src/@scss/global.scss
Normal file
|
@ -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;
|
||||
}
|
55
frontend/src/@scss/index.scss
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
6
frontend/src/@scss/variable.scss
Normal file
|
@ -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 |
7
frontend/src/@static/react.svg
Normal file
|
@ -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 |
10
frontend/src/@storage/local.ts
Normal file
|
@ -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());
|
||||
},
|
||||
};
|
258
frontend/src/@types/api.d.ts
vendored
Normal file
|
@ -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;
|
||||
};
|
34
frontend/src/@types/basic.d.ts
vendored
Normal file
|
@ -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)
|
||||
];
|
76
frontend/src/@types/form.d.ts
vendored
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
149
frontend/src/@types/react-table.d.ts
vendored
Normal file
|
@ -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> {}
|
||||
}
|
201
frontend/src/@types/settings.d.ts
vendored
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
3
frontend/src/@types/storage.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
interface LocalStorageType {
|
||||
pageSize: number;
|
||||
}
|
35
frontend/src/@types/system.d.ts
vendored
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
39
frontend/src/@types/utilities.d.ts
vendored
Normal file
|
@ -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>;
|
12
frontend/src/@types/window.d.ts
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
declare global {
|
||||
interface Window {
|
||||
Bazarr: BazarrServer;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BazarrServer {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
canUpdate: boolean;
|
||||
hasUpdate: boolean;
|
||||
}
|
153
frontend/src/App/Header.tsx
Normal file
|
@ -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;
|
75
frontend/src/App/Router.tsx
Normal file
|
@ -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;
|
30
frontend/src/App/header.scss
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
108
frontend/src/App/index.tsx
Normal file
|
@ -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;
|
62
frontend/src/App/notifications/index.tsx
Normal file
|
@ -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;
|
23
frontend/src/App/notifications/style.scss
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
106
frontend/src/Auth/index.tsx
Normal file
|
@ -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;
|
3
frontend/src/Auth/style.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.auth-card {
|
||||
width: 24rem;
|
||||
}
|
42
frontend/src/Blacklist/Movies/index.tsx
Normal file
|
@ -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;
|
84
frontend/src/Blacklist/Movies/table.tsx
Normal file
|
@ -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;
|
30
frontend/src/Blacklist/Router.tsx
Normal file
|
@ -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;
|
42
frontend/src/Blacklist/Series/index.tsx
Normal file
|
@ -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;
|
90
frontend/src/Blacklist/Series/table.tsx
Normal file
|
@ -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;
|
113
frontend/src/History/Movies/index.tsx
Normal file
|
@ -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;
|
34
frontend/src/History/Router.tsx
Normal file
|
@ -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;
|
122
frontend/src/History/Series/index.tsx
Normal file
|
@ -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;
|
131
frontend/src/History/Statistics/index.tsx
Normal file
|
@ -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;
|
33
frontend/src/History/Statistics/options.ts
Normal file
|
@ -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",
|
||||
},
|
||||
];
|
43
frontend/src/History/generic/index.tsx
Normal file
|
@ -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;
|
170
frontend/src/Movies/Detail/index.tsx
Normal file
|
@ -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);
|
119
frontend/src/Movies/Detail/table.tsx
Normal file
|
@ -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;
|
21
frontend/src/Movies/Router.tsx
Normal file
|
@ -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;
|
131
frontend/src/Movies/index.tsx
Normal file
|
@ -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;
|
68
frontend/src/Series/Episodes/components.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
160
frontend/src/Series/Episodes/index.tsx
Normal file
|
@ -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);
|
226
frontend/src/Series/Episodes/table.tsx
Normal file
|
@ -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;
|
21
frontend/src/Series/Router.tsx
Normal file
|
@ -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;
|