mirror of https://github.com/morpheus65535/bazarr
Merge development into master
This commit is contained in:
commit
5882fc07d2
|
@ -37,7 +37,7 @@ jobs:
|
||||||
- name: Setup NodeJS
|
- name: Setup NodeJS
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "16"
|
node-version: "lts/*"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
|
@ -38,7 +38,7 @@ jobs:
|
||||||
- name: Setup NodeJS
|
- name: Setup NodeJS
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "15.x"
|
node-version: "lts/*"
|
||||||
|
|
||||||
- name: Install Global Tools
|
- name: Install Global Tools
|
||||||
run: npm install -g release-it auto-changelog
|
run: npm install -g release-it auto-changelog
|
||||||
|
|
|
@ -40,7 +40,7 @@ jobs:
|
||||||
- name: Setup NodeJS
|
- name: Setup NodeJS
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "15.x"
|
node-version: "lts/*"
|
||||||
|
|
||||||
- name: Install Global Tools
|
- name: Install Global Tools
|
||||||
run: npm install -g release-it auto-changelog
|
run: npm install -g release-it auto-changelog
|
||||||
|
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
- name: Setup NodeJS
|
- name: Setup NodeJS
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "15.x"
|
node-version: "lts/*"
|
||||||
|
|
||||||
- name: Install UI Dependencies
|
- name: Install UI Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
|
@ -64,7 +64,7 @@ class EpisodesBlacklist(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not episodeInfo:
|
if not episodeInfo:
|
||||||
return 'Episode not found', 500
|
return 'Episode not found', 404
|
||||||
|
|
||||||
media_path = episodeInfo['path']
|
media_path = episodeInfo['path']
|
||||||
subtitles_path = request.form.get('subtitles_path')
|
subtitles_path = request.form.get('subtitles_path')
|
||||||
|
|
|
@ -22,7 +22,7 @@ class Episodes(Resource):
|
||||||
.order_by(TableEpisodes.season.desc(), TableEpisodes.episode.desc())\
|
.order_by(TableEpisodes.season.desc(), TableEpisodes.episode.desc())\
|
||||||
.dicts()
|
.dicts()
|
||||||
else:
|
else:
|
||||||
return "Series or Episode ID not provided", 400
|
return "Series or Episode ID not provided", 404
|
||||||
|
|
||||||
result = list(result)
|
result = list(result)
|
||||||
for item in result:
|
for item in result:
|
||||||
|
|
|
@ -39,7 +39,7 @@ class EpisodesSubtitles(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not episodeInfo:
|
if not episodeInfo:
|
||||||
return 'Episode not found', 500
|
return 'Episode not found', 404
|
||||||
|
|
||||||
title = episodeInfo['title']
|
title = episodeInfo['title']
|
||||||
episodePath = path_mappings.path_replace(episodeInfo['path'])
|
episodePath = path_mappings.path_replace(episodeInfo['path'])
|
||||||
|
@ -98,7 +98,7 @@ class EpisodesSubtitles(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not episodeInfo:
|
if not episodeInfo:
|
||||||
return 'Episode not found', 500
|
return 'Episode not found', 404
|
||||||
|
|
||||||
title = episodeInfo['title']
|
title = episodeInfo['title']
|
||||||
episodePath = path_mappings.path_replace(episodeInfo['path'])
|
episodePath = path_mappings.path_replace(episodeInfo['path'])
|
||||||
|
@ -164,7 +164,7 @@ class EpisodesSubtitles(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not episodeInfo:
|
if not episodeInfo:
|
||||||
return 'Episode not found', 500
|
return 'Episode not found', 404
|
||||||
|
|
||||||
episodePath = path_mappings.path_replace(episodeInfo['path'])
|
episodePath = path_mappings.path_replace(episodeInfo['path'])
|
||||||
|
|
||||||
|
|
|
@ -60,7 +60,7 @@ class MoviesBlacklist(Resource):
|
||||||
data = TableMovies.select(TableMovies.path).where(TableMovies.radarrId == radarr_id).dicts().get_or_none()
|
data = TableMovies.select(TableMovies.path).where(TableMovies.radarrId == radarr_id).dicts().get_or_none()
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
return 'Movie not found', 500
|
return 'Movie not found', 404
|
||||||
|
|
||||||
media_path = data['path']
|
media_path = data['path']
|
||||||
subtitles_path = request.form.get('subtitles_path')
|
subtitles_path = request.form.get('subtitles_path')
|
||||||
|
|
|
@ -49,7 +49,7 @@ class Movies(Resource):
|
||||||
try:
|
try:
|
||||||
profileId = int(profileId)
|
profileId = int(profileId)
|
||||||
except Exception:
|
except Exception:
|
||||||
return '', 400
|
return 'Languages profile not found', 404
|
||||||
|
|
||||||
TableMovies.update({
|
TableMovies.update({
|
||||||
TableMovies.profileId: profileId
|
TableMovies.profileId: profileId
|
||||||
|
@ -79,4 +79,4 @@ class Movies(Resource):
|
||||||
wanted_search_missing_subtitles_movies()
|
wanted_search_missing_subtitles_movies()
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
return '', 400
|
return 'Unknown action', 400
|
||||||
|
|
|
@ -39,7 +39,7 @@ class MoviesSubtitles(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not movieInfo:
|
if not movieInfo:
|
||||||
return 'Movie not found', 500
|
return 'Movie not found', 404
|
||||||
|
|
||||||
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
|
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
|
||||||
sceneName = movieInfo['sceneName'] or 'None'
|
sceneName = movieInfo['sceneName'] or 'None'
|
||||||
|
@ -99,7 +99,7 @@ class MoviesSubtitles(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not movieInfo:
|
if not movieInfo:
|
||||||
return 'Movie not found', 500
|
return 'Movie not found', 404
|
||||||
|
|
||||||
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
|
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
|
||||||
sceneName = movieInfo['sceneName'] or 'None'
|
sceneName = movieInfo['sceneName'] or 'None'
|
||||||
|
@ -161,7 +161,7 @@ class MoviesSubtitles(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not movieInfo:
|
if not movieInfo:
|
||||||
return 'Movie not found', 500
|
return 'Movie not found', 404
|
||||||
|
|
||||||
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
|
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
|
||||||
|
|
||||||
|
|
|
@ -50,4 +50,4 @@ class Providers(Resource):
|
||||||
reset_throttled_providers()
|
reset_throttled_providers()
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
return '', 400
|
return 'Unknown action', 400
|
||||||
|
|
|
@ -30,7 +30,7 @@ class ProviderEpisodes(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not episodeInfo:
|
if not episodeInfo:
|
||||||
return 'Episode not found', 500
|
return 'Episode not found', 404
|
||||||
|
|
||||||
title = episodeInfo['title']
|
title = episodeInfo['title']
|
||||||
episodePath = path_mappings.path_replace(episodeInfo['path'])
|
episodePath = path_mappings.path_replace(episodeInfo['path'])
|
||||||
|
@ -58,7 +58,7 @@ class ProviderEpisodes(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not episodeInfo:
|
if not episodeInfo:
|
||||||
return 'Episode not found', 500
|
return 'Episode not found', 404
|
||||||
|
|
||||||
title = episodeInfo['title']
|
title = episodeInfo['title']
|
||||||
episodePath = path_mappings.path_replace(episodeInfo['path'])
|
episodePath = path_mappings.path_replace(episodeInfo['path'])
|
||||||
|
|
|
@ -31,7 +31,7 @@ class ProviderMovies(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not movieInfo:
|
if not movieInfo:
|
||||||
return 'Movie not found', 500
|
return 'Movie not found', 404
|
||||||
|
|
||||||
title = movieInfo['title']
|
title = movieInfo['title']
|
||||||
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
|
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
|
||||||
|
@ -58,7 +58,7 @@ class ProviderMovies(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not movieInfo:
|
if not movieInfo:
|
||||||
return 'Movie not found', 500
|
return 'Movie not found', 404
|
||||||
|
|
||||||
title = movieInfo['title']
|
title = movieInfo['title']
|
||||||
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
|
moviePath = path_mappings.path_replace_movie(movieInfo['path'])
|
||||||
|
|
|
@ -75,7 +75,7 @@ class Series(Resource):
|
||||||
try:
|
try:
|
||||||
profileId = int(profileId)
|
profileId = int(profileId)
|
||||||
except Exception:
|
except Exception:
|
||||||
return '', 400
|
return 'Languages profile not found', 404
|
||||||
|
|
||||||
TableShows.update({
|
TableShows.update({
|
||||||
TableShows.profileId: profileId
|
TableShows.profileId: profileId
|
||||||
|
@ -113,4 +113,4 @@ class Series(Resource):
|
||||||
wanted_search_missing_subtitles_series()
|
wanted_search_missing_subtitles_series()
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
return '', 400
|
return 'Unknown action', 400
|
||||||
|
|
|
@ -37,14 +37,14 @@ class Subtitles(Resource):
|
||||||
.get_or_none()
|
.get_or_none()
|
||||||
|
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return 'Episode not found', 500
|
return 'Episode not found', 404
|
||||||
|
|
||||||
video_path = path_mappings.path_replace(metadata['path'])
|
video_path = path_mappings.path_replace(metadata['path'])
|
||||||
else:
|
else:
|
||||||
metadata = TableMovies.select(TableMovies.path).where(TableMovies.radarrId == id).dicts().get_or_none()
|
metadata = TableMovies.select(TableMovies.path).where(TableMovies.radarrId == id).dicts().get_or_none()
|
||||||
|
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return 'Movie not found', 500
|
return 'Movie not found', 404
|
||||||
|
|
||||||
video_path = path_mappings.path_replace_movie(metadata['path'])
|
video_path = path_mappings.path_replace_movie(metadata['path'])
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ from utilities.helper import check_credentials
|
||||||
class SystemAccount(Resource):
|
class SystemAccount(Resource):
|
||||||
def post(self):
|
def post(self):
|
||||||
if settings.auth.type != 'form':
|
if settings.auth.type != 'form':
|
||||||
return '', 405
|
return 'Unknown authentication type define in config.ini', 404
|
||||||
|
|
||||||
action = request.args.get('action')
|
action = request.args.get('action')
|
||||||
if action == 'login':
|
if action == 'login':
|
||||||
|
@ -26,4 +26,4 @@ class SystemAccount(Resource):
|
||||||
gc.collect()
|
gc.collect()
|
||||||
return '', 204
|
return '', 204
|
||||||
|
|
||||||
return '', 401
|
return 'Unknown action', 400
|
||||||
|
|
|
@ -26,7 +26,7 @@ class SystemBackups(Resource):
|
||||||
restored = prepare_restore(filename)
|
restored = prepare_restore(filename)
|
||||||
if restored:
|
if restored:
|
||||||
return '', 204
|
return '', 204
|
||||||
return '', 501
|
return 'Filename not provided', 400
|
||||||
|
|
||||||
@authenticate
|
@authenticate
|
||||||
def delete(self):
|
def delete(self):
|
||||||
|
@ -35,4 +35,4 @@ class SystemBackups(Resource):
|
||||||
deleted = delete_backup_file(filename)
|
deleted = delete_backup_file(filename)
|
||||||
if deleted:
|
if deleted:
|
||||||
return '', 204
|
return '', 204
|
||||||
return '', 501
|
return 'Filename not provided', 400
|
||||||
|
|
|
@ -39,7 +39,7 @@ class WebHooksPlex(Resource):
|
||||||
if len(splitted_id) == 2:
|
if len(splitted_id) == 2:
|
||||||
ids.append({splitted_id[0]: splitted_id[1]})
|
ids.append({splitted_id[0]: splitted_id[1]})
|
||||||
if not ids:
|
if not ids:
|
||||||
return '', 404
|
return 'No GUID found', 400
|
||||||
|
|
||||||
if media_type == 'episode':
|
if media_type == 'episode':
|
||||||
try:
|
try:
|
||||||
|
@ -53,7 +53,7 @@ class WebHooksPlex(Resource):
|
||||||
series_imdb_id = show_metadata_dict['props']['pageProps']['aboveTheFoldData']['series']['series']['id']
|
series_imdb_id = show_metadata_dict['props']['pageProps']['aboveTheFoldData']['series']['series']['id']
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.debug('BAZARR is unable to get series IMDB id.')
|
logging.debug('BAZARR is unable to get series IMDB id.')
|
||||||
return '', 404
|
return 'IMDB series ID not found', 404
|
||||||
else:
|
else:
|
||||||
sonarrEpisodeId = TableEpisodes.select(TableEpisodes.sonarrEpisodeId) \
|
sonarrEpisodeId = TableEpisodes.select(TableEpisodes.sonarrEpisodeId) \
|
||||||
.join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) \
|
.join(TableShows, on=(TableEpisodes.sonarrSeriesId == TableShows.sonarrSeriesId)) \
|
||||||
|
@ -69,7 +69,8 @@ class WebHooksPlex(Resource):
|
||||||
try:
|
try:
|
||||||
movie_imdb_id = [x['imdb'] for x in ids if 'imdb' in x][0]
|
movie_imdb_id = [x['imdb'] for x in ids if 'imdb' in x][0]
|
||||||
except Exception:
|
except Exception:
|
||||||
return '', 404
|
logging.debug('BAZARR is unable to get movie IMDB id.')
|
||||||
|
return 'IMDB movie ID not found', 404
|
||||||
else:
|
else:
|
||||||
radarrId = TableMovies.select(TableMovies.radarrId)\
|
radarrId = TableMovies.select(TableMovies.radarrId)\
|
||||||
.where(TableMovies.imdbId == movie_imdb_id)\
|
.where(TableMovies.imdbId == movie_imdb_id)\
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
# coding=utf-8
|
# coding=utf-8
|
||||||
|
|
||||||
from flask import Flask, redirect
|
from flask import Flask, redirect
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask_cors import CORS
|
||||||
from flask_socketio import SocketIO
|
from flask_socketio import SocketIO
|
||||||
|
|
||||||
from .get_args import args
|
from .get_args import args
|
||||||
|
@ -18,6 +21,9 @@ def create_app():
|
||||||
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True
|
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = True
|
||||||
app.config['JSON_AS_ASCII'] = False
|
app.config['JSON_AS_ASCII'] = False
|
||||||
|
|
||||||
|
if settings.get('cors', 'enabled'):
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
if args.dev:
|
if args.dev:
|
||||||
app.config["DEBUG"] = True
|
app.config["DEBUG"] = True
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -20,6 +20,12 @@ class SimpleConfigParser(simpleconfigparser):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def base_url_slash_cleaner(uri):
|
||||||
|
while "//" in uri:
|
||||||
|
uri = uri.replace("//", "/")
|
||||||
|
return uri
|
||||||
|
|
||||||
|
|
||||||
defaults = {
|
defaults = {
|
||||||
'general': {
|
'general': {
|
||||||
'ip': '0.0.0.0',
|
'ip': '0.0.0.0',
|
||||||
|
@ -79,6 +85,9 @@ defaults = {
|
||||||
'username': '',
|
'username': '',
|
||||||
'password': ''
|
'password': ''
|
||||||
},
|
},
|
||||||
|
'cors': {
|
||||||
|
'enabled': 'False'
|
||||||
|
},
|
||||||
'backup': {
|
'backup': {
|
||||||
'folder': os.path.join(args.config_dir, 'backup'),
|
'folder': os.path.join(args.config_dir, 'backup'),
|
||||||
'retention': '31',
|
'retention': '31',
|
||||||
|
@ -206,6 +215,7 @@ defaults = {
|
||||||
'included_codecs': '[]',
|
'included_codecs': '[]',
|
||||||
'hi_fallback': 'False',
|
'hi_fallback': 'False',
|
||||||
'timeout': '600',
|
'timeout': '600',
|
||||||
|
'unknown_as_english': 'False',
|
||||||
},
|
},
|
||||||
'karagarga': {
|
'karagarga': {
|
||||||
'username': '',
|
'username': '',
|
||||||
|
@ -219,7 +229,8 @@ defaults = {
|
||||||
'subsync_threshold': '90',
|
'subsync_threshold': '90',
|
||||||
'use_subsync_movie_threshold': 'False',
|
'use_subsync_movie_threshold': 'False',
|
||||||
'subsync_movie_threshold': '70',
|
'subsync_movie_threshold': '70',
|
||||||
'debug': 'False'
|
'debug': 'False',
|
||||||
|
'force_audio': 'False'
|
||||||
},
|
},
|
||||||
'series_scores': {
|
'series_scores': {
|
||||||
"hash": 359,
|
"hash": 359,
|
||||||
|
@ -284,6 +295,11 @@ if int(settings.sonarr.episodes_sync) < 15:
|
||||||
if int(settings.radarr.movies_sync) < 15:
|
if int(settings.radarr.movies_sync) < 15:
|
||||||
settings.radarr.movies_sync = "60"
|
settings.radarr.movies_sync = "60"
|
||||||
|
|
||||||
|
# Make sure to get of double slashes in base_url
|
||||||
|
settings.general.base_url = base_url_slash_cleaner(uri=settings.general.base_url)
|
||||||
|
settings.sonarr.base_url = base_url_slash_cleaner(uri=settings.sonarr.base_url)
|
||||||
|
settings.radarr.base_url = base_url_slash_cleaner(uri=settings.radarr.base_url)
|
||||||
|
|
||||||
if os.path.exists(os.path.join(args.config_dir, 'config', 'config.ini')):
|
if os.path.exists(os.path.join(args.config_dir, 'config', 'config.ini')):
|
||||||
with open(os.path.join(args.config_dir, 'config', 'config.ini'), 'w+') as handle:
|
with open(os.path.join(args.config_dir, 'config', 'config.ini'), 'w+') as handle:
|
||||||
settings.write(handle)
|
settings.write(handle)
|
||||||
|
@ -373,6 +389,9 @@ def save_settings(settings_items):
|
||||||
elif value == 'false':
|
elif value == 'false':
|
||||||
value = 'False'
|
value = 'False'
|
||||||
|
|
||||||
|
if key in ['settings-general-base_url', 'settings-sonarr-base_url', 'settings-radarr-base_url']:
|
||||||
|
value = base_url_slash_cleaner(value)
|
||||||
|
|
||||||
if key == 'settings-auth-password':
|
if key == 'settings-auth-password':
|
||||||
if value != settings.auth.password and value is not None:
|
if value != settings.auth.password and value is not None:
|
||||||
value = hashlib.md5(value.encode('utf-8')).hexdigest()
|
value = hashlib.md5(value.encode('utf-8')).hexdigest()
|
||||||
|
|
|
@ -436,7 +436,12 @@ def get_desired_languages(profile_id):
|
||||||
if profile_id and profile_id != 'null':
|
if profile_id and profile_id != 'null':
|
||||||
for profile in profile_id_list:
|
for profile in profile_id_list:
|
||||||
profileId, name, cutoff, items, mustContain, mustNotContain, originalFormat = profile.values()
|
profileId, name, cutoff, items, mustContain, mustNotContain, originalFormat = profile.values()
|
||||||
if profileId == int(profile_id):
|
try:
|
||||||
|
profile_id_int = int(profile_id)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if profileId == profile_id_int:
|
||||||
languages = [x['language'] for x in items]
|
languages = [x['language'] for x in items]
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
|
@ -232,6 +232,7 @@ def get_providers_auth():
|
||||||
'ffprobe_path': _FFPROBE_BINARY,
|
'ffprobe_path': _FFPROBE_BINARY,
|
||||||
'ffmpeg_path': _FFMPEG_BINARY,
|
'ffmpeg_path': _FFMPEG_BINARY,
|
||||||
'timeout': settings.embeddedsubtitles.timeout,
|
'timeout': settings.embeddedsubtitles.timeout,
|
||||||
|
'unknown_as_english': settings.embeddedsubtitles.getboolean('unknown_as_english'),
|
||||||
},
|
},
|
||||||
'karagarga': {
|
'karagarga': {
|
||||||
'username': settings.karagarga.username,
|
'username': settings.karagarga.username,
|
||||||
|
|
|
@ -3,11 +3,14 @@
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import threading
|
||||||
|
|
||||||
from requests import Session
|
from requests import Session
|
||||||
from signalr import Connection
|
from signalr import Connection
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError
|
||||||
from signalrcore.hub_connection_builder import HubConnectionBuilder
|
from signalrcore.hub_connection_builder import HubConnectionBuilder
|
||||||
|
from collections import deque
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
from constants import headers
|
from constants import headers
|
||||||
from sonarr.sync.episodes import sync_episodes, sync_one_episode
|
from sonarr.sync.episodes import sync_episodes, sync_one_episode
|
||||||
|
@ -21,6 +24,12 @@ from .scheduler import scheduler
|
||||||
from .get_args import args
|
from .get_args import args
|
||||||
|
|
||||||
|
|
||||||
|
sonarr_queue = deque()
|
||||||
|
radarr_queue = deque()
|
||||||
|
|
||||||
|
last_event_data = None
|
||||||
|
|
||||||
|
|
||||||
class SonarrSignalrClientLegacy:
|
class SonarrSignalrClientLegacy:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(SonarrSignalrClientLegacy, self).__init__()
|
super(SonarrSignalrClientLegacy, self).__init__()
|
||||||
|
@ -46,8 +55,9 @@ class SonarrSignalrClientLegacy:
|
||||||
except json.decoder.JSONDecodeError:
|
except json.decoder.JSONDecodeError:
|
||||||
logging.error("BAZARR cannot parse JSON returned by SignalR feed. This is caused by a permissions "
|
logging.error("BAZARR cannot parse JSON returned by SignalR feed. This is caused by a permissions "
|
||||||
"issue when Sonarr try to access its /config/.config directory."
|
"issue when Sonarr try to access its /config/.config directory."
|
||||||
"Typically permissions are too permissive - only the user and group Sonarr runs as should have Read/Write permissions (e.g. files 664 / folders 775)"
|
"Typically permissions are too permissive - only the user and group Sonarr runs as "
|
||||||
"You should fix permissions on that directory and restart Sonarr. Also, if you're a Docker image "
|
"should have Read/Write permissions (e.g. files 664 / folders 775). You should fix "
|
||||||
|
"permissions on that directory and restart Sonarr. Also, if you're a Docker image "
|
||||||
"user, you should make sure you properly defined PUID/PGID environment variables. "
|
"user, you should make sure you properly defined PUID/PGID environment variables. "
|
||||||
"Otherwise, please contact Sonarr support.")
|
"Otherwise, please contact Sonarr support.")
|
||||||
else:
|
else:
|
||||||
|
@ -61,21 +71,19 @@ class SonarrSignalrClientLegacy:
|
||||||
try:
|
try:
|
||||||
self.connection.close()
|
self.connection.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
self.connection.started = False
|
||||||
if log:
|
if log:
|
||||||
logging.info('BAZARR SignalR client for Sonarr is now disconnected.')
|
logging.info('BAZARR SignalR client for Sonarr is now disconnected.')
|
||||||
|
|
||||||
def restart(self):
|
def restart(self):
|
||||||
if self.connection:
|
if self.connection:
|
||||||
if self.connection.started:
|
if self.connection.started:
|
||||||
try:
|
|
||||||
self.stop(log=False)
|
self.stop(log=False)
|
||||||
except Exception:
|
|
||||||
self.connection.started = False
|
|
||||||
if settings.general.getboolean('use_sonarr'):
|
if settings.general.getboolean('use_sonarr'):
|
||||||
self.start()
|
self.start()
|
||||||
|
|
||||||
def exception_handler(self, type, exception, traceback):
|
def exception_handler(self):
|
||||||
|
sonarr_queue.clear()
|
||||||
logging.error('BAZARR connection to Sonarr SignalR feed has been lost.')
|
logging.error('BAZARR connection to Sonarr SignalR feed has been lost.')
|
||||||
self.restart()
|
self.restart()
|
||||||
|
|
||||||
|
@ -87,7 +95,7 @@ class SonarrSignalrClientLegacy:
|
||||||
|
|
||||||
sonarr_method = ['series', 'episode']
|
sonarr_method = ['series', 'episode']
|
||||||
for item in sonarr_method:
|
for item in sonarr_method:
|
||||||
sonarr_hub.client.on(item, dispatcher)
|
sonarr_hub.client.on(item, feed_queue)
|
||||||
|
|
||||||
self.connection.exception += self.exception_handler
|
self.connection.exception += self.exception_handler
|
||||||
|
|
||||||
|
@ -119,6 +127,7 @@ class SonarrSignalrClient:
|
||||||
self.start()
|
self.start()
|
||||||
|
|
||||||
def exception_handler(self):
|
def exception_handler(self):
|
||||||
|
sonarr_queue.clear()
|
||||||
logging.error("BAZARR connection to Sonarr SignalR feed has failed. We'll try to reconnect.")
|
logging.error("BAZARR connection to Sonarr SignalR feed has failed. We'll try to reconnect.")
|
||||||
self.restart()
|
self.restart()
|
||||||
|
|
||||||
|
@ -148,7 +157,7 @@ class SonarrSignalrClient:
|
||||||
'Trying to reconnect...'))
|
'Trying to reconnect...'))
|
||||||
self.connection.on_close(lambda: logging.debug('BAZARR SignalR client for Sonarr is disconnected.'))
|
self.connection.on_close(lambda: logging.debug('BAZARR SignalR client for Sonarr is disconnected.'))
|
||||||
self.connection.on_error(self.exception_handler)
|
self.connection.on_error(self.exception_handler)
|
||||||
self.connection.on("receiveMessage", dispatcher)
|
self.connection.on("receiveMessage", feed_queue)
|
||||||
|
|
||||||
|
|
||||||
class RadarrSignalrClient:
|
class RadarrSignalrClient:
|
||||||
|
@ -178,6 +187,7 @@ class RadarrSignalrClient:
|
||||||
self.start()
|
self.start()
|
||||||
|
|
||||||
def exception_handler(self):
|
def exception_handler(self):
|
||||||
|
radarr_queue.clear()
|
||||||
logging.error("BAZARR connection to Radarr SignalR feed has failed. We'll try to reconnect.")
|
logging.error("BAZARR connection to Radarr SignalR feed has failed. We'll try to reconnect.")
|
||||||
self.restart()
|
self.restart()
|
||||||
|
|
||||||
|
@ -206,38 +216,49 @@ class RadarrSignalrClient:
|
||||||
'Trying to reconnect...'))
|
'Trying to reconnect...'))
|
||||||
self.connection.on_close(lambda: logging.debug('BAZARR SignalR client for Radarr is disconnected.'))
|
self.connection.on_close(lambda: logging.debug('BAZARR SignalR client for Radarr is disconnected.'))
|
||||||
self.connection.on_error(self.exception_handler)
|
self.connection.on_error(self.exception_handler)
|
||||||
self.connection.on("receiveMessage", dispatcher)
|
self.connection.on("receiveMessage", feed_queue)
|
||||||
|
|
||||||
|
|
||||||
def dispatcher(data):
|
def dispatcher(data):
|
||||||
try:
|
try:
|
||||||
topic = media_id = action = None
|
series_title = series_year = episode_title = season_number = episode_number = movie_title = movie_year = None
|
||||||
episodesChanged = None
|
|
||||||
if isinstance(data, dict):
|
#
|
||||||
topic = data['name']
|
|
||||||
try:
|
try:
|
||||||
|
episodesChanged = False
|
||||||
|
topic = data['name']
|
||||||
|
|
||||||
media_id = data['body']['resource']['id']
|
media_id = data['body']['resource']['id']
|
||||||
action = data['body']['action']
|
action = data['body']['action']
|
||||||
|
if topic == 'series':
|
||||||
if 'episodesChanged' in data['body']['resource']:
|
if 'episodesChanged' in data['body']['resource']:
|
||||||
episodesChanged = data['body']['resource']['episodesChanged']
|
episodesChanged = data['body']['resource']['episodesChanged']
|
||||||
except KeyError:
|
series_title = data['body']['resource']['title']
|
||||||
return
|
series_year = data['body']['resource']['year']
|
||||||
elif isinstance(data, list):
|
elif topic == 'episode':
|
||||||
topic = data[0]['name']
|
series_title = data['body']['resource']['series']['title']
|
||||||
try:
|
series_year = data['body']['resource']['series']['year']
|
||||||
media_id = data[0]['body']['resource']['id']
|
episode_title = data['body']['resource']['title']
|
||||||
action = data[0]['body']['action']
|
season_number = data['body']['resource']['seasonNumber']
|
||||||
|
episode_number = data['body']['resource']['episodeNumber']
|
||||||
|
elif topic == 'movie':
|
||||||
|
movie_title = data['body']['resource']['title']
|
||||||
|
movie_year = data['body']['resource']['year']
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return
|
return
|
||||||
|
|
||||||
if topic == 'series':
|
if topic == 'series':
|
||||||
|
logging.debug(f'Event received from Sonarr for series: {series_title} ({series_year})')
|
||||||
update_one_series(series_id=media_id, action=action)
|
update_one_series(series_id=media_id, action=action)
|
||||||
if episodesChanged:
|
if episodesChanged:
|
||||||
# this will happen if a season monitored status is changed.
|
# this will happen if a season monitored status is changed.
|
||||||
sync_episodes(series_id=media_id, send_event=True)
|
sync_episodes(series_id=media_id, send_event=True)
|
||||||
elif topic == 'episode':
|
elif topic == 'episode':
|
||||||
|
logging.debug(f'Event received from Sonarr for episode: {series_title} ({series_year}) - '
|
||||||
|
f'S{season_number:0>2}E{episode_number:0>2} - {episode_title}')
|
||||||
sync_one_episode(episode_id=media_id, defer_search=settings.sonarr.getboolean('defer_search_signalr'))
|
sync_one_episode(episode_id=media_id, defer_search=settings.sonarr.getboolean('defer_search_signalr'))
|
||||||
elif topic == 'movie':
|
elif topic == 'movie':
|
||||||
|
logging.debug(f'Event received from Radarr for movie: {movie_title} ({movie_year})')
|
||||||
update_one_movie(movie_id=media_id, action=action,
|
update_one_movie(movie_id=media_id, action=action,
|
||||||
defer_search=settings.radarr.getboolean('defer_search_signalr'))
|
defer_search=settings.radarr.getboolean('defer_search_signalr'))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -246,6 +267,43 @@ def dispatcher(data):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def feed_queue(data):
|
||||||
|
# check if event is duplicate from the previous one
|
||||||
|
global last_event_data
|
||||||
|
if data == last_event_data:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
last_event_data = data
|
||||||
|
|
||||||
|
# some sonarr version send event as a list of a single dict, we make it a dict
|
||||||
|
if isinstance(data, list) and len(data):
|
||||||
|
data = data[0]
|
||||||
|
|
||||||
|
# if data is a dict and contain an event for series, episode or movie, we add it to the event queue
|
||||||
|
if isinstance(data, dict) and 'name' in data:
|
||||||
|
if data['name'] in ['series', 'episode']:
|
||||||
|
sonarr_queue.append(data)
|
||||||
|
elif data['name'] == 'movie':
|
||||||
|
radarr_queue.append(data)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_queue(queue):
|
||||||
|
# get events data from queue one at a time and dispatch it
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = queue.popleft()
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
dispatcher(data)
|
||||||
|
sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
# start both queue consuming threads
|
||||||
|
threading.Thread(target=consume_queue, args=(sonarr_queue,)).start()
|
||||||
|
threading.Thread(target=consume_queue, args=(radarr_queue,)).start()
|
||||||
|
|
||||||
|
# instantiate proper SignalR client
|
||||||
sonarr_signalr_client = SonarrSignalrClientLegacy() if get_sonarr_info.version().startswith(('0.', '2.', '3.')) else \
|
sonarr_signalr_client = SonarrSignalrClientLegacy() if get_sonarr_info.version().startswith(('0.', '2.', '3.')) else \
|
||||||
SonarrSignalrClient()
|
SonarrSignalrClient()
|
||||||
radarr_signalr_client = RadarrSignalrClient()
|
radarr_signalr_client = RadarrSignalrClient()
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
from flask import request, abort, render_template, Response, session, send_file, stream_with_context, Blueprint
|
from flask import request, abort, render_template, Response, session, send_file, stream_with_context, Blueprint
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
@ -30,6 +31,14 @@ static_bp = Blueprint('images', __name__,
|
||||||
ui_bp.register_blueprint(static_bp)
|
ui_bp.register_blueprint(static_bp)
|
||||||
|
|
||||||
|
|
||||||
|
mimetypes.add_type('application/javascript', '.js')
|
||||||
|
mimetypes.add_type('text/css', '.css')
|
||||||
|
mimetypes.add_type('font/woff2', '.woff2')
|
||||||
|
mimetypes.add_type('image/svg+xml', '.svg')
|
||||||
|
mimetypes.add_type('image/png', '.png')
|
||||||
|
mimetypes.add_type('image/x-icon', '.ico')
|
||||||
|
|
||||||
|
|
||||||
def check_login(actual_method):
|
def check_login(actual_method):
|
||||||
@wraps(actual_method)
|
@wraps(actual_method)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
|
@ -122,6 +131,12 @@ def movies_images(url):
|
||||||
return Response(stream_with_context(req.iter_content(2048)), content_type=req.headers['content-type'])
|
return Response(stream_with_context(req.iter_content(2048)), content_type=req.headers['content-type'])
|
||||||
|
|
||||||
|
|
||||||
|
@check_login
|
||||||
|
@ui_bp.route('/system/backup/download/<path:filename>', methods=['GET'])
|
||||||
|
def backup_download(filename):
|
||||||
|
return send_file(os.path.join(settings.backup.folder, filename), cache_timeout=0, as_attachment=True)
|
||||||
|
|
||||||
|
|
||||||
def configured():
|
def configured():
|
||||||
System.update({System.configured: '1'}).execute()
|
System.update({System.configured: '1'}).execute()
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# coding=utf-8
|
# coding=utf-8
|
||||||
|
|
||||||
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from peewee import IntegrityError
|
from peewee import IntegrityError
|
||||||
|
@ -69,7 +70,12 @@ def update_movies(send_event=True):
|
||||||
|
|
||||||
if movie['hasFile'] is True:
|
if movie['hasFile'] is True:
|
||||||
if 'movieFile' in movie:
|
if 'movieFile' in movie:
|
||||||
if movie['movieFile']['size'] > 20480:
|
try:
|
||||||
|
bazarr_file_size = \
|
||||||
|
os.path.getsize(path_mappings.path_replace_movie(movie['movieFile']['path']))
|
||||||
|
except OSError:
|
||||||
|
bazarr_file_size = 0
|
||||||
|
if movie['movieFile']['size'] > 20480 or bazarr_file_size > 20480:
|
||||||
# Add movies in radarr to current movies list
|
# Add movies in radarr to current movies list
|
||||||
current_movies_radarr.append(str(movie['tmdbId']))
|
current_movies_radarr.append(str(movie['tmdbId']))
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# coding=utf-8
|
# coding=utf-8
|
||||||
|
|
||||||
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from peewee import IntegrityError
|
from peewee import IntegrityError
|
||||||
|
@ -71,7 +72,12 @@ def sync_episodes(series_id=None, send_event=True):
|
||||||
if 'hasFile' in episode:
|
if 'hasFile' in episode:
|
||||||
if episode['hasFile'] is True:
|
if episode['hasFile'] is True:
|
||||||
if 'episodeFile' in episode:
|
if 'episodeFile' in episode:
|
||||||
if episode['episodeFile']['size'] > 20480:
|
try:
|
||||||
|
bazarr_file_size = \
|
||||||
|
os.path.getsize(path_mappings.path_replace(episode['episodeFile']['path']))
|
||||||
|
except OSError:
|
||||||
|
bazarr_file_size = 0
|
||||||
|
if episode['episodeFile']['size'] > 20480 or bazarr_file_size > 20480:
|
||||||
# Add episodes in sonarr to current episode list
|
# Add episodes in sonarr to current episode list
|
||||||
current_episodes_sonarr.append(episode['id'])
|
current_episodes_sonarr.append(episode['id'])
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import os
|
||||||
|
|
||||||
from app.database import TableShows
|
from app.database import TableShows
|
||||||
from sonarr.info import get_sonarr_info
|
from sonarr.info import get_sonarr_info
|
||||||
|
from utilities.path_mappings import path_mappings
|
||||||
|
|
||||||
from .converter import SonarrFormatVideoCodec, SonarrFormatAudioCodec
|
from .converter import SonarrFormatVideoCodec, SonarrFormatAudioCodec
|
||||||
|
|
||||||
|
@ -28,7 +29,10 @@ def seriesParser(show, action, tags_dict, serie_default_profile, audio_profiles)
|
||||||
if get_sonarr_info.is_legacy():
|
if get_sonarr_info.is_legacy():
|
||||||
audio_language = profile_id_to_language(show['qualityProfileId'], audio_profiles)
|
audio_language = profile_id_to_language(show['qualityProfileId'], audio_profiles)
|
||||||
else:
|
else:
|
||||||
|
if 'languageProfileId' in show:
|
||||||
audio_language = profile_id_to_language(show['languageProfileId'], audio_profiles)
|
audio_language = profile_id_to_language(show['languageProfileId'], audio_profiles)
|
||||||
|
else:
|
||||||
|
audio_language = []
|
||||||
|
|
||||||
tags = [d['label'] for d in tags_dict if d['id'] in show['tags']]
|
tags = [d['label'] for d in tags_dict if d['id'] in show['tags']]
|
||||||
|
|
||||||
|
@ -79,7 +83,11 @@ def episodeParser(episode):
|
||||||
if 'hasFile' in episode:
|
if 'hasFile' in episode:
|
||||||
if episode['hasFile'] is True:
|
if episode['hasFile'] is True:
|
||||||
if 'episodeFile' in episode:
|
if 'episodeFile' in episode:
|
||||||
if episode['episodeFile']['size'] > 20480:
|
try:
|
||||||
|
bazarr_file_size = os.path.getsize(path_mappings.path_replace(episode['episodeFile']['path']))
|
||||||
|
except OSError:
|
||||||
|
bazarr_file_size = 0
|
||||||
|
if episode['episodeFile']['size'] > 20480 or bazarr_file_size > 20480:
|
||||||
if 'sceneName' in episode['episodeFile']:
|
if 'sceneName' in episode['episodeFile']:
|
||||||
sceneName = episode['episodeFile']['sceneName']
|
sceneName = episode['episodeFile']['sceneName']
|
||||||
else:
|
else:
|
||||||
|
@ -91,6 +99,12 @@ def episodeParser(episode):
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
if 'name' in item:
|
if 'name' in item:
|
||||||
audio_language.append(item['name'])
|
audio_language.append(item['name'])
|
||||||
|
elif 'languages' in episode['episodeFile'] and len(episode['episodeFile']['languages']):
|
||||||
|
items = episode['episodeFile']['languages']
|
||||||
|
if isinstance(items, list):
|
||||||
|
for item in items:
|
||||||
|
if 'name' in item:
|
||||||
|
audio_language.append(item['name'])
|
||||||
else:
|
else:
|
||||||
audio_language = TableShows.get(TableShows.sonarrSeriesId == episode['seriesId']).audio_language
|
audio_language = TableShows.get(TableShows.sonarrSeriesId == episode['seriesId']).audio_language
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,10 @@ def get_profile_list():
|
||||||
logging.exception("BAZARR Error trying to get profiles from Sonarr.")
|
logging.exception("BAZARR Error trying to get profiles from Sonarr.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# return an empty list when using Sonarr v4 that do not support series languages profiles anymore
|
||||||
|
if profiles_json.status_code == 404:
|
||||||
|
return profiles_list
|
||||||
|
|
||||||
# Parsing data returned from Sonarr
|
# Parsing data returned from Sonarr
|
||||||
if get_sonarr_info.is_legacy():
|
if get_sonarr_info.is_legacy():
|
||||||
for profile in profiles_json.json():
|
for profile in profiles_json.json():
|
||||||
|
@ -86,6 +90,10 @@ def get_series_from_sonarr_api(url, apikey_sonarr, sonarr_series_id=None):
|
||||||
except requests.exceptions.RequestException:
|
except requests.exceptions.RequestException:
|
||||||
logging.exception("BAZARR Error trying to get series from Sonarr.")
|
logging.exception("BAZARR Error trying to get series from Sonarr.")
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
|
result = r.json()
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return list(result)
|
||||||
else:
|
else:
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
|
@ -55,7 +55,7 @@ def store_subtitles_movie(original_path, reversed_path, use_cache=True):
|
||||||
if subtitle_hi:
|
if subtitle_hi:
|
||||||
lang = lang + ':hi'
|
lang = lang + ':hi'
|
||||||
logging.debug("BAZARR embedded subtitles detected: " + lang)
|
logging.debug("BAZARR embedded subtitles detected: " + lang)
|
||||||
actual_subtitles.append([lang, None])
|
actual_subtitles.append([lang, None, None])
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.debug("BAZARR unable to index this unrecognized language: " + subtitle_language)
|
logging.debug("BAZARR unable to index this unrecognized language: " + subtitle_language)
|
||||||
pass
|
pass
|
||||||
|
@ -68,6 +68,22 @@ def store_subtitles_movie(original_path, reversed_path, use_cache=True):
|
||||||
try:
|
try:
|
||||||
dest_folder = get_subtitle_destination_folder() or ''
|
dest_folder = get_subtitle_destination_folder() or ''
|
||||||
core.CUSTOM_PATHS = [dest_folder] if dest_folder else []
|
core.CUSTOM_PATHS = [dest_folder] if dest_folder else []
|
||||||
|
|
||||||
|
# get previously indexed subtitles that haven't changed:
|
||||||
|
item = TableMovies.select(TableMovies.subtitles) \
|
||||||
|
.where(TableMovies.path == original_path) \
|
||||||
|
.dicts() \
|
||||||
|
.get_or_none()
|
||||||
|
if not item:
|
||||||
|
previously_indexed_subtitles_to_exclude = []
|
||||||
|
else:
|
||||||
|
previously_indexed_subtitles = ast.literal_eval(item['subtitles']) if item['subtitles'] else []
|
||||||
|
previously_indexed_subtitles_to_exclude = [x for x in previously_indexed_subtitles
|
||||||
|
if len(x) == 3 and
|
||||||
|
x[1] and
|
||||||
|
os.path.isfile(path_mappings.path_replace(x[1])) and
|
||||||
|
os.stat(path_mappings.path_replace(x[1])).st_size == x[2]]
|
||||||
|
|
||||||
subtitles = search_external_subtitles(reversed_path, languages=get_language_set())
|
subtitles = search_external_subtitles(reversed_path, languages=get_language_set())
|
||||||
full_dest_folder_path = os.path.dirname(reversed_path)
|
full_dest_folder_path = os.path.dirname(reversed_path)
|
||||||
if dest_folder:
|
if dest_folder:
|
||||||
|
@ -75,7 +91,8 @@ def store_subtitles_movie(original_path, reversed_path, use_cache=True):
|
||||||
full_dest_folder_path = dest_folder
|
full_dest_folder_path = dest_folder
|
||||||
elif settings.general.subfolder == "relative":
|
elif settings.general.subfolder == "relative":
|
||||||
full_dest_folder_path = os.path.join(os.path.dirname(reversed_path), dest_folder)
|
full_dest_folder_path = os.path.join(os.path.dirname(reversed_path), dest_folder)
|
||||||
subtitles = guess_external_subtitles(full_dest_folder_path, subtitles)
|
subtitles = guess_external_subtitles(full_dest_folder_path, subtitles, "movie",
|
||||||
|
previously_indexed_subtitles_to_exclude)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("BAZARR unable to index external subtitles.")
|
logging.exception("BAZARR unable to index external subtitles.")
|
||||||
pass
|
pass
|
||||||
|
@ -107,7 +124,8 @@ def store_subtitles_movie(original_path, reversed_path, use_cache=True):
|
||||||
else:
|
else:
|
||||||
language_str = str(language)
|
language_str = str(language)
|
||||||
logging.debug("BAZARR external subtitles detected: " + language_str)
|
logging.debug("BAZARR external subtitles detected: " + language_str)
|
||||||
actual_subtitles.append([language_str, path_mappings.path_replace_reverse_movie(subtitle_path)])
|
actual_subtitles.append([language_str, path_mappings.path_replace_reverse_movie(subtitle_path),
|
||||||
|
os.stat(subtitle_path).st_size])
|
||||||
|
|
||||||
TableMovies.update({TableMovies.subtitles: str(actual_subtitles)})\
|
TableMovies.update({TableMovies.subtitles: str(actual_subtitles)})\
|
||||||
.where(TableMovies.path == original_path)\
|
.where(TableMovies.path == original_path)\
|
||||||
|
|
|
@ -55,7 +55,7 @@ def store_subtitles(original_path, reversed_path, use_cache=True):
|
||||||
if subtitle_hi:
|
if subtitle_hi:
|
||||||
lang = lang + ":hi"
|
lang = lang + ":hi"
|
||||||
logging.debug("BAZARR embedded subtitles detected: " + lang)
|
logging.debug("BAZARR embedded subtitles detected: " + lang)
|
||||||
actual_subtitles.append([lang, None])
|
actual_subtitles.append([lang, None, None])
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logging.debug("BAZARR unable to index this unrecognized language: %s (%s)", subtitle_language, error)
|
logging.debug("BAZARR unable to index this unrecognized language: %s (%s)", subtitle_language, error)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
@ -66,6 +66,22 @@ def store_subtitles(original_path, reversed_path, use_cache=True):
|
||||||
try:
|
try:
|
||||||
dest_folder = get_subtitle_destination_folder()
|
dest_folder = get_subtitle_destination_folder()
|
||||||
core.CUSTOM_PATHS = [dest_folder] if dest_folder else []
|
core.CUSTOM_PATHS = [dest_folder] if dest_folder else []
|
||||||
|
|
||||||
|
# get previously indexed subtitles that haven't changed:
|
||||||
|
item = TableEpisodes.select(TableEpisodes.subtitles) \
|
||||||
|
.where(TableEpisodes.path == original_path) \
|
||||||
|
.dicts() \
|
||||||
|
.get_or_none()
|
||||||
|
if not item:
|
||||||
|
previously_indexed_subtitles_to_exclude = []
|
||||||
|
else:
|
||||||
|
previously_indexed_subtitles = ast.literal_eval(item['subtitles']) if item['subtitles'] else []
|
||||||
|
previously_indexed_subtitles_to_exclude = [x for x in previously_indexed_subtitles
|
||||||
|
if len(x) == 3 and
|
||||||
|
x[1] and
|
||||||
|
os.path.isfile(path_mappings.path_replace(x[1])) and
|
||||||
|
os.stat(path_mappings.path_replace(x[1])).st_size == x[2]]
|
||||||
|
|
||||||
subtitles = search_external_subtitles(reversed_path, languages=get_language_set(),
|
subtitles = search_external_subtitles(reversed_path, languages=get_language_set(),
|
||||||
only_one=settings.general.getboolean('single_language'))
|
only_one=settings.general.getboolean('single_language'))
|
||||||
full_dest_folder_path = os.path.dirname(reversed_path)
|
full_dest_folder_path = os.path.dirname(reversed_path)
|
||||||
|
@ -74,7 +90,8 @@ def store_subtitles(original_path, reversed_path, use_cache=True):
|
||||||
full_dest_folder_path = dest_folder
|
full_dest_folder_path = dest_folder
|
||||||
elif settings.general.subfolder == "relative":
|
elif settings.general.subfolder == "relative":
|
||||||
full_dest_folder_path = os.path.join(os.path.dirname(reversed_path), dest_folder)
|
full_dest_folder_path = os.path.join(os.path.dirname(reversed_path), dest_folder)
|
||||||
subtitles = guess_external_subtitles(full_dest_folder_path, subtitles)
|
subtitles = guess_external_subtitles(full_dest_folder_path, subtitles, "series",
|
||||||
|
previously_indexed_subtitles_to_exclude)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("BAZARR unable to index external subtitles.")
|
logging.exception("BAZARR unable to index external subtitles.")
|
||||||
else:
|
else:
|
||||||
|
@ -105,7 +122,8 @@ def store_subtitles(original_path, reversed_path, use_cache=True):
|
||||||
else:
|
else:
|
||||||
language_str = str(language)
|
language_str = str(language)
|
||||||
logging.debug("BAZARR external subtitles detected: " + language_str)
|
logging.debug("BAZARR external subtitles detected: " + language_str)
|
||||||
actual_subtitles.append([language_str, path_mappings.path_replace_reverse(subtitle_path)])
|
actual_subtitles.append([language_str, path_mappings.path_replace_reverse(subtitle_path),
|
||||||
|
os.stat(subtitle_path).st_size])
|
||||||
|
|
||||||
TableEpisodes.update({TableEpisodes.subtitles: str(actual_subtitles)})\
|
TableEpisodes.update({TableEpisodes.subtitles: str(actual_subtitles)})\
|
||||||
.where(TableEpisodes.path == original_path)\
|
.where(TableEpisodes.path == original_path)\
|
||||||
|
|
|
@ -11,6 +11,7 @@ from charamel import Detector
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from constants import hi_regex
|
from constants import hi_regex
|
||||||
|
from utilities.path_mappings import path_mappings
|
||||||
|
|
||||||
|
|
||||||
def get_external_subtitles_path(file, subtitle):
|
def get_external_subtitles_path(file, subtitle):
|
||||||
|
@ -40,16 +41,34 @@ def get_external_subtitles_path(file, subtitle):
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def guess_external_subtitles(dest_folder, subtitles):
|
def guess_external_subtitles(dest_folder, subtitles, media_type, previously_indexed_subtitles_to_exclude=None):
|
||||||
for subtitle, language in subtitles.items():
|
for subtitle, language in subtitles.items():
|
||||||
if not language:
|
|
||||||
subtitle_path = os.path.join(dest_folder, subtitle)
|
subtitle_path = os.path.join(dest_folder, subtitle)
|
||||||
|
reversed_subtitle_path = path_mappings.path_replace_reverse(subtitle_path) if media_type == "series" \
|
||||||
|
else path_mappings.path_replace_reverse_movie(subtitle_path)
|
||||||
|
|
||||||
|
if previously_indexed_subtitles_to_exclude:
|
||||||
|
x_found_lang = None
|
||||||
|
for x_lang, x_path, x_size in previously_indexed_subtitles_to_exclude:
|
||||||
|
if x_path == reversed_subtitle_path and x_size == os.stat(subtitle_path).st_size:
|
||||||
|
x_found_lang = x_lang
|
||||||
|
break
|
||||||
|
if x_found_lang:
|
||||||
|
if not language:
|
||||||
|
x_hi = ':hi' in x_found_lang
|
||||||
|
subtitles[subtitle] = Language.rebuild(Language.fromietf(x_found_lang), hi=x_hi)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not language:
|
||||||
if os.path.exists(subtitle_path) and os.path.splitext(subtitle_path)[1] in core.SUBTITLE_EXTENSIONS:
|
if os.path.exists(subtitle_path) and os.path.splitext(subtitle_path)[1] in core.SUBTITLE_EXTENSIONS:
|
||||||
logging.debug("BAZARR falling back to file content analysis to detect language.")
|
logging.debug("BAZARR falling back to file content analysis to detect language.")
|
||||||
detected_language = None
|
detected_language = None
|
||||||
|
|
||||||
|
# detect forced subtitles
|
||||||
|
forced = True if os.path.splitext(os.path.splitext(subtitle)[0])[1] == '.forced' else False
|
||||||
|
|
||||||
# to improve performance, skip detection of files larger that 1M
|
# to improve performance, skip detection of files larger that 1M
|
||||||
if os.path.getsize(subtitle_path) > 1*1024*1024:
|
if os.path.getsize(subtitle_path) > 1 * 1024 * 1024:
|
||||||
logging.debug("BAZARR subtitles file is too large to be text based. Skipping this file: " +
|
logging.debug("BAZARR subtitles file is too large to be text based. Skipping this file: " +
|
||||||
subtitle_path)
|
subtitle_path)
|
||||||
continue
|
continue
|
||||||
|
@ -92,21 +111,21 @@ def guess_external_subtitles(dest_folder, subtitles):
|
||||||
logging.debug("BAZARR external subtitles detected and guessed this language: " + str(
|
logging.debug("BAZARR external subtitles detected and guessed this language: " + str(
|
||||||
detected_language))
|
detected_language))
|
||||||
try:
|
try:
|
||||||
subtitles[subtitle] = Language.rebuild(Language.fromietf(detected_language), forced=False,
|
subtitles[subtitle] = Language.rebuild(Language.fromietf(detected_language), forced=forced,
|
||||||
hi=False)
|
hi=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# If language is still None (undetected), skip it
|
# If language is still None (undetected), skip it
|
||||||
if not language:
|
if hasattr(subtitles[subtitle], 'basename') and not subtitles[subtitle].basename:
|
||||||
pass
|
continue
|
||||||
|
|
||||||
# Skip HI detection if forced
|
# Skip HI detection if forced
|
||||||
elif language.forced:
|
if hasattr(language, 'forced') and language.forced:
|
||||||
pass
|
continue
|
||||||
|
|
||||||
# Detect hearing-impaired external subtitles not identified in filename
|
# Detect hearing-impaired external subtitles not identified in filename
|
||||||
elif not subtitles[subtitle].hi:
|
if hasattr(subtitles[subtitle], 'hi') and not subtitles[subtitle].hi:
|
||||||
subtitle_path = os.path.join(dest_folder, subtitle)
|
subtitle_path = os.path.join(dest_folder, subtitle)
|
||||||
|
|
||||||
# check if file exist:
|
# check if file exist:
|
||||||
|
|
|
@ -81,11 +81,9 @@ def process_subtitle(subtitle, media_type, audio_language, path, max_score, is_u
|
||||||
radarr_id=movie_metadata['radarrId'])
|
radarr_id=movie_metadata['radarrId'])
|
||||||
|
|
||||||
if use_postprocessing is True:
|
if use_postprocessing is True:
|
||||||
command = pp_replace(postprocessing_cmd, path, downloaded_path, downloaded_language,
|
command = pp_replace(postprocessing_cmd, path, downloaded_path, downloaded_language, downloaded_language_code2,
|
||||||
downloaded_language_code2, downloaded_language_code3, audio_language,
|
downloaded_language_code3, audio_language, audio_language_code2, audio_language_code3,
|
||||||
audio_language_code2, audio_language_code3, subtitle.language.forced,
|
percent_score, subtitle_id, downloaded_provider, series_id, episode_id)
|
||||||
percent_score, subtitle_id, downloaded_provider, series_id, episode_id,
|
|
||||||
subtitle.language.hi)
|
|
||||||
|
|
||||||
if media_type == 'series':
|
if media_type == 'series':
|
||||||
use_pp_threshold = settings.general.getboolean('use_postprocessing_threshold')
|
use_pp_threshold = settings.general.getboolean('use_postprocessing_threshold')
|
||||||
|
|
|
@ -54,6 +54,10 @@ class SubSyncer:
|
||||||
try:
|
try:
|
||||||
unparsed_args = [self.reference, '-i', self.srtin, '-o', self.srtout, '--ffmpegpath', self.ffmpeg_path,
|
unparsed_args = [self.reference, '-i', self.srtin, '-o', self.srtout, '--ffmpegpath', self.ffmpeg_path,
|
||||||
'--vad', self.vad, '--log-dir-path', self.log_dir_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('--reference-stream')
|
||||||
|
unparsed_args.append('a:0')
|
||||||
if settings.subsync.getboolean('debug'):
|
if settings.subsync.getboolean('debug'):
|
||||||
unparsed_args.append('--make-test-case')
|
unparsed_args.append('--make-test-case')
|
||||||
parser = make_parser()
|
parser = make_parser()
|
||||||
|
|
|
@ -37,7 +37,7 @@ def upgrade_subtitles():
|
||||||
if settings.general.getboolean('use_sonarr'):
|
if settings.general.getboolean('use_sonarr'):
|
||||||
upgradable_episodes_conditions = [(TableHistory.action << query_actions),
|
upgradable_episodes_conditions = [(TableHistory.action << query_actions),
|
||||||
(TableHistory.timestamp > minimum_timestamp),
|
(TableHistory.timestamp > minimum_timestamp),
|
||||||
(TableHistory.score is not None)]
|
(TableHistory.score.is_null(False))]
|
||||||
upgradable_episodes_conditions += get_exclusion_clause('series')
|
upgradable_episodes_conditions += get_exclusion_clause('series')
|
||||||
upgradable_episodes = TableHistory.select(TableHistory.video_path,
|
upgradable_episodes = TableHistory.select(TableHistory.video_path,
|
||||||
TableHistory.language,
|
TableHistory.language,
|
||||||
|
@ -86,7 +86,7 @@ def upgrade_subtitles():
|
||||||
if settings.general.getboolean('use_radarr'):
|
if settings.general.getboolean('use_radarr'):
|
||||||
upgradable_movies_conditions = [(TableHistoryMovie.action << query_actions),
|
upgradable_movies_conditions = [(TableHistoryMovie.action << query_actions),
|
||||||
(TableHistoryMovie.timestamp > minimum_timestamp),
|
(TableHistoryMovie.timestamp > minimum_timestamp),
|
||||||
(TableHistoryMovie.score is not None)]
|
(TableHistoryMovie.score.is_null(False))]
|
||||||
upgradable_movies_conditions += get_exclusion_clause('movie')
|
upgradable_movies_conditions += get_exclusion_clause('movie')
|
||||||
upgradable_movies = TableHistoryMovie.select(TableHistoryMovie.video_path,
|
upgradable_movies = TableHistoryMovie.select(TableHistoryMovie.video_path,
|
||||||
TableHistoryMovie.language,
|
TableHistoryMovie.language,
|
||||||
|
|
|
@ -150,10 +150,9 @@ def manual_upload_subtitle(path, language, forced, hi, title, scene_name, media_
|
||||||
percent_score=100, radarr_id=movie_metadata['radarrId'], forced=forced)
|
percent_score=100, radarr_id=movie_metadata['radarrId'], forced=forced)
|
||||||
|
|
||||||
if use_postprocessing:
|
if use_postprocessing:
|
||||||
command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language,
|
command = pp_replace(postprocessing_cmd, path, subtitle_path, uploaded_language, uploaded_language_code2,
|
||||||
uploaded_language_code2, uploaded_language_code3, audio_language,
|
uploaded_language_code3, audio_language, audio_language_code2, audio_language_code3, 100,
|
||||||
audio_language_code2, audio_language_code3, forced, 100, "1", "manual", series_id,
|
"1", "manual", series_id, episode_id)
|
||||||
episode_id, hi=hi)
|
|
||||||
postprocessing(command, path)
|
postprocessing(command, path)
|
||||||
|
|
||||||
if media_type == 'series':
|
if media_type == 'series':
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
"machine": "aarch64",
|
"machine": "aarch64",
|
||||||
"directory": "unrar",
|
"directory": "unrar",
|
||||||
"name": "unrar",
|
"name": "unrar",
|
||||||
"checksum": "07a6371cc7db8493352739ce26b19ea1",
|
"checksum": "be7a08a75ffb1dcc5f8c6b16a75e822c",
|
||||||
"url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Linux/aarch64/unrar/unrar"
|
"url": "https://github.com/morpheus65535/bazarr-binaries/raw/master/bin/Linux/aarch64/unrar/unrar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,24 +1,36 @@
|
||||||
# coding=utf-8
|
# coding=utf-8
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
def pp_replace(pp_command, episode, subtitles, language, language_code2, language_code3, episode_language, episode_language_code2, episode_language_code3, forced, score, subtitle_id, provider, series_id, episode_id, hi):
|
# Wraps the input string within quotes & escapes the string
|
||||||
pp_command = pp_command.replace('{{directory}}', os.path.dirname(episode))
|
def _escape(in_str):
|
||||||
pp_command = pp_command.replace('{{episode}}', episode)
|
raw_map = {8: r'\\b', 7: r'\\a', 12: r'\\f', 10: r'\\n', 13: r'\\r', 9: r'\\t', 11: r'\\v', 34: r'\"', 92: r'\\'}
|
||||||
pp_command = pp_command.replace('{{episode_name}}', os.path.splitext(os.path.basename(episode))[0])
|
raw_str = r''.join(raw_map.get(ord(i), i) for i in in_str)
|
||||||
pp_command = pp_command.replace('{{subtitles}}', str(subtitles))
|
return f"\"{raw_str}\""
|
||||||
pp_command = pp_command.replace('{{subtitles_language}}', str(language))
|
|
||||||
pp_command = pp_command.replace('{{subtitles_language_code2}}', str(language_code2))
|
|
||||||
pp_command = pp_command.replace('{{subtitles_language_code3}}', str(language_code3))
|
def pp_replace(pp_command, episode, subtitles, language, language_code2, language_code3, episode_language,
|
||||||
pp_command = pp_command.replace('{{subtitles_language_code2_dot}}', str(language_code2).replace(':', '.'))
|
episode_language_code2, episode_language_code3, score, subtitle_id, provider, series_id, episode_id):
|
||||||
pp_command = pp_command.replace('{{subtitles_language_code3_dot}}', str(language_code3).replace(':', '.'))
|
pp_command = re.sub(r'[\'"]?{{directory}}[\'"]?', _escape(os.path.dirname(episode)), pp_command)
|
||||||
pp_command = pp_command.replace('{{episode_language}}', str(episode_language))
|
pp_command = re.sub(r'[\'"]?{{episode}}[\'"]?', _escape(episode), pp_command)
|
||||||
pp_command = pp_command.replace('{{episode_language_code2}}', str(episode_language_code2))
|
pp_command = re.sub(r'[\'"]?{{episode_name}}[\'"]?', _escape(os.path.splitext(os.path.basename(episode))[0]),
|
||||||
pp_command = pp_command.replace('{{episode_language_code3}}', str(episode_language_code3))
|
pp_command)
|
||||||
pp_command = pp_command.replace('{{score}}', str(score))
|
pp_command = re.sub(r'[\'"]?{{subtitles}}[\'"]?', _escape(str(subtitles)), pp_command)
|
||||||
pp_command = pp_command.replace('{{subtitle_id}}', str(subtitle_id))
|
pp_command = re.sub(r'[\'"]?{{subtitles_language}}[\'"]?', _escape(str(language)), pp_command)
|
||||||
pp_command = pp_command.replace('{{provider}}', str(provider))
|
pp_command = re.sub(r'[\'"]?{{subtitles_language_code2}}[\'"]?', _escape(str(language_code2)), pp_command)
|
||||||
pp_command = pp_command.replace('{{series_id}}', str(series_id))
|
pp_command = re.sub(r'[\'"]?{{subtitles_language_code3}}[\'"]?', _escape(str(language_code3)), pp_command)
|
||||||
pp_command = pp_command.replace('{{episode_id}}', str(episode_id))
|
pp_command = re.sub(r'[\'"]?{{subtitles_language_code2_dot}}[\'"]?',
|
||||||
|
_escape(str(language_code2).replace(':', '.')), pp_command)
|
||||||
|
pp_command = re.sub(r'[\'"]?{{subtitles_language_code3_dot}}[\'"]?',
|
||||||
|
_escape(str(language_code3).replace(':', '.')), pp_command)
|
||||||
|
pp_command = re.sub(r'[\'"]?{{episode_language}}[\'"]?', _escape(str(episode_language)), pp_command)
|
||||||
|
pp_command = re.sub(r'[\'"]?{{episode_language_code2}}[\'"]?', _escape(str(episode_language_code2)), pp_command)
|
||||||
|
pp_command = re.sub(r'[\'"]?{{episode_language_code3}}[\'"]?', _escape(str(episode_language_code3)), pp_command)
|
||||||
|
pp_command = re.sub(r'[\'"]?{{score}}[\'"]?', _escape(str(score)), pp_command)
|
||||||
|
pp_command = re.sub(r'[\'"]?{{subtitle_id}}[\'"]?', _escape(str(subtitle_id)), pp_command)
|
||||||
|
pp_command = re.sub(r'[\'"]?{{provider}}[\'"]?', _escape(str(provider)), pp_command)
|
||||||
|
pp_command = re.sub(r'[\'"]?{{series_id}}[\'"]?', _escape(str(series_id)), pp_command)
|
||||||
|
pp_command = re.sub(r'[\'"]?{{episode_id}}[\'"]?', _escape(str(episode_id)), pp_command)
|
||||||
return pp_command
|
return pp_command
|
||||||
|
|
|
@ -3,5 +3,5 @@
|
||||||
|
|
||||||
[ -n "$CI" ] && exit 0
|
[ -n "$CI" ] && exit 0
|
||||||
|
|
||||||
cd frontend
|
cd frontend || exit
|
||||||
npx pretty-quick --staged
|
npx pretty-quick --staged --pattern "frontend/**/*.*"
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -50,7 +50,7 @@
|
||||||
"lodash": "^4",
|
"lodash": "^4",
|
||||||
"moment": "^2.29",
|
"moment": "^2.29",
|
||||||
"prettier": "^2",
|
"prettier": "^2",
|
||||||
"prettier-plugin-organize-imports": "^2",
|
"prettier-plugin-organize-imports": "^3",
|
||||||
"pretty-quick": "^3",
|
"pretty-quick": "^3",
|
||||||
"react-dropzone": "^14",
|
"react-dropzone": "^14",
|
||||||
"react-table": "^7",
|
"react-table": "^7",
|
||||||
|
|
|
@ -24,8 +24,10 @@ const App: FunctionComponent = () => {
|
||||||
setCriticalError(detail.message);
|
setCriticalError(detail.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
useWindowEvent("app-login-required", () => {
|
useWindowEvent("app-auth-changed", (ev) => {
|
||||||
|
if (!ev.detail.authenticated) {
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useWindowEvent("app-online-status", ({ detail }) => {
|
useWindowEvent("app-online-status", ({ detail }) => {
|
||||||
|
|
|
@ -10,6 +10,8 @@ import api from "../raw";
|
||||||
|
|
||||||
const cacheEpisodes = (client: QueryClient, episodes: Item.Episode[]) => {
|
const cacheEpisodes = (client: QueryClient, episodes: Item.Episode[]) => {
|
||||||
episodes.forEach((item) => {
|
episodes.forEach((item) => {
|
||||||
|
client.setQueryData([QueryKeys.Episodes, item.sonarrEpisodeId], item);
|
||||||
|
|
||||||
client.setQueryData(
|
client.setQueryData(
|
||||||
[
|
[
|
||||||
QueryKeys.Series,
|
QueryKeys.Series,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Environment } from "@/utilities";
|
import { Environment } from "@/utilities";
|
||||||
import { setLoginRequired } from "@/utilities/event";
|
import { setAuthenticated } from "@/utilities/event";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||||
import { QueryKeys } from "../queries/keys";
|
import { QueryKeys } from "../queries/keys";
|
||||||
|
@ -173,7 +173,7 @@ export function useSystem() {
|
||||||
() => api.system.logout(),
|
() => api.system.logout(),
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setLoginRequired();
|
setAuthenticated(false);
|
||||||
client.clear();
|
client.clear();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import SocketIO from "@/modules/socketio";
|
import socketio from "@/modules/socketio";
|
||||||
import { notification } from "@/modules/task";
|
import { notification } from "@/modules/task";
|
||||||
import { LOG } from "@/utilities/console";
|
import { LOG } from "@/utilities/console";
|
||||||
import { setLoginRequired } from "@/utilities/event";
|
import { setAuthenticated } from "@/utilities/event";
|
||||||
import { showNotification } from "@mantine/notifications";
|
import { showNotification } from "@mantine/notifications";
|
||||||
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
|
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
|
||||||
import { Environment } from "../../utilities";
|
import { Environment } from "../../utilities";
|
||||||
|
@ -17,17 +17,19 @@ function GetErrorMessage(data: unknown, defaultMsg = "Unknown error"): string {
|
||||||
class BazarrClient {
|
class BazarrClient {
|
||||||
axios!: AxiosInstance;
|
axios!: AxiosInstance;
|
||||||
source!: CancelTokenSource;
|
source!: CancelTokenSource;
|
||||||
|
bIsAuthenticated: boolean;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.bIsAuthenticated = false;
|
||||||
const baseUrl = `${Environment.baseUrl}/api/`;
|
const baseUrl = `${Environment.baseUrl}/api/`;
|
||||||
|
|
||||||
LOG("info", "initializing BazarrClient with", baseUrl);
|
|
||||||
|
|
||||||
this.initialize(baseUrl, Environment.apiKey);
|
this.initialize(baseUrl, Environment.apiKey);
|
||||||
SocketIO.initialize();
|
socketio.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize(url: string, apikey?: string) {
|
initialize(url: string, apikey?: string) {
|
||||||
|
LOG("info", "initializing BazarrClient with baseUrl", url);
|
||||||
|
|
||||||
this.axios = Axios.create({
|
this.axios = Axios.create({
|
||||||
baseURL: url,
|
baseURL: url,
|
||||||
});
|
});
|
||||||
|
@ -45,6 +47,10 @@ class BazarrClient {
|
||||||
this.axios.interceptors.response.use(
|
this.axios.interceptors.response.use(
|
||||||
(resp) => {
|
(resp) => {
|
||||||
if (resp.status >= 200 && resp.status < 300) {
|
if (resp.status >= 200 && resp.status < 300) {
|
||||||
|
if (!this.bIsAuthenticated) {
|
||||||
|
this.bIsAuthenticated = true;
|
||||||
|
setAuthenticated(true);
|
||||||
|
}
|
||||||
return Promise.resolve(resp);
|
return Promise.resolve(resp);
|
||||||
} else {
|
} else {
|
||||||
const error: BackendError = {
|
const error: BackendError = {
|
||||||
|
@ -78,8 +84,9 @@ class BazarrClient {
|
||||||
const { code, message } = error;
|
const { code, message } = error;
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case 401:
|
case 401:
|
||||||
setLoginRequired();
|
this.bIsAuthenticated = false;
|
||||||
break;
|
setAuthenticated(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
LOG("error", "A error has occurred", code);
|
LOG("error", "A error has occurred", code);
|
||||||
|
|
||||||
|
|
|
@ -291,6 +291,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({
|
||||||
return (
|
return (
|
||||||
<Selector
|
<Selector
|
||||||
{...episodeOptions}
|
{...episodeOptions}
|
||||||
|
searchable
|
||||||
className={classes.select}
|
className={classes.select}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(item) => {
|
onChange={(item) => {
|
||||||
|
|
|
@ -47,7 +47,12 @@ const TimeOffsetForm: FunctionComponent<Props> = ({ selections, onSubmit }) => {
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={form.onSubmit(({ positive, hour, min, sec, ms }) => {
|
onSubmit={form.onSubmit(({ positive, hour, min, sec, ms }) => {
|
||||||
const action = convertToAction(hour, min, sec, ms);
|
let action: string;
|
||||||
|
if (positive) {
|
||||||
|
action = convertToAction(hour, min, sec, ms);
|
||||||
|
} else {
|
||||||
|
action = convertToAction(-hour, -min, -sec, -ms);
|
||||||
|
}
|
||||||
|
|
||||||
selections.forEach((s) =>
|
selections.forEach((s) =>
|
||||||
task.create(s.path, TaskName, mutateAsync, {
|
task.create(s.path, TaskName, mutateAsync, {
|
||||||
|
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Anchor,
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Code,
|
||||||
Collapse,
|
Collapse,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
|
@ -26,6 +27,7 @@ import {
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useHover } from "@mantine/hooks";
|
import { useHover } from "@mantine/hooks";
|
||||||
|
import { isString } from "lodash";
|
||||||
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
import { FunctionComponent, useCallback, useMemo, useState } from "react";
|
||||||
import { UseQueryResult } from "react-query";
|
import { UseQueryResult } from "react-query";
|
||||||
import { Column } from "react-table";
|
import { Column } from "react-table";
|
||||||
|
@ -186,6 +188,9 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
||||||
[download, item]
|
[download, item]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const bSceneNameAvailable =
|
||||||
|
isString(item.sceneName) && item.sceneName.length !== 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Alert
|
<Alert
|
||||||
|
@ -193,7 +198,9 @@ function ManualSearchView<T extends SupportType>(props: Props<T>) {
|
||||||
color="gray"
|
color="gray"
|
||||||
icon={<FontAwesomeIcon icon={faInfoCircle}></FontAwesomeIcon>}
|
icon={<FontAwesomeIcon icon={faInfoCircle}></FontAwesomeIcon>}
|
||||||
>
|
>
|
||||||
{item?.path}
|
<Text size="sm">{item?.path}</Text>
|
||||||
|
<Divider hidden={!bSceneNameAvailable} my="xs"></Divider>
|
||||||
|
<Code hidden={!bSceneNameAvailable}>{item?.sceneName}</Code>
|
||||||
</Alert>
|
</Alert>
|
||||||
<Collapse in={!isStale && !results.isFetching}>
|
<Collapse in={!isStale && !results.isFetching}>
|
||||||
<PageTable
|
<PageTable
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { debounce, forIn, remove, uniq } from "lodash";
|
import { debounce, forIn, remove, uniq } from "lodash";
|
||||||
import { onlineManager } from "react-query";
|
import { onlineManager } from "react-query";
|
||||||
import { io, Socket } from "socket.io-client";
|
import { io, Socket } from "socket.io-client";
|
||||||
import { Environment } from "../../utilities";
|
import { Environment, isDevEnv } from "../../utilities";
|
||||||
import { ENSURE, LOG } from "../../utilities/console";
|
import { ENSURE, GROUP, LOG } from "../../utilities/console";
|
||||||
import { createDefaultReducer } from "./reducer";
|
import { createDefaultReducer } from "./reducer";
|
||||||
|
|
||||||
class SocketIOClient {
|
class SocketIOClient {
|
||||||
|
@ -31,13 +31,50 @@ class SocketIOClient {
|
||||||
this.reducers = [];
|
this.reducers = [];
|
||||||
|
|
||||||
onlineManager.setOnline(false);
|
onlineManager.setOnline(false);
|
||||||
|
|
||||||
|
if (isDevEnv) {
|
||||||
|
window.socketIO = {
|
||||||
|
dump: () => {
|
||||||
|
GROUP("Socket.IO Reducers", (logger) => {
|
||||||
|
this.reducers.forEach((reducer) => {
|
||||||
|
logger(reducer.key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
emit: (e) => {
|
||||||
|
if (e) {
|
||||||
|
this.onEvent(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
|
LOG("info", "Initializing Socket.IO client...");
|
||||||
this.reducers.push(...createDefaultReducer());
|
this.reducers.push(...createDefaultReducer());
|
||||||
|
|
||||||
|
window.addEventListener("app-auth-changed", (ev) => {
|
||||||
|
const authenticated = ev.detail.authenticated;
|
||||||
|
LOG("info", "Authentication status change to", authenticated);
|
||||||
|
if (authenticated) {
|
||||||
|
this.connect();
|
||||||
|
} else {
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
LOG("info", "Connecting Socket.IO client...");
|
||||||
this.socket.connect();
|
this.socket.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
LOG("info", "Disconnecting Socket.IO client...");
|
||||||
|
this.socket.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
addReducer(reducer: SocketIO.Reducer) {
|
addReducer(reducer: SocketIO.Reducer) {
|
||||||
this.reducers.push(reducer);
|
this.reducers.push(reducer);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import queryClient from "@/apis/queries";
|
import queryClient from "@/apis/queries";
|
||||||
import { QueryKeys } from "@/apis/queries/keys";
|
import { QueryKeys } from "@/apis/queries/keys";
|
||||||
|
import { LOG } from "@/utilities/console";
|
||||||
import { setCriticalError, setOnlineStatus } from "@/utilities/event";
|
import { setCriticalError, setOnlineStatus } from "@/utilities/event";
|
||||||
import { showNotification } from "@mantine/notifications";
|
import { showNotification } from "@mantine/notifications";
|
||||||
import { notification, task } from "../task";
|
import { notification, task } from "../task";
|
||||||
|
@ -36,11 +37,13 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
|
||||||
{
|
{
|
||||||
key: "series",
|
key: "series",
|
||||||
update: (ids) => {
|
update: (ids) => {
|
||||||
|
LOG("info", "Invalidating series", ids);
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
queryClient.invalidateQueries([QueryKeys.Series, id]);
|
queryClient.invalidateQueries([QueryKeys.Series, id]);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
delete: (ids) => {
|
delete: (ids) => {
|
||||||
|
LOG("info", "Invalidating series", ids);
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
queryClient.invalidateQueries([QueryKeys.Series, id]);
|
queryClient.invalidateQueries([QueryKeys.Series, id]);
|
||||||
});
|
});
|
||||||
|
@ -49,11 +52,13 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
|
||||||
{
|
{
|
||||||
key: "movie",
|
key: "movie",
|
||||||
update: (ids) => {
|
update: (ids) => {
|
||||||
|
LOG("info", "Invalidating movies", ids);
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
queryClient.invalidateQueries([QueryKeys.Movies, id]);
|
queryClient.invalidateQueries([QueryKeys.Movies, id]);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
delete: (ids) => {
|
delete: (ids) => {
|
||||||
|
LOG("info", "Invalidating movies", ids);
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
queryClient.invalidateQueries([QueryKeys.Movies, id]);
|
queryClient.invalidateQueries([QueryKeys.Movies, id]);
|
||||||
});
|
});
|
||||||
|
@ -62,13 +67,36 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
|
||||||
{
|
{
|
||||||
key: "episode",
|
key: "episode",
|
||||||
update: (ids) => {
|
update: (ids) => {
|
||||||
|
// Currently invalidate episodes is impossible because we don't directly fetch episodes (we fetch episodes by series id)
|
||||||
|
// So we need to invalidate series instead
|
||||||
|
// TODO: Make a query for episodes and invalidate that instead
|
||||||
|
LOG("info", "Invalidating episodes", ids);
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
queryClient.invalidateQueries([QueryKeys.Episodes, id]);
|
const episode = queryClient.getQueryData<Item.Episode>([
|
||||||
|
QueryKeys.Episodes,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (episode !== undefined) {
|
||||||
|
queryClient.invalidateQueries([
|
||||||
|
QueryKeys.Series,
|
||||||
|
episode.sonarrSeriesId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
delete: (ids) => {
|
delete: (ids) => {
|
||||||
|
LOG("info", "Invalidating episodes", ids);
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
queryClient.invalidateQueries([QueryKeys.Episodes, id]);
|
const episode = queryClient.getQueryData<Item.Episode>([
|
||||||
|
QueryKeys.Episodes,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (episode !== undefined) {
|
||||||
|
queryClient.invalidateQueries([
|
||||||
|
QueryKeys.Series,
|
||||||
|
episode.sonarrSeriesId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -21,7 +21,6 @@ import {
|
||||||
Selector,
|
Selector,
|
||||||
Text,
|
Text,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
import { BaseUrlModification } from "../utilities/modifications";
|
|
||||||
import { branchOptions, proxyOptions, securityOptions } from "./options";
|
import { branchOptions, proxyOptions, securityOptions } from "./options";
|
||||||
|
|
||||||
const characters = "abcdef0123456789";
|
const characters = "abcdef0123456789";
|
||||||
|
@ -58,7 +57,7 @@ const SettingsGeneralView: FunctionComponent = () => {
|
||||||
icon="/"
|
icon="/"
|
||||||
settingKey="settings-general-base_url"
|
settingKey="settings-general-base_url"
|
||||||
settingOptions={{
|
settingOptions={{
|
||||||
onLoaded: BaseUrlModification,
|
onLoaded: (s) => s.general.base_url?.slice(1) ?? "",
|
||||||
onSubmit: (v) => "/" + v,
|
onSubmit: (v) => "/" + v,
|
||||||
}}
|
}}
|
||||||
></Text>
|
></Text>
|
||||||
|
@ -116,6 +115,14 @@ const SettingsGeneralView: FunctionComponent = () => {
|
||||||
}
|
}
|
||||||
settingKey={settingApiKey}
|
settingKey={settingApiKey}
|
||||||
></Text>
|
></Text>
|
||||||
|
<Check
|
||||||
|
label="Enable CORS headers"
|
||||||
|
settingKey="settings-cors-enabled"
|
||||||
|
></Check>
|
||||||
|
<Message>
|
||||||
|
Allow third parties to make requests towards your Bazarr installation.
|
||||||
|
Requires a restart of Bazarr when changed
|
||||||
|
</Message>
|
||||||
</Section>
|
</Section>
|
||||||
<Section header="Proxy">
|
<Section header="Proxy">
|
||||||
<Selector
|
<Selector
|
||||||
|
|
|
@ -103,6 +103,11 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
|
||||||
key: "hi_fallback",
|
key: "hi_fallback",
|
||||||
name: "Use HI subtitles as a fallback (don't enable it if you have a HI language profile)",
|
name: "Use HI subtitles as a fallback (don't enable it if you have a HI language profile)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "switch",
|
||||||
|
key: "unknown_as_english",
|
||||||
|
name: "Use subtitles with unknown info/language as english",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
message:
|
message:
|
||||||
"Warning for cloud users: this provider needs to read the entire file in order to extract subtitles.",
|
"Warning for cloud users: this provider needs to read the entire file in order to extract subtitles.",
|
||||||
|
|
|
@ -14,7 +14,6 @@ import {
|
||||||
URLTestButton,
|
URLTestButton,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
import { moviesEnabledKey } from "../keys";
|
import { moviesEnabledKey } from "../keys";
|
||||||
import { BaseUrlModification } from "../utilities/modifications";
|
|
||||||
|
|
||||||
const SettingsRadarrView: FunctionComponent = () => {
|
const SettingsRadarrView: FunctionComponent = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -32,7 +31,7 @@ const SettingsRadarrView: FunctionComponent = () => {
|
||||||
icon="/"
|
icon="/"
|
||||||
settingKey="settings-radarr-base_url"
|
settingKey="settings-radarr-base_url"
|
||||||
settingOptions={{
|
settingOptions={{
|
||||||
onLoaded: BaseUrlModification,
|
onLoaded: (s) => s.radarr.base_url?.slice(1) ?? "",
|
||||||
onSubmit: (v) => "/" + v,
|
onSubmit: (v) => "/" + v,
|
||||||
}}
|
}}
|
||||||
></Text>
|
></Text>
|
||||||
|
|
|
@ -16,7 +16,6 @@ import {
|
||||||
} from "../components";
|
} from "../components";
|
||||||
import { seriesEnabledKey } from "../keys";
|
import { seriesEnabledKey } from "../keys";
|
||||||
import { seriesTypeOptions } from "../options";
|
import { seriesTypeOptions } from "../options";
|
||||||
import { BaseUrlModification } from "../utilities/modifications";
|
|
||||||
|
|
||||||
const SettingsSonarrView: FunctionComponent = () => {
|
const SettingsSonarrView: FunctionComponent = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -34,7 +33,7 @@ const SettingsSonarrView: FunctionComponent = () => {
|
||||||
icon="/"
|
icon="/"
|
||||||
settingKey="settings-sonarr-base_url"
|
settingKey="settings-sonarr-base_url"
|
||||||
settingOptions={{
|
settingOptions={{
|
||||||
onLoaded: BaseUrlModification,
|
onLoaded: (s) => s.sonarr.base_url?.slice(1) ?? "",
|
||||||
onSubmit: (v) => "/" + v,
|
onSubmit: (v) => "/" + v,
|
||||||
}}
|
}}
|
||||||
></Text>
|
></Text>
|
||||||
|
|
|
@ -364,6 +364,14 @@ const SettingsSubtitlesView: FunctionComponent = () => {
|
||||||
<Text placeholder="0777" settingKey="settings-general-chmod"></Text>
|
<Text placeholder="0777" settingKey="settings-general-chmod"></Text>
|
||||||
<Message>Must be 4 digit octal</Message>
|
<Message>Must be 4 digit octal</Message>
|
||||||
</CollapseBox>
|
</CollapseBox>
|
||||||
|
<Check
|
||||||
|
label="Always use Audio Track as Reference for Syncing"
|
||||||
|
settingKey="settings-subsync-force_audio"
|
||||||
|
></Check>
|
||||||
|
<Message>
|
||||||
|
Use the audio track as reference for syncing, instead of using the
|
||||||
|
embedded subtitle.
|
||||||
|
</Message>
|
||||||
<Check
|
<Check
|
||||||
label="Automatic Subtitles Synchronization"
|
label="Automatic Subtitles Synchronization"
|
||||||
settingKey="settings-subsync-use_subsync"
|
settingKey="settings-subsync-use_subsync"
|
||||||
|
|
|
@ -3,9 +3,10 @@ import { Toolbox } from "@/components";
|
||||||
import { LoadingProvider } from "@/contexts";
|
import { LoadingProvider } from "@/contexts";
|
||||||
import { useOnValueChange } from "@/utilities";
|
import { useOnValueChange } from "@/utilities";
|
||||||
import { LOG } from "@/utilities/console";
|
import { LOG } from "@/utilities/console";
|
||||||
|
import { usePrompt } from "@/utilities/routers";
|
||||||
import { useUpdateLocalStorage } from "@/utilities/storage";
|
import { useUpdateLocalStorage } from "@/utilities/storage";
|
||||||
import { faSave } from "@fortawesome/free-solid-svg-icons";
|
import { faSave } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { Container, Group, LoadingOverlay } from "@mantine/core";
|
import { Badge, Container, Group, LoadingOverlay } from "@mantine/core";
|
||||||
import { useDocumentTitle, useForm } from "@mantine/hooks";
|
import { useDocumentTitle, useForm } from "@mantine/hooks";
|
||||||
import { FunctionComponent, ReactNode, useCallback, useMemo } from "react";
|
import { FunctionComponent, ReactNode, useCallback, useMemo } from "react";
|
||||||
import { enabledLanguageKey, languageProfileKey } from "../keys";
|
import { enabledLanguageKey, languageProfileKey } from "../keys";
|
||||||
|
@ -84,6 +85,11 @@ const Layout: FunctionComponent<Props> = (props) => {
|
||||||
return Object.keys(object).length;
|
return Object.keys(object).length;
|
||||||
}, [form.values.settings, form.values.storages]);
|
}, [form.values.settings, form.values.storages]);
|
||||||
|
|
||||||
|
usePrompt(
|
||||||
|
totalStagedCount > 0,
|
||||||
|
`You have ${totalStagedCount} unsaved changes, are you sure you want to leave?`
|
||||||
|
);
|
||||||
|
|
||||||
useDocumentTitle(`${name} - Bazarr (Settings)`);
|
useDocumentTitle(`${name} - Bazarr (Settings)`);
|
||||||
|
|
||||||
if (settings === undefined) {
|
if (settings === undefined) {
|
||||||
|
@ -101,6 +107,11 @@ const Layout: FunctionComponent<Props> = (props) => {
|
||||||
icon={faSave}
|
icon={faSave}
|
||||||
loading={isMutating}
|
loading={isMutating}
|
||||||
disabled={totalStagedCount === 0}
|
disabled={totalStagedCount === 0}
|
||||||
|
rightIcon={
|
||||||
|
<Badge size="xs" radius="sm" hidden={totalStagedCount === 0}>
|
||||||
|
{totalStagedCount}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Toolbox.Button>
|
</Toolbox.Button>
|
||||||
|
|
|
@ -130,15 +130,16 @@ type SliderProps = BaseInput<number> &
|
||||||
|
|
||||||
export const Slider: FunctionComponent<SliderProps> = (props) => {
|
export const Slider: FunctionComponent<SliderProps> = (props) => {
|
||||||
const { value, update, rest } = useBaseInput(props);
|
const { value, update, rest } = useBaseInput(props);
|
||||||
|
const { label, ...sliderProps } = rest;
|
||||||
|
|
||||||
const { min = 0, max = 100 } = props;
|
const { min = 0, max = 100 } = props;
|
||||||
|
|
||||||
const marks = useSliderMarks([min, max]);
|
const marks = useSliderMarks([min, max]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputWrapper label={rest.label}>
|
<InputWrapper label={label}>
|
||||||
<MantineSlider
|
<MantineSlider
|
||||||
{...rest}
|
{...sliderProps}
|
||||||
marks={marks}
|
marks={marks}
|
||||||
onChange={update}
|
onChange={update}
|
||||||
value={value ?? 0}
|
value={value ?? 0}
|
||||||
|
|
|
@ -18,10 +18,16 @@ export const URLTestButton: FunctionComponent<{
|
||||||
const click = useCallback(() => {
|
const click = useCallback(() => {
|
||||||
if (address && apikey && ssl !== null) {
|
if (address && apikey && ssl !== null) {
|
||||||
let testUrl: string;
|
let testUrl: string;
|
||||||
|
|
||||||
|
let baseUrl = url;
|
||||||
|
if (baseUrl && baseUrl.startsWith("/") === false) {
|
||||||
|
baseUrl = "/" + baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if (port) {
|
if (port) {
|
||||||
testUrl = `${address}:${port}${url ?? ""}`;
|
testUrl = `${address}:${port}${baseUrl ?? ""}`;
|
||||||
} else {
|
} else {
|
||||||
testUrl = `${address}${url ?? ""}`;
|
testUrl = `${address}${baseUrl ?? ""}`;
|
||||||
}
|
}
|
||||||
const request = {
|
const request = {
|
||||||
protocol: ssl ? "https" : "http",
|
protocol: ssl ? "https" : "http",
|
||||||
|
|
|
@ -1,6 +1,3 @@
|
||||||
export const BaseUrlModification = (settings: Settings) =>
|
|
||||||
settings.general.base_url?.slice(1) ?? "";
|
|
||||||
|
|
||||||
export const SubzeroModification = (key: string) => (settings: Settings) =>
|
export const SubzeroModification = (key: string) => (settings: Settings) =>
|
||||||
settings.general.subzero_mods?.includes(key) ?? false;
|
settings.general.subzero_mods?.includes(key) ?? false;
|
||||||
|
|
||||||
|
|
|
@ -2,9 +2,10 @@ import { useDeleteBackups, useRestoreBackups } from "@/apis/hooks";
|
||||||
import { Action, PageTable } from "@/components";
|
import { Action, PageTable } from "@/components";
|
||||||
import { useModals } from "@/modules/modals";
|
import { useModals } from "@/modules/modals";
|
||||||
import { useTableStyles } from "@/styles";
|
import { useTableStyles } from "@/styles";
|
||||||
|
import { Environment } from "@/utilities";
|
||||||
import { faClock, faHistory, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faClock, faHistory, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Group, Text } from "@mantine/core";
|
import { Anchor, Group, Text } from "@mantine/core";
|
||||||
import { FunctionComponent, useMemo } from "react";
|
import { FunctionComponent, useMemo } from "react";
|
||||||
import { Column } from "react-table";
|
import { Column } from "react-table";
|
||||||
|
|
||||||
|
@ -23,8 +24,13 @@ const Table: FunctionComponent<Props> = ({ backups }) => {
|
||||||
Header: "Name",
|
Header: "Name",
|
||||||
accessor: "filename",
|
accessor: "filename",
|
||||||
Cell: ({ value }) => {
|
Cell: ({ value }) => {
|
||||||
const { classes } = useTableStyles();
|
return (
|
||||||
return <Text className={classes.primary}>{value}</Text>;
|
<Anchor
|
||||||
|
href={`${Environment.baseUrl}/system/backup/download/${value}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Anchor>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -111,6 +111,7 @@ declare namespace Settings {
|
||||||
use_subsync_movie_threshold: boolean;
|
use_subsync_movie_threshold: boolean;
|
||||||
subsync_movie_threshold: number;
|
subsync_movie_threshold: number;
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
|
force_audio: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Analytic {
|
interface Analytic {
|
||||||
|
|
|
@ -6,12 +6,12 @@ interface SocketIODebugger {
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
Bazarr: BazarrServer;
|
Bazarr: BazarrServer;
|
||||||
_socketio: SocketIODebugger;
|
socketIO: SocketIODebugger;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WindowEventMap {
|
interface WindowEventMap {
|
||||||
|
"app-auth-changed": CustomEvent<{ authenticated: boolean }>;
|
||||||
"app-critical-error": CustomEvent<{ message: string }>;
|
"app-critical-error": CustomEvent<{ message: string }>;
|
||||||
"app-login-required": CustomEvent;
|
|
||||||
"app-online-status": CustomEvent<{ online: boolean }>;
|
"app-online-status": CustomEvent<{ online: boolean }>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,4 +23,15 @@ export function ENSURE(condition: boolean, msg: string, ...payload: any[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GROUP(
|
||||||
|
header: string,
|
||||||
|
content: (logger: typeof console.log) => void
|
||||||
|
) {
|
||||||
|
if (!isProdEnv) {
|
||||||
|
console.group(header);
|
||||||
|
content(console.log);
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const ASSERT = console.assert;
|
export const ASSERT = console.assert;
|
||||||
|
|
|
@ -7,8 +7,8 @@ function createEvent<
|
||||||
return new CustomEvent<P>(event, { bubbles: true, detail: payload });
|
return new CustomEvent<P>(event, { bubbles: true, detail: payload });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLoginRequired() {
|
export function setAuthenticated(authenticated: boolean) {
|
||||||
const event = createEvent("app-login-required", {});
|
const event = createEvent("app-auth-changed", { authenticated });
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
// A workaround of built-in hooks in React-Router v6
|
||||||
|
// https://gist.github.com/rmorse/426ffcc579922a82749934826fa9f743
|
||||||
|
|
||||||
|
import type { Blocker, History, Transition } from "history";
|
||||||
|
import { useContext, useEffect } from "react";
|
||||||
|
import { UNSAFE_NavigationContext } from "react-router-dom";
|
||||||
|
|
||||||
|
export function useBlocker(blocker: Blocker, when = true) {
|
||||||
|
const navigator = useContext(UNSAFE_NavigationContext).navigator as History;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!when) return;
|
||||||
|
|
||||||
|
const unblock = navigator.block((tx: Transition) => {
|
||||||
|
const autoUnblockingTx = {
|
||||||
|
...tx,
|
||||||
|
retry() {
|
||||||
|
// Automatically unblock the transition so it can play all the way
|
||||||
|
// through before retrying it. TODO: Figure out how to re-enable
|
||||||
|
// this block if the transition is cancelled for some reason.
|
||||||
|
unblock();
|
||||||
|
tx.retry();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
blocker(autoUnblockingTx);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unblock;
|
||||||
|
}, [navigator, blocker, when]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with Mantine's confirmation modal
|
||||||
|
export function usePrompt(when: boolean, message: string) {
|
||||||
|
useBlocker((tx) => {
|
||||||
|
if (window.confirm(message)) {
|
||||||
|
tx.retry();
|
||||||
|
}
|
||||||
|
}, when);
|
||||||
|
}
|
|
@ -4,4 +4,4 @@
|
||||||
from .container import FFprobeVideoContainer
|
from .container import FFprobeVideoContainer
|
||||||
from .stream import FFprobeSubtitleStream
|
from .stream import FFprobeSubtitleStream
|
||||||
|
|
||||||
__version__ = "0.2"
|
__version__ = "0.2.2"
|
||||||
|
|
|
@ -8,12 +8,21 @@ from .exceptions import LanguageNotFound
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LANGUAGE_FALLBACK = None
|
||||||
|
|
||||||
|
|
||||||
class FFprobeGenericSubtitleTags:
|
class FFprobeGenericSubtitleTags:
|
||||||
_DETECTABLE_TAGS = None
|
_DETECTABLE_TAGS = None
|
||||||
|
|
||||||
def __init__(self, data: dict):
|
def __init__(self, data: dict):
|
||||||
|
try:
|
||||||
self.language = _get_language(data)
|
self.language = _get_language(data)
|
||||||
|
except LanguageNotFound:
|
||||||
|
if LANGUAGE_FALLBACK is not None:
|
||||||
|
self.language = Language.fromietf(LANGUAGE_FALLBACK)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
self._data = data
|
self._data = data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
flask_cors
|
||||||
|
~~~~
|
||||||
|
Flask-CORS is a simple extension to Flask allowing you to support cross
|
||||||
|
origin resource sharing (CORS) using a simple decorator.
|
||||||
|
|
||||||
|
:copyright: (c) 2016 by Cory Dolphin.
|
||||||
|
:license: MIT, see LICENSE for more details.
|
||||||
|
"""
|
||||||
|
from .decorator import cross_origin
|
||||||
|
from .extension import CORS
|
||||||
|
from .version import __version__
|
||||||
|
|
||||||
|
__all__ = ['CORS', 'cross_origin']
|
||||||
|
|
||||||
|
# Set default logging handler to avoid "No handler found" warnings.
|
||||||
|
import logging
|
||||||
|
from logging import NullHandler
|
||||||
|
|
||||||
|
# Set initial level to WARN. Users must manually enable logging for
|
||||||
|
# flask_cors to see our logging.
|
||||||
|
rootlogger = logging.getLogger(__name__)
|
||||||
|
rootlogger.addHandler(NullHandler())
|
||||||
|
|
||||||
|
if rootlogger.level == logging.NOTSET:
|
||||||
|
rootlogger.setLevel(logging.WARN)
|
|
@ -0,0 +1,383 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
core
|
||||||
|
~~~~
|
||||||
|
Core functionality shared between the extension and the decorator.
|
||||||
|
|
||||||
|
:copyright: (c) 2016 by Cory Dolphin.
|
||||||
|
:license: MIT, see LICENSE for more details.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
try:
|
||||||
|
# on python 3
|
||||||
|
from collections.abc import Iterable
|
||||||
|
except ImportError:
|
||||||
|
# on python 2.7 and pypy
|
||||||
|
from collections import Iterable
|
||||||
|
from datetime import timedelta
|
||||||
|
from six import string_types
|
||||||
|
from flask import request, current_app
|
||||||
|
from werkzeug.datastructures import Headers, MultiDict
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Response Headers
|
||||||
|
ACL_ORIGIN = 'Access-Control-Allow-Origin'
|
||||||
|
ACL_METHODS = 'Access-Control-Allow-Methods'
|
||||||
|
ACL_ALLOW_HEADERS = 'Access-Control-Allow-Headers'
|
||||||
|
ACL_EXPOSE_HEADERS = 'Access-Control-Expose-Headers'
|
||||||
|
ACL_CREDENTIALS = 'Access-Control-Allow-Credentials'
|
||||||
|
ACL_MAX_AGE = 'Access-Control-Max-Age'
|
||||||
|
|
||||||
|
# Request Header
|
||||||
|
ACL_REQUEST_METHOD = 'Access-Control-Request-Method'
|
||||||
|
ACL_REQUEST_HEADERS = 'Access-Control-Request-Headers'
|
||||||
|
|
||||||
|
ALL_METHODS = ['GET', 'HEAD', 'POST', 'OPTIONS', 'PUT', 'PATCH', 'DELETE']
|
||||||
|
CONFIG_OPTIONS = ['CORS_ORIGINS', 'CORS_METHODS', 'CORS_ALLOW_HEADERS',
|
||||||
|
'CORS_EXPOSE_HEADERS', 'CORS_SUPPORTS_CREDENTIALS',
|
||||||
|
'CORS_MAX_AGE', 'CORS_SEND_WILDCARD',
|
||||||
|
'CORS_AUTOMATIC_OPTIONS', 'CORS_VARY_HEADER',
|
||||||
|
'CORS_RESOURCES', 'CORS_INTERCEPT_EXCEPTIONS',
|
||||||
|
'CORS_ALWAYS_SEND']
|
||||||
|
# Attribute added to request object by decorator to indicate that CORS
|
||||||
|
# was evaluated, in case the decorator and extension are both applied
|
||||||
|
# to a view.
|
||||||
|
FLASK_CORS_EVALUATED = '_FLASK_CORS_EVALUATED'
|
||||||
|
|
||||||
|
# Strange, but this gets the type of a compiled regex, which is otherwise not
|
||||||
|
# exposed in a public API.
|
||||||
|
RegexObject = type(re.compile(''))
|
||||||
|
DEFAULT_OPTIONS = dict(origins='*',
|
||||||
|
methods=ALL_METHODS,
|
||||||
|
allow_headers='*',
|
||||||
|
expose_headers=None,
|
||||||
|
supports_credentials=False,
|
||||||
|
max_age=None,
|
||||||
|
send_wildcard=False,
|
||||||
|
automatic_options=True,
|
||||||
|
vary_header=True,
|
||||||
|
resources=r'/*',
|
||||||
|
intercept_exceptions=True,
|
||||||
|
always_send=True)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_resources(resources):
|
||||||
|
if isinstance(resources, dict):
|
||||||
|
# To make the API more consistent with the decorator, allow a
|
||||||
|
# resource of '*', which is not actually a valid regexp.
|
||||||
|
resources = [(re_fix(k), v) for k, v in resources.items()]
|
||||||
|
|
||||||
|
# Sort by regex length to provide consistency of matching and
|
||||||
|
# to provide a proxy for specificity of match. E.G. longer
|
||||||
|
# regular expressions are tried first.
|
||||||
|
def pattern_length(pair):
|
||||||
|
maybe_regex, _ = pair
|
||||||
|
return len(get_regexp_pattern(maybe_regex))
|
||||||
|
|
||||||
|
return sorted(resources,
|
||||||
|
key=pattern_length,
|
||||||
|
reverse=True)
|
||||||
|
|
||||||
|
elif isinstance(resources, string_types):
|
||||||
|
return [(re_fix(resources), {})]
|
||||||
|
|
||||||
|
elif isinstance(resources, Iterable):
|
||||||
|
return [(re_fix(r), {}) for r in resources]
|
||||||
|
|
||||||
|
# Type of compiled regex is not part of the public API. Test for this
|
||||||
|
# at runtime.
|
||||||
|
elif isinstance(resources, RegexObject):
|
||||||
|
return [(re_fix(resources), {})]
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError("Unexpected value for resources argument.")
|
||||||
|
|
||||||
|
|
||||||
|
def get_regexp_pattern(regexp):
|
||||||
|
"""
|
||||||
|
Helper that returns regexp pattern from given value.
|
||||||
|
|
||||||
|
:param regexp: regular expression to stringify
|
||||||
|
:type regexp: _sre.SRE_Pattern or str
|
||||||
|
:returns: string representation of given regexp pattern
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return regexp.pattern
|
||||||
|
except AttributeError:
|
||||||
|
return str(regexp)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cors_origins(options, request_origin):
|
||||||
|
origins = options.get('origins')
|
||||||
|
wildcard = r'.*' in origins
|
||||||
|
|
||||||
|
# If the Origin header is not present terminate this set of steps.
|
||||||
|
# The request is outside the scope of this specification.-- W3Spec
|
||||||
|
if request_origin:
|
||||||
|
LOG.debug("CORS request received with 'Origin' %s", request_origin)
|
||||||
|
|
||||||
|
# If the allowed origins is an asterisk or 'wildcard', always match
|
||||||
|
if wildcard and options.get('send_wildcard'):
|
||||||
|
LOG.debug("Allowed origins are set to '*'. Sending wildcard CORS header.")
|
||||||
|
return ['*']
|
||||||
|
# If the value of the Origin header is a case-sensitive match
|
||||||
|
# for any of the values in list of origins
|
||||||
|
elif try_match_any(request_origin, origins):
|
||||||
|
LOG.debug("The request's Origin header matches. Sending CORS headers.", )
|
||||||
|
# Add a single Access-Control-Allow-Origin header, with either
|
||||||
|
# the value of the Origin header or the string "*" as value.
|
||||||
|
# -- W3Spec
|
||||||
|
return [request_origin]
|
||||||
|
else:
|
||||||
|
LOG.debug("The request's Origin header does not match any of allowed origins.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
elif options.get('always_send'):
|
||||||
|
if wildcard:
|
||||||
|
# If wildcard is in the origins, even if 'send_wildcard' is False,
|
||||||
|
# simply send the wildcard. Unless supports_credentials is True,
|
||||||
|
# since that is forbidded by the spec..
|
||||||
|
# It is the most-likely to be correct thing to do (the only other
|
||||||
|
# option is to return nothing, which almost certainly not what
|
||||||
|
# the developer wants if the '*' origin was specified.
|
||||||
|
if options.get('supports_credentials'):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return ['*']
|
||||||
|
else:
|
||||||
|
# Return all origins that are not regexes.
|
||||||
|
return sorted([o for o in origins if not probably_regex(o)])
|
||||||
|
|
||||||
|
# Terminate these steps, return the original request untouched.
|
||||||
|
else:
|
||||||
|
LOG.debug("The request did not contain an 'Origin' header. This means the browser or client did not request CORS, ensure the Origin Header is set.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_allow_headers(options, acl_request_headers):
|
||||||
|
if acl_request_headers:
|
||||||
|
request_headers = [h.strip() for h in acl_request_headers.split(',')]
|
||||||
|
|
||||||
|
# any header that matches in the allow_headers
|
||||||
|
matching_headers = filter(
|
||||||
|
lambda h: try_match_any(h, options.get('allow_headers')),
|
||||||
|
request_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
return ', '.join(sorted(matching_headers))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_cors_headers(options, request_headers, request_method):
|
||||||
|
origins_to_set = get_cors_origins(options, request_headers.get('Origin'))
|
||||||
|
headers = MultiDict()
|
||||||
|
|
||||||
|
if not origins_to_set: # CORS is not enabled for this route
|
||||||
|
return headers
|
||||||
|
|
||||||
|
for origin in origins_to_set:
|
||||||
|
headers.add(ACL_ORIGIN, origin)
|
||||||
|
|
||||||
|
headers[ACL_EXPOSE_HEADERS] = options.get('expose_headers')
|
||||||
|
|
||||||
|
if options.get('supports_credentials'):
|
||||||
|
headers[ACL_CREDENTIALS] = 'true' # case sensative
|
||||||
|
|
||||||
|
# This is a preflight request
|
||||||
|
# http://www.w3.org/TR/cors/#resource-preflight-requests
|
||||||
|
if request_method == 'OPTIONS':
|
||||||
|
acl_request_method = request_headers.get(ACL_REQUEST_METHOD, '').upper()
|
||||||
|
|
||||||
|
# If there is no Access-Control-Request-Method header or if parsing
|
||||||
|
# failed, do not set any additional headers
|
||||||
|
if acl_request_method and acl_request_method in options.get('methods'):
|
||||||
|
|
||||||
|
# If method is not a case-sensitive match for any of the values in
|
||||||
|
# list of methods do not set any additional headers and terminate
|
||||||
|
# this set of steps.
|
||||||
|
headers[ACL_ALLOW_HEADERS] = get_allow_headers(options, request_headers.get(ACL_REQUEST_HEADERS))
|
||||||
|
headers[ACL_MAX_AGE] = options.get('max_age')
|
||||||
|
headers[ACL_METHODS] = options.get('methods')
|
||||||
|
else:
|
||||||
|
LOG.info("The request's Access-Control-Request-Method header does not match allowed methods. CORS headers will not be applied.")
|
||||||
|
|
||||||
|
# http://www.w3.org/TR/cors/#resource-implementation
|
||||||
|
if options.get('vary_header'):
|
||||||
|
# Only set header if the origin returned will vary dynamically,
|
||||||
|
# i.e. if we are not returning an asterisk, and there are multiple
|
||||||
|
# origins that can be matched.
|
||||||
|
if headers[ACL_ORIGIN] == '*':
|
||||||
|
pass
|
||||||
|
elif (len(options.get('origins')) > 1 or
|
||||||
|
len(origins_to_set) > 1 or
|
||||||
|
any(map(probably_regex, options.get('origins')))):
|
||||||
|
headers.add('Vary', 'Origin')
|
||||||
|
|
||||||
|
return MultiDict((k, v) for k, v in headers.items() if v)
|
||||||
|
|
||||||
|
|
||||||
|
def set_cors_headers(resp, options):
|
||||||
|
"""
|
||||||
|
Performs the actual evaluation of Flas-CORS options and actually
|
||||||
|
modifies the response object.
|
||||||
|
|
||||||
|
This function is used both in the decorator and the after_request
|
||||||
|
callback
|
||||||
|
"""
|
||||||
|
|
||||||
|
# If CORS has already been evaluated via the decorator, skip
|
||||||
|
if hasattr(resp, FLASK_CORS_EVALUATED):
|
||||||
|
LOG.debug('CORS have been already evaluated, skipping')
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# Some libraries, like OAuthlib, set resp.headers to non Multidict
|
||||||
|
# objects (Werkzeug Headers work as well). This is a problem because
|
||||||
|
# headers allow repeated values.
|
||||||
|
if (not isinstance(resp.headers, Headers)
|
||||||
|
and not isinstance(resp.headers, MultiDict)):
|
||||||
|
resp.headers = MultiDict(resp.headers)
|
||||||
|
|
||||||
|
headers_to_set = get_cors_headers(options, request.headers, request.method)
|
||||||
|
|
||||||
|
LOG.debug('Settings CORS headers: %s', str(headers_to_set))
|
||||||
|
|
||||||
|
for k, v in headers_to_set.items():
|
||||||
|
resp.headers.add(k, v)
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def probably_regex(maybe_regex):
|
||||||
|
if isinstance(maybe_regex, RegexObject):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
common_regex_chars = ['*', '\\', ']', '?', '$', '^', '[', ']', '(', ')']
|
||||||
|
# Use common characters used in regular expressions as a proxy
|
||||||
|
# for if this string is in fact a regex.
|
||||||
|
return any((c in maybe_regex for c in common_regex_chars))
|
||||||
|
|
||||||
|
def re_fix(reg):
|
||||||
|
"""
|
||||||
|
Replace the invalid regex r'*' with the valid, wildcard regex r'/.*' to
|
||||||
|
enable the CORS app extension to have a more user friendly api.
|
||||||
|
"""
|
||||||
|
return r'.*' if reg == r'*' else reg
|
||||||
|
|
||||||
|
|
||||||
|
def try_match_any(inst, patterns):
|
||||||
|
return any(try_match(inst, pattern) for pattern in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def try_match(request_origin, maybe_regex):
|
||||||
|
"""Safely attempts to match a pattern or string to a request origin."""
|
||||||
|
if isinstance(maybe_regex, RegexObject):
|
||||||
|
return re.match(maybe_regex, request_origin)
|
||||||
|
elif probably_regex(maybe_regex):
|
||||||
|
return re.match(maybe_regex, request_origin, flags=re.IGNORECASE)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return request_origin.lower() == maybe_regex.lower()
|
||||||
|
except AttributeError:
|
||||||
|
return request_origin == maybe_regex
|
||||||
|
|
||||||
|
|
||||||
|
def get_cors_options(appInstance, *dicts):
|
||||||
|
"""
|
||||||
|
Compute CORS options for an application by combining the DEFAULT_OPTIONS,
|
||||||
|
the app's configuration-specified options and any dictionaries passed. The
|
||||||
|
last specified option wins.
|
||||||
|
"""
|
||||||
|
options = DEFAULT_OPTIONS.copy()
|
||||||
|
options.update(get_app_kwarg_dict(appInstance))
|
||||||
|
if dicts:
|
||||||
|
for d in dicts:
|
||||||
|
options.update(d)
|
||||||
|
|
||||||
|
return serialize_options(options)
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_kwarg_dict(appInstance=None):
|
||||||
|
"""Returns the dictionary of CORS specific app configurations."""
|
||||||
|
app = (appInstance or current_app)
|
||||||
|
|
||||||
|
# In order to support blueprints which do not have a config attribute
|
||||||
|
app_config = getattr(app, 'config', {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
k.lower().replace('cors_', ''): app_config.get(k)
|
||||||
|
for k in CONFIG_OPTIONS
|
||||||
|
if app_config.get(k) is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def flexible_str(obj):
|
||||||
|
"""
|
||||||
|
A more flexible str function which intelligently handles stringifying
|
||||||
|
strings, lists and other iterables. The results are lexographically sorted
|
||||||
|
to ensure generated responses are consistent when iterables such as Set
|
||||||
|
are used.
|
||||||
|
"""
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
elif(not isinstance(obj, string_types)
|
||||||
|
and isinstance(obj, Iterable)):
|
||||||
|
return ', '.join(str(item) for item in sorted(obj))
|
||||||
|
else:
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_option(options_dict, key, upper=False):
|
||||||
|
if key in options_dict:
|
||||||
|
value = flexible_str(options_dict[key])
|
||||||
|
options_dict[key] = value.upper() if upper else value
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_iterable(inst):
|
||||||
|
"""
|
||||||
|
Wraps scalars or string types as a list, or returns the iterable instance.
|
||||||
|
"""
|
||||||
|
if isinstance(inst, string_types):
|
||||||
|
return [inst]
|
||||||
|
elif not isinstance(inst, Iterable):
|
||||||
|
return [inst]
|
||||||
|
else:
|
||||||
|
return inst
|
||||||
|
|
||||||
|
def sanitize_regex_param(param):
|
||||||
|
return [re_fix(x) for x in ensure_iterable(param)]
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_options(opts):
|
||||||
|
"""
|
||||||
|
A helper method to serialize and processes the options dictionary.
|
||||||
|
"""
|
||||||
|
options = (opts or {}).copy()
|
||||||
|
|
||||||
|
for key in opts.keys():
|
||||||
|
if key not in DEFAULT_OPTIONS:
|
||||||
|
LOG.warning("Unknown option passed to Flask-CORS: %s", key)
|
||||||
|
|
||||||
|
# Ensure origins is a list of allowed origins with at least one entry.
|
||||||
|
options['origins'] = sanitize_regex_param(options.get('origins'))
|
||||||
|
options['allow_headers'] = sanitize_regex_param(options.get('allow_headers'))
|
||||||
|
|
||||||
|
# This is expressly forbidden by the spec. Raise a value error so people
|
||||||
|
# don't get burned in production.
|
||||||
|
if r'.*' in options['origins'] and options['supports_credentials'] and options['send_wildcard']:
|
||||||
|
raise ValueError("Cannot use supports_credentials in conjunction with"
|
||||||
|
"an origin string of '*'. See: "
|
||||||
|
"http://www.w3.org/TR/cors/#resource-requests")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
serialize_option(options, 'expose_headers')
|
||||||
|
serialize_option(options, 'methods', upper=True)
|
||||||
|
|
||||||
|
if isinstance(options.get('max_age'), timedelta):
|
||||||
|
options['max_age'] = str(int(options['max_age'].total_seconds()))
|
||||||
|
|
||||||
|
return options
|
|
@ -0,0 +1,135 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
decorator
|
||||||
|
~~~~
|
||||||
|
This unit exposes a single decorator which should be used to wrap a
|
||||||
|
Flask route with. It accepts all parameters and options as
|
||||||
|
the CORS extension.
|
||||||
|
|
||||||
|
:copyright: (c) 2016 by Cory Dolphin.
|
||||||
|
:license: MIT, see LICENSE for more details.
|
||||||
|
"""
|
||||||
|
from functools import update_wrapper
|
||||||
|
from flask import make_response, request, current_app
|
||||||
|
from .core import *
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def cross_origin(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
This function is the decorator which is used to wrap a Flask route with.
|
||||||
|
In the simplest case, simply use the default parameters to allow all
|
||||||
|
origins in what is the most permissive configuration. If this method
|
||||||
|
modifies state or performs authentication which may be brute-forced, you
|
||||||
|
should add some degree of protection, such as Cross Site Forgery
|
||||||
|
Request protection.
|
||||||
|
|
||||||
|
:param origins:
|
||||||
|
The origin, or list of origins to allow requests from.
|
||||||
|
The origin(s) may be regular expressions, case-sensitive strings,
|
||||||
|
or else an asterisk
|
||||||
|
|
||||||
|
Default : '*'
|
||||||
|
:type origins: list, string or regex
|
||||||
|
|
||||||
|
:param methods:
|
||||||
|
The method or list of methods which the allowed origins are allowed to
|
||||||
|
access for non-simple requests.
|
||||||
|
|
||||||
|
Default : [GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE]
|
||||||
|
:type methods: list or string
|
||||||
|
|
||||||
|
:param expose_headers:
|
||||||
|
The header or list which are safe to expose to the API of a CORS API
|
||||||
|
specification.
|
||||||
|
|
||||||
|
Default : None
|
||||||
|
:type expose_headers: list or string
|
||||||
|
|
||||||
|
:param allow_headers:
|
||||||
|
The header or list of header field names which can be used when this
|
||||||
|
resource is accessed by allowed origins. The header(s) may be regular
|
||||||
|
expressions, case-sensitive strings, or else an asterisk.
|
||||||
|
|
||||||
|
Default : '*', allow all headers
|
||||||
|
:type allow_headers: list, string or regex
|
||||||
|
|
||||||
|
:param supports_credentials:
|
||||||
|
Allows users to make authenticated requests. If true, injects the
|
||||||
|
`Access-Control-Allow-Credentials` header in responses. This allows
|
||||||
|
cookies and credentials to be submitted across domains.
|
||||||
|
|
||||||
|
:note: This option cannot be used in conjuction with a '*' origin
|
||||||
|
|
||||||
|
Default : False
|
||||||
|
:type supports_credentials: bool
|
||||||
|
|
||||||
|
:param max_age:
|
||||||
|
The maximum time for which this CORS request maybe cached. This value
|
||||||
|
is set as the `Access-Control-Max-Age` header.
|
||||||
|
|
||||||
|
Default : None
|
||||||
|
:type max_age: timedelta, integer, string or None
|
||||||
|
|
||||||
|
:param send_wildcard: If True, and the origins parameter is `*`, a wildcard
|
||||||
|
`Access-Control-Allow-Origin` header is sent, rather than the
|
||||||
|
request's `Origin` header.
|
||||||
|
|
||||||
|
Default : False
|
||||||
|
:type send_wildcard: bool
|
||||||
|
|
||||||
|
:param vary_header:
|
||||||
|
If True, the header Vary: Origin will be returned as per the W3
|
||||||
|
implementation guidelines.
|
||||||
|
|
||||||
|
Setting this header when the `Access-Control-Allow-Origin` is
|
||||||
|
dynamically generated (e.g. when there is more than one allowed
|
||||||
|
origin, and an Origin than '*' is returned) informs CDNs and other
|
||||||
|
caches that the CORS headers are dynamic, and cannot be cached.
|
||||||
|
|
||||||
|
If False, the Vary header will never be injected or altered.
|
||||||
|
|
||||||
|
Default : True
|
||||||
|
:type vary_header: bool
|
||||||
|
|
||||||
|
:param automatic_options:
|
||||||
|
Only applies to the `cross_origin` decorator. If True, Flask-CORS will
|
||||||
|
override Flask's default OPTIONS handling to return CORS headers for
|
||||||
|
OPTIONS requests.
|
||||||
|
|
||||||
|
Default : True
|
||||||
|
:type automatic_options: bool
|
||||||
|
|
||||||
|
"""
|
||||||
|
_options = kwargs
|
||||||
|
|
||||||
|
def decorator(f):
|
||||||
|
LOG.debug("Enabling %s for cross_origin using options:%s", f, _options)
|
||||||
|
|
||||||
|
# If True, intercept OPTIONS requests by modifying the view function,
|
||||||
|
# replicating Flask's default behavior, and wrapping the response with
|
||||||
|
# CORS headers.
|
||||||
|
#
|
||||||
|
# If f.provide_automatic_options is unset or True, Flask's route
|
||||||
|
# decorator (which is actually wraps the function object we return)
|
||||||
|
# intercepts OPTIONS handling, and requests will not have CORS headers
|
||||||
|
if _options.get('automatic_options', True):
|
||||||
|
f.required_methods = getattr(f, 'required_methods', set())
|
||||||
|
f.required_methods.add('OPTIONS')
|
||||||
|
f.provide_automatic_options = False
|
||||||
|
|
||||||
|
def wrapped_function(*args, **kwargs):
|
||||||
|
# Handle setting of Flask-Cors parameters
|
||||||
|
options = get_cors_options(current_app, _options)
|
||||||
|
|
||||||
|
if options.get('automatic_options') and request.method == 'OPTIONS':
|
||||||
|
resp = current_app.make_default_options_response()
|
||||||
|
else:
|
||||||
|
resp = make_response(f(*args, **kwargs))
|
||||||
|
|
||||||
|
set_cors_headers(resp, options)
|
||||||
|
setattr(resp, FLASK_CORS_EVALUATED, True)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
return update_wrapper(wrapped_function, f)
|
||||||
|
return decorator
|
|
@ -0,0 +1,190 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
extension
|
||||||
|
~~~~
|
||||||
|
Flask-CORS is a simple extension to Flask allowing you to support cross
|
||||||
|
origin resource sharing (CORS) using a simple decorator.
|
||||||
|
|
||||||
|
:copyright: (c) 2016 by Cory Dolphin.
|
||||||
|
:license: MIT, see LICENSE for more details.
|
||||||
|
"""
|
||||||
|
from flask import request
|
||||||
|
from .core import *
|
||||||
|
try:
|
||||||
|
from urllib.parse import unquote_plus
|
||||||
|
except ImportError:
|
||||||
|
from urllib import unquote_plus
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class CORS(object):
|
||||||
|
"""
|
||||||
|
Initializes Cross Origin Resource sharing for the application. The
|
||||||
|
arguments are identical to :py:func:`cross_origin`, with the addition of a
|
||||||
|
`resources` parameter. The resources parameter defines a series of regular
|
||||||
|
expressions for resource paths to match and optionally, the associated
|
||||||
|
options to be applied to the particular resource. These options are
|
||||||
|
identical to the arguments to :py:func:`cross_origin`.
|
||||||
|
|
||||||
|
The settings for CORS are determined in the following order
|
||||||
|
|
||||||
|
1. Resource level settings (e.g when passed as a dictionary)
|
||||||
|
2. Keyword argument settings
|
||||||
|
3. App level configuration settings (e.g. CORS_*)
|
||||||
|
4. Default settings
|
||||||
|
|
||||||
|
Note: as it is possible for multiple regular expressions to match a
|
||||||
|
resource path, the regular expressions are first sorted by length,
|
||||||
|
from longest to shortest, in order to attempt to match the most
|
||||||
|
specific regular expression. This allows the definition of a
|
||||||
|
number of specific resource options, with a wildcard fallback
|
||||||
|
for all other resources.
|
||||||
|
|
||||||
|
:param resources:
|
||||||
|
The series of regular expression and (optionally) associated CORS
|
||||||
|
options to be applied to the given resource path.
|
||||||
|
|
||||||
|
If the argument is a dictionary, it's keys must be regular expressions,
|
||||||
|
and the values must be a dictionary of kwargs, identical to the kwargs
|
||||||
|
of this function.
|
||||||
|
|
||||||
|
If the argument is a list, it is expected to be a list of regular
|
||||||
|
expressions, for which the app-wide configured options are applied.
|
||||||
|
|
||||||
|
If the argument is a string, it is expected to be a regular expression
|
||||||
|
for which the app-wide configured options are applied.
|
||||||
|
|
||||||
|
Default : Match all and apply app-level configuration
|
||||||
|
|
||||||
|
:type resources: dict, iterable or string
|
||||||
|
|
||||||
|
:param origins:
|
||||||
|
The origin, or list of origins to allow requests from.
|
||||||
|
The origin(s) may be regular expressions, case-sensitive strings,
|
||||||
|
or else an asterisk
|
||||||
|
|
||||||
|
Default : '*'
|
||||||
|
:type origins: list, string or regex
|
||||||
|
|
||||||
|
:param methods:
|
||||||
|
The method or list of methods which the allowed origins are allowed to
|
||||||
|
access for non-simple requests.
|
||||||
|
|
||||||
|
Default : [GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE]
|
||||||
|
:type methods: list or string
|
||||||
|
|
||||||
|
:param expose_headers:
|
||||||
|
The header or list which are safe to expose to the API of a CORS API
|
||||||
|
specification.
|
||||||
|
|
||||||
|
Default : None
|
||||||
|
:type expose_headers: list or string
|
||||||
|
|
||||||
|
:param allow_headers:
|
||||||
|
The header or list of header field names which can be used when this
|
||||||
|
resource is accessed by allowed origins. The header(s) may be regular
|
||||||
|
expressions, case-sensitive strings, or else an asterisk.
|
||||||
|
|
||||||
|
Default : '*', allow all headers
|
||||||
|
:type allow_headers: list, string or regex
|
||||||
|
|
||||||
|
:param supports_credentials:
|
||||||
|
Allows users to make authenticated requests. If true, injects the
|
||||||
|
`Access-Control-Allow-Credentials` header in responses. This allows
|
||||||
|
cookies and credentials to be submitted across domains.
|
||||||
|
|
||||||
|
:note: This option cannot be used in conjunction with a '*' origin
|
||||||
|
|
||||||
|
Default : False
|
||||||
|
:type supports_credentials: bool
|
||||||
|
|
||||||
|
:param max_age:
|
||||||
|
The maximum time for which this CORS request maybe cached. This value
|
||||||
|
is set as the `Access-Control-Max-Age` header.
|
||||||
|
|
||||||
|
Default : None
|
||||||
|
:type max_age: timedelta, integer, string or None
|
||||||
|
|
||||||
|
:param send_wildcard: If True, and the origins parameter is `*`, a wildcard
|
||||||
|
`Access-Control-Allow-Origin` header is sent, rather than the
|
||||||
|
request's `Origin` header.
|
||||||
|
|
||||||
|
Default : False
|
||||||
|
:type send_wildcard: bool
|
||||||
|
|
||||||
|
:param vary_header:
|
||||||
|
If True, the header Vary: Origin will be returned as per the W3
|
||||||
|
implementation guidelines.
|
||||||
|
|
||||||
|
Setting this header when the `Access-Control-Allow-Origin` is
|
||||||
|
dynamically generated (e.g. when there is more than one allowed
|
||||||
|
origin, and an Origin than '*' is returned) informs CDNs and other
|
||||||
|
caches that the CORS headers are dynamic, and cannot be cached.
|
||||||
|
|
||||||
|
If False, the Vary header will never be injected or altered.
|
||||||
|
|
||||||
|
Default : True
|
||||||
|
:type vary_header: bool
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app=None, **kwargs):
|
||||||
|
self._options = kwargs
|
||||||
|
if app is not None:
|
||||||
|
self.init_app(app, **kwargs)
|
||||||
|
|
||||||
|
def init_app(self, app, **kwargs):
|
||||||
|
# The resources and options may be specified in the App Config, the CORS constructor
|
||||||
|
# or the kwargs to the call to init_app.
|
||||||
|
options = get_cors_options(app, self._options, kwargs)
|
||||||
|
|
||||||
|
# Flatten our resources into a list of the form
|
||||||
|
# (pattern_or_regexp, dictionary_of_options)
|
||||||
|
resources = parse_resources(options.get('resources'))
|
||||||
|
|
||||||
|
# Compute the options for each resource by combining the options from
|
||||||
|
# the app's configuration, the constructor, the kwargs to init_app, and
|
||||||
|
# finally the options specified in the resources dictionary.
|
||||||
|
resources = [
|
||||||
|
(pattern, get_cors_options(app, options, opts))
|
||||||
|
for (pattern, opts) in resources
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create a human readable form of these resources by converting the compiled
|
||||||
|
# regular expressions into strings.
|
||||||
|
resources_human = {get_regexp_pattern(pattern): opts for (pattern,opts) in resources}
|
||||||
|
LOG.debug("Configuring CORS with resources: %s", resources_human)
|
||||||
|
|
||||||
|
cors_after_request = make_after_request_function(resources)
|
||||||
|
app.after_request(cors_after_request)
|
||||||
|
|
||||||
|
# Wrap exception handlers with cross_origin
|
||||||
|
# These error handlers will still respect the behavior of the route
|
||||||
|
if options.get('intercept_exceptions', True):
|
||||||
|
def _after_request_decorator(f):
|
||||||
|
def wrapped_function(*args, **kwargs):
|
||||||
|
return cors_after_request(app.make_response(f(*args, **kwargs)))
|
||||||
|
return wrapped_function
|
||||||
|
|
||||||
|
if hasattr(app, 'handle_exception'):
|
||||||
|
app.handle_exception = _after_request_decorator(
|
||||||
|
app.handle_exception)
|
||||||
|
app.handle_user_exception = _after_request_decorator(
|
||||||
|
app.handle_user_exception)
|
||||||
|
|
||||||
|
def make_after_request_function(resources):
|
||||||
|
def cors_after_request(resp):
|
||||||
|
# If CORS headers are set in a view decorator, pass
|
||||||
|
if resp.headers is not None and resp.headers.get(ACL_ORIGIN):
|
||||||
|
LOG.debug('CORS have been already evaluated, skipping')
|
||||||
|
return resp
|
||||||
|
normalized_path = unquote_plus(request.path)
|
||||||
|
for res_regex, res_options in resources:
|
||||||
|
if try_match(normalized_path, res_regex):
|
||||||
|
LOG.debug("Request to '%s' matches CORS resource '%s'. Using options: %s",
|
||||||
|
request.path, get_regexp_pattern(res_regex), res_options)
|
||||||
|
set_cors_headers(resp, res_options)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
LOG.debug('No CORS rule matches')
|
||||||
|
return resp
|
||||||
|
return cors_after_request
|
|
@ -0,0 +1 @@
|
||||||
|
__version__ = '3.0.10'
|
|
@ -167,16 +167,19 @@ class Episode(Video):
|
||||||
if guess['type'] != 'episode':
|
if guess['type'] != 'episode':
|
||||||
raise ValueError('The guess must be an episode guess')
|
raise ValueError('The guess must be an episode guess')
|
||||||
|
|
||||||
if 'title' not in guess or 'episode' not in guess:
|
# We'll ignore missing fields. The Video instance will be refined anyway.
|
||||||
raise ValueError('Insufficient data to process the guess')
|
|
||||||
|
# if 'title' not in guess or 'episode' not in guess:
|
||||||
|
# raise ValueError('Insufficient data to process the guess')
|
||||||
|
|
||||||
|
|
||||||
# Currently we only have single-ep support (guessit returns a multi-ep as a list with int values)
|
# Currently we only have single-ep support (guessit returns a multi-ep as a list with int values)
|
||||||
# Most providers only support single-ep, so make sure it contains only 1 episode
|
# Most providers only support single-ep, so make sure it contains only 1 episode
|
||||||
# In case of multi-ep, take the lowest episode (subtitles will normally be available on lowest episode number)
|
# In case of multi-ep, take the lowest episode (subtitles will normally be available on lowest episode number)
|
||||||
episode_guess = guess.get('episode')
|
episode_guess = guess.get('episode', 1)
|
||||||
episode = min(episode_guess) if episode_guess and isinstance(episode_guess, list) else episode_guess
|
episode = min(episode_guess) if episode_guess and isinstance(episode_guess, list) else episode_guess
|
||||||
|
|
||||||
return cls(name, guess['title'], guess.get('season', 1), episode, title=guess.get('episode_title'),
|
return cls(name, guess.get("title", "Unknown Title"), guess.get('season', 1), episode, title=guess.get('episode_title'),
|
||||||
year=guess.get('year'), source=guess.get('source'), original_series='year' not in guess,
|
year=guess.get('year'), source=guess.get('source'), original_series='year' not in guess,
|
||||||
release_group=guess.get('release_group'), resolution=guess.get('screen_size'),
|
release_group=guess.get('release_group'), resolution=guess.get('screen_size'),
|
||||||
video_codec=guess.get('video_codec'), audio_codec=guess.get('audio_codec'),
|
video_codec=guess.get('video_codec'), audio_codec=guess.get('audio_codec'),
|
||||||
|
@ -220,14 +223,16 @@ class Movie(Video):
|
||||||
if guess['type'] != 'movie':
|
if guess['type'] != 'movie':
|
||||||
raise ValueError('The guess must be a movie guess')
|
raise ValueError('The guess must be a movie guess')
|
||||||
|
|
||||||
if 'title' not in guess:
|
# We'll ignore missing fields. The Video instance will be refined anyway.
|
||||||
raise ValueError('Insufficient data to process the guess')
|
|
||||||
|
# if 'title' not in guess:
|
||||||
|
# raise ValueError('Insufficient data to process the guess')
|
||||||
|
|
||||||
alternative_titles = []
|
alternative_titles = []
|
||||||
if 'alternative_title' in guess:
|
if 'alternative_title' in guess:
|
||||||
alternative_titles.append(u"%s %s" % (guess['title'], guess['alternative_title']))
|
alternative_titles.append(u"%s %s" % (guess['title'], guess['alternative_title']))
|
||||||
|
|
||||||
return cls(name, guess['title'], source=guess.get('source'), release_group=guess.get('release_group'),
|
return cls(name, guess.get('title', 'Unknown Title'), source=guess.get('source'), release_group=guess.get('release_group'),
|
||||||
resolution=guess.get('screen_size'), video_codec=guess.get('video_codec'),
|
resolution=guess.get('screen_size'), video_codec=guess.get('video_codec'),
|
||||||
audio_codec=guess.get('audio_codec'), year=guess.get('year'), alternative_titles=alternative_titles,
|
audio_codec=guess.get('audio_codec'), year=guess.get('year'), alternative_titles=alternative_titles,
|
||||||
streaming_service=guess.get("streaming_service"), edition=guess.get("edition"))
|
streaming_service=guess.get("streaming_service"), edition=guess.get("edition"))
|
||||||
|
|
|
@ -70,6 +70,16 @@ def remove_crap_from_fn(fn):
|
||||||
return REMOVE_CRAP_FROM_FILENAME.sub(repl, fn)
|
return REMOVE_CRAP_FROM_FILENAME.sub(repl, fn)
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_update(item, to_update):
|
||||||
|
for k, v in to_update.items():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
item[k] = _nested_update(item.get(k, {}), v)
|
||||||
|
else:
|
||||||
|
item[k] = v
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
class _ProviderConfigs(dict):
|
class _ProviderConfigs(dict):
|
||||||
def __init__(self, pool, *args, **kwargs):
|
def __init__(self, pool, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -108,7 +118,9 @@ class _ProviderConfigs(dict):
|
||||||
else:
|
else:
|
||||||
logger.debug("No provider config updates")
|
logger.debug("No provider config updates")
|
||||||
|
|
||||||
return super().update(items)
|
_nested_update(self, items)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class _Banlist:
|
class _Banlist:
|
||||||
|
@ -727,7 +739,6 @@ def scan_video(path, dont_use_actual_file=False, hints=None, providers=None, ski
|
||||||
|
|
||||||
"""
|
"""
|
||||||
hints = hints or {}
|
hints = hints or {}
|
||||||
video_type = hints.get("type")
|
|
||||||
|
|
||||||
# check for non-existing path
|
# check for non-existing path
|
||||||
if not dont_use_actual_file and not os.path.exists(path):
|
if not dont_use_actual_file and not os.path.exists(path):
|
||||||
|
@ -740,42 +751,15 @@ def scan_video(path, dont_use_actual_file=False, hints=None, providers=None, ski
|
||||||
dirpath, filename = os.path.split(path)
|
dirpath, filename = os.path.split(path)
|
||||||
logger.info('Determining basic video properties for %r in %r', filename, dirpath)
|
logger.info('Determining basic video properties for %r in %r', filename, dirpath)
|
||||||
|
|
||||||
# hint guessit the filename itself and its 2 parent directories if we're an episode (most likely
|
|
||||||
# Series name/Season/filename), else only one
|
|
||||||
split_path = os.path.normpath(path).split(os.path.sep)[-3 if video_type == "episode" else -2:]
|
|
||||||
|
|
||||||
# remove crap from folder names
|
|
||||||
if video_type == "episode":
|
|
||||||
if len(split_path) > 2:
|
|
||||||
split_path[-3] = remove_crap_from_fn(split_path[-3])
|
|
||||||
else:
|
|
||||||
if len(split_path) > 1:
|
|
||||||
split_path[-2] = remove_crap_from_fn(split_path[-2])
|
|
||||||
|
|
||||||
guess_from = os.path.join(*split_path)
|
|
||||||
|
|
||||||
# remove crap from file name
|
|
||||||
guess_from = remove_crap_from_fn(guess_from)
|
|
||||||
|
|
||||||
# guess
|
|
||||||
hints["single_value"] = True
|
hints["single_value"] = True
|
||||||
# if "title" in hints:
|
# if "title" in hints:
|
||||||
# hints["expected_title"] = [hints["title"]]
|
# hints["expected_title"] = [hints["title"]]
|
||||||
|
|
||||||
guessed_result = guessit(guess_from, options=hints)
|
guessed_result = guessit(path, options=hints)
|
||||||
|
|
||||||
logger.debug('GuessIt found: %s', json.dumps(guessed_result, cls=GuessitEncoder, indent=4, ensure_ascii=False))
|
logger.debug('GuessIt found: %s', json.dumps(guessed_result, cls=GuessitEncoder, indent=4, ensure_ascii=False))
|
||||||
video = Video.fromguess(path, guessed_result)
|
video = Video.fromguess(path, guessed_result)
|
||||||
video.hints = hints
|
video.hints = hints # ?
|
||||||
|
|
||||||
# get possibly alternative title from the filename itself
|
|
||||||
alt_guess = guessit(filename, options=hints)
|
|
||||||
if "title" in alt_guess and alt_guess["title"] != guessed_result["title"]:
|
|
||||||
if video_type == "episode":
|
|
||||||
video.alternative_series.append(alt_guess["title"])
|
|
||||||
else:
|
|
||||||
video.alternative_titles.append(alt_guess["title"])
|
|
||||||
logger.debug("Adding alternative title: %s", alt_guess["title"])
|
|
||||||
|
|
||||||
if dont_use_actual_file and not hash_from:
|
if dont_use_actual_file and not hash_from:
|
||||||
return video
|
return video
|
||||||
|
|
|
@ -79,9 +79,7 @@ class EmbeddedSubtitlesProvider(Provider):
|
||||||
ffmpeg_path=None,
|
ffmpeg_path=None,
|
||||||
hi_fallback=False,
|
hi_fallback=False,
|
||||||
timeout=600,
|
timeout=600,
|
||||||
include_ass=None,
|
unknown_as_english=False,
|
||||||
include_srt=None,
|
|
||||||
mergerfs_mode=None,
|
|
||||||
):
|
):
|
||||||
self._included_codecs = set(included_codecs or _ALLOWED_CODECS)
|
self._included_codecs = set(included_codecs or _ALLOWED_CODECS)
|
||||||
|
|
||||||
|
@ -93,6 +91,7 @@ class EmbeddedSubtitlesProvider(Provider):
|
||||||
cache_dir or tempfile.gettempdir(), self.__class__.__name__.lower()
|
cache_dir or tempfile.gettempdir(), self.__class__.__name__.lower()
|
||||||
)
|
)
|
||||||
self._hi_fallback = hi_fallback
|
self._hi_fallback = hi_fallback
|
||||||
|
self._unknown_as_english = unknown_as_english
|
||||||
self._cached_paths = {}
|
self._cached_paths = {}
|
||||||
self._timeout = int(timeout)
|
self._timeout = int(timeout)
|
||||||
|
|
||||||
|
@ -105,6 +104,9 @@ class EmbeddedSubtitlesProvider(Provider):
|
||||||
# Default is True
|
# Default is True
|
||||||
container.FFMPEG_STATS = False
|
container.FFMPEG_STATS = False
|
||||||
|
|
||||||
|
tags.LANGUAGE_FALLBACK = "en" if self._unknown_as_english else None
|
||||||
|
logger.debug("Language fallback set: %s", tags.LANGUAGE_FALLBACK)
|
||||||
|
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
os.makedirs(self._cache_dir, exist_ok=True)
|
os.makedirs(self._cache_dir, exist_ok=True)
|
||||||
|
|
||||||
|
|
|
@ -191,7 +191,9 @@ class SubtitleModifications(object):
|
||||||
sub = processor.process(sub)
|
sub = processor.process(sub)
|
||||||
|
|
||||||
if sub.strip():
|
if sub.strip():
|
||||||
if not sub.isupper():
|
# only consider alphabetic characters to determine if uppercase
|
||||||
|
alpha_sub = ''.join([i for i in sub if i.isalpha()])
|
||||||
|
if alpha_sub and not alpha_sub.isupper():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
entry_used = True
|
entry_used = True
|
||||||
|
|
|
@ -8,6 +8,7 @@ dogpile.cache==1.1.5
|
||||||
enzyme==0.4.1
|
enzyme==0.4.1
|
||||||
fese==0.1.2
|
fese==0.1.2
|
||||||
ffsubsync==0.4.20
|
ffsubsync==0.4.20
|
||||||
|
flask-cors==3.0.10
|
||||||
flask-restful==0.3.9
|
flask-restful==0.3.9
|
||||||
Flask-SocketIO==5.1.1
|
Flask-SocketIO==5.1.1
|
||||||
Flask==2.0.2
|
Flask==2.0.2
|
||||||
|
|
|
@ -34,6 +34,7 @@ def test_get_providers_auth_embeddedsubtitles():
|
||||||
assert isinstance(item["ffprobe_path"], str)
|
assert isinstance(item["ffprobe_path"], str)
|
||||||
assert isinstance(item["ffmpeg_path"], str)
|
assert isinstance(item["ffmpeg_path"], str)
|
||||||
assert isinstance(item["timeout"], str)
|
assert isinstance(item["timeout"], str)
|
||||||
|
assert isinstance(item["unknown_as_english"], bool)
|
||||||
|
|
||||||
|
|
||||||
def test_get_providers_auth_karagarga():
|
def test_get_providers_auth_karagarga():
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from subliminal_patch import core
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_video_movie(tmpdir):
|
||||||
|
video_path = Path(tmpdir, "Taxi Driver 1976 Bluray 720p x264.mkv")
|
||||||
|
video_path.touch()
|
||||||
|
|
||||||
|
result = core.scan_video(str(video_path))
|
||||||
|
assert isinstance(result, core.Movie)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_video_episode(tmpdir):
|
||||||
|
video_path = Path(tmpdir, "The Wire S01E01 Bluray 720p x264.mkv")
|
||||||
|
video_path.touch()
|
||||||
|
|
||||||
|
result = core.scan_video(str(video_path))
|
||||||
|
assert isinstance(result, core.Episode)
|
|
@ -4,6 +4,7 @@ import os
|
||||||
from fese import FFprobeSubtitleStream
|
from fese import FFprobeSubtitleStream
|
||||||
from fese import FFprobeVideoContainer
|
from fese import FFprobeVideoContainer
|
||||||
from fese import tags
|
from fese import tags
|
||||||
|
from fese.exceptions import LanguageNotFound
|
||||||
import pytest
|
import pytest
|
||||||
from subliminal_patch.core import Episode
|
from subliminal_patch.core import Episode
|
||||||
from subliminal_patch.core import Movie
|
from subliminal_patch.core import Movie
|
||||||
|
@ -123,13 +124,36 @@ def fake_streams():
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tags_", [{}, {"language": "und", "title": "Unknown"}])
|
||||||
|
def test_list_subtitles_unknown_as_english(mocker, tags_):
|
||||||
|
with EmbeddedSubtitlesProvider(unknown_as_english=True):
|
||||||
|
fake = FFprobeSubtitleStream(
|
||||||
|
{"index": 3, "codec_name": "subrip", "tags": tags_}
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
"subliminal_patch.providers.embeddedsubtitles._MemoizedFFprobeVideoContainer.get_subtitles",
|
||||||
|
return_value=[fake],
|
||||||
|
)
|
||||||
|
streams = _MemoizedFFprobeVideoContainer.get_subtitles("")
|
||||||
|
assert len(streams) == 1
|
||||||
|
assert streams[0].language == Language.fromietf("en")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tags_", [{}, {"language": "und", "title": "Unknown"}])
|
||||||
|
def test_list_subtitles_unknown_as_english_disabled(tags_):
|
||||||
|
with EmbeddedSubtitlesProvider(unknown_as_english=False):
|
||||||
|
with pytest.raises(LanguageNotFound):
|
||||||
|
assert FFprobeSubtitleStream(
|
||||||
|
{"index": 3, "codec_name": "subrip", "tags": tags_}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_list_subtitles_hi_fallback_one_stream(
|
def test_list_subtitles_hi_fallback_one_stream(
|
||||||
video_single_language, fake_streams, mocker
|
video_single_language, fake_streams, mocker
|
||||||
):
|
):
|
||||||
with EmbeddedSubtitlesProvider(hi_fallback=True) as provider:
|
with EmbeddedSubtitlesProvider(hi_fallback=True) as provider:
|
||||||
language = Language.fromalpha2("en")
|
language = Language.fromalpha2("en")
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
# "fese.FFprobeVideoContainer.get_subtitles",
|
|
||||||
"subliminal_patch.providers.embeddedsubtitles._MemoizedFFprobeVideoContainer.get_subtitles",
|
"subliminal_patch.providers.embeddedsubtitles._MemoizedFFprobeVideoContainer.get_subtitles",
|
||||||
return_value=[fake_streams["en_hi"]],
|
return_value=[fake_streams["en_hi"]],
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue