Merge development into master

This commit is contained in:
github-actions[bot] 2022-10-15 12:45:09 +00:00 committed by GitHub
commit 70346950fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
394 changed files with 52262 additions and 7036 deletions

View File

@ -16,13 +16,13 @@ file_list=$(cat .github/files_to_copy)
for f in $file_list
do
echo "**** copying $f to release ****"
cp -r --parents $f $to_dist
cp -r --parents "$f" $to_dist
done
# COPY VERSION file
cp VERSION $to_dist
pushd __builds__/bazarr
zip -r ../bazarr.zip . -b $(mktemp -d)
zip -r ../bazarr.zip . -b "$(mktemp -d)"
popd
rm -rf $to_dist

View File

@ -8,7 +8,7 @@ master_version=$(git describe --tags --abbrev=0 --match "v[0-9].[0-9].[0-9]")
latest_verion=$(git describe --tags --abbrev=0)
if [[ $RELEASE_MASTER -eq 1 ]]; then
auto-changelog --stdout -t changelog-master.hbs --starting-version $master_version --commit-limit 3
auto-changelog --stdout -t changelog-master.hbs --starting-version "$master_version" --commit-limit 3
else
auto-changelog --stdout --starting-version $latest_verion --unreleased --commit-limit 0
auto-changelog --stdout --starting-version "$latest_verion" --unreleased --commit-limit 0
fi

View File

@ -9,7 +9,7 @@ file_list=$(cat .github/files_to_copy)
for f in $file_list
do
echo "**** checking $f ****"
if [ ! -f $f ] && [ ! -d $f ]; then
if [ ! -f "$f" ] && [ ! -d "$f" ]; then
echo "**** $f doesn't exist, skipping release ****"
exit 1
fi

View File

@ -1,25 +1,51 @@
# coding=utf-8
from .badges import api_bp_badges
from .system import api_bp_system
from .series import api_bp_series
from .episodes import api_bp_episodes
from .providers import api_bp_providers
from .subtitles import api_bp_subtitles
from .webhooks import api_bp_webhooks
from .history import api_bp_history
from .files import api_bp_files
from .movies import api_bp_movies
from flask import Blueprint, url_for
from flask_restx import Api, apidoc
api_bp_list = [
api_bp_badges,
api_bp_system,
api_bp_series,
api_bp_episodes,
api_bp_providers,
api_bp_subtitles,
api_bp_webhooks,
api_bp_history,
api_bp_files,
api_bp_movies
from .badges import api_ns_list_badges
from .episodes import api_ns_list_episodes
from .files import api_ns_list_files
from .history import api_ns_list_history
from .movies import api_ns_list_movies
from .providers import api_ns_list_providers
from .series import api_ns_list_series
from .subtitles import api_ns_list_subtitles
from .system import api_ns_list_system
from .webhooks import api_ns_list_webhooks
from .swaggerui import swaggerui_api_params
api_ns_list = [
api_ns_list_badges,
api_ns_list_episodes,
api_ns_list_files,
api_ns_list_history,
api_ns_list_movies,
api_ns_list_providers,
api_ns_list_series,
api_ns_list_subtitles,
api_ns_list_system,
api_ns_list_webhooks,
]
authorizations = {
'apikey': {
'type': 'apiKey',
'in': 'header',
'name': 'X-API-KEY'
}
}
api_bp = Blueprint('api', __name__, url_prefix='/api')
@apidoc.apidoc.add_app_template_global
def swagger_static(filename):
return url_for('ui.swaggerui_static', filename=filename)
api = Api(api_bp, authorizations=authorizations, security='apikey', validate=True, **swaggerui_api_params)
for api_ns in api_ns_list:
for item in api_ns:
api.add_namespace(item, "/")

View File

@ -1,12 +1,7 @@
# coding=utf-8
from flask import Blueprint
from flask_restful import Api
from .badges import api_ns_badges
from .badges import Badges
api_bp_badges = Blueprint('api_badges', __name__)
api = Api(api_bp_badges)
api.add_resource(Badges, '/badges')
api_ns_list_badges = [
api_ns_badges
]

View File

@ -3,8 +3,7 @@
import operator
from functools import reduce
from flask import jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, fields
from app.database import get_exclusion_clause, TableEpisodes, TableShows, TableMovies
from app.get_providers import get_throttled_providers
@ -12,10 +11,25 @@ from utilities.health import get_health_issues
from ..utils import authenticate
api_ns_badges = Namespace('Badges', description='Get badges count to update the UI (episodes and movies wanted '
'subtitles, providers with issues and health issues.')
@api_ns_badges.route('badges')
class Badges(Resource):
get_model = api_ns_badges.model('BadgesGet', {
'episodes': fields.Integer(),
'movies': fields.Integer(),
'providers': fields.Integer(),
'status': fields.Integer(),
})
@authenticate
@api_ns_badges.marshal_with(get_model, code=200)
@api_ns_badges.response(401, 'Not Authenticated')
@api_ns_badges.doc(parser=None)
def get(self):
"""Get badges count to update the UI"""
episodes_conditions = [(TableEpisodes.missing_subtitles.is_null(False)),
(TableEpisodes.missing_subtitles != '[]')]
episodes_conditions += get_exclusion_clause('series')
@ -44,4 +58,4 @@ class Badges(Resource):
"providers": throttled_providers,
"status": health_issues
}
return jsonify(result)
return result

View File

@ -1,20 +1,16 @@
# coding=utf-8
from flask import Blueprint
from flask_restful import Api
from .episodes import Episodes
from .episodes_subtitles import EpisodesSubtitles
from .history import EpisodesHistory
from .wanted import EpisodesWanted
from .blacklist import EpisodesBlacklist
from .episodes import api_ns_episodes
from .episodes_subtitles import api_ns_episodes_subtitles
from .history import api_ns_episodes_history
from .wanted import api_ns_episodes_wanted
from .blacklist import api_ns_episodes_blacklist
api_bp_episodes = Blueprint('api_episodes', __name__)
api = Api(api_bp_episodes)
api.add_resource(Episodes, '/episodes')
api.add_resource(EpisodesWanted, '/episodes/wanted')
api.add_resource(EpisodesSubtitles, '/episodes/subtitles')
api.add_resource(EpisodesHistory, '/episodes/history')
api.add_resource(EpisodesBlacklist, '/episodes/blacklist')
api_ns_list_episodes = [
api_ns_episodes,
api_ns_episodes_blacklist,
api_ns_episodes_history,
api_ns_episodes_subtitles,
api_ns_episodes_wanted,
]

View File

@ -3,8 +3,7 @@
import datetime
import pretty
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from app.database import TableEpisodes, TableShows, TableBlacklist
from subtitles.tools.delete import delete_subtitles
@ -12,18 +11,43 @@ from sonarr.blacklist import blacklist_log, blacklist_delete_all, blacklist_dele
from utilities.path_mappings import path_mappings
from subtitles.mass_download import episode_download_subtitles
from app.event_handler import event_stream
from api.swaggerui import subtitles_language_model
from ..utils import authenticate, postprocessEpisode
api_ns_episodes_blacklist = Namespace('Episodes Blacklist', description='List, add or remove subtitles to or from '
'episodes blacklist')
# GET: get blacklist
# POST: add blacklist
# DELETE: remove blacklist
@api_ns_episodes_blacklist.route('episodes/blacklist')
class EpisodesBlacklist(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('start', type=int, required=False, default=0, help='Paging start integer')
get_request_parser.add_argument('length', type=int, required=False, default=-1, help='Paging length integer')
get_language_model = api_ns_episodes_blacklist.model('subtitles_language_model', subtitles_language_model)
get_response_model = api_ns_episodes_blacklist.model('EpisodeBlacklistGetResponse', {
'seriesTitle': fields.String(),
'episode_number': fields.String(),
'episodeTitle': fields.String(),
'sonarrSeriesId': fields.Integer(),
'provider': fields.String(),
'subs_id': fields.String(),
'language': fields.Nested(get_language_model),
'timestamp': fields.String(),
'parsed_timestamp': fields.String(),
})
@authenticate
@api_ns_episodes_blacklist.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_episodes_blacklist.response(401, 'Not Authenticated')
@api_ns_episodes_blacklist.doc(parser=get_request_parser)
def get(self):
start = request.args.get('start') or 0
length = request.args.get('length') or -1
"""List blacklisted episodes subtitles"""
args = self.get_request_parser.parse_args()
start = args.get('start')
length = args.get('length')
data = TableBlacklist.select(TableShows.title.alias('seriesTitle'),
TableEpisodes.season.concat('x').concat(TableEpisodes.episode).alias('episode_number'),
@ -48,15 +72,29 @@ class EpisodesBlacklist(Resource):
postprocessEpisode(item)
return jsonify(data=data)
return data
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('seriesid', type=int, required=True, help='Series ID')
post_request_parser.add_argument('episodeid', type=int, required=True, help='Episode ID')
post_request_parser.add_argument('provider', type=str, required=True, help='Provider name')
post_request_parser.add_argument('subs_id', type=str, required=True, help='Subtitles ID')
post_request_parser.add_argument('language', type=str, required=True, help='Subtitles language')
post_request_parser.add_argument('subtitles_path', type=str, required=True, help='Subtitles file path')
@authenticate
@api_ns_episodes_blacklist.doc(parser=post_request_parser)
@api_ns_episodes_blacklist.response(200, 'Success')
@api_ns_episodes_blacklist.response(401, 'Not Authenticated')
@api_ns_episodes_blacklist.response(404, 'Episode not found')
def post(self):
sonarr_series_id = int(request.args.get('seriesid'))
sonarr_episode_id = int(request.args.get('episodeid'))
provider = request.form.get('provider')
subs_id = request.form.get('subs_id')
language = request.form.get('language')
"""Add an episodes subtitles to blacklist"""
args = self.post_request_parser.parse_args()
sonarr_series_id = args.get('seriesid')
sonarr_episode_id = args.get('episodeid')
provider = args.get('provider')
subs_id = args.get('subs_id')
language = args.get('language')
episodeInfo = TableEpisodes.select(TableEpisodes.path)\
.where(TableEpisodes.sonarrEpisodeId == sonarr_episode_id)\
@ -67,7 +105,7 @@ class EpisodesBlacklist(Resource):
return 'Episode not found', 404
media_path = episodeInfo['path']
subtitles_path = request.form.get('subtitles_path')
subtitles_path = args.get('subtitles_path')
blacklist_log(sonarr_series_id=sonarr_series_id,
sonarr_episode_id=sonarr_episode_id,
@ -86,12 +124,22 @@ class EpisodesBlacklist(Resource):
event_stream(type='episode-history')
return '', 200
delete_request_parser = reqparse.RequestParser()
delete_request_parser.add_argument('all', type=str, required=False, help='Empty episodes subtitles blacklist')
delete_request_parser.add_argument('provider', type=str, required=True, help='Provider name')
delete_request_parser.add_argument('subs_id', type=str, required=True, help='Subtitles ID')
@authenticate
@api_ns_episodes_blacklist.doc(parser=delete_request_parser)
@api_ns_episodes_blacklist.response(204, 'Success')
@api_ns_episodes_blacklist.response(401, 'Not Authenticated')
def delete(self):
if request.args.get("all") == "true":
"""Delete an episodes subtitles from blacklist"""
args = self.post_request_parser.parse_args()
if args.get("all") == "true":
blacklist_delete_all()
else:
provider = request.form.get('provider')
subs_id = request.form.get('subs_id')
provider = args.get('provider')
subs_id = args.get('subs_id')
blacklist_delete(provider=provider, subs_id=subs_id)
return '', 204

View File

@ -1,18 +1,60 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from app.database import TableEpisodes
from api.swaggerui import subtitles_model, subtitles_language_model, audio_language_model
from ..utils import authenticate, postprocessEpisode
api_ns_episodes = Namespace('Episodes', description='List episodes metadata for specific series or episodes.')
@api_ns_episodes.route('episodes')
class Episodes(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('seriesid[]', type=int, action='append', required=False, default=[],
help='Series IDs to list episodes for')
get_request_parser.add_argument('episodeid[]', type=int, action='append', required=False, default=[],
help='Episodes ID to list')
get_subtitles_model = api_ns_episodes.model('subtitles_model', subtitles_model)
get_subtitles_language_model = api_ns_episodes.model('subtitles_language_model', subtitles_language_model)
get_audio_language_model = api_ns_episodes.model('audio_language_model', audio_language_model)
get_response_model = api_ns_episodes.model('EpisodeGetResponse', {
'rowid': fields.Integer(),
'audio_codec': fields.String(),
'audio_language': fields.Nested(get_audio_language_model),
'episode': fields.Integer(),
'episode_file_id': fields.Integer(),
'failedAttempts': fields.String(),
'file_size': fields.Integer(),
'format': fields.String(),
'missing_subtitles': fields.Nested(get_subtitles_language_model),
'monitored': fields.Boolean(),
'path': fields.String(),
'resolution': fields.String(),
'season': fields.Integer(),
'sonarrEpisodeId': fields.Integer(),
'sonarrSeriesId': fields.Integer(),
'subtitles': fields.Nested(get_subtitles_model),
'title': fields.String(),
'video_codec': fields.String(),
'sceneName': fields.String(),
})
@authenticate
@api_ns_episodes.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_episodes.doc(parser=get_request_parser)
@api_ns_episodes.response(200, 'Success')
@api_ns_episodes.response(401, 'Not Authenticated')
@api_ns_episodes.response(404, 'Series or Episode ID not provided')
def get(self):
seriesId = request.args.getlist('seriesid[]')
episodeId = request.args.getlist('episodeid[]')
"""List episodes metadata for specific series or episodes"""
args = self.get_request_parser.parse_args()
seriesId = args.get('seriesid[]')
episodeId = args.get('episodeid[]')
if len(episodeId) > 0:
result = TableEpisodes.select().where(TableEpisodes.sonarrEpisodeId.in_(episodeId)).dicts()
@ -28,4 +70,4 @@ class Episodes(Resource):
for item in result:
postprocessEpisode(item)
return jsonify(data=result)
return result

View File

@ -3,9 +3,9 @@
import os
import logging
from flask import request
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse
from subliminal_patch.core import SUBTITLE_EXTENSIONS
from werkzeug.datastructures import FileStorage
from app.database import TableShows, TableEpisodes, get_audio_profile_languages, get_profile_id
from utilities.path_mappings import path_mappings
@ -20,15 +20,28 @@ from app.config import settings
from ..utils import authenticate
api_ns_episodes_subtitles = Namespace('Episodes Subtitles', description='Download, upload or delete episodes subtitles')
# PATCH: Download Subtitles
# POST: Upload Subtitles
# DELETE: Delete Subtitles
@api_ns_episodes_subtitles.route('episodes/subtitles')
class EpisodesSubtitles(Resource):
patch_request_parser = reqparse.RequestParser()
patch_request_parser.add_argument('seriesid', type=int, required=True, help='Series ID')
patch_request_parser.add_argument('episodeid', type=int, required=True, help='Episode ID')
patch_request_parser.add_argument('language', type=str, required=True, help='Language code2')
patch_request_parser.add_argument('forced', type=str, required=True, help='Forced true/false as string')
patch_request_parser.add_argument('hi', type=str, required=True, help='HI true/false as string')
@authenticate
@api_ns_episodes_subtitles.doc(parser=patch_request_parser)
@api_ns_episodes_subtitles.response(204, 'Success')
@api_ns_episodes_subtitles.response(401, 'Not Authenticated')
@api_ns_episodes_subtitles.response(404, 'Episode not found')
def patch(self):
sonarrSeriesId = request.args.get('seriesid')
sonarrEpisodeId = request.args.get('episodeid')
"""Download an episode subtitles"""
args = self.patch_request_parser.parse_args()
sonarrSeriesId = args.get('seriesid')
sonarrEpisodeId = args.get('episodeid')
episodeInfo = TableEpisodes.select(TableEpisodes.path,
TableEpisodes.scene_name,
TableEpisodes.audio_language,
@ -45,9 +58,9 @@ class EpisodesSubtitles(Resource):
episodePath = path_mappings.path_replace(episodeInfo['path'])
sceneName = episodeInfo['scene_name'] or "None"
language = request.form.get('language')
hi = request.form.get('hi').capitalize()
forced = request.form.get('forced').capitalize()
language = args.get('language')
hi = args.get('hi').capitalize()
forced = args.get('forced').capitalize()
audio_language_list = get_audio_profile_languages(episode_id=sonarrEpisodeId)
if len(audio_language_list) > 0:
@ -85,10 +98,25 @@ class EpisodesSubtitles(Resource):
return '', 204
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('seriesid', type=int, required=True, help='Series ID')
post_request_parser.add_argument('episodeid', type=int, required=True, help='Episode ID')
post_request_parser.add_argument('language', type=str, required=True, help='Language code2')
post_request_parser.add_argument('forced', type=str, required=True, help='Forced true/false as string')
post_request_parser.add_argument('hi', type=str, required=True, help='HI true/false as string')
post_request_parser.add_argument('file', type=FileStorage, location='files', required=True,
help='Subtitles file as file upload object')
@authenticate
@api_ns_episodes_subtitles.doc(parser=post_request_parser)
@api_ns_episodes_subtitles.response(204, 'Success')
@api_ns_episodes_subtitles.response(401, 'Not Authenticated')
@api_ns_episodes_subtitles.response(404, 'Episode not found')
def post(self):
sonarrSeriesId = request.args.get('seriesid')
sonarrEpisodeId = request.args.get('episodeid')
"""Upload an episode subtitles"""
args = self.post_request_parser.parse_args()
sonarrSeriesId = args.get('seriesid')
sonarrEpisodeId = args.get('episodeid')
episodeInfo = TableEpisodes.select(TableEpisodes.title,
TableEpisodes.path,
TableEpisodes.scene_name,
@ -105,10 +133,10 @@ class EpisodesSubtitles(Resource):
sceneName = episodeInfo['scene_name'] or "None"
audio_language = episodeInfo['audio_language']
language = request.form.get('language')
forced = True if request.form.get('forced') == 'true' else False
hi = True if request.form.get('hi') == 'true' else False
subFile = request.files.get('file')
language = args.get('language')
forced = True if args.get('forced') == 'true' else False
hi = True if args.get('hi') == 'true' else False
subFile = args.get('file')
_, ext = os.path.splitext(subFile.filename)
@ -151,10 +179,24 @@ class EpisodesSubtitles(Resource):
return '', 204
delete_request_parser = reqparse.RequestParser()
delete_request_parser.add_argument('seriesid', type=int, required=True, help='Series ID')
delete_request_parser.add_argument('episodeid', type=int, required=True, help='Episode ID')
delete_request_parser.add_argument('language', type=str, required=True, help='Language code2')
delete_request_parser.add_argument('forced', type=str, required=True, help='Forced true/false as string')
delete_request_parser.add_argument('hi', type=str, required=True, help='HI true/false as string')
delete_request_parser.add_argument('path', type=str, required=True, help='Path of the subtitles file')
@authenticate
@api_ns_episodes_subtitles.doc(parser=delete_request_parser)
@api_ns_episodes_subtitles.response(204, 'Success')
@api_ns_episodes_subtitles.response(401, 'Not Authenticated')
@api_ns_episodes_subtitles.response(404, 'Episode not found')
def delete(self):
sonarrSeriesId = request.args.get('seriesid')
sonarrEpisodeId = request.args.get('episodeid')
"""Delete an episode subtitles"""
args = self.delete_request_parser.parse_args()
sonarrSeriesId = args.get('seriesid')
sonarrEpisodeId = args.get('episodeid')
episodeInfo = TableEpisodes.select(TableEpisodes.title,
TableEpisodes.path,
TableEpisodes.scene_name,
@ -168,10 +210,10 @@ class EpisodesSubtitles(Resource):
episodePath = path_mappings.path_replace(episodeInfo['path'])
language = request.form.get('language')
forced = request.form.get('forced')
hi = request.form.get('hi')
subtitlesPath = request.form.get('path')
language = args.get('language')
forced = args.get('forced')
hi = args.get('hi')
subtitlesPath = args.get('path')
subtitlesPath = path_mappings.path_replace_reverse(subtitlesPath)

View File

@ -5,8 +5,7 @@ import os
import operator
import pretty
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from functools import reduce
from peewee import fn
from datetime import timedelta
@ -14,16 +13,62 @@ from datetime import timedelta
from app.database import get_exclusion_clause, TableEpisodes, TableShows, TableHistory, TableBlacklist
from app.config import settings
from utilities.path_mappings import path_mappings
from api.swaggerui import subtitles_language_model
from ..utils import authenticate, postprocessEpisode
api_ns_episodes_history = Namespace('Episodes History', description='List episodes history events')
@api_ns_episodes_history.route('episodes/history')
class EpisodesHistory(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('start', type=int, required=False, default=0, help='Paging start integer')
get_request_parser.add_argument('length', type=int, required=False, default=-1, help='Paging length integer')
get_request_parser.add_argument('episodeid', type=int, required=False, help='Episode ID')
get_language_model = api_ns_episodes_history.model('subtitles_language_model', subtitles_language_model)
data_model = api_ns_episodes_history.model('history_episodes_data_model', {
'id': fields.Integer(),
'seriesTitle': fields.String(),
'monitored': fields.Boolean(),
'episode_number': fields.String(),
'episodeTitle': fields.String(),
'timestamp': fields.String(),
'subs_id': fields.String(),
'description': fields.String(),
'sonarrSeriesId': fields.Integer(),
'language': fields.Nested(get_language_model),
'score': fields.String(),
'tags': fields.List(fields.String),
'action': fields.Integer(),
'video_path': fields.String(),
'subtitles_path': fields.String(),
'sonarrEpisodeId': fields.Integer(),
'provider': fields.String(),
'seriesType': fields.String(),
'upgradable': fields.Boolean(),
'raw_timestamp': fields.Integer(),
'parsed_timestamp': fields.String(),
'blacklisted': fields.Boolean(),
})
get_response_model = api_ns_episodes_history.model('EpisodeHistoryGetResponse', {
'data': fields.Nested(data_model),
'total': fields.Integer(),
})
@authenticate
@api_ns_episodes_history.marshal_with(get_response_model, code=200)
@api_ns_episodes_history.response(401, 'Not Authenticated')
@api_ns_episodes_history.doc(parser=get_request_parser)
def get(self):
start = request.args.get('start') or 0
length = request.args.get('length') or -1
episodeid = request.args.get('episodeid')
"""List episodes history events"""
args = self.get_request_parser.parse_args()
start = args.get('start')
length = args.get('length')
episodeid = args.get('episodeid')
upgradable_episodes_not_perfect = []
if settings.general.getboolean('upgrade_subs'):
@ -133,4 +178,4 @@ class EpisodesHistory(Resource):
.join(TableEpisodes, on=(TableHistory.sonarrEpisodeId == TableEpisodes.sonarrEpisodeId))\
.where(TableEpisodes.title.is_null(False)).count()
return jsonify(data=episode_history, total=count)
return {'data': episode_history, 'total': count}

View File

@ -2,20 +2,54 @@
import operator
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from functools import reduce
from app.database import get_exclusion_clause, TableEpisodes, TableShows
from api.swaggerui import subtitles_language_model
from ..utils import authenticate, postprocessEpisode
api_ns_episodes_wanted = Namespace('Episodes Wanted', description='List episodes wanted subtitles')
# GET: Get Wanted Episodes
@api_ns_episodes_wanted.route('episodes/wanted')
class EpisodesWanted(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('start', type=int, required=False, default=0, help='Paging start integer')
get_request_parser.add_argument('length', type=int, required=False, default=-1, help='Paging length integer')
get_request_parser.add_argument('episodeid[]', type=int, action='append', required=False, default=[],
help='Episodes ID to list')
get_subtitles_language_model = api_ns_episodes_wanted.model('subtitles_language_model', subtitles_language_model)
data_model = api_ns_episodes_wanted.model('wanted_episodes_data_model', {
'seriesTitle': fields.String(),
'monitored': fields.Boolean(),
'episode_number': fields.String(),
'episodeTitle': fields.String(),
'missing_subtitles': fields.Nested(get_subtitles_language_model),
'sonarrSeriesId': fields.Integer(),
'sonarrEpisodeId': fields.Integer(),
'sceneName': fields.String(),
'tags': fields.List(fields.String),
'failedAttempts': fields.String(),
'seriesType': fields.String(),
})
get_response_model = api_ns_episodes_wanted.model('EpisodeWantedGetResponse', {
'data': fields.Nested(data_model),
'total': fields.Integer(),
})
@authenticate
@api_ns_episodes_wanted.marshal_with(get_response_model, code=200)
@api_ns_episodes_wanted.response(401, 'Not Authenticated')
@api_ns_episodes_wanted.doc(parser=get_request_parser)
def get(self):
episodeid = request.args.getlist('episodeid[]')
"""List episodes wanted subtitles"""
args = self.get_request_parser.parse_args()
episodeid = args.get('episodeid[]')
wanted_conditions = [(TableEpisodes.missing_subtitles != '[]')]
if len(episodeid) > 0:
@ -39,8 +73,8 @@ class EpisodesWanted(Resource):
.where(wanted_condition)\
.dicts()
else:
start = request.args.get('start') or 0
length = request.args.get('length') or -1
start = args.get('start')
length = args.get('length')
data = TableEpisodes.select(TableShows.title.alias('seriesTitle'),
TableEpisodes.monitored,
TableEpisodes.season.concat('x').concat(TableEpisodes.episode).alias('episode_number'),
@ -72,4 +106,4 @@ class EpisodesWanted(Resource):
.where(reduce(operator.and_, count_conditions))\
.count()
return jsonify(data=data, total=count)
return {'data': data, 'total': count}

View File

@ -1,16 +1,11 @@
# coding=utf-8
from flask import Blueprint
from flask_restful import Api
from .files import api_ns_files
from .files_sonarr import api_ns_files_sonarr
from .files_radarr import api_ns_files_radarr
from .files import BrowseBazarrFS
from .files_sonarr import BrowseSonarrFS
from .files_radarr import BrowseRadarrFS
api_bp_files = Blueprint('api_files', __name__)
api = Api(api_bp_files)
api.add_resource(BrowseBazarrFS, '/files')
api.add_resource(BrowseSonarrFS, '/files/sonarr')
api.add_resource(BrowseRadarrFS, '/files/radarr')
api_ns_list_files = [
api_ns_files,
api_ns_files_radarr,
api_ns_files_sonarr,
]

View File

@ -1,24 +1,40 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from utilities.filesystem import browse_bazarr_filesystem
from ..utils import authenticate
api_ns_files = Namespace('Files Browser for Bazarr', description='Browse content of file system as seen by Bazarr')
@api_ns_files.route('files')
class BrowseBazarrFS(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('path', type=str, default='', help='Path to browse')
get_response_model = api_ns_files.model('BazarrFileBrowserGetResponse', {
'name': fields.String(),
'children': fields.Boolean(),
'path': fields.String(),
})
@authenticate
@api_ns_files.marshal_with(get_response_model, code=200)
@api_ns_files.response(401, 'Not Authenticated')
@api_ns_files.doc(parser=get_request_parser)
def get(self):
path = request.args.get('path') or ''
"""List Bazarr file system content"""
args = self.get_request_parser.parse_args()
path = args.get('path')
data = []
try:
result = browse_bazarr_filesystem(path)
if result is None:
raise ValueError
except Exception:
return jsonify([])
return []
for item in result['directories']:
data.append({'name': item['name'], 'children': True, 'path': item['path']})
return jsonify(data)
return data

View File

@ -1,24 +1,41 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from radarr.filesystem import browse_radarr_filesystem
from ..utils import authenticate
api_ns_files_radarr = Namespace('Files Browser for Radarr', description='Browse content of file system as seen by '
'Radarr')
@api_ns_files_radarr.route('files/radarr')
class BrowseRadarrFS(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('path', type=str, default='', help='Path to browse')
get_response_model = api_ns_files_radarr.model('RadarrFileBrowserGetResponse', {
'name': fields.String(),
'children': fields.Boolean(),
'path': fields.String(),
})
@authenticate
@api_ns_files_radarr.marshal_with(get_response_model, code=200)
@api_ns_files_radarr.response(401, 'Not Authenticated')
@api_ns_files_radarr.doc(parser=get_request_parser)
def get(self):
path = request.args.get('path') or ''
"""List Radarr file system content"""
args = self.get_request_parser.parse_args()
path = args.get('path')
data = []
try:
result = browse_radarr_filesystem(path)
if result is None:
raise ValueError
except Exception:
return jsonify([])
return []
for item in result['directories']:
data.append({'name': item['name'], 'children': True, 'path': item['path']})
return jsonify(data)
return data

View File

@ -1,24 +1,41 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from sonarr.filesystem import browse_sonarr_filesystem
from ..utils import authenticate
api_ns_files_sonarr = Namespace('Files Browser for Sonarr', description='Browse content of file system as seen by '
'Sonarr')
@api_ns_files_sonarr.route('files/sonarr')
class BrowseSonarrFS(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('path', type=str, default='', help='Path to browse')
get_response_model = api_ns_files_sonarr.model('SonarrFileBrowserGetResponse', {
'name': fields.String(),
'children': fields.Boolean(),
'path': fields.String(),
})
@authenticate
@api_ns_files_sonarr.marshal_with(get_response_model, code=200)
@api_ns_files_sonarr.response(401, 'Not Authenticated')
@api_ns_files_sonarr.doc(parser=get_request_parser)
def get(self):
path = request.args.get('path') or ''
"""List Sonarr file system content"""
args = self.get_request_parser.parse_args()
path = args.get('path')
data = []
try:
result = browse_sonarr_filesystem(path)
if result is None:
raise ValueError
except Exception:
return jsonify([])
return []
for item in result['directories']:
data.append({'name': item['name'], 'children': True, 'path': item['path']})
return jsonify(data)
return data

View File

@ -1,12 +1,8 @@
# coding=utf-8
from flask import Blueprint
from flask_restful import Api
from .stats import HistoryStats
from .stats import api_ns_history_stats
api_bp_history = Blueprint('api_history', __name__)
api = Api(api_bp_history)
api.add_resource(HistoryStats, '/history/stats')
api_ns_list_history = [
api_ns_history_stats
]

View File

@ -5,8 +5,7 @@ import datetime
import operator
from dateutil import rrule
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from functools import reduce
from peewee import fn
@ -14,14 +13,45 @@ from app.database import TableHistory, TableHistoryMovie
from ..utils import authenticate
api_ns_history_stats = Namespace('History Statistics', description='Get history statistics')
@api_ns_history_stats.route('history/stats')
class HistoryStats(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('timeFrame', type=str, default='month',
help='Timeframe to get stats for. Must be in ["week", "month", "trimester", '
'"year"]')
get_request_parser.add_argument('action', type=str, default='All', help='Action type to filter for.')
get_request_parser.add_argument('provider', type=str, default='All', help='Provider name to filter for.')
get_request_parser.add_argument('language', type=str, default='All', help='Language name to filter for')
series_data_model = api_ns_history_stats.model('history_series_stats_data_model', {
'date': fields.String(),
'count': fields.Integer(),
})
movies_data_model = api_ns_history_stats.model('history_movies_stats_data_model', {
'date': fields.String(),
'count': fields.Integer(),
})
get_response_model = api_ns_history_stats.model('HistoryStatsGetResponse', {
'series': fields.Nested(series_data_model),
'movies': fields.Nested(movies_data_model),
})
@authenticate
@api_ns_history_stats.marshal_with(get_response_model, code=200)
@api_ns_history_stats.response(401, 'Not Authenticated')
@api_ns_history_stats.doc(parser=get_request_parser)
def get(self):
timeframe = request.args.get('timeFrame') or 'month'
action = request.args.get('action') or 'All'
provider = request.args.get('provider') or 'All'
language = request.args.get('language') or 'All'
"""Get history statistics"""
args = self.get_request_parser.parse_args()
timeframe = args.get('timeFrame')
action = args.get('action')
provider = args.get('provider')
language = args.get('language')
# timeframe must be in ['week', 'month', 'trimester', 'year']
if timeframe == 'year':
@ -82,4 +112,4 @@ class HistoryStats(Resource):
sorted_data_series = sorted(data_series, key=lambda i: i['date'])
sorted_data_movies = sorted(data_movies, key=lambda i: i['date'])
return jsonify(series=sorted_data_series, movies=sorted_data_movies)
return {'series': sorted_data_series, 'movies': sorted_data_movies}

View File

@ -1,20 +1,16 @@
# coding=utf-8
from flask import Blueprint
from flask_restful import Api
from .movies import Movies
from .movies_subtitles import MoviesSubtitles
from .history import MoviesHistory
from .wanted import MoviesWanted
from .blacklist import MoviesBlacklist
from .movies import api_ns_movies
from .movies_subtitles import api_ns_movies_subtitles
from .history import api_ns_movies_history
from .wanted import api_ns_movies_wanted
from .blacklist import api_ns_movies_blacklist
api_bp_movies = Blueprint('api_movies', __name__)
api = Api(api_bp_movies)
api.add_resource(Movies, '/movies')
api.add_resource(MoviesWanted, '/movies/wanted')
api.add_resource(MoviesSubtitles, '/movies/subtitles')
api.add_resource(MoviesHistory, '/movies/history')
api.add_resource(MoviesBlacklist, '/movies/blacklist')
api_ns_list_movies = [
api_ns_movies,
api_ns_movies_blacklist,
api_ns_movies_history,
api_ns_movies_subtitles,
api_ns_movies_wanted,
]

View File

@ -3,8 +3,7 @@
import datetime
import pretty
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from app.database import TableMovies, TableBlacklistMovie
from subtitles.tools.delete import delete_subtitles
@ -12,18 +11,41 @@ from radarr.blacklist import blacklist_log_movie, blacklist_delete_all_movie, bl
from utilities.path_mappings import path_mappings
from subtitles.mass_download import movies_download_subtitles
from app.event_handler import event_stream
from api.swaggerui import subtitles_language_model
from ..utils import authenticate, postprocessMovie
api_ns_movies_blacklist = Namespace('Movies Blacklist', description='List, add or remove subtitles to or from '
'movies blacklist')
# GET: get blacklist
# POST: add blacklist
# DELETE: remove blacklist
@api_ns_movies_blacklist.route('movies/blacklist')
class MoviesBlacklist(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('start', type=int, required=False, default=0, help='Paging start integer')
get_request_parser.add_argument('length', type=int, required=False, default=-1, help='Paging length integer')
get_language_model = api_ns_movies_blacklist.model('subtitles_language_model', subtitles_language_model)
get_response_model = api_ns_movies_blacklist.model('MovieBlacklistGetResponse', {
'title': fields.String(),
'radarrId': fields.Integer(),
'provider': fields.String(),
'subs_id': fields.String(),
'language': fields.Nested(get_language_model),
'timestamp': fields.String(),
'parsed_timestamp': fields.String(),
})
@authenticate
@api_ns_movies_blacklist.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_movies_blacklist.response(401, 'Not Authenticated')
@api_ns_movies_blacklist.doc(parser=get_request_parser)
def get(self):
start = request.args.get('start') or 0
length = request.args.get('length') or -1
"""List blacklisted movies subtitles"""
args = self.get_request_parser.parse_args()
start = args.get('start')
length = args.get('length')
data = TableBlacklistMovie.select(TableMovies.title,
TableMovies.radarrId,
@ -45,14 +67,27 @@ class MoviesBlacklist(Resource):
item["parsed_timestamp"] = datetime.datetime.fromtimestamp(int(item['timestamp'])).strftime('%x %X')
item.update({'timestamp': pretty.date(datetime.datetime.fromtimestamp(item['timestamp']))})
return jsonify(data=data)
return data
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('radarrid', type=int, required=True, help='Radarr ID')
post_request_parser.add_argument('provider', type=str, required=True, help='Provider name')
post_request_parser.add_argument('subs_id', type=str, required=True, help='Subtitles ID')
post_request_parser.add_argument('language', type=str, required=True, help='Subtitles language')
post_request_parser.add_argument('subtitles_path', type=str, required=True, help='Subtitles file path')
@authenticate
@api_ns_movies_blacklist.doc(parser=post_request_parser)
@api_ns_movies_blacklist.response(200, 'Success')
@api_ns_movies_blacklist.response(401, 'Not Authenticated')
@api_ns_movies_blacklist.response(404, 'Movie not found')
def post(self):
radarr_id = int(request.args.get('radarrid'))
provider = request.form.get('provider')
subs_id = request.form.get('subs_id')
language = request.form.get('language')
"""Add a movies subtitles to blacklist"""
args = self.post_request_parser.parse_args()
radarr_id = args.get('radarrid')
provider = args.get('provider')
subs_id = args.get('subs_id')
language = args.get('language')
# TODO
forced = False
hi = False
@ -63,7 +98,7 @@ class MoviesBlacklist(Resource):
return 'Movie not found', 404
media_path = data['path']
subtitles_path = request.form.get('subtitles_path')
subtitles_path = args.get('subtitles_path')
blacklist_log_movie(radarr_id=radarr_id,
provider=provider,
@ -80,12 +115,22 @@ class MoviesBlacklist(Resource):
event_stream(type='movie-history')
return '', 200
delete_request_parser = reqparse.RequestParser()
delete_request_parser.add_argument('all', type=str, required=False, help='Empty movies subtitles blacklist')
delete_request_parser.add_argument('provider', type=str, required=True, help='Provider name')
delete_request_parser.add_argument('subs_id', type=str, required=True, help='Subtitles ID')
@authenticate
@api_ns_movies_blacklist.doc(parser=delete_request_parser)
@api_ns_movies_blacklist.response(204, 'Success')
@api_ns_movies_blacklist.response(401, 'Not Authenticated')
def delete(self):
if request.args.get("all") == "true":
"""Delete a movies subtitles from blacklist"""
args = self.post_request_parser.parse_args()
if args.get("all") == "true":
blacklist_delete_all_movie()
else:
provider = request.form.get('provider')
subs_id = request.form.get('subs_id')
provider = args.get('provider')
subs_id = args.get('subs_id')
blacklist_delete_movie(provider=provider, subs_id=subs_id)
return '', 200

View File

@ -5,8 +5,7 @@ import os
import operator
import pretty
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from functools import reduce
from peewee import fn
from datetime import timedelta
@ -14,16 +13,58 @@ from datetime import timedelta
from app.database import get_exclusion_clause, TableMovies, TableHistoryMovie, TableBlacklistMovie
from app.config import settings
from utilities.path_mappings import path_mappings
from api.swaggerui import subtitles_language_model
from ..utils import authenticate, postprocessMovie
api_ns_movies_history = Namespace('Movies History', description='List movies history events')
@api_ns_movies_history.route('movies/history')
class MoviesHistory(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('start', type=int, required=False, default=0, help='Paging start integer')
get_request_parser.add_argument('length', type=int, required=False, default=-1, help='Paging length integer')
get_request_parser.add_argument('radarrid', type=int, required=False, help='Movie ID')
get_language_model = api_ns_movies_history.model('subtitles_language_model', subtitles_language_model)
data_model = api_ns_movies_history.model('history_movies_data_model', {
'id': fields.Integer(),
'action': fields.Integer(),
'title': fields.String(),
'timestamp': fields.String(),
'description': fields.String(),
'radarrId': fields.Integer(),
'monitored': fields.Boolean(),
'path': fields.String(),
'language': fields.Nested(get_language_model),
'tags': fields.List(fields.String),
'score': fields.String(),
'subs_id': fields.String(),
'provider': fields.String(),
'subtitles_path': fields.String(),
'upgradable': fields.Boolean(),
'raw_timestamp': fields.Integer(),
'parsed_timestamp': fields.String(),
'blacklisted': fields.Boolean(),
})
get_response_model = api_ns_movies_history.model('MovieHistoryGetResponse', {
'data': fields.Nested(data_model),
'total': fields.Integer(),
})
@authenticate
@api_ns_movies_history.marshal_with(get_response_model, code=200)
@api_ns_movies_history.response(401, 'Not Authenticated')
@api_ns_movies_history.doc(parser=get_request_parser)
def get(self):
start = request.args.get('start') or 0
length = request.args.get('length') or -1
radarrid = request.args.get('radarrid')
"""List movies history events"""
args = self.get_request_parser.parse_args()
start = args.get('start')
length = args.get('length')
radarrid = args.get('radarrid')
upgradable_movies = []
upgradable_movies_not_perfect = []
@ -129,4 +170,4 @@ class MoviesHistory(Resource):
.where(TableMovies.title.is_null(False))\
.count()
return jsonify(data=movie_history, total=count)
return {'data': movie_history, 'total': count}

View File

@ -1,23 +1,78 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from app.database import TableMovies
from subtitles.indexer.movies import list_missing_subtitles_movies, movies_scan_subtitles
from app.event_handler import event_stream
from subtitles.wanted import wanted_search_missing_subtitles_movies
from subtitles.mass_download import movies_download_subtitles
from api.swaggerui import subtitles_model, subtitles_language_model, audio_language_model
from ..utils import authenticate, postprocessMovie, None_Keys
api_ns_movies = Namespace('Movies', description='List movies metadata, update movie languages profile or run actions '
'for specific movies.')
@api_ns_movies.route('movies')
class Movies(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('start', type=int, required=False, default=0, help='Paging start integer')
get_request_parser.add_argument('length', type=int, required=False, default=-1, help='Paging length integer')
get_request_parser.add_argument('radarrid[]', type=int, action='append', required=False, default=[],
help='Movies IDs to get metadata for')
get_subtitles_model = api_ns_movies.model('subtitles_model', subtitles_model)
get_subtitles_language_model = api_ns_movies.model('subtitles_language_model', subtitles_language_model)
get_audio_language_model = api_ns_movies.model('audio_language_model', audio_language_model)
data_model = api_ns_movies.model('movies_data_model', {
'alternativeTitles': fields.List(fields.String),
'audio_codec': fields.String(),
'audio_language': fields.Nested(get_audio_language_model),
'failedAttempts': fields.String(),
'fanart': fields.String(),
'file_size': fields.Integer(),
'format': fields.String(),
'imdbId': fields.String(),
'missing_subtitles': fields.Nested(get_subtitles_language_model),
'monitored': fields.Boolean(),
'movie_file_id': fields.Integer(),
'overview': fields.String(),
'path': fields.String(),
'poster': fields.String(),
'profileId': fields.Integer(),
'radarrId': fields.Integer(),
'resolution': fields.String(),
'rowid': fields.Integer(),
'sceneName': fields.String(),
'sortTitle': fields.String(),
'subtitles': fields.Nested(get_subtitles_model),
'tags': fields.List(fields.String),
'title': fields.String(),
'tmdbId': fields.String(),
'video_codec': fields.String(),
'year': fields.String(),
})
get_response_model = api_ns_movies.model('MoviesGetResponse', {
'data': fields.Nested(data_model),
'total': fields.Integer(),
})
@authenticate
@api_ns_movies.marshal_with(get_response_model, code=200)
@api_ns_movies.doc(parser=get_request_parser)
@api_ns_movies.response(200, 'Success')
@api_ns_movies.response(401, 'Not Authenticated')
def get(self):
start = request.args.get('start') or 0
length = request.args.get('length') or -1
radarrId = request.args.getlist('radarrid[]')
"""List movies metadata for specific movies"""
args = self.get_request_parser.parse_args()
start = args.get('start')
length = args.get('length')
radarrId = args.get('radarrid[]')
count = TableMovies.select().count()
@ -32,12 +87,24 @@ class Movies(Resource):
for item in result:
postprocessMovie(item)
return jsonify(data=result, total=count)
return {'data': result, 'total': count}
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('radarrid', type=int, action='append', required=False, default=[],
help='Radarr movie(s) ID')
post_request_parser.add_argument('profileid', type=str, action='append', required=False, default=[],
help='Languages profile(s) ID or "none"')
@authenticate
@api_ns_movies.doc(parser=post_request_parser)
@api_ns_movies.response(204, 'Success')
@api_ns_movies.response(401, 'Not Authenticated')
@api_ns_movies.response(404, 'Languages profile not found')
def post(self):
radarrIdList = request.form.getlist('radarrid')
profileIdList = request.form.getlist('profileid')
"""Update specific movies languages profile"""
args = self.post_request_parser.parse_args()
radarrIdList = args.get('radarrid')
profileIdList = args.get('profileid')
for idx in range(len(radarrIdList)):
radarrId = radarrIdList[idx]
@ -65,10 +132,21 @@ class Movies(Resource):
return '', 204
patch_request_parser = reqparse.RequestParser()
patch_request_parser.add_argument('radarrid', type=int, required=False, help='Radarr movie ID')
patch_request_parser.add_argument('action', type=str, required=False, help='Action to perform from ["scan-disk", '
'"search-missing", "search-wanted"]')
@authenticate
@api_ns_movies.doc(parser=patch_request_parser)
@api_ns_movies.response(204, 'Success')
@api_ns_movies.response(400, 'Unknown action')
@api_ns_movies.response(401, 'Not Authenticated')
def patch(self):
radarrid = request.form.get('radarrid')
action = request.form.get('action')
"""Run actions on specific movies"""
args = self.patch_request_parser.parse_args()
radarrid = args.get('radarrid')
action = args.get('action')
if action == "scan-disk":
movies_scan_subtitles(radarrid)
return '', 204

View File

@ -3,9 +3,9 @@
import os
import logging
from flask import request
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse
from subliminal_patch.core import SUBTITLE_EXTENSIONS
from werkzeug.datastructures import FileStorage
from app.database import TableMovies, get_audio_profile_languages, get_profile_id
from utilities.path_mappings import path_mappings
@ -21,14 +21,26 @@ from app.config import settings
from ..utils import authenticate
# PATCH: Download Subtitles
# POST: Upload Subtitles
# DELETE: Delete Subtitles
api_ns_movies_subtitles = Namespace('Movies Subtitles', description='Download, upload or delete movies subtitles')
@api_ns_movies_subtitles.route('movies/subtitles')
class MoviesSubtitles(Resource):
patch_request_parser = reqparse.RequestParser()
patch_request_parser.add_argument('radarrid', type=int, required=True, help='Movie ID')
patch_request_parser.add_argument('language', type=str, required=True, help='Language code2')
patch_request_parser.add_argument('forced', type=str, required=True, help='Forced true/false as string')
patch_request_parser.add_argument('hi', type=str, required=True, help='HI true/false as string')
@authenticate
@api_ns_movies_subtitles.doc(parser=patch_request_parser)
@api_ns_movies_subtitles.response(204, 'Success')
@api_ns_movies_subtitles.response(401, 'Not Authenticated')
@api_ns_movies_subtitles.response(404, 'Movie not found')
def patch(self):
# Download
radarrId = request.args.get('radarrid')
"""Download a movie subtitles"""
args = self.patch_request_parser.parse_args()
radarrId = args.get('radarrid')
movieInfo = TableMovies.select(TableMovies.title,
TableMovies.path,
@ -47,9 +59,9 @@ class MoviesSubtitles(Resource):
title = movieInfo['title']
audio_language = movieInfo['audio_language']
language = request.form.get('language')
hi = request.form.get('hi').capitalize()
forced = request.form.get('forced').capitalize()
language = args.get('language')
hi = args.get('hi').capitalize()
forced = args.get('forced').capitalize()
audio_language_list = get_audio_profile_languages(movie_id=radarrId)
if len(audio_language_list) > 0:
@ -85,11 +97,25 @@ class MoviesSubtitles(Resource):
return '', 204
# POST: Upload Subtitles
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('radarrid', type=int, required=True, help='Movie ID')
post_request_parser.add_argument('language', type=str, required=True, help='Language code2')
post_request_parser.add_argument('forced', type=str, required=True, help='Forced true/false as string')
post_request_parser.add_argument('hi', type=str, required=True, help='HI true/false as string')
post_request_parser.add_argument('file', type=FileStorage, location='files', required=True,
help='Subtitles file as file upload object')
@authenticate
@api_ns_movies_subtitles.doc(parser=post_request_parser)
@api_ns_movies_subtitles.response(204, 'Success')
@api_ns_movies_subtitles.response(401, 'Not Authenticated')
@api_ns_movies_subtitles.response(404, 'Movie not found')
def post(self):
# Upload
"""Upload a movie subtitles"""
# TODO: Support Multiply Upload
radarrId = request.args.get('radarrid')
args = self.post_request_parser.parse_args()
radarrId = args.get('radarrid')
movieInfo = TableMovies.select(TableMovies.title,
TableMovies.path,
TableMovies.sceneName,
@ -107,10 +133,10 @@ class MoviesSubtitles(Resource):
title = movieInfo['title']
audioLanguage = movieInfo['audio_language']
language = request.form.get('language')
forced = True if request.form.get('forced') == 'true' else False
hi = True if request.form.get('hi') == 'true' else False
subFile = request.files.get('file')
language = args.get('language')
forced = True if args.get('forced') == 'true' else False
hi = True if args.get('hi') == 'true' else False
subFile = args.get('file')
_, ext = os.path.splitext(subFile.filename)
@ -151,10 +177,23 @@ class MoviesSubtitles(Resource):
return '', 204
# DELETE: Delete Subtitles
delete_request_parser = reqparse.RequestParser()
delete_request_parser.add_argument('radarrid', type=int, required=True, help='Movie ID')
delete_request_parser.add_argument('language', type=str, required=True, help='Language code2')
delete_request_parser.add_argument('forced', type=str, required=True, help='Forced true/false as string')
delete_request_parser.add_argument('hi', type=str, required=True, help='HI true/false as string')
delete_request_parser.add_argument('path', type=str, required=True, help='Path of the subtitles file')
@authenticate
@api_ns_movies_subtitles.doc(parser=delete_request_parser)
@api_ns_movies_subtitles.response(204, 'Success')
@api_ns_movies_subtitles.response(401, 'Not Authenticated')
@api_ns_movies_subtitles.response(404, 'Movie not found')
def delete(self):
# Delete
radarrId = request.args.get('radarrid')
"""Delete a movie subtitles"""
args = self.delete_request_parser.parse_args()
radarrId = args.get('radarrid')
movieInfo = TableMovies.select(TableMovies.path) \
.where(TableMovies.radarrId == radarrId) \
.dicts() \
@ -165,21 +204,19 @@ class MoviesSubtitles(Resource):
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
language = request.form.get('language')
forced = request.form.get('forced')
hi = request.form.get('hi')
subtitlesPath = request.form.get('path')
language = args.get('language')
forced = args.get('forced')
hi = args.get('hi')
subtitlesPath = args.get('path')
subtitlesPath = path_mappings.path_replace_reverse_movie(subtitlesPath)
result = delete_subtitles(media_type='movie',
language=language,
forced=forced,
hi=hi,
media_path=moviePath,
subtitles_path=subtitlesPath,
radarr_id=radarrId)
if result:
return '', 202
else:
return '', 204
delete_subtitles(media_type='movie',
language=language,
forced=forced,
hi=hi,
media_path=moviePath,
subtitles_path=subtitlesPath,
radarr_id=radarrId)
return '', 204

View File

@ -2,20 +2,51 @@
import operator
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from functools import reduce
from app.database import get_exclusion_clause, TableMovies
from api.swaggerui import subtitles_language_model
from ..utils import authenticate, postprocessMovie
# GET: Get Wanted Movies
api_ns_movies_wanted = Namespace('Movies Wanted', description='List movies wanted subtitles')
@api_ns_movies_wanted.route('movies/wanted')
class MoviesWanted(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('start', type=int, required=False, default=0, help='Paging start integer')
get_request_parser.add_argument('length', type=int, required=False, default=-1, help='Paging length integer')
get_request_parser.add_argument('radarrid[]', type=int, action='append', required=False, default=[],
help='Movies ID to list')
get_subtitles_language_model = api_ns_movies_wanted.model('subtitles_language_model', subtitles_language_model)
data_model = api_ns_movies_wanted.model('wanted_movies_data_model', {
'title': fields.String(),
'monitored': fields.Boolean(),
'missing_subtitles': fields.Nested(get_subtitles_language_model),
'radarrId': fields.Integer(),
'sceneName': fields.String(),
'tags': fields.List(fields.String),
'failedAttempts': fields.String(),
})
get_response_model = api_ns_movies_wanted.model('MovieWantedGetResponse', {
'data': fields.Nested(data_model),
'total': fields.Integer(),
})
@authenticate
@api_ns_movies_wanted.marshal_with(get_response_model, code=200)
@api_ns_movies_wanted.response(401, 'Not Authenticated')
@api_ns_movies_wanted.doc(parser=get_request_parser)
def get(self):
radarrid = request.args.getlist("radarrid[]")
"""List movies wanted subtitles"""
args = self.get_request_parser.parse_args()
radarrid = args.get("radarrid[]")
wanted_conditions = [(TableMovies.missing_subtitles != '[]')]
if len(radarrid) > 0:
@ -34,8 +65,8 @@ class MoviesWanted(Resource):
.where(wanted_condition)\
.dicts()
else:
start = request.args.get('start') or 0
length = request.args.get('length') or -1
start = args.get('start')
length = args.get('length')
result = TableMovies.select(TableMovies.title,
TableMovies.missing_subtitles,
TableMovies.radarrId,
@ -60,4 +91,4 @@ class MoviesWanted(Resource):
.where(reduce(operator.and_, count_conditions))\
.count()
return jsonify(data=result, total=count)
return {'data': result, 'total': count}

View File

@ -1,16 +1,12 @@
# coding=utf-8
from flask import Blueprint
from flask_restful import Api
from .providers import Providers
from .providers_episodes import ProviderEpisodes
from .providers_movies import ProviderMovies
from .providers import api_ns_providers
from .providers_episodes import api_ns_providers_episodes
from .providers_movies import api_ns_providers_movies
api_bp_providers = Blueprint('api_providers', __name__)
api = Api(api_bp_providers)
api.add_resource(Providers, '/providers')
api.add_resource(ProviderMovies, '/providers/movies')
api.add_resource(ProviderEpisodes, '/providers/episodes')
api_ns_list_providers = [
api_ns_providers,
api_ns_providers_episodes,
api_ns_providers_movies,
]

View File

@ -1,7 +1,6 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from operator import itemgetter
from app.database import TableHistory, TableHistoryMovie
@ -9,11 +8,29 @@ from app.get_providers import list_throttled_providers, reset_throttled_provider
from ..utils import authenticate, False_Keys
api_ns_providers = Namespace('Providers', description='Get and reset providers status')
@api_ns_providers.route('providers')
class Providers(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('history', type=str, required=False, help='Provider name for history stats')
get_response_model = api_ns_providers.model('MovieBlacklistGetResponse', {
'name': fields.String(),
'status': fields.String(),
'retry': fields.String(),
})
@authenticate
@api_ns_providers.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_providers.response(200, 'Success')
@api_ns_providers.response(401, 'Not Authenticated')
@api_ns_providers.doc(parser=get_request_parser)
def get(self):
history = request.args.get('history')
"""Get providers status"""
args = self.get_request_parser.parse_args()
history = args.get('history')
if history and history not in False_Keys:
providers = list(TableHistory.select(TableHistory.provider)
.where(TableHistory.provider is not None and TableHistory.provider != "manual")
@ -29,22 +46,30 @@ class Providers(Resource):
'status': 'History',
'retry': '-'
})
return jsonify(data=sorted(providers_dicts, key=itemgetter('name')))
else:
throttled_providers = list_throttled_providers()
throttled_providers = list_throttled_providers()
providers_dicts = list()
for provider in throttled_providers:
providers_dicts.append({
"name": provider[0],
"status": provider[1] if provider[1] is not None else "Good",
"retry": provider[2] if provider[2] != "now" else "-"
})
return sorted(providers_dicts, key=itemgetter('name'))
providers = list()
for provider in throttled_providers:
providers.append({
"name": provider[0],
"status": provider[1] if provider[1] is not None else "Good",
"retry": provider[2] if provider[2] != "now" else "-"
})
return jsonify(data=providers)
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('action', type=str, required=True, help='Action to perform from ["reset"]')
@authenticate
@api_ns_providers.doc(parser=post_request_parser)
@api_ns_providers.response(204, 'Success')
@api_ns_providers.response(401, 'Not Authenticated')
@api_ns_providers.response(400, 'Unknown action')
def post(self):
action = request.form.get('action')
"""Reset providers status"""
args = self.post_request_parser.parse_args()
action = args.get('action')
if action == 'reset':
reset_throttled_providers()

View File

@ -1,7 +1,6 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from app.database import TableEpisodes, TableShows, get_audio_profile_languages, get_profile_id
from utilities.path_mappings import path_mappings
@ -15,11 +14,40 @@ from subtitles.indexer.series import store_subtitles
from ..utils import authenticate
api_ns_providers_episodes = Namespace('Providers Episodes', description='List and download episodes subtitles manually')
@api_ns_providers_episodes.route('providers/episodes')
class ProviderEpisodes(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('episodeid', type=int, required=True, help='Episode ID')
get_response_model = api_ns_providers_episodes.model('ProviderEpisodesGetResponse', {
'dont_matches': fields.List(fields.String),
'forced': fields.String(),
'hearing_impaired': fields.String(),
'language': fields.String(),
'matches': fields.List(fields.String),
'original_format': fields.String(),
'orig_score': fields.Integer(),
'provider': fields.String(),
'release_info': fields.List(fields.String),
'score': fields.Integer(),
'score_without_hash': fields.Integer(),
'subtitle': fields.String(),
'uploader': fields.String(),
'url': fields.String(),
})
@authenticate
@api_ns_providers_episodes.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_providers_episodes.response(401, 'Not Authenticated')
@api_ns_providers_episodes.response(404, 'Episode not found')
@api_ns_providers_episodes.doc(parser=get_request_parser)
def get(self):
# Manual Search
sonarrEpisodeId = request.args.get('episodeid')
"""Search manually for an episode subtitles"""
args = self.get_request_parser.parse_args()
sonarrEpisodeId = args.get('episodeid')
episodeInfo = TableEpisodes.select(TableEpisodes.path,
TableEpisodes.scene_name,
TableShows.title,
@ -42,13 +70,28 @@ class ProviderEpisodes(Resource):
data = manual_search(episodePath, profileId, providers_list, sceneName, title, 'series')
if not data:
data = []
return jsonify(data=data)
return data
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('seriesid', type=int, required=True, help='Series ID')
post_request_parser.add_argument('episodeid', type=int, required=True, help='Episode ID')
post_request_parser.add_argument('hi', type=str, required=True, help='HI subtitles from ["True", "False"]')
post_request_parser.add_argument('forced', type=str, required=True, help='Forced subtitles from ["True", "False"]')
post_request_parser.add_argument('original_format', type=str, required=True,
help='Use original subtitles format from ["True", "False"]')
post_request_parser.add_argument('provider', type=str, required=True, help='Provider name')
post_request_parser.add_argument('subtitle', type=str, required=True, help='Pickled subtitles as return by GET')
@authenticate
@api_ns_providers_episodes.doc(parser=post_request_parser)
@api_ns_providers_episodes.response(204, 'Success')
@api_ns_providers_episodes.response(401, 'Not Authenticated')
@api_ns_providers_episodes.response(404, 'Episode not found')
def post(self):
# Manual Download
sonarrSeriesId = request.args.get('seriesid')
sonarrEpisodeId = request.args.get('episodeid')
"""Manually download an episode subtitles"""
args = self.post_request_parser.parse_args()
sonarrSeriesId = args.get('seriesid')
sonarrEpisodeId = args.get('episodeid')
episodeInfo = TableEpisodes.select(TableEpisodes.path,
TableEpisodes.scene_name,
TableShows.title) \
@ -64,11 +107,11 @@ class ProviderEpisodes(Resource):
episodePath = path_mappings.path_replace(episodeInfo['path'])
sceneName = episodeInfo['scene_name'] or "None"
hi = request.form.get('hi').capitalize()
forced = request.form.get('forced').capitalize()
use_original_format = request.form.get('original_format').capitalize()
selected_provider = request.form.get('provider')
subtitle = request.form.get('subtitle')
hi = args.get('hi').capitalize()
forced = args.get('forced').capitalize()
use_original_format = args.get('original_format').capitalize()
selected_provider = args.get('provider')
subtitle = args.get('subtitle')
audio_language_list = get_audio_profile_languages(episode_id=sonarrEpisodeId)
if len(audio_language_list) > 0:
@ -78,7 +121,8 @@ class ProviderEpisodes(Resource):
try:
result = manual_download_subtitle(episodePath, audio_language, hi, forced, subtitle, selected_provider,
sceneName, title, 'series', use_original_format, profile_id=get_profile_id(episode_id=sonarrEpisodeId))
sceneName, title, 'series', use_original_format,
profile_id=get_profile_id(episode_id=sonarrEpisodeId))
if result is not None:
message = result[0]
path = result[1]

View File

@ -1,9 +1,6 @@
# coding=utf-8
import logging
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from app.database import TableMovies, get_audio_profile_languages, get_profile_id
from utilities.path_mappings import path_mappings
@ -17,11 +14,40 @@ from subtitles.indexer.movies import store_subtitles_movie
from ..utils import authenticate
api_ns_providers_movies = Namespace('Providers Movies', description='List and download movies subtitles manually')
@api_ns_providers_movies.route('providers/movies')
class ProviderMovies(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('radarrid', type=int, required=True, help='Movie ID')
get_response_model = api_ns_providers_movies.model('ProviderMoviesGetResponse', {
'dont_matches': fields.List(fields.String),
'forced': fields.String(),
'hearing_impaired': fields.String(),
'language': fields.String(),
'matches': fields.List(fields.String),
'original_format': fields.String(),
'orig_score': fields.Integer(),
'provider': fields.String(),
'release_info': fields.List(fields.String),
'score': fields.Integer(),
'score_without_hash': fields.Integer(),
'subtitle': fields.String(),
'uploader': fields.String(),
'url': fields.String(),
})
@authenticate
@api_ns_providers_movies.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_providers_movies.response(401, 'Not Authenticated')
@api_ns_providers_movies.response(404, 'Movie not found')
@api_ns_providers_movies.doc(parser=get_request_parser)
def get(self):
# Manual Search
radarrId = request.args.get('radarrid')
"""Search manually for a movie subtitles"""
args = self.get_request_parser.parse_args()
radarrId = args.get('radarrid')
movieInfo = TableMovies.select(TableMovies.title,
TableMovies.path,
TableMovies.sceneName,
@ -43,12 +69,26 @@ class ProviderMovies(Resource):
data = manual_search(moviePath, profileId, providers_list, sceneName, title, 'movie')
if not data:
data = []
return jsonify(data=data)
return data
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('radarrid', type=int, required=True, help='Movie ID')
post_request_parser.add_argument('hi', type=str, required=True, help='HI subtitles from ["True", "False"]')
post_request_parser.add_argument('forced', type=str, required=True, help='Forced subtitles from ["True", "False"]')
post_request_parser.add_argument('original_format', type=str, required=True,
help='Use original subtitles format from ["True", "False"]')
post_request_parser.add_argument('provider', type=str, required=True, help='Provider name')
post_request_parser.add_argument('subtitle', type=str, required=True, help='Pickled subtitles as return by GET')
@authenticate
@api_ns_providers_movies.doc(parser=post_request_parser)
@api_ns_providers_movies.response(204, 'Success')
@api_ns_providers_movies.response(401, 'Not Authenticated')
@api_ns_providers_movies.response(404, 'Movie not found')
def post(self):
# Manual Download
radarrId = request.args.get('radarrid')
"""Manually download a movie subtitles"""
args = self.post_request_parser.parse_args()
radarrId = args.get('radarrid')
movieInfo = TableMovies.select(TableMovies.title,
TableMovies.path,
TableMovies.sceneName,
@ -64,12 +104,11 @@ class ProviderMovies(Resource):
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
sceneName = movieInfo['sceneName'] or "None"
hi = request.form.get('hi').capitalize()
forced = request.form.get('forced').capitalize()
use_original_format = request.form.get('original_format').capitalize()
logging.debug(f"use_original_format {use_original_format}")
selected_provider = request.form.get('provider')
subtitle = request.form.get('subtitle')
hi = args.get('hi').capitalize()
forced = args.get('forced').capitalize()
use_original_format = args.get('original_format').capitalize()
selected_provider = args.get('provider')
subtitle = args.get('subtitle')
audio_language_list = get_audio_profile_languages(movie_id=radarrId)
if len(audio_language_list) > 0:
@ -79,7 +118,8 @@ class ProviderMovies(Resource):
try:
result = manual_download_subtitle(moviePath, audio_language, hi, forced, subtitle, selected_provider,
sceneName, title, 'movie', use_original_format, profile_id=get_profile_id(movie_id=radarrId))
sceneName, title, 'movie', use_original_format,
profile_id=get_profile_id(movie_id=radarrId))
if result is not None:
message = result[0]
path = result[1]

View File

@ -1,12 +1,8 @@
# coding=utf-8
from flask import Blueprint
from flask_restful import Api
from .series import Series
from .series import api_ns_series
api_bp_series = Blueprint('api_series', __name__)
api = Api(api_bp_series)
api.add_resource(Series, '/series')
api_ns_list_series = [
api_ns_series,
]

View File

@ -2,8 +2,7 @@
import operator
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from functools import reduce
from app.database import get_exclusion_clause, TableEpisodes, TableShows
@ -11,16 +10,64 @@ from subtitles.indexer.series import list_missing_subtitles, series_scan_subtitl
from subtitles.mass_download import series_download_subtitles
from subtitles.wanted import wanted_search_missing_subtitles_series
from app.event_handler import event_stream
from api.swaggerui import subtitles_model, subtitles_language_model, audio_language_model
from ..utils import authenticate, postprocessSeries, None_Keys
api_ns_series = Namespace('Series', description='List series metadata, update series languages profile or run actions '
'for specific series.')
@api_ns_series.route('series')
class Series(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('start', type=int, required=False, default=0, help='Paging start integer')
get_request_parser.add_argument('length', type=int, required=False, default=-1, help='Paging length integer')
get_request_parser.add_argument('seriesid[]', type=int, action='append', required=False, default=[],
help='Series IDs to get metadata for')
get_subtitles_model = api_ns_series.model('subtitles_model', subtitles_model)
get_subtitles_language_model = api_ns_series.model('subtitles_language_model', subtitles_language_model)
get_audio_language_model = api_ns_series.model('audio_language_model', audio_language_model)
data_model = api_ns_series.model('series_data_model', {
'alternativeTitles': fields.List(fields.String),
'audio_language': fields.Nested(get_audio_language_model),
'episodeFileCount': fields.Integer(),
'episodeMissingCount': fields.Integer(),
'fanart': fields.String(),
'imdbId': fields.String(),
'monitored': fields.Boolean(),
'overview': fields.String(),
'path': fields.String(),
'poster': fields.String(),
'profileId': fields.Integer(),
'seriesType': fields.String(),
'sonarrSeriesId': fields.Integer(),
'sortTitle': fields.String(),
'tags': fields.List(fields.String),
'title': fields.String(),
'tvdbId': fields.Integer(),
'year': fields.String(),
})
get_response_model = api_ns_series.model('SeriesGetResponse', {
'data': fields.Nested(data_model),
'total': fields.Integer(),
})
@authenticate
@api_ns_series.marshal_with(get_response_model, code=200)
@api_ns_series.doc(parser=get_request_parser)
@api_ns_series.response(200, 'Success')
@api_ns_series.response(401, 'Not Authenticated')
def get(self):
start = request.args.get('start') or 0
length = request.args.get('length') or -1
seriesId = request.args.getlist('seriesid[]')
"""List series metadata for specific series"""
args = self.get_request_parser.parse_args()
start = args.get('start')
length = args.get('length')
seriesId = args.get('seriesid[]')
count = TableShows.select().count()
@ -58,12 +105,24 @@ class Series(Resource):
.count()
item.update({"episodeFileCount": episodeFileCount})
return jsonify(data=result, total=count)
return {'data': result, 'total': count}
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('seriesid', type=int, action='append', required=False, default=[],
help='Sonarr series ID')
post_request_parser.add_argument('profileid', type=str, action='append', required=False, default=[],
help='Languages profile(s) ID or "none"')
@authenticate
@api_ns_series.doc(parser=post_request_parser)
@api_ns_series.response(204, 'Success')
@api_ns_series.response(401, 'Not Authenticated')
@api_ns_series.response(404, 'Languages profile not found')
def post(self):
seriesIdList = request.form.getlist('seriesid')
profileIdList = request.form.getlist('profileid')
"""Update specific series languages profile"""
args = self.post_request_parser.parse_args()
seriesIdList = args.get('seriesid')
profileIdList = args.get('profileid')
for idx in range(len(seriesIdList)):
seriesId = seriesIdList[idx]
@ -99,10 +158,21 @@ class Series(Resource):
return '', 204
patch_request_parser = reqparse.RequestParser()
patch_request_parser.add_argument('seriesid', type=int, required=False, help='Sonarr series ID')
patch_request_parser.add_argument('action', type=str, required=False, help='Action to perform from ["scan-disk", '
'"search-missing", "search-wanted"]')
@authenticate
@api_ns_series.doc(parser=patch_request_parser)
@api_ns_series.response(204, 'Success')
@api_ns_series.response(400, 'Unknown action')
@api_ns_series.response(401, 'Not Authenticated')
def patch(self):
seriesid = request.form.get('seriesid')
action = request.form.get('action')
"""Run actions on specific series"""
args = self.patch_request_parser.parse_args()
seriesid = args.get('seriesid')
action = args.get('action')
if action == "scan-disk":
series_scan_subtitles(seriesid)
return '', 204

View File

@ -1,14 +1,10 @@
# coding=utf-8
from flask import Blueprint
from flask_restful import Api
from .subtitles import Subtitles
from .subtitles_info import SubtitleNameInfo
from .subtitles import api_ns_subtitles
from .subtitles_info import api_ns_subtitles_info
api_bp_subtitles = Blueprint('api_subtitles', __name__)
api = Api(api_bp_subtitles)
api.add_resource(Subtitles, '/subtitles')
api.add_resource(SubtitleNameInfo, '/subtitles/info')
api_ns_list_subtitles = [
api_ns_subtitles,
api_ns_subtitles_info,
]

View File

@ -4,8 +4,7 @@ import os
import sys
import gc
from flask import request
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse
from app.database import TableEpisodes, TableMovies
from utilities.path_mappings import path_mappings
@ -20,15 +19,37 @@ from app.event_handler import event_stream
from ..utils import authenticate
class Subtitles(Resource):
@authenticate
def patch(self):
action = request.args.get('action')
api_ns_subtitles = Namespace('Subtitles', description='Apply mods/tools on external subtitles')
language = request.form.get('language')
subtitles_path = request.form.get('path')
media_type = request.form.get('type')
id = request.form.get('id')
@api_ns_subtitles.route('subtitles')
class Subtitles(Resource):
patch_request_parser = reqparse.RequestParser()
patch_request_parser.add_argument('action', type=str, required=True,
help='Action from ["sync", "translate" or mods name]')
patch_request_parser.add_argument('language', type=str, required=True, help='Language code2')
patch_request_parser.add_argument('path', type=str, required=True, help='Subtitles file path')
patch_request_parser.add_argument('type', type=str, required=True, help='Media type from ["episode", "movie"]')
patch_request_parser.add_argument('id', type=int, required=True, help='Episode ID')
patch_request_parser.add_argument('forced', type=str, required=False, help='Forced subtitles from ["True", "False"]')
patch_request_parser.add_argument('hi', type=str, required=False, help='HI subtitles from ["True", "False"]')
patch_request_parser.add_argument('original_format', type=str, required=False,
help='Use original subtitles format from ["True", "False"]')
@authenticate
@api_ns_subtitles.doc(parser=patch_request_parser)
@api_ns_subtitles.response(204, 'Success')
@api_ns_subtitles.response(401, 'Not Authenticated')
@api_ns_subtitles.response(404, 'Episode/movie not found')
def patch(self):
"""Apply mods/tools on external subtitles"""
args = self.patch_request_parser.parse_args()
action = args.get('action')
language = args.get('language')
subtitles_path = args.get('path')
media_type = args.get('type')
id = args.get('id')
if media_type == 'episode':
metadata = TableEpisodes.select(TableEpisodes.path, TableEpisodes.sonarrSeriesId)\
@ -62,8 +83,8 @@ class Subtitles(Resource):
elif action == 'translate':
from_language = os.path.splitext(subtitles_path)[0].rsplit(".", 1)[1].replace('_', '-')
dest_language = language
forced = True if request.form.get('forced') == 'true' else False
hi = True if request.form.get('hi') == 'true' else False
forced = True if args.get('forced') == 'true' else False
hi = True if args.get('hi') == 'true' else False
translate_subtitles_file(video_path=video_path, source_srt_file=subtitles_path,
from_lang=from_language, to_lang=dest_language, forced=forced, hi=hi,
media_type="series" if media_type == "episode" else "movies",
@ -71,7 +92,7 @@ class Subtitles(Resource):
sonarr_episode_id=int(id),
radarr_id=id)
else:
use_original_format = True if request.form.get('original_format') == 'true' else False
use_original_format = True if args.get('original_format') == 'true' else False
subtitles_apply_mods(language, subtitles_path, [action], use_original_format)
# apply chmod if required

View File

@ -1,16 +1,37 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from subliminal_patch.core import guessit
from ..utils import authenticate
api_ns_subtitles_info = Namespace('Subtitles Info', description='Guess season number, episode number or language from '
'uploaded subtitles filename')
@api_ns_subtitles_info.route('subtitles/info')
class SubtitleNameInfo(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('filenames[]', type=str, required=True, action='append',
help='Subtitles filenames')
get_response_model = api_ns_subtitles_info.model('SubtitlesInfoGetResponse', {
'filename': fields.String(),
'subtitle_language': fields.String(),
'season': fields.Integer(),
'episode': fields.Integer(),
})
@authenticate
@api_ns_subtitles_info.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_subtitles_info.response(200, 'Success')
@api_ns_subtitles_info.response(401, 'Not Authenticated')
@api_ns_subtitles_info.doc(parser=get_request_parser)
def get(self):
names = request.args.getlist('filenames[]')
"""Guessit over subtitles filename"""
args = self.get_request_parser.parse_args()
names = args.get('filenames[]')
results = []
for name in names:
opts = dict()
@ -27,16 +48,16 @@ class SubtitleNameInfo(Resource):
# for multiple episodes file, choose the first episode number
if len(guessit_result['episode']):
# make sure that guessit returned a list of more than 0 items
result['episode'] = int(guessit_result['episode'][0])
elif isinstance(guessit_result['episode'], (str, int)):
# if single episode (should be int but just in case we cast it to int)
result['episode'] = int(guessit_result['episode'])
result['episode'] = guessit_result['episode'][0]
elif isinstance(guessit_result['episode'], int):
# if single episode
result['episode'] = guessit_result['episode']
if 'season' in guessit_result:
result['season'] = int(guessit_result['season'])
result['season'] = guessit_result['season']
else:
result['season'] = 0
results.append(result)
return jsonify(data=results)
return results

33
bazarr/api/swaggerui.py Normal file
View File

@ -0,0 +1,33 @@
# coding=utf-8
import os
from flask_restx import fields
swaggerui_api_params = {"version": os.environ["BAZARR_VERSION"],
"description": "API docs for Bazarr",
"title": "Bazarr",
}
subtitles_model = {
"name": fields.String(),
"code2": fields.String(),
"code3": fields.String(),
"path": fields.String(),
"forced": fields.Boolean(),
"hi": fields.Boolean()
}
subtitles_language_model = {
"name": fields.String(),
"code2": fields.String(),
"code3": fields.String(),
"forced": fields.Boolean(),
"hi": fields.Boolean()
}
audio_language_model = {
"name": fields.String(),
"code2": fields.String(),
"code3": fields.String()
}

View File

@ -1,35 +1,31 @@
# coding=utf-8
from flask import Blueprint
from flask_restful import Api
from .system import api_ns_system
from .searches import api_ns_system_searches
from .account import api_ns_system_account
from .backups import api_ns_system_backups
from .tasks import api_ns_system_tasks
from .logs import api_ns_system_logs
from .status import api_ns_system_status
from .health import api_ns_system_health
from .releases import api_ns_system_releases
from .settings import api_ns_system_settings
from .languages import api_ns_system_languages
from .languages_profiles import api_ns_system_languages_profiles
from .notifications import api_ns_system_notifications
from .system import System
from .searches import Searches
from .account import SystemAccount
from .backups import SystemBackups
from .tasks import SystemTasks
from .logs import SystemLogs
from .status import SystemStatus
from .health import SystemHealth
from .releases import SystemReleases
from .settings import SystemSettings
from .languages import Languages
from .languages_profiles import LanguagesProfiles
from .notifications import Notifications
api_bp_system = Blueprint('api_system', __name__)
api = Api(api_bp_system)
api.add_resource(System, '/system')
api.add_resource(Searches, '/system/searches')
api.add_resource(SystemAccount, '/system/account')
api.add_resource(SystemBackups, '/system/backups')
api.add_resource(SystemTasks, '/system/tasks')
api.add_resource(SystemLogs, '/system/logs')
api.add_resource(SystemStatus, '/system/status')
api.add_resource(SystemHealth, '/system/health')
api.add_resource(SystemReleases, '/system/releases')
api.add_resource(SystemSettings, '/system/settings')
api.add_resource(Languages, '/system/languages')
api.add_resource(LanguagesProfiles, '/system/languages/profiles')
api.add_resource(Notifications, '/system/notifications')
api_ns_list_system = [
api_ns_system,
api_ns_system_account,
api_ns_system_backups,
api_ns_system_health,
api_ns_system_languages,
api_ns_system_languages_profiles,
api_ns_system_logs,
api_ns_system_notifications,
api_ns_system_releases,
api_ns_system_searches,
api_ns_system_settings,
api_ns_system_status,
api_ns_system_tasks,
]

View File

@ -2,22 +2,37 @@
import gc
from flask import request, session
from flask_restful import Resource
from flask import session
from flask_restx import Resource, Namespace, reqparse
from app.config import settings
from utilities.helper import check_credentials
api_ns_system_account = Namespace('System Account', description='Login or logout from Bazarr UI')
@api_ns_system_account.hide
@api_ns_system_account.route('system/account')
class SystemAccount(Resource):
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('action', type=str, required=True, help='Action from ["login", "logout"]')
post_request_parser.add_argument('username', type=str, required=False, help='Bazarr username')
post_request_parser.add_argument('password', type=str, required=False, help='Bazarr password')
@api_ns_system_account.doc(parser=post_request_parser)
@api_ns_system_account.response(204, 'Success')
@api_ns_system_account.response(400, 'Unknown action')
@api_ns_system_account.response(404, 'Unknown authentication type define in config.ini')
def post(self):
"""Login or logout from Bazarr UI when using form login"""
args = self.post_request_parser.parse_args()
if settings.auth.type != 'form':
return 'Unknown authentication type define in config.ini', 404
action = request.args.get('action')
action = args.get('action')
if action == 'login':
username = request.form.get('username')
password = request.form.get('password')
username = args.get('username')
password = args.get('password')
if check_credentials(username, password):
session['logged_in'] = True
return '', 204

View File

@ -1,36 +1,72 @@
# coding=utf-8
from flask import jsonify, request
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from utilities.backup import get_backup_files, prepare_restore, delete_backup_file, backup_to_zip
from ..utils import authenticate
api_ns_system_backups = Namespace('System Backups', description='List, create, restore or delete backups')
@api_ns_system_backups.route('system/backups')
class SystemBackups(Resource):
@authenticate
def get(self):
backups = get_backup_files(fullpath=False)
return jsonify(data=backups)
get_response_model = api_ns_system_backups.model('SystemBackupsGetResponse', {
'date': fields.String(),
'filename': fields.String(),
'size': fields.String(),
'type': fields.String(),
})
@authenticate
@api_ns_system_backups.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_system_backups.doc(parser=None)
@api_ns_system_backups.response(204, 'Success')
@api_ns_system_backups.response(401, 'Not Authenticated')
def get(self):
"""List backup files"""
backups = get_backup_files(fullpath=False)
return backups
@authenticate
@api_ns_system_backups.doc(parser=None)
@api_ns_system_backups.response(204, 'Success')
@api_ns_system_backups.response(401, 'Not Authenticated')
def post(self):
"""Create a new backup"""
backup_to_zip()
return '', 204
patch_request_parser = reqparse.RequestParser()
patch_request_parser.add_argument('filename', type=str, required=True, help='Backups to restore filename')
@authenticate
@api_ns_system_backups.doc(parser=patch_request_parser)
@api_ns_system_backups.response(204, 'Success')
@api_ns_system_backups.response(400, 'Filename not provided')
@api_ns_system_backups.response(401, 'Not Authenticated')
def patch(self):
filename = request.form.get('filename')
"""Restore a backup file"""
args = self.patch_request_parser.parse_args()
filename = args.get('filename')
if filename:
restored = prepare_restore(filename)
if restored:
return '', 204
return 'Filename not provided', 400
delete_request_parser = reqparse.RequestParser()
delete_request_parser.add_argument('filename', type=str, required=True, help='Backups to delete filename')
@authenticate
@api_ns_system_backups.doc(parser=delete_request_parser)
@api_ns_system_backups.response(204, 'Success')
@api_ns_system_backups.response(400, 'Filename not provided')
@api_ns_system_backups.response(401, 'Not Authenticated')
def delete(self):
filename = request.form.get('filename')
"""Delete a backup file"""
args = self.delete_request_parser.parse_args()
filename = args.get('filename')
if filename:
deleted = delete_backup_file(filename)
if deleted:

View File

@ -1,14 +1,20 @@
# coding=utf-8
from flask import jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace
from utilities.health import get_health_issues
from ..utils import authenticate
api_ns_system_health = Namespace('System Health', description='List health issues')
@api_ns_system_health.route('system/health')
class SystemHealth(Resource):
@authenticate
@api_ns_system_health.doc(parser=None)
@api_ns_system_health.response(200, 'Success')
@api_ns_system_health.response(401, 'Not Authenticated')
def get(self):
return jsonify(data=get_health_issues())
"""List health issues"""
return {'data': get_health_issues()}

View File

@ -1,7 +1,6 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse
from operator import itemgetter
from app.database import TableHistory, TableHistoryMovie, TableSettingsLanguages
@ -9,11 +8,22 @@ from languages.get_languages import alpha2_from_alpha3, language_from_alpha2
from ..utils import authenticate, False_Keys
api_ns_system_languages = Namespace('System Languages', description='Get languages list')
@api_ns_system_languages.route('system/languages')
class Languages(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('history', type=str, required=False, help='Language name for history stats')
@authenticate
@api_ns_system_languages.doc(parser=get_request_parser)
@api_ns_system_languages.response(200, 'Success')
@api_ns_system_languages.response(401, 'Not Authenticated')
def get(self):
history = request.args.get('history')
"""List languages for history filter or for language filter menu"""
args = self.get_request_parser.parse_args()
history = args.get('history')
if history and history not in False_Keys:
languages = list(TableHistory.select(TableHistory.language)
.where(TableHistory.language.is_null(False))
@ -42,13 +52,13 @@ class Languages(Resource):
})
except Exception:
continue
return jsonify(sorted(languages_dicts, key=itemgetter('name')))
else:
languages_dicts = TableSettingsLanguages.select(TableSettingsLanguages.name,
TableSettingsLanguages.code2,
TableSettingsLanguages.enabled)\
.order_by(TableSettingsLanguages.name).dicts()
languages_dicts = list(languages_dicts)
for item in languages_dicts:
item['enabled'] = item['enabled'] == 1
result = TableSettingsLanguages.select(TableSettingsLanguages.name,
TableSettingsLanguages.code2,
TableSettingsLanguages.enabled)\
.order_by(TableSettingsLanguages.name).dicts()
result = list(result)
for item in result:
item['enabled'] = item['enabled'] == 1
return jsonify(result)
return sorted(languages_dicts, key=itemgetter('name'))

View File

@ -1,14 +1,20 @@
# coding=utf-8
from flask import jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace
from app.database import get_profiles_list
from ..utils import authenticate
api_ns_system_languages_profiles = Namespace('System Languages Profiles', description='List languages profiles')
@api_ns_system_languages_profiles.route('system/languages/profiles')
class LanguagesProfiles(Resource):
@authenticate
@api_ns_system_languages_profiles.doc(parser=None)
@api_ns_system_languages_profiles.response(200, 'Success')
@api_ns_system_languages_profiles.response(401, 'Not Authenticated')
def get(self):
return jsonify(get_profiles_list())
"""List languages profiles"""
return get_profiles_list()

View File

@ -3,18 +3,32 @@
import io
import os
from flask import jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, fields
from app.logger import empty_log
from app.get_args import args
from ..utils import authenticate
api_ns_system_logs = Namespace('System Logs', description='List log file entries or empty log file')
@api_ns_system_logs.route('system/logs')
class SystemLogs(Resource):
get_response_model = api_ns_system_logs.model('SystemBackupsGetResponse', {
'timestamp': fields.String(),
'type': fields.String(),
'message': fields.String(),
'exception': fields.String(),
})
@authenticate
@api_ns_system_logs.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_system_logs.doc(parser=None)
@api_ns_system_logs.response(200, 'Success')
@api_ns_system_logs.response(401, 'Not Authenticated')
def get(self):
"""List log entries"""
logs = []
with io.open(os.path.join(args.config_dir, 'log', 'bazarr.log'), encoding='UTF-8') as file:
raw_lines = file.read()
@ -31,12 +45,18 @@ class SystemLogs(Resource):
log["message"] = raw_message[3]
if raw_message_len > 4 and raw_message[4] != '\n':
log['exception'] = raw_message[4].strip('\'').replace(' ', '\u2003\u2003')
else:
log['exception'] = None
logs.append(log)
logs.reverse()
return jsonify(data=logs)
return logs
@authenticate
@api_ns_system_logs.doc(parser=None)
@api_ns_system_logs.response(204, 'Success')
@api_ns_system_logs.response(401, 'Not Authenticated')
def delete(self):
"""Force log rotation and create a new log file"""
empty_log()
return '', 204

View File

@ -2,16 +2,27 @@
import apprise
from flask import request
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse
from ..utils import authenticate
api_ns_system_notifications = Namespace('System Notifications', description='Send test notifications provider message')
@api_ns_system_notifications.hide
@api_ns_system_notifications.route('system/notifications')
class Notifications(Resource):
patch_request_parser = reqparse.RequestParser()
patch_request_parser.add_argument('url', type=str, required=True, help='Notifications provider URL')
@authenticate
@api_ns_system_notifications.doc(parser=patch_request_parser)
@api_ns_system_notifications.response(204, 'Success')
@api_ns_system_notifications.response(401, 'Not Authenticated')
def patch(self):
url = request.form.get("url")
"""Test a notifications provider URL"""
args = self.patch_request_parser.parse_args()
url = args.get("url")
asset = apprise.AppriseAsset(async_mode=False)

View File

@ -5,18 +5,33 @@ import json
import os
import logging
from flask import jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, fields
from app.config import settings
from app.get_args import args
from ..utils import authenticate
api_ns_system_releases = Namespace('System Releases', description='List Bazarr releases from Github')
@api_ns_system_releases.route('system/releases')
class SystemReleases(Resource):
get_response_model = api_ns_system_releases.model('SystemBackupsGetResponse', {
'body': fields.List(fields.String),
'name': fields.String(),
'date': fields.String(),
'prerelease': fields.Boolean(),
'current': fields.Boolean(),
})
@authenticate
@api_ns_system_releases.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_system_releases.doc(parser=None)
@api_ns_system_releases.response(200, 'Success')
@api_ns_system_releases.response(401, 'Not Authenticated')
def get(self):
"""Get Bazarr releases"""
filtered_releases = []
try:
with io.open(os.path.join(args.config_dir, 'config', 'releases.txt'), 'r', encoding='UTF-8') as f:
@ -45,4 +60,4 @@ class SystemReleases(Resource):
except Exception:
logging.exception(
'BAZARR cannot parse releases caching file: ' + os.path.join(args.config_dir, 'config', 'releases.txt'))
return jsonify(data=filtered_releases)
return filtered_releases

View File

@ -1,18 +1,28 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse
from app.config import settings
from app.database import TableShows, TableMovies
from ..utils import authenticate
api_ns_system_searches = Namespace('System Searches', description='Search for series or movies by name')
@api_ns_system_searches.route('system/searches')
class Searches(Resource):
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('query', type=str, required=True, help='Series or movie name to search for')
@authenticate
@api_ns_system_searches.doc(parser=get_request_parser)
@api_ns_system_searches.response(200, 'Success')
@api_ns_system_searches.response(401, 'Not Authenticated')
def get(self):
query = request.args.get('query')
"""List results from query"""
args = self.get_request_parser.parse_args()
query = args.get('query')
search_list = []
if query:
@ -38,4 +48,4 @@ class Searches(Resource):
movies = list(movies)
search_list += movies
return jsonify(search_list)
return search_list

View File

@ -3,7 +3,7 @@
import json
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace
from app.database import TableLanguagesProfiles, TableSettingsLanguages, TableShows, TableMovies, \
TableSettingsNotifier
@ -15,7 +15,11 @@ from subtitles.indexer.movies import list_missing_subtitles_movies
from ..utils import authenticate
api_ns_system_settings = Namespace('systemSettings', description='System settings API endpoint')
@api_ns_system_settings.hide
@api_ns_system_settings.route('system/settings')
class SystemSettings(Resource):
@authenticate
def get(self):

View File

@ -4,8 +4,7 @@ import os
import platform
import logging
from flask import jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace
from tzlocal import get_localzone_name
from radarr.info import get_radarr_info
@ -15,10 +14,14 @@ from init import startTime
from ..utils import authenticate
api_ns_system_status = Namespace('System Status', description='List environment information and versions')
@api_ns_system_status.route('system/status')
class SystemStatus(Resource):
@authenticate
def get(self):
"""Return environment information and versions"""
package_version = ''
if 'BAZARR_PACKAGE_VERSION' in os.environ:
package_version = os.environ['BAZARR_PACKAGE_VERSION']
@ -44,4 +47,4 @@ class SystemStatus(Resource):
system_status.update({'start_time': startTime})
system_status.update({'timezone': timezone})
return jsonify(data=system_status)
return {'data': system_status}

View File

@ -1,16 +1,28 @@
# coding=utf-8
from flask import request
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse
from ..utils import authenticate
api_ns_system = Namespace('System', description='Shutdown or restart Bazarr')
@api_ns_system.hide
@api_ns_system.route('system')
class System(Resource):
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('action', type=str, required=True,
help='Action to perform from ["shutdown", "restart"]')
@authenticate
@api_ns_system.doc(parser=post_request_parser)
@api_ns_system.response(204, 'Success')
@api_ns_system.response(401, 'Not Authenticated')
def post(self):
"""Shutdown or restart Bazarr"""
args = self.post_request_parser.parse_args()
from app.server import webserver
action = request.args.get('action')
action = args.get('action')
if action == "shutdown":
webserver.shutdown()
elif action == "restart":

View File

@ -1,17 +1,37 @@
# coding=utf-8
from flask import request, jsonify
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse, fields
from app.scheduler import scheduler
from ..utils import authenticate
api_ns_system_tasks = Namespace('System Tasks', description='List or execute tasks')
@api_ns_system_tasks.route('system/tasks')
class SystemTasks(Resource):
get_response_model = api_ns_system_tasks.model('SystemBackupsGetResponse', {
'interval': fields.String(),
'job_id': fields.String(),
'job_running': fields.Boolean(),
'name': fields.String(),
'next_run_in': fields.String(),
'next_run_time': fields.String(),
})
get_request_parser = reqparse.RequestParser()
get_request_parser.add_argument('taskid', type=str, required=False, help='List tasks or a single task properties')
@authenticate
@api_ns_system_tasks.marshal_with(get_response_model, envelope='data', code=200)
@api_ns_system_tasks.doc(parser=None)
@api_ns_system_tasks.response(200, 'Success')
@api_ns_system_tasks.response(401, 'Not Authenticated')
def get(self):
taskid = request.args.get('taskid')
"""List tasks"""
args = self.get_request_parser.parse_args()
taskid = args.get('taskid')
task_list = scheduler.get_task_list()
@ -21,11 +41,19 @@ class SystemTasks(Resource):
task_list = [item]
continue
return jsonify(data=task_list)
return task_list
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('taskid', type=str, required=True, help='Task id of the task to run')
@authenticate
@api_ns_system_tasks.doc(parser=post_request_parser)
@api_ns_system_tasks.response(204, 'Success')
@api_ns_system_tasks.response(401, 'Not Authenticated')
def post(self):
taskid = request.form.get('taskid')
"""Run task"""
args = self.post_request_parser.parse_args()
taskid = args.get('taskid')
scheduler.execute_job_now(taskid)

View File

@ -1,16 +1,12 @@
# coding=utf-8
from flask import Blueprint
from flask_restful import Api
from .plex import WebHooksPlex
from .sonarr import WebHooksSonarr
from .radarr import WebHooksRadarr
from .plex import api_ns_webhooks_plex
from .sonarr import api_ns_webhooks_sonarr
from .radarr import api_ns_webhooks_radarr
api_bp_webhooks = Blueprint('api_webhooks', __name__)
api = Api(api_bp_webhooks)
api.add_resource(WebHooksPlex, '/webhooks/plex')
api.add_resource(WebHooksSonarr, '/webhooks/sonarr')
api.add_resource(WebHooksRadarr, '/webhooks/radarr')
api_ns_list_webhooks = [
api_ns_webhooks_plex,
api_ns_webhooks_radarr,
api_ns_webhooks_sonarr,
]

View File

@ -5,8 +5,7 @@ import requests
import os
import logging
from flask import request
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse
from bs4 import BeautifulSoup as bso
from app.database import TableEpisodes, TableShows, TableMovies
@ -15,15 +14,31 @@ from subtitles.mass_download import episode_download_subtitles, movies_download_
from ..utils import authenticate
api_ns_webhooks_plex = Namespace('Webhooks Plex', description='Webhooks endpoint that can be configured in Plex to '
'trigger a subtitles search when playback start.')
@api_ns_webhooks_plex.route('webhooks/plex')
class WebHooksPlex(Resource):
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('payload', type=str, required=True, help='Webhook payload')
@authenticate
@api_ns_webhooks_plex.doc(parser=post_request_parser)
@api_ns_webhooks_plex.response(200, 'Success')
@api_ns_webhooks_plex.response(204, 'Unhandled event')
@api_ns_webhooks_plex.response(400, 'No GUID found')
@api_ns_webhooks_plex.response(401, 'Not Authenticated')
@api_ns_webhooks_plex.response(404, 'IMDB series/movie ID not found')
def post(self):
json_webhook = request.form.get('payload')
"""Trigger subtitles search on play media event in Plex"""
args = self.post_request_parser.parse_args()
json_webhook = args.get('payload')
parsed_json_webhook = json.loads(json_webhook)
event = parsed_json_webhook['event']
if event not in ['media.play']:
return '', 204
return 'Unhandled event', 204
media_type = parsed_json_webhook['Metadata']['type']

View File

@ -1,7 +1,6 @@
# coding=utf-8
from flask import request
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse
from app.database import TableMovies
from subtitles.mass_download import movies_download_subtitles
@ -11,10 +10,23 @@ from utilities.path_mappings import path_mappings
from ..utils import authenticate
api_ns_webhooks_radarr = Namespace('Webhooks Radarr', description='Webhooks to trigger subtitles search based on '
'Radarr movie file ID')
@api_ns_webhooks_radarr.route('webhooks/radarr')
class WebHooksRadarr(Resource):
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('radarr_moviefile_id', type=int, required=True, help='Movie file ID')
@authenticate
@api_ns_webhooks_radarr.doc(parser=post_request_parser)
@api_ns_webhooks_radarr.response(200, 'Success')
@api_ns_webhooks_radarr.response(401, 'Not Authenticated')
def post(self):
movie_file_id = request.form.get('radarr_moviefile_id')
"""Search for missing subtitles for a specific movie file id"""
args = self.post_request_parser.parse_args()
movie_file_id = args.get('radarr_moviefile_id')
radarrMovieId = TableMovies.select(TableMovies.radarrId,
TableMovies.path) \

View File

@ -1,7 +1,6 @@
# coding=utf-8
from flask import request
from flask_restful import Resource
from flask_restx import Resource, Namespace, reqparse
from app.database import TableEpisodes, TableShows
from subtitles.mass_download import episode_download_subtitles
@ -11,10 +10,23 @@ from utilities.path_mappings import path_mappings
from ..utils import authenticate
api_ns_webhooks_sonarr = Namespace('Webhooks Sonarr', description='Webhooks to trigger subtitles search based on '
'Sonarr episode file ID')
@api_ns_webhooks_sonarr.route('webhooks/sonarr')
class WebHooksSonarr(Resource):
post_request_parser = reqparse.RequestParser()
post_request_parser.add_argument('sonarr_episodefile_id', type=int, required=True, help='Episode file ID')
@authenticate
@api_ns_webhooks_sonarr.doc(parser=post_request_parser)
@api_ns_webhooks_sonarr.response(200, 'Success')
@api_ns_webhooks_sonarr.response(401, 'Not Authenticated')
def post(self):
episode_file_id = request.form.get('sonarr_episodefile_id')
"""Search for missing subtitles for a specific episode file id"""
args = self.post_request_parser.parse_args()
episode_file_id = args.get('sonarr_episodefile_id')
sonarrEpisodeId = TableEpisodes.select(TableEpisodes.sonarrEpisodeId,
TableEpisodes.path) \

View File

@ -1,7 +1,6 @@
# coding=utf-8
from flask import Flask, redirect
import os
from flask_cors import CORS
from flask_socketio import SocketIO
@ -21,6 +20,8 @@ def create_app():
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True
app.config['JSON_AS_ASCII'] = False
app.config['RESTX_MASK_SWAGGER'] = False
if settings.get('cors', 'enabled'):
CORS(app)

View File

@ -215,6 +215,7 @@ class TableShows(BaseModel):
audio_language = TextField(null=True)
fanart = TextField(null=True)
imdbId = TextField(default='""', null=True)
monitored = TextField(null=True)
overview = TextField(null=True)
path = TextField(unique=True)
poster = TextField(null=True)
@ -302,6 +303,7 @@ def migrate_db():
migrator.add_column('table_shows', 'seriesType', TextField(default='""', null=True)),
migrator.add_column('table_shows', 'imdbId', TextField(default='""', null=True)),
migrator.add_column('table_shows', 'profileId', IntegerField(null=True)),
migrator.add_column('table_shows', 'monitored', TextField(null=True)),
migrator.add_column('table_episodes', 'format', TextField(null=True)),
migrator.add_column('table_episodes', 'resolution', TextField(null=True)),
migrator.add_column('table_episodes', 'video_codec', TextField(null=True)),
@ -384,6 +386,7 @@ def get_exclusion_clause(exclusion_type):
monitoredOnly = settings.sonarr.getboolean('only_monitored')
if monitoredOnly:
where_clause.append((TableEpisodes.monitored == 'True'))
where_clause.append((TableShows.monitored == 'True'))
else:
monitoredOnly = settings.radarr.getboolean('only_monitored')
if monitoredOnly:

View File

@ -4,10 +4,12 @@ import warnings
import logging
import os
import io
import errno
from waitress.server import create_server
from time import sleep
from api import api_bp_list
from api import api_bp
from .ui import ui_bp
from .get_args import args
from .config import settings, base_url
@ -15,10 +17,7 @@ from .database import database
from .app import create_app
app = create_app()
for item in api_bp_list:
ui_bp.register_blueprint(item, url_prefix='/api')
ui_bp.register_blueprint(api_bp, url_prefix='/api')
app.register_blueprint(ui_bp, url_prefix=base_url.rstrip('/'))
@ -31,16 +30,39 @@ class Server:
# Mute Python3 BrokenPipeError
warnings.simplefilter("ignore", BrokenPipeError)
self.server = create_server(app,
host=str(settings.general.ip),
port=int(args.port) if args.port else int(settings.general.port),
threads=100)
self.server = None
self.connected = False
self.address = str(settings.general.ip)
self.port = int(args.port) if args.port else int(settings.general.port)
while not self.connected:
sleep(0.1)
self.configure_server()
def configure_server(self):
try:
self.server = create_server(app,
host=self.address,
port=self.port,
threads=100)
self.connected = True
except OSError as error:
if error.errno == errno.EADDRNOTAVAIL:
logging.exception("BAZARR cannot bind to specified IP, trying with default (0.0.0.0)")
self.address = '0.0.0.0'
self.connected = False
elif error.errno == errno.EADDRINUSE:
logging.exception("BAZARR cannot bind to specified TCP port, trying with default (6767)")
self.port = '6767'
self.connected = False
else:
logging.exception("BAZARR cannot start because of unhandled exception.")
self.shutdown()
def start(self):
try:
logging.info(
'BAZARR is started and waiting for request on http://' + str(settings.general.ip) + ':' + (str(
args.port) if args.port else str(settings.general.port)) + str(base_url))
logging.info(f'BAZARR is started and waiting for request on http://{self.server.effective_host}:'
f'{self.server.effective_port}')
try:
self.server.run()
except Exception:

View File

@ -13,11 +13,13 @@ from collections import deque
from time import sleep
from constants import headers
from app.event_handler import event_stream
from sonarr.sync.episodes import sync_episodes, sync_one_episode
from sonarr.sync.series import update_series, update_one_series
from radarr.sync.movies import update_movies, update_one_movie
from sonarr.info import get_sonarr_info, url_sonarr
from radarr.info import url_radarr
from .database import TableShows
from .config import settings
from .scheduler import scheduler
@ -236,8 +238,17 @@ def dispatcher(data):
series_title = data['body']['resource']['title']
series_year = data['body']['resource']['year']
elif topic == 'episode':
series_title = data['body']['resource']['series']['title']
series_year = data['body']['resource']['series']['year']
if 'series' in data['body']['resource']:
series_title = data['body']['resource']['series']['title']
series_year = data['body']['resource']['series']['year']
else:
series_metadata = TableShows.select(TableShows.title, TableShows.year)\
.where(TableShows.sonarrSeriesId == data['body']['resource']['seriesId'])\
.dicts()\
.get_or_none()
if series_metadata:
series_title = series_metadata['title']
series_year = series_metadata['year']
episode_title = data['body']['resource']['title']
season_number = data['body']['resource']['seasonNumber']
episode_number = data['body']['resource']['episodeNumber']
@ -264,6 +275,7 @@ def dispatcher(data):
except Exception as e:
logging.debug('BAZARR an exception occurred while parsing SignalR feed: {}'.format(repr(e)))
finally:
event_stream(type='badges')
return

View File

@ -137,6 +137,12 @@ def backup_download(filename):
return send_file(os.path.join(settings.backup.folder, filename), cache_timeout=0, as_attachment=True)
@ui_bp.route('/api/swaggerui/static/<path:filename>', methods=['GET'])
def swaggerui_static(filename):
return send_file(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'libs', 'flask_restx',
'static', filename))
def configured():
System.update({System.configured: '1'}).execute()

View File

@ -2,6 +2,8 @@
import os
from threading import Thread
bazarr_version = 'unknown'
version_file = os.path.join(os.path.dirname(__file__), '..', 'VERSION')
@ -14,22 +16,29 @@ os.environ["BAZARR_VERSION"] = bazarr_version.lstrip('v')
import app.libs # noqa W0611
from threading import Thread # noqa E402
from app.get_args import args # noqa E402
from app.check_update import apply_update, check_releases, check_if_new_update # noqa E402
from app.config import settings, configure_proxy_func, base_url # noqa E402
from init import * # noqa E402
from app.database import System # noqa E402
from app.notifier import update_notifier # noqa E402
from languages.get_languages import load_language_in_db # noqa E402
from app.signalr_client import sonarr_signalr_client, radarr_signalr_client # noqa E402
from app.check_update import apply_update, check_releases # noqa E402
from app.server import webserver # noqa E402
# Install downloaded update
if bazarr_version != '':
apply_update()
check_releases()
# Check for new update and install latest
if args.no_update or not settings.general.getboolean('auto_update'):
# user have explicitly requested that we do not update or is using some kind of package/docker that prevent it
check_releases()
else:
# we want to update to the latest version before loading too much stuff. This should prevent deadlock when
# there's missing embedded packages after a commit
check_if_new_update()
from app.database import System # noqa E402
from app.notifier import update_notifier # noqa E402
from languages.get_languages import load_language_in_db # noqa E402
from app.signalr_client import sonarr_signalr_client, radarr_signalr_client # noqa E402
from app.server import webserver # noqa E402
configure_proxy_func()

View File

@ -45,7 +45,7 @@ def update_movies(send_event=True):
# Get movies data from radarr
movies = get_movies_from_radarr_api(url=url_radarr(), apikey_radarr=apikey_radarr)
if not movies:
if not isinstance(movies, list):
return
else:
# Get current movies in DB
@ -219,7 +219,7 @@ def update_one_movie(movie_id, action, defer_search=False):
movie = movieParser(movie_data, action='insert', tags_dict=tagsDict,
movie_default_profile=movie_default_profile, audio_profiles=audio_profiles)
except Exception:
logging.debug('BAZARR cannot get movie returned by SignalR feed from Radarr API.')
logging.exception('BAZARR cannot get movie returned by SignalR feed from Radarr API.')
return
# Drop useless events

View File

@ -198,7 +198,7 @@ def sync_one_episode(episode_id, defer_search=False):
episode_file_id=episode_data['episodeFileId'])
episode = episodeParser(episode_data)
except Exception:
logging.debug('BAZARR cannot get episode returned by SignalR feed from Sonarr API.')
logging.exception('BAZARR cannot get episode returned by SignalR feed from Sonarr API.')
return
# Drop useless events

View File

@ -52,7 +52,8 @@ def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles)
'alternateTitles': alternate_titles,
'tags': str(tags),
'seriesType': show['seriesType'],
'imdbId': imdbId}
'imdbId': imdbId,
'monitored': str(bool(show['monitored']))}
else:
return {'title': show["title"],
'path': show["path"],
@ -68,7 +69,8 @@ def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles)
'tags': str(tags),
'seriesType': show['seriesType'],
'imdbId': imdbId,
'profileId': serie_default_profile}
'profileId': serie_default_profile,
'monitored': str(bool(show['monitored']))}
def profile_id_to_language(id_, profiles):

View File

@ -37,7 +37,7 @@ def update_series(send_event=True):
# Get shows data from Sonarr
series = get_series_from_sonarr_api(url=url_sonarr(), apikey_sonarr=apikey_sonarr)
if not series:
if not isinstance(series, list):
return
else:
# Get current shows in DB
@ -100,7 +100,8 @@ def update_series(send_event=True):
TableShows.alternateTitles,
TableShows.tags,
TableShows.seriesType,
TableShows.imdbId).dicts()
TableShows.imdbId,
TableShows.monitored).dicts()
for item in series_in_db:
series_in_db_list.append(item)
@ -181,15 +182,15 @@ def update_one_series(series_id, action):
return
else:
if action == 'updated' and existing_series:
series = seriesParser(series_data, action='update', tags_dict=tagsDict,
series = seriesParser(series_data[0], action='update', tags_dict=tagsDict,
serie_default_profile=serie_default_profile,
audio_profiles=audio_profiles)
elif action == 'updated' and not existing_series:
series = seriesParser(series_data, action='insert', tags_dict=tagsDict,
series = seriesParser(series_data[0], action='insert', tags_dict=tagsDict,
serie_default_profile=serie_default_profile,
audio_profiles=audio_profiles)
except Exception:
logging.debug('BAZARR cannot parse series returned by SignalR feed.')
logging.exception('BAZARR cannot get series returned by SignalR feed from Sonarr API.')
return
# Update existing series in DB

View File

@ -93,7 +93,7 @@ def get_series_from_sonarr_api(url, apikey_sonarr, sonarr_series_id=None):
else:
result = r.json()
if isinstance(result, dict):
return list(result)
return [result]
else:
return r.json()

View File

@ -18,6 +18,10 @@ from radarr.notify import notify_radarr
def delete_subtitles(media_type, language, forced, hi, media_path, subtitles_path, sonarr_series_id=None,
sonarr_episode_id=None, radarr_id=None):
if not subtitles_path:
logging.error('No subtitles to delete.')
return False
if not os.path.splitext(subtitles_path)[1] in SUBTITLE_EXTENSIONS:
logging.error('BAZARR can only delete subtitles files.')
return False

View File

@ -55,7 +55,7 @@ class SubSyncer:
unparsed_args = [self.reference, '-i', self.srtin, '-o', self.srtout, '--ffmpegpath', self.ffmpeg_path,
'--vad', self.vad, '--log-dir-path', self.log_dir_path]
if settings.subsync.getboolean('force_audio'):
unparsed_args.append('--no-fix-framerate ')
unparsed_args.append('--no-fix-framerate')
unparsed_args.append('--reference-stream')
unparsed_args.append('a:0')
if settings.subsync.getboolean('debug'):

View File

@ -51,19 +51,6 @@ def manual_upload_subtitle(path, language, forced, hi, title, scene_name, media_
if forced:
lang_obj = Language.rebuild(lang_obj, forced=True)
sub = Subtitle(
lang_obj,
mods=get_array_from(settings.general.subzero_mods)
)
sub.content = subtitle.read()
if not sub.is_valid():
logging.exception('BAZARR Invalid subtitle file: ' + subtitle.filename)
sub.mods = None
if settings.general.getboolean('utf8_encode'):
sub.set_encoding("utf-8")
if media_type == 'series':
episode_metadata = TableEpisodes.select(TableEpisodes.sonarrSeriesId,
TableEpisodes.sonarrEpisodeId,
@ -88,6 +75,20 @@ def manual_upload_subtitle(path, language, forced, hi, title, scene_name, media_
else:
use_original_format = False
sub = Subtitle(
lang_obj,
mods=get_array_from(settings.general.subzero_mods),
original_format=use_original_format
)
sub.content = subtitle.read()
if not sub.is_valid():
logging.exception('BAZARR Invalid subtitle file: ' + subtitle.filename)
sub.mods = None
if settings.general.getboolean('utf8_encode'):
sub.set_encoding("utf-8")
try:
sub.format = (get_format_identifier(os.path.splitext(subtitle.filename)[1]),)
except Exception:

View File

@ -3,31 +3,48 @@
import { readFile } from "fs/promises";
async function read(path: string, sectionName: string, fieldName: string) {
const config = await readFile(path, "utf8");
class ConfigReader {
config?: string;
const targetSection = config
.split("\n\n")
.filter((section) => section.includes(`[${sectionName}]`));
if (targetSection.length === 0) {
throw new Error(`Cannot find [${sectionName}] section in config`);
constructor() {
this.config = undefined;
}
const section = targetSection[0];
for (const line of section.split("\n")) {
const matched = line.startsWith(fieldName);
if (matched) {
const results = line.split("=");
if (results.length === 2) {
const key = results[1].trim();
return key;
}
async open(path: string) {
try {
this.config = await readFile(path, "utf8");
} catch (err) {
// We don't want to catch the error here, handle it on getValue method
}
}
throw new Error(`Cannot find ${fieldName} in config`);
getValue(sectionName: string, fieldName: string) {
if (!this.config) {
throw new Error("Cannot find config to read");
}
const targetSection = this.config
.split("\n\n")
.filter((section) => section.includes(`[${sectionName}]`));
if (targetSection.length === 0) {
throw new Error(`Cannot find [${sectionName}] section in config`);
}
const section = targetSection[0];
for (const line of section.split("\n")) {
const matched = line.startsWith(fieldName);
if (matched) {
const results = line.split("=");
if (results.length === 2) {
const key = results[1].trim();
return key;
}
}
}
throw new Error(`Cannot find ${fieldName} in config`);
}
}
export default async function overrideEnv(env: Record<string, string>) {
@ -37,9 +54,12 @@ export default async function overrideEnv(env: Record<string, string>) {
return;
}
const reader = new ConfigReader();
await reader.open(configPath);
if (env["VITE_API_KEY"] === undefined) {
try {
const apiKey = await read(configPath, "auth", "apikey");
const apiKey = reader.getValue("auth", "apikey");
console.log(`Using API key: ${apiKey}`);
@ -54,8 +74,8 @@ export default async function overrideEnv(env: Record<string, string>) {
if (env["VITE_PROXY_URL"] === undefined) {
try {
const port = await read(configPath, "general", "port");
const baseUrl = await read(configPath, "general", "base_url");
const port = reader.getValue("general", "port");
const baseUrl = reader.getValue("general", "base_url");
const url = `http://127.0.0.1:${port}${baseUrl}`;

View File

@ -4,7 +4,7 @@
<title>Bazarr</title>
<base href="{{baseUrl}}" />
<meta charset="utf-8" />
<link rel="icon" type="image/x-icon" href="./favicon.ico" />
<link rel="icon" type="image/x-icon" href="./images/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1"

File diff suppressed because it is too large Load Diff

View File

@ -13,13 +13,17 @@
},
"private": true,
"dependencies": {
"@mantine/core": "^4",
"@mantine/hooks": "^4",
"@mantine/core": "^5",
"@mantine/form": "^5",
"@mantine/hooks": "^5",
"@mantine/modals": "^5",
"@mantine/notifications": "^5",
"@mantine/dropzone": "^5",
"axios": "^0.27",
"react": "^17",
"react-dom": "^17",
"react-query": "^3",
"react-router-dom": "^6",
"react-router-dom": "6.3.*",
"socket.io-client": "^4"
},
"devDependencies": {
@ -28,19 +32,17 @@
"@fortawesome/free-brands-svg-icons": "^6",
"@fortawesome/free-regular-svg-icons": "^6",
"@fortawesome/free-solid-svg-icons": "^6",
"@fortawesome/react-fontawesome": "^0.1",
"@mantine/modals": "^4",
"@mantine/notifications": "^4",
"@fortawesome/react-fontawesome": "^0.2",
"@testing-library/jest-dom": "latest",
"@testing-library/react": "12",
"@testing-library/react-hooks": "latest",
"@testing-library/user-event": "latest",
"@types/lodash": "^4",
"@types/node": "^17",
"@types/node": "^18",
"@types/react": "^17",
"@types/react-dom": "^17",
"@types/react-table": "^7",
"@vitejs/plugin-react": "^1.3",
"@vitejs/plugin-react": "^2",
"clsx": "^1",
"eslint": "^8",
"eslint-config-react-app": "^7",
@ -52,7 +54,6 @@
"prettier": "^2",
"prettier-plugin-organize-imports": "^3",
"pretty-quick": "^3",
"react-dropzone": "^14",
"react-table": "^7",
"recharts": "^2",
"sass": "^1",

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -81,8 +81,8 @@ const AppHeader: FunctionComponent = () => {
</Group>
<Group spacing="xs" position="right" noWrap>
<Search></Search>
<Menu
control={
<Menu>
<Menu.Target>
<Action
label="System"
tooltip={{ position: "left", openDelay: 2000 }}
@ -92,24 +92,25 @@ const AppHeader: FunctionComponent = () => {
size="lg"
variant="light"
></Action>
}
>
<Menu.Item
icon={<FontAwesomeIcon icon={faArrowRotateLeft} />}
onClick={() => restart()}
>
Restart
</Menu.Item>
<Menu.Item
icon={<FontAwesomeIcon icon={faPowerOff} />}
onClick={() => shutdown()}
>
Shutdown
</Menu.Item>
<Divider hidden={!hasLogout}></Divider>
<Menu.Item hidden={!hasLogout} onClick={() => logout()}>
Logout
</Menu.Item>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
icon={<FontAwesomeIcon icon={faArrowRotateLeft} />}
onClick={() => restart()}
>
Restart
</Menu.Item>
<Menu.Item
icon={<FontAwesomeIcon icon={faPowerOff} />}
onClick={() => shutdown()}
>
Shutdown
</Menu.Item>
<Divider hidden={!hasLogout}></Divider>
<Menu.Item hidden={!hasLogout} onClick={() => logout()}>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>

View File

@ -138,7 +138,7 @@ const AppNavbar: FunctionComponent = () => {
<Action
label="Change Theme"
color={dark ? "yellow" : "indigo"}
variant="hover"
variant="subtle"
onClick={() => toggleColorScheme()}
icon={dark ? faSun : faMoon}
></Action>
@ -149,7 +149,7 @@ const AppNavbar: FunctionComponent = () => {
<Action
label="Donate"
icon={faHeart}
variant="hover"
variant="subtle"
color="red"
></Action>
</Anchor>
@ -254,8 +254,16 @@ const useStyles = createStyles((theme) => {
const hoverBackgroundColor =
theme.colorScheme === "light" ? theme.colors.gray[0] : theme.colors.dark[7];
const textColor =
theme.colorScheme === "light" ? theme.colors.gray[8] : theme.colors.gray[5];
return {
text: { display: "inline-flex", alignItems: "center", width: "100%" },
text: {
display: "inline-flex",
alignItems: "center",
width: "100%",
color: textColor,
},
anchor: {
textDecoration: "none",
borderLeft: `2px solid ${borderColor}`,
@ -273,6 +281,7 @@ const useStyles = createStyles((theme) => {
marginLeft: "auto",
textDecoration: "none",
boxShadow: theme.shadows.xs,
color: textColor,
},
};
});
@ -323,7 +332,6 @@ const NavbarItem: FunctionComponent<NavbarItemProps> = ({
inline
p="xs"
size="sm"
color="gray"
weight={primary ? "bold" : "normal"}
className={classes.text}
>
@ -336,7 +344,6 @@ const NavbarItem: FunctionComponent<NavbarItemProps> = ({
{name}
<Badge
className={classes.badge}
color="gray"
radius="xs"
hidden={badge === undefined || badge === 0}
>

View File

@ -5,6 +5,7 @@ import NavbarProvider from "@/contexts/Navbar";
import OnlineProvider from "@/contexts/Online";
import { notification } from "@/modules/task";
import CriticalError from "@/pages/CriticalError";
import { RouterNames } from "@/Router/RouterNames";
import { Environment } from "@/utilities";
import { AppShell } from "@mantine/core";
import { useWindowEvent } from "@mantine/hooks";
@ -26,7 +27,7 @@ const App: FunctionComponent = () => {
useWindowEvent("app-auth-changed", (ev) => {
if (!ev.detail.authenticated) {
navigate("/login");
navigate(RouterNames.Auth);
}
});

View File

@ -1,6 +1,7 @@
import {
ColorScheme,
ColorSchemeProvider,
createEmotionCache,
MantineProvider,
MantineThemeOverride,
} from "@mantine/core";
@ -49,6 +50,8 @@ function useAutoColorScheme() {
return { colorScheme, setColorScheme, toggleColorScheme };
}
const emotionCache = createEmotionCache({ key: "bazarr" });
const ThemeProvider: FunctionComponent = ({ children }) => {
const { colorScheme, toggleColorScheme } = useAutoColorScheme();
@ -61,7 +64,7 @@ const ThemeProvider: FunctionComponent = ({ children }) => {
withGlobalStyles
withNormalizeCSS
theme={{ colorScheme, ...theme }}
emotionOptions={{ key: "bazarr" }}
emotionCache={emotionCache}
>
{children}
</MantineProvider>

View File

@ -0,0 +1,4 @@
export const RouterNames = {
Auth: "/login",
NotFound: "/not-found",
};

View File

@ -49,6 +49,7 @@ import {
} from "react";
import { BrowserRouter } from "react-router-dom";
import Redirector from "./Redirector";
import { RouterNames } from "./RouterNames";
import { CustomRouteObject } from "./type";
const HistoryStats = lazy(() => import("@/pages/History/Statistics"));
@ -285,7 +286,7 @@ function useRoutes(): CustomRouteObject[] {
],
},
{
path: "/login",
path: RouterNames.Auth,
hidden: true,
element: <Authentication></Authentication>,
},

View File

@ -2,7 +2,12 @@ import { useServerSearch } from "@/apis/hooks";
import { useDebouncedValue } from "@/utilities";
import { faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Anchor, Autocomplete, SelectItemProps } from "@mantine/core";
import {
Anchor,
Autocomplete,
createStyles,
SelectItemProps,
} from "@mantine/core";
import { forwardRef, FunctionComponent, useMemo, useState } from "react";
import { Link } from "react-router-dom";
@ -36,12 +41,31 @@ function useSearch(query: string) {
);
}
const useStyles = createStyles((theme) => {
return {
result: {
color:
theme.colorScheme === "light"
? theme.colors.dark[8]
: theme.colors.gray[1],
},
};
});
type ResultCompProps = SelectItemProps & SearchResultItem;
const ResultComponent = forwardRef<HTMLDivElement, ResultCompProps>(
({ link, value }, ref) => {
const styles = useStyles();
return (
<Anchor component={Link} to={link} underline={false} color="gray" p="sm">
<Anchor
component={Link}
to={link}
underline={false}
className={styles.classes.result}
p="sm"
>
{value}
</Anchor>
);

View File

@ -105,7 +105,7 @@ export function useTools() {
interface Props {
selections: FormType.ModifySubtitle[];
children?: ReactElement;
menu?: Omit<MenuProps, "control" | "children">;
menu?: Omit<MenuProps, "children">;
onAction?: (action: "delete" | "search") => void;
}
@ -138,70 +138,67 @@ const SubtitleToolsMenu: FunctionComponent<Props> = ({
const disabledTools = selections.length === 0;
return (
<Menu
control={children}
withArrow
placement="end"
position="left"
{...menu}
>
<Menu.Label>Tools</Menu.Label>
{tools.map((tool) => (
<Menu withArrow position="left-end" {...menu}>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Tools</Menu.Label>
{tools.map((tool) => (
<Menu.Item
key={tool.key}
disabled={disabledTools}
icon={<FontAwesomeIcon icon={tool.icon}></FontAwesomeIcon>}
onClick={() => {
if (tool.modal) {
modals.openContextModal(tool.modal, { selections });
} else {
process(tool.key, tool.name);
}
}}
>
{tool.name}
</Menu.Item>
))}
<Divider></Divider>
<Menu.Label>Actions</Menu.Label>
<Menu.Item
key={tool.key}
disabled={disabledTools}
icon={<FontAwesomeIcon icon={tool.icon}></FontAwesomeIcon>}
disabled={selections.length !== 0 || onAction === undefined}
icon={<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>}
onClick={() => {
if (tool.modal) {
modals.openContextModal(tool.modal, { selections });
} else {
process(tool.key, tool.name);
}
onAction?.("search");
}}
>
{tool.name}
Search
</Menu.Item>
))}
<Divider></Divider>
<Menu.Label>Actions</Menu.Label>
<Menu.Item
disabled={selections.length !== 0 || onAction === undefined}
icon={<FontAwesomeIcon icon={faSearch}></FontAwesomeIcon>}
onClick={() => {
onAction?.("search");
}}
>
Search
</Menu.Item>
<Menu.Item
disabled={selections.length === 0 || onAction === undefined}
color="red"
icon={<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>}
onClick={() => {
modals.openConfirmModal({
title: "The following subtitles will be deleted",
size: "lg",
children: (
<ScrollArea style={{ maxHeight: "20rem" }}>
<List>
{selections.map((s) => (
<List.Item my="md" key={s.path}>
{s.path}
</List.Item>
))}
</List>
</ScrollArea>
),
onConfirm: () => {
onAction?.("delete");
},
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
});
}}
>
Delete...
</Menu.Item>
<Menu.Item
disabled={selections.length === 0 || onAction === undefined}
color="red"
icon={<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>}
onClick={() => {
modals.openConfirmModal({
title: "The following subtitles will be deleted",
size: "lg",
children: (
<ScrollArea style={{ maxHeight: "20rem" }}>
<List>
{selections.map((s) => (
<List.Item my="md" key={s.path}>
{s.path}
</List.Item>
))}
</List>
</ScrollArea>
),
onConfirm: () => {
onAction?.("delete");
},
labels: { confirm: "Delete", cancel: "Cancel" },
confirmProps: { color: "red" },
});
}}
>
Delete...
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};

View File

@ -3,7 +3,7 @@ import { useCallback, useState } from "react";
import { UseMutationResult } from "react-query";
type MutateButtonProps<DATA, VAR> = Omit<
ButtonProps<"button">,
ButtonProps,
"onClick" | "loading" | "color"
> & {
mutation: UseMutationResult<DATA, unknown, VAR>;

View File

@ -4,7 +4,7 @@ import { FunctionComponent } from "react";
export type AudioListProps = GroupProps & {
audios: Language.Info[];
badgeProps?: BadgeProps<"div">;
badgeProps?: BadgeProps;
};
const AudioList: FunctionComponent<AudioListProps> = ({

View File

@ -2,7 +2,7 @@ import { BuildKey } from "@/utilities";
import { Badge, Group, Text, TextProps } from "@mantine/core";
import { FunctionComponent, useMemo } from "react";
type LanguageTextProps = TextProps<"div"> & {
type LanguageTextProps = TextProps & {
value: Language.Info;
long?: boolean;
};

View File

@ -2,8 +2,9 @@ import { useSubtitleAction } from "@/apis/hooks";
import { Selector, SelectorOption } from "@/components";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import FormUtils from "@/utilities/form";
import { Button, Divider, Stack } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { useForm } from "@mantine/form";
import { FunctionComponent } from "react";
const TaskName = "Changing Color";
@ -92,8 +93,11 @@ const ColorToolForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
initialValues: {
color: "",
},
validationRules: {
color: (c) => colorOptions.find((op) => op.value === c) !== undefined,
validate: {
color: FormUtils.validation(
(value) => colorOptions.find((op) => op.value === value) !== undefined,
"Must select a color"
),
},
});

View File

@ -1,8 +1,9 @@
import { useSubtitleAction } from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import FormUtils from "@/utilities/form";
import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { useForm } from "@mantine/form";
import { FunctionComponent } from "react";
const TaskName = "Changing Frame Rate";
@ -25,9 +26,15 @@ const FrameRateForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
from: 0,
to: 0,
},
validationRules: {
from: (v) => v > 0,
to: (v) => v > 0,
validate: {
from: FormUtils.validation(
(value) => value > 0,
"The From value must be larger than 0"
),
to: FormUtils.validation(
(value) => value > 0,
"The To value must be larger than 0"
),
},
});

View File

@ -3,7 +3,7 @@ import { MultiSelector, Selector } from "@/components/inputs";
import { useModals, withModal } from "@/modules/modals";
import { GetItemId, useSelectorOptions } from "@/utilities";
import { Button, Divider, Group, LoadingOverlay, Stack } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { useForm } from "@mantine/form";
import { FunctionComponent, useMemo } from "react";
import { UseMutationResult } from "react-query";

View File

@ -3,6 +3,7 @@ import { useModals, withModal } from "@/modules/modals";
import { task, TaskGroup } from "@/modules/task";
import { useTableStyles } from "@/styles";
import { useArrayAction, useSelectorOptions } from "@/utilities";
import FormUtils from "@/utilities/form";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
@ -23,7 +24,7 @@ import {
Stack,
Text,
} from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { useForm } from "@mantine/form";
import { isString } from "lodash";
import { FunctionComponent, useEffect, useMemo } from "react";
import { Column } from "react-table";
@ -112,8 +113,8 @@ const MovieUploadForm: FunctionComponent<Props> = ({
validateResult: validator(movie, v),
})),
},
validationRules: {
files: (values) => {
validate: {
files: FormUtils.validation((values) => {
return (
values.find(
(v) =>
@ -122,7 +123,7 @@ const MovieUploadForm: FunctionComponent<Props> = ({
v.validateResult.state === "error"
) === undefined
);
},
}, "Some files cannot be uploaded, please check"),
},
});

View File

@ -3,10 +3,10 @@ import { useModals, withModal } from "@/modules/modals";
import { useTableStyles } from "@/styles";
import { useArrayAction, useSelectorOptions } from "@/utilities";
import { LOG } from "@/utilities/console";
import FormUtils from "@/utilities/form";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import {
Accordion,
Alert,
Button,
Checkbox,
Stack,
@ -14,7 +14,7 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { useForm } from "@mantine/form";
import { FunctionComponent, useCallback, useMemo } from "react";
import { Column } from "react-table";
import ChipInput from "../inputs/ChipInput";
@ -49,15 +49,14 @@ const ProfileEditForm: FunctionComponent<Props> = ({
const form = useForm({
initialValues: profile,
validationRules: {
name: (value) => value.length > 0,
items: (value) => value.length > 0,
},
errorMessages: {
items: (
<Alert color="yellow" variant="outline">
Must contain at lease 1 language
</Alert>
validate: {
name: FormUtils.validation(
(value) => value.length > 0,
"Must have a name"
),
items: FormUtils.validation(
(value) => value.length > 0,
"Must contain at lease 1 language"
),
},
});
@ -239,19 +238,18 @@ const ProfileEditForm: FunctionComponent<Props> = ({
<Stack>
<TextInput label="Name" {...form.getInputProps("name")}></TextInput>
<Accordion
offsetIcon={false}
multiple
iconPosition="right"
initialItem={0}
chevronPosition="right"
defaultValue={["Languages"]}
styles={(theme) => ({
contentInner: {
content: {
[theme.fn.smallerThan("md")]: {
padding: 0,
},
},
})}
>
<Accordion.Item label="Languages">
<Accordion.Item value="Languages">
<Stack>
{form.errors.items}
<SimpleTable
@ -272,7 +270,7 @@ const ProfileEditForm: FunctionComponent<Props> = ({
></Selector>
</Stack>
</Accordion.Item>
<Accordion.Item label="Release Info">
<Accordion.Item value="Release Info">
<Stack>
<ChipInput
label="Must contain"
@ -295,7 +293,7 @@ const ProfileEditForm: FunctionComponent<Props> = ({
</Text>
</Stack>
</Accordion.Item>
<Accordion.Item label="Subtitles">
<Accordion.Item value="Subtitles">
<Stack my="xs">
<Switch
label="Use Original Format"

View File

@ -7,6 +7,7 @@ import { useModals, withModal } from "@/modules/modals";
import { task, TaskGroup } from "@/modules/task";
import { useTableStyles } from "@/styles";
import { useArrayAction, useSelectorOptions } from "@/utilities";
import FormUtils from "@/utilities/form";
import {
useLanguageProfileBy,
useProfileItemsToLanguages,
@ -27,7 +28,7 @@ import {
Stack,
Text,
} from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { useForm } from "@mantine/form";
import { isString } from "lodash";
import { FunctionComponent, useEffect, useMemo } from "react";
import { Column } from "react-table";
@ -125,15 +126,18 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
validateResult: validator(file),
})),
},
validationRules: {
files: (values) =>
values.find(
(v) =>
v.language === null ||
v.episode === null ||
v.validateResult === undefined ||
v.validateResult.state === "error"
) === undefined,
validate: {
files: FormUtils.validation(
(values) =>
values.find(
(v) =>
v.language === null ||
v.episode === null ||
v.validateResult === undefined ||
v.validateResult.state === "error"
) === undefined,
"Some files cannot be uploaded, please check"
),
},
});

View File

@ -1,10 +1,11 @@
import { useSubtitleAction } from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import FormUtils from "@/utilities/form";
import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Divider, Group, NumberInput, Stack } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { useForm } from "@mantine/form";
import { FunctionComponent } from "react";
const TaskName = "Changing Time";
@ -30,11 +31,14 @@ const TimeOffsetForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
sec: 0,
ms: 0,
},
validationRules: {
hour: (v) => v >= 0,
min: (v) => v >= 0,
sec: (v) => v >= 0,
ms: (v) => v >= 0,
validate: {
hour: FormUtils.validation((v) => v >= 0, "Hour must be larger than 0"),
min: FormUtils.validation((v) => v >= 0, "Minute must be larger than 0"),
sec: FormUtils.validation((v) => v >= 0, "Second must be larger than 0"),
ms: FormUtils.validation(
(v) => v >= 0,
"Millisecond must be larger than 0"
),
},
});

View File

@ -2,9 +2,10 @@ import { useSubtitleAction } from "@/apis/hooks";
import { useModals, withModal } from "@/modules/modals";
import { task } from "@/modules/task";
import { useSelectorOptions } from "@/utilities";
import FormUtils from "@/utilities/form";
import { useEnabledLanguages } from "@/utilities/languages";
import { Alert, Button, Divider, Stack } from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { useForm } from "@mantine/form";
import { isObject } from "lodash";
import { FunctionComponent, useMemo } from "react";
import { Selector } from "../inputs";
@ -138,8 +139,8 @@ const TranslationForm: FunctionComponent<Props> = ({
initialValues: {
language: null as Language.Info | null,
},
validationRules: {
language: isObject,
validate: {
language: FormUtils.validation(isObject, "Please select a language"),
},
});

View File

@ -11,7 +11,7 @@ import {
} from "@mantine/core";
import { forwardRef } from "react";
export type ActionProps = ActionIconProps<"button"> & {
export type ActionProps = MantineComp<ActionIconProps, "button"> & {
icon: IconDefinition;
label: string;
tooltip?: Omit<TooltipProps, "label" | "children">;

View File

@ -26,7 +26,9 @@ const ChipInput: FunctionComponent<ChipInputProps> = ({ ...props }) => {
getCreateLabel={(query) => `Add "${query}"`}
onCreate={(query) => {
onChange?.([...(value ?? []), query]);
return query;
}}
buildOption={(value) => value}
></MultiSelector>
);
};

View File

@ -0,0 +1,43 @@
import {
faArrowUp,
faFileCirclePlus,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createStyles, Group, Stack, Text } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { FunctionComponent } from "react";
const useStyle = createStyles((theme) => {
return {
container: {
pointerEvents: "none",
minHeight: 220,
},
};
});
export const DropContent: FunctionComponent = () => {
const { classes } = useStyle();
return (
<Group position="center" spacing="xl" className={classes.container}>
<Dropzone.Idle>
<FontAwesomeIcon icon={faFileCirclePlus} size="2x" />
</Dropzone.Idle>
<Dropzone.Accept>
<FontAwesomeIcon icon={faArrowUp} size="2x" />
</Dropzone.Accept>
<Dropzone.Reject>
<FontAwesomeIcon icon={faXmark} size="2x" />
</Dropzone.Reject>
<Stack spacing={0}>
<Text size="lg">Upload Subtitles</Text>
<Text color="dimmed" size="sm">
Attach as many files as you like, you will need to select file
metadata before uploading
</Text>
</Stack>
</Group>
);
};

View File

@ -1,129 +0,0 @@
import {
faArrowUp,
faFileCirclePlus,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Box, createStyles, Overlay, Stack, Text } from "@mantine/core";
import clsx from "clsx";
import { FunctionComponent, useMemo } from "react";
import { DropzoneState } from "react-dropzone";
const useStyle = createStyles((theme) => {
return {
container: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
inner: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
margin: theme.spacing.md,
borderRadius: theme.radius.md,
borderWidth: "0.2rem",
borderStyle: "dashed",
borderColor: theme.colors.gray[7],
backgroundColor: theme.fn.rgba(theme.colors.gray[0], 0.4),
},
accepted: {
borderColor: theme.colors.brand[7],
backgroundColor: theme.fn.rgba(theme.colors.brand[0], 0.6),
},
rejected: {
borderColor: theme.colors.red[7],
backgroundColor: theme.fn.rgba(theme.colors.red[0], 0.9),
},
};
});
export interface DropOverlayProps {
state: DropzoneState;
zIndex?: number;
}
export const DropOverlay: FunctionComponent<DropOverlayProps> = ({
state,
children,
zIndex = 10,
}) => {
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept: accepted,
isDragReject: rejected,
} = state;
const { classes } = useStyle();
const visible = isDragActive;
const icon = useMemo(() => {
if (accepted) {
return faArrowUp;
} else if (rejected) {
return faXmark;
} else {
return faFileCirclePlus;
}
}, [accepted, rejected]);
const title = useMemo(() => {
if (accepted) {
return "Release to Upload";
} else if (rejected) {
return "Cannot Upload Files";
} else {
return "Upload Subtitles";
}
}, [accepted, rejected]);
const subtitle = useMemo(() => {
if (accepted) {
return "";
} else if (rejected) {
return "Some files are invalid";
} else {
return "Drop to upload";
}
}, [accepted, rejected]);
return (
<Box sx={{ position: "relative" }} {...getRootProps()}>
{/* Fix for some browsers. Some browsers need a input element to trigger the file browser panel */}
<input {...getInputProps()} hidden />
{visible && (
<Box className={classes.container} style={{ zIndex }}>
<Stack
spacing="xs"
className={clsx(classes.inner, {
[classes.accepted]: accepted,
[classes.rejected]: rejected,
})}
style={{ zIndex: zIndex + 1 }}
>
<Box>
<FontAwesomeIcon icon={icon} size="3x" />
</Box>
<Text size="xl">{title}</Text>
<Text color="gray" size="sm">
{subtitle}
</Text>
</Stack>
<Overlay zIndex={zIndex}></Overlay>
</Box>
)}
{children}
</Box>
);
};

View File

@ -109,6 +109,7 @@ export type MultiSelectorProps<T> = Override<
options: readonly SelectorOption<T>[];
onChange?: (value: T[]) => void;
getkey?: (value: T) => string;
buildOption?: (value: string) => T;
},
Omit<MultiSelectProps, "data">
>;
@ -119,11 +120,15 @@ export function MultiSelector<T>({
options,
onChange,
getkey = DefaultKeyBuilder,
buildOption,
...select
}: MultiSelectorProps<T>) {
const labelRef = useRef(getkey);
labelRef.current = getkey;
const buildRef = useRef(buildOption);
buildRef.current = buildOption;
const data = useMemo(
() =>
options.map<SelectItemWithPayload<T>>(({ value, ...option }) => ({
@ -150,6 +155,8 @@ export function MultiSelector<T>({
const payload = data.find((v) => v.value === value)?.payload;
if (payload) {
payloads.push(payload);
} else if (buildRef.current) {
payloads.push(buildRef.current(value));
}
}
onChange?.(payloads);
@ -159,10 +166,10 @@ export function MultiSelector<T>({
return (
<MultiSelect
{...select}
value={wrappedValue}
defaultValue={wrappedDefaultValue}
onChange={wrappedOnChange}
{...select}
data={data}
></MultiSelect>
);

View File

@ -1,4 +1,4 @@
export { default as Action } from "./Action";
export * from "./DropOverlay";
export * from "./DropContent";
export * from "./FileBrowser";
export * from "./Selector";

View File

@ -237,40 +237,38 @@ const StateIcon: FunctionComponent<{ matches: string[]; dont: string[] }> = ({
const { ref, hovered } = useHover();
return (
<Popover
opened={hovered}
placement="center"
position="top"
target={
<Popover opened={hovered} position="top" width={360} withArrow>
<Popover.Target>
<Text color={hasIssues ? "yellow" : "green"} ref={ref}>
<FontAwesomeIcon
icon={hasIssues ? faExclamationCircle : faCheckCircle}
></FontAwesomeIcon>
</Text>
}
>
<Group align="flex-start" spacing="xl">
<Stack align="flex-start" spacing="xs">
<Text color="green">
<FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>
</Text>
<List>
{matches.map((v, idx) => (
<List.Item key={BuildKey(idx, v, "match")}>{v}</List.Item>
))}
</List>
</Stack>
<Stack align="flex-start" spacing="xs">
<Text color="yellow">
<FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>
</Text>
<List>
{dont.map((v, idx) => (
<List.Item key={BuildKey(idx, v, "miss")}>{v}</List.Item>
))}
</List>
</Stack>
</Group>
</Popover.Target>
<Popover.Dropdown>
<Group position="left" spacing="xl" noWrap grow>
<Stack align="flex-start" justify="flex-start" spacing="xs" mb="auto">
<Text color="green">
<FontAwesomeIcon icon={faCheck}></FontAwesomeIcon>
</Text>
<List>
{matches.map((v, idx) => (
<List.Item key={BuildKey(idx, v, "match")}>{v}</List.Item>
))}
</List>
</Stack>
<Stack align="flex-start" justify="flex-start" spacing="xs" mb="auto">
<Text color="yellow">
<FontAwesomeIcon icon={faTimes}></FontAwesomeIcon>
</Text>
<List>
{dont.map((v, idx) => (
<List.Item key={BuildKey(idx, v, "miss")}>{v}</List.Item>
))}
</List>
</Stack>
</Group>
</Popover.Dropdown>
</Popover>
);
};

View File

@ -2,19 +2,18 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, ButtonProps, Text } from "@mantine/core";
import {
ComponentProps,
FunctionComponent,
PropsWithChildren,
useCallback,
useState,
} from "react";
type ToolboxButtonProps = Omit<
ButtonProps<"button">,
"color" | "variant" | "leftIcon"
> & {
icon: IconDefinition;
children: string;
};
type ToolboxButtonProps = Omit<ButtonProps, "color" | "variant" | "leftIcon"> &
Omit<ComponentProps<"button">, "ref"> & {
icon: IconDefinition;
children: string;
};
const ToolboxButton: FunctionComponent<ToolboxButtonProps> = ({
icon,

View File

@ -10,7 +10,7 @@ import {
Stack,
TextInput,
} from "@mantine/core";
import { useForm } from "@mantine/hooks";
import { useForm } from "@mantine/form";
import { FunctionComponent } from "react";
const Authentication: FunctionComponent = () => {
@ -33,7 +33,11 @@ const Authentication: FunctionComponent = () => {
src={`${Environment.baseUrl}/images/logo128.png`}
></Avatar>
<Divider></Divider>
<form onSubmit={form.onSubmit(login)}>
<form
onSubmit={form.onSubmit((values) => {
login(values);
})}
>
<Stack>
<TextInput
placeholder="Username"

View File

@ -2,7 +2,7 @@ import { useEpisodeSubtitleModification } from "@/apis/hooks";
import Language from "@/components/bazarr/Language";
import SubtitleToolsMenu from "@/components/SubtitleToolsMenu";
import { task, TaskGroup } from "@/modules/task";
import { Badge, FloatingTooltip, MantineColor } from "@mantine/core";
import { Badge, MantineColor, Tooltip } from "@mantine/core";
import { FunctionComponent, useMemo, useState } from "react";
interface Props {
@ -49,11 +49,20 @@ export const Subtitle: FunctionComponent<Props> = ({
return list;
}, [episodeId, subtitle.code2, subtitle.path]);
const ctx = (
<Badge color={color}>
<Language.Text value={subtitle} long={false}></Language.Text>
</Badge>
);
if (disabled) {
return <Tooltip.Floating label="Embedded Subtitle">{ctx}</Tooltip.Floating>;
}
return (
<SubtitleToolsMenu
menu={{
trigger: "hover",
opened: disabled ? false : undefined,
onOpen: () => setOpen(true),
onClose: () => setOpen(false),
}}
@ -93,11 +102,7 @@ export const Subtitle: FunctionComponent<Props> = ({
}
}}
>
<FloatingTooltip label="Embedded Subtitle" disabled={!disabled}>
<Badge color={color}>
<Language.Text value={subtitle} long={false}></Language.Text>
</Badge>
</FloatingTooltip>
{ctx}
</SubtitleToolsMenu>
);
};

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