mirror of
https://github.com/hrfee/jellyfin-accounts.git
synced 2024-12-22 09:00:14 +00:00
Formatted with black
This commit is contained in:
parent
079dff8d9f
commit
24045034c8
@ -13,192 +13,223 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from flask import Flask, g
|
from flask import Flask, g
|
||||||
from jellyfin_accounts.data_store import JSONStorage
|
from jellyfin_accounts.data_store import JSONStorage
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="jellyfin-accounts")
|
parser = argparse.ArgumentParser(description="jellyfin-accounts")
|
||||||
|
|
||||||
parser.add_argument("-c", "--config",
|
parser.add_argument("-c", "--config", help="specifies path to configuration file.")
|
||||||
help="specifies path to configuration file.")
|
parser.add_argument(
|
||||||
parser.add_argument("-d", "--data",
|
"-d",
|
||||||
help=("specifies directory to store data in. " +
|
"--data",
|
||||||
"defaults to ~/.jf-accounts."))
|
help=("specifies directory to store data in. " + "defaults to ~/.jf-accounts."),
|
||||||
parser.add_argument("--host",
|
)
|
||||||
help="address to host web ui on.")
|
parser.add_argument("--host", help="address to host web ui on.")
|
||||||
parser.add_argument("-p", "--port",
|
parser.add_argument("-p", "--port", help="port to host web ui on.")
|
||||||
help="port to host web ui on.")
|
parser.add_argument(
|
||||||
parser.add_argument("-g", "--get_defaults",
|
"-g",
|
||||||
help=("tool to grab a JF users " +
|
"--get_defaults",
|
||||||
"policy (access, perms, etc.) and " +
|
help=(
|
||||||
"homescreen layout and " +
|
"tool to grab a JF users "
|
||||||
"output it as json to be used as a user template."),
|
+ "policy (access, perms, etc.) and "
|
||||||
action='store_true')
|
+ "homescreen layout and "
|
||||||
|
+ "output it as json to be used as a user template."
|
||||||
|
),
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
|
||||||
args, leftovers = parser.parse_known_args()
|
args, leftovers = parser.parse_known_args()
|
||||||
|
|
||||||
if args.data is not None:
|
if args.data is not None:
|
||||||
data_dir = Path(args.data)
|
data_dir = Path(args.data)
|
||||||
else:
|
else:
|
||||||
data_dir = Path.home() / '.jf-accounts'
|
data_dir = Path.home() / ".jf-accounts"
|
||||||
|
|
||||||
local_dir = (Path(__file__).parent / 'data').resolve()
|
local_dir = (Path(__file__).parent / "data").resolve()
|
||||||
|
|
||||||
first_run = False
|
first_run = False
|
||||||
if data_dir.exists() is False or (data_dir / 'config.ini').exists() is False:
|
if data_dir.exists() is False or (data_dir / "config.ini").exists() is False:
|
||||||
if not data_dir.exists():
|
if not data_dir.exists():
|
||||||
Path.mkdir(data_dir)
|
Path.mkdir(data_dir)
|
||||||
print(f'Config dir not found, so created at {str(data_dir)}')
|
print(f"Config dir not found, so created at {str(data_dir)}")
|
||||||
if args.config is None:
|
if args.config is None:
|
||||||
config_path = data_dir / 'config.ini'
|
config_path = data_dir / "config.ini"
|
||||||
shutil.copy(str(local_dir / 'config-default.ini'),
|
shutil.copy(str(local_dir / "config-default.ini"), str(config_path))
|
||||||
str(config_path))
|
|
||||||
print("Setup through the web UI, or quit and edit the configuration manually.")
|
print("Setup through the web UI, or quit and edit the configuration manually.")
|
||||||
first_run = True
|
first_run = True
|
||||||
else:
|
else:
|
||||||
config_path = Path(args.config)
|
config_path = Path(args.config)
|
||||||
print(f'config.ini can be found at {str(config_path)}')
|
print(f"config.ini can be found at {str(config_path)}")
|
||||||
else:
|
else:
|
||||||
config_path = data_dir / 'config.ini'
|
config_path = data_dir / "config.ini"
|
||||||
|
|
||||||
config = configparser.RawConfigParser()
|
config = configparser.RawConfigParser()
|
||||||
config.read(config_path)
|
config.read(config_path)
|
||||||
|
|
||||||
|
|
||||||
def create_log(name):
|
def create_log(name):
|
||||||
log = logging.getLogger(name)
|
log = logging.getLogger(name)
|
||||||
handler = logging.StreamHandler(sys.stdout)
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
if config.getboolean('ui', 'debug'):
|
if config.getboolean("ui", "debug"):
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging.DEBUG)
|
||||||
handler.setLevel(logging.DEBUG)
|
handler.setLevel(logging.DEBUG)
|
||||||
else:
|
else:
|
||||||
log.setLevel(logging.INFO)
|
log.setLevel(logging.INFO)
|
||||||
handler.setLevel(logging.INFO)
|
handler.setLevel(logging.INFO)
|
||||||
fmt = ' %(name)s - %(levelname)s - %(message)s'
|
fmt = " %(name)s - %(levelname)s - %(message)s"
|
||||||
format = logging.Formatter(fmt)
|
format = logging.Formatter(fmt)
|
||||||
handler.setFormatter(format)
|
handler.setFormatter(format)
|
||||||
log.addHandler(handler)
|
log.addHandler(handler)
|
||||||
log.propagate = False
|
log.propagate = False
|
||||||
return log
|
return log
|
||||||
|
|
||||||
log = create_log('main')
|
|
||||||
web_log = create_log('waitress')
|
log = create_log("main")
|
||||||
|
web_log = create_log("waitress")
|
||||||
if not first_run:
|
if not first_run:
|
||||||
email_log = create_log('emails')
|
email_log = create_log("emails")
|
||||||
auth_log = create_log('auth')
|
auth_log = create_log("auth")
|
||||||
|
|
||||||
if args.host is not None:
|
if args.host is not None:
|
||||||
log.debug(f'Using specified host {args.host}')
|
log.debug(f"Using specified host {args.host}")
|
||||||
config['ui']['host'] = args.host
|
config["ui"]["host"] = args.host
|
||||||
if args.port is not None:
|
if args.port is not None:
|
||||||
log.debug(f'Using specified port {args.port}')
|
log.debug(f"Using specified port {args.port}")
|
||||||
config['ui']['port'] = args.port
|
config["ui"]["port"] = args.port
|
||||||
|
|
||||||
for key in config['files']:
|
for key in config["files"]:
|
||||||
if config['files'][key] == '':
|
if config["files"][key] == "":
|
||||||
if key != 'custom_css':
|
if key != "custom_css":
|
||||||
log.debug(f'Using default {key}')
|
log.debug(f"Using default {key}")
|
||||||
config['files'][key] = str(data_dir / (key + '.json'))
|
config["files"][key] = str(data_dir / (key + ".json"))
|
||||||
|
|
||||||
for key in ['user_configuration', 'user_displayprefs']:
|
for key in ["user_configuration", "user_displayprefs"]:
|
||||||
if key not in config['files']:
|
if key not in config["files"]:
|
||||||
log.debug(f'Using default {key}')
|
log.debug(f"Using default {key}")
|
||||||
config['files'][key] = str(data_dir / (key + '.json'))
|
config["files"][key] = str(data_dir / (key + ".json"))
|
||||||
|
|
||||||
with open(config['files']['invites'], 'r') as f:
|
with open(config["files"]["invites"], "r") as f:
|
||||||
temp_invites = json.load(f)
|
temp_invites = json.load(f)
|
||||||
if 'invites' in temp_invites:
|
if "invites" in temp_invites:
|
||||||
new_invites = {}
|
new_invites = {}
|
||||||
log.info('Converting invites.json to new format, temporary.')
|
log.info("Converting invites.json to new format, temporary.")
|
||||||
for el in temp_invites['invites']:
|
for el in temp_invites["invites"]:
|
||||||
i = {'valid_till': el['valid_till']}
|
i = {"valid_till": el["valid_till"]}
|
||||||
if 'email' in el:
|
if "email" in el:
|
||||||
i['email'] = el['email']
|
i["email"] = el["email"]
|
||||||
new_invites[el['code']] = i
|
new_invites[el["code"]] = i
|
||||||
with open(config['files']['invites'], 'w') as f:
|
with open(config["files"]["invites"], "w") as f:
|
||||||
f.write(json.dumps(new_invites, indent=4, default=str))
|
f.write(json.dumps(new_invites, indent=4, default=str))
|
||||||
|
|
||||||
|
|
||||||
data_store = JSONStorage(config['files']['emails'],
|
data_store = JSONStorage(
|
||||||
config['files']['invites'],
|
config["files"]["emails"],
|
||||||
config['files']['user_template'],
|
config["files"]["invites"],
|
||||||
config['files']['user_displayprefs'],
|
config["files"]["user_template"],
|
||||||
config['files']['user_configuration'])
|
config["files"]["user_displayprefs"],
|
||||||
|
config["files"]["user_configuration"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def default_css():
|
def default_css():
|
||||||
css = {}
|
css = {}
|
||||||
css['href'] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
|
css[
|
||||||
css['integrity'] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
|
"href"
|
||||||
css['crossorigin'] = "anonymous"
|
] = "https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
|
||||||
|
css[
|
||||||
|
"integrity"
|
||||||
|
] = "sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
|
||||||
|
css["crossorigin"] = "anonymous"
|
||||||
return css
|
return css
|
||||||
|
|
||||||
|
|
||||||
css = {}
|
css = {}
|
||||||
css = default_css()
|
css = default_css()
|
||||||
if 'custom_css' in config['files']:
|
if "custom_css" in config["files"]:
|
||||||
if config['files']['custom_css'] != '':
|
if config["files"]["custom_css"] != "":
|
||||||
try:
|
try:
|
||||||
shutil.copy(config['files']['custom_css'],
|
shutil.copy(
|
||||||
(local_dir / 'static' / 'bootstrap.css'))
|
config["files"]["custom_css"], (local_dir / "static" / "bootstrap.css")
|
||||||
log.debug('Loaded custom CSS')
|
)
|
||||||
css['href'] = '/bootstrap.css'
|
log.debug("Loaded custom CSS")
|
||||||
css['integrity'] = ''
|
css["href"] = "/bootstrap.css"
|
||||||
css['crossorigin'] = ''
|
css["integrity"] = ""
|
||||||
|
css["crossorigin"] = ""
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
log.error(f'Custom CSS {config["files"]["custom_css"]} not found, using default.')
|
log.error(
|
||||||
|
f'Custom CSS {config["files"]["custom_css"]} not found, using default.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if ('email_html' not in config['password_resets'] or
|
if (
|
||||||
config['password_resets']['email_html'] == ''):
|
"email_html" not in config["password_resets"]
|
||||||
log.debug('Using default password reset email HTML template')
|
or config["password_resets"]["email_html"] == ""
|
||||||
config['password_resets']['email_html'] = str(local_dir / 'email.html')
|
):
|
||||||
if ('email_text' not in config['password_resets'] or
|
log.debug("Using default password reset email HTML template")
|
||||||
config['password_resets']['email_text'] == ''):
|
config["password_resets"]["email_html"] = str(local_dir / "email.html")
|
||||||
log.debug('Using default password reset email plaintext template')
|
if (
|
||||||
config['password_resets']['email_text'] = str(local_dir / 'email.txt')
|
"email_text" not in config["password_resets"]
|
||||||
|
or config["password_resets"]["email_text"] == ""
|
||||||
|
):
|
||||||
|
log.debug("Using default password reset email plaintext template")
|
||||||
|
config["password_resets"]["email_text"] = str(local_dir / "email.txt")
|
||||||
|
|
||||||
if ('email_html' not in config['invite_emails'] or
|
if (
|
||||||
config['invite_emails']['email_html'] == ''):
|
"email_html" not in config["invite_emails"]
|
||||||
log.debug('Using default invite email HTML template')
|
or config["invite_emails"]["email_html"] == ""
|
||||||
config['invite_emails']['email_html'] = str(local_dir /
|
):
|
||||||
'invite-email.html')
|
log.debug("Using default invite email HTML template")
|
||||||
if ('email_text' not in config['invite_emails'] or
|
config["invite_emails"]["email_html"] = str(local_dir / "invite-email.html")
|
||||||
config['invite_emails']['email_text'] == ''):
|
if (
|
||||||
log.debug('Using default invite email plaintext template')
|
"email_text" not in config["invite_emails"]
|
||||||
config['invite_emails']['email_text'] = str(local_dir /
|
or config["invite_emails"]["email_text"] == ""
|
||||||
'invite-email.txt')
|
):
|
||||||
if ('public_server' not in config['jellyfin'] or
|
log.debug("Using default invite email plaintext template")
|
||||||
config['jellyfin']['public_server'] == ''):
|
config["invite_emails"]["email_text"] = str(local_dir / "invite-email.txt")
|
||||||
config['jellyfin']['public_server'] = config['jellyfin']['server']
|
if (
|
||||||
|
"public_server" not in config["jellyfin"]
|
||||||
|
or config["jellyfin"]["public_server"] == ""
|
||||||
|
):
|
||||||
|
config["jellyfin"]["public_server"] = config["jellyfin"]["server"]
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if args.get_defaults:
|
if args.get_defaults:
|
||||||
import json
|
import json
|
||||||
from jellyfin_accounts.jf_api import Jellyfin
|
from jellyfin_accounts.jf_api import Jellyfin
|
||||||
jf = Jellyfin(config['jellyfin']['server'],
|
|
||||||
config['jellyfin']['client'],
|
jf = Jellyfin(
|
||||||
config['jellyfin']['version'],
|
config["jellyfin"]["server"],
|
||||||
config['jellyfin']['device'],
|
config["jellyfin"]["client"],
|
||||||
config['jellyfin']['device_id'])
|
config["jellyfin"]["version"],
|
||||||
|
config["jellyfin"]["device"],
|
||||||
|
config["jellyfin"]["device_id"],
|
||||||
|
)
|
||||||
print("NOTE: This can now be done through the web ui.")
|
print("NOTE: This can now be done through the web ui.")
|
||||||
print("""
|
print(
|
||||||
|
"""
|
||||||
This tool lets you grab various settings from a user,
|
This tool lets you grab various settings from a user,
|
||||||
so that they can be applied every time a new account is
|
so that they can be applied every time a new account is
|
||||||
created. """)
|
created. """
|
||||||
|
)
|
||||||
print("Step 1: User Policy.")
|
print("Step 1: User Policy.")
|
||||||
print("""
|
print(
|
||||||
|
"""
|
||||||
A user policy stores a users permissions (e.g access rights and
|
A user policy stores a users permissions (e.g access rights and
|
||||||
most of the other settings in the 'Profile' and 'Access' tabs
|
most of the other settings in the 'Profile' and 'Access' tabs
|
||||||
of a user). """)
|
of a user). """
|
||||||
|
)
|
||||||
success = False
|
success = False
|
||||||
msg = "Get public users only or all users? (requires auth) [public/all]: "
|
msg = "Get public users only or all users? (requires auth) [public/all]: "
|
||||||
public = False
|
public = False
|
||||||
while not success:
|
while not success:
|
||||||
choice = input(msg)
|
choice = input(msg)
|
||||||
if choice == 'public':
|
if choice == "public":
|
||||||
public = True
|
public = True
|
||||||
print("Make sure the user is publicly visible!")
|
print("Make sure the user is publicly visible!")
|
||||||
success = True
|
success = True
|
||||||
elif choice == 'all':
|
elif choice == "all":
|
||||||
jf.authenticate(config['jellyfin']['username'],
|
jf.authenticate(
|
||||||
config['jellyfin']['password'])
|
config["jellyfin"]["username"], config["jellyfin"]["password"]
|
||||||
|
)
|
||||||
public = False
|
public = False
|
||||||
success = True
|
success = True
|
||||||
users = jf.getUsers(public=public)
|
users = jf.getUsers(public=public)
|
||||||
@ -207,69 +238,78 @@ def main():
|
|||||||
success = False
|
success = False
|
||||||
while not success:
|
while not success:
|
||||||
try:
|
try:
|
||||||
user_index = int(input(">: "))-1
|
user_index = int(input(">: ")) - 1
|
||||||
policy = users[user_index]['Policy']
|
policy = users[user_index]["Policy"]
|
||||||
success = True
|
success = True
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
pass
|
pass
|
||||||
data_store.user_template = policy
|
data_store.user_template = policy
|
||||||
print(f'Policy written to "{config["files"]["user_template"]}".')
|
print(f'Policy written to "{config["files"]["user_template"]}".')
|
||||||
print('In future, this policy will be copied to all new users.')
|
print("In future, this policy will be copied to all new users.")
|
||||||
print('Step 2: Homescreen Layout')
|
print("Step 2: Homescreen Layout")
|
||||||
print("""
|
print(
|
||||||
|
"""
|
||||||
You may want to customize the default layout of a new user's
|
You may want to customize the default layout of a new user's
|
||||||
home screen. These settings can be applied to an account through
|
home screen. These settings can be applied to an account through
|
||||||
the 'Home' section in a user's settings. """)
|
the 'Home' section in a user's settings. """
|
||||||
|
)
|
||||||
success = False
|
success = False
|
||||||
while not success:
|
while not success:
|
||||||
choice = input("Grab the chosen user's homescreen layout? [y/n]: ")
|
choice = input("Grab the chosen user's homescreen layout? [y/n]: ")
|
||||||
if choice.lower() == 'y':
|
if choice.lower() == "y":
|
||||||
user_id = users[user_index]['Id']
|
user_id = users[user_index]["Id"]
|
||||||
configuration = users[user_index]['Configuration']
|
configuration = users[user_index]["Configuration"]
|
||||||
display_prefs = jf.getDisplayPreferences(user_id)
|
display_prefs = jf.getDisplayPreferences(user_id)
|
||||||
data_store.user_configuration = configuration
|
data_store.user_configuration = configuration
|
||||||
print(f'Configuration written to "{config["files"]["user_configuration"]}".')
|
print(
|
||||||
|
f'Configuration written to "{config["files"]["user_configuration"]}".'
|
||||||
|
)
|
||||||
data_store.user_displayprefs = display_prefs
|
data_store.user_displayprefs = display_prefs
|
||||||
print(f'Display Prefs written to "{config["files"]["user_displayprefs"]}".')
|
print(
|
||||||
|
f'Display Prefs written to "{config["files"]["user_displayprefs"]}".'
|
||||||
|
)
|
||||||
success = True
|
success = True
|
||||||
elif choice.lower() == 'n':
|
elif choice.lower() == "n":
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
print('Quitting...')
|
print("Quitting...")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
global app
|
global app
|
||||||
app = Flask(__name__, root_path=str(local_dir))
|
app = Flask(__name__, root_path=str(local_dir))
|
||||||
app.config['DEBUG'] = config.getboolean('ui', 'debug')
|
app.config["DEBUG"] = config.getboolean("ui", "debug")
|
||||||
app.config['SECRET_KEY'] = secrets.token_urlsafe(16)
|
app.config["SECRET_KEY"] = secrets.token_urlsafe(16)
|
||||||
|
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
if first_run:
|
if first_run:
|
||||||
import jellyfin_accounts.setup
|
import jellyfin_accounts.setup
|
||||||
host = config['ui']['host']
|
|
||||||
port = config['ui']['port']
|
host = config["ui"]["host"]
|
||||||
log.info('Starting web UI for first run setup...')
|
port = config["ui"]["port"]
|
||||||
serve(app,
|
log.info("Starting web UI for first run setup...")
|
||||||
host=host,
|
serve(app, host=host, port=port)
|
||||||
port=port)
|
|
||||||
else:
|
else:
|
||||||
import jellyfin_accounts.web_api
|
import jellyfin_accounts.web_api
|
||||||
import jellyfin_accounts.web
|
import jellyfin_accounts.web
|
||||||
host = config['ui']['host']
|
|
||||||
port = config['ui']['port']
|
host = config["ui"]["host"]
|
||||||
log.info(f'Starting web UI on {host}:{port}')
|
port = config["ui"]["port"]
|
||||||
if config.getboolean('password_resets', 'enabled'):
|
log.info(f"Starting web UI on {host}:{port}")
|
||||||
|
if config.getboolean("password_resets", "enabled"):
|
||||||
|
|
||||||
def start_pwr():
|
def start_pwr():
|
||||||
import jellyfin_accounts.pw_reset
|
import jellyfin_accounts.pw_reset
|
||||||
|
|
||||||
jellyfin_accounts.pw_reset.start()
|
jellyfin_accounts.pw_reset.start()
|
||||||
|
|
||||||
pwr = threading.Thread(target=start_pwr, daemon=True)
|
pwr = threading.Thread(target=start_pwr, daemon=True)
|
||||||
log.info('Starting email thread')
|
log.info("Starting email thread")
|
||||||
pwr.start()
|
pwr.start()
|
||||||
|
|
||||||
serve(app,
|
serve(app, host=host, port=int(port))
|
||||||
host=host,
|
|
||||||
port=int(port))
|
|
||||||
|
@ -1,24 +1,26 @@
|
|||||||
import json
|
import json
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
class JSONFile(dict):
|
class JSONFile(dict):
|
||||||
"""
|
"""
|
||||||
Behaves like a dictionary, but automatically
|
Behaves like a dictionary, but automatically
|
||||||
reads and writes to a JSON file (most of the time).
|
reads and writes to a JSON file (most of the time).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def readJSON(path):
|
def readJSON(path):
|
||||||
try:
|
try:
|
||||||
with open(path, 'r') as f:
|
with open(path, "r") as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def writeJSON(path, data):
|
def writeJSON(path, data):
|
||||||
with open(path, 'w') as f:
|
with open(path, "w") as f:
|
||||||
return f.write(json.dumps(data, indent=4, default=str))
|
return f.write(json.dumps(data, indent=4, default=str))
|
||||||
|
|
||||||
def __init__(self, path, data=None):
|
def __init__(self, path, data=None):
|
||||||
self.path = path
|
self.path = path
|
||||||
if data is None:
|
if data is None:
|
||||||
@ -34,14 +36,14 @@ class JSONFile(dict):
|
|||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key, value):
|
||||||
data = self.readJSON(self.path)
|
data = self.readJSON(self.path)
|
||||||
data[key] = value
|
data[key] = value
|
||||||
self.writeJSON(self.path, data)
|
self.writeJSON(self.path, data)
|
||||||
super(JSONFile, self).__init__(data)
|
super(JSONFile, self).__init__(data)
|
||||||
|
|
||||||
def __delitem__(self, key):
|
def __delitem__(self, key):
|
||||||
data = self.readJSON(self.path)
|
data = self.readJSON(self.path)
|
||||||
super(JSONFile, self).__init__(data)
|
super(JSONFile, self).__init__(data)
|
||||||
del data[key]
|
del data[key]
|
||||||
self.writeJSON(self.path, data)
|
self.writeJSON(self.path, data)
|
||||||
super(JSONFile, self).__delitem__(key)
|
super(JSONFile, self).__delitem__(key)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -50,18 +52,15 @@ class JSONFile(dict):
|
|||||||
|
|
||||||
|
|
||||||
class JSONStorage:
|
class JSONStorage:
|
||||||
def __init__(self,
|
def __init__(
|
||||||
emails,
|
self, emails, invites, user_template, user_displayprefs, user_configuration
|
||||||
invites,
|
):
|
||||||
user_template,
|
|
||||||
user_displayprefs,
|
|
||||||
user_configuration):
|
|
||||||
self.emails = JSONFile(path=emails)
|
self.emails = JSONFile(path=emails)
|
||||||
self.invites = JSONFile(path=invites)
|
self.invites = JSONFile(path=invites)
|
||||||
self.user_template = JSONFile(path=user_template)
|
self.user_template = JSONFile(path=user_template)
|
||||||
self.user_displayprefs = JSONFile(path=user_displayprefs)
|
self.user_displayprefs = JSONFile(path=user_displayprefs)
|
||||||
self.user_configuration = JSONFile(path=user_configuration)
|
self.user_configuration = JSONFile(path=user_configuration)
|
||||||
|
|
||||||
def __setattr__(self, name, value):
|
def __setattr__(self, name, value):
|
||||||
if hasattr(self, name):
|
if hasattr(self, name):
|
||||||
path = self.__dict__[name].path
|
path = self.__dict__[name].path
|
||||||
|
@ -12,97 +12,109 @@ from jellyfin_accounts import config
|
|||||||
from jellyfin_accounts import email_log as log
|
from jellyfin_accounts import email_log as log
|
||||||
|
|
||||||
|
|
||||||
class Email():
|
class Email:
|
||||||
def __init__(self, address):
|
def __init__(self, address):
|
||||||
self.address = address
|
self.address = address
|
||||||
log.debug(f'{self.address}: Creating email')
|
log.debug(f"{self.address}: Creating email")
|
||||||
self.content = {}
|
self.content = {}
|
||||||
self.from_address = config['email']['address']
|
self.from_address = config["email"]["address"]
|
||||||
self.from_name = config['email']['from']
|
self.from_name = config["email"]["from"]
|
||||||
log.debug((
|
log.debug(
|
||||||
f'{self.address}: Sending from {self.from_address} ' +
|
(
|
||||||
f'({self.from_name})'))
|
f"{self.address}: Sending from {self.from_address} "
|
||||||
|
+ f"({self.from_name})"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def pretty_time(self, expiry):
|
def pretty_time(self, expiry):
|
||||||
current_time = datetime.datetime.now()
|
current_time = datetime.datetime.now()
|
||||||
date = expiry.strftime(config['email']['date_format'])
|
date = expiry.strftime(config["email"]["date_format"])
|
||||||
if config.getboolean('email', 'use_24h'):
|
if config.getboolean("email", "use_24h"):
|
||||||
log.debug(f'{self.address}: Using 24h time')
|
log.debug(f"{self.address}: Using 24h time")
|
||||||
time = expiry.strftime('%H:%M')
|
time = expiry.strftime("%H:%M")
|
||||||
else:
|
else:
|
||||||
log.debug(f'{self.address}: Using 12h time')
|
log.debug(f"{self.address}: Using 12h time")
|
||||||
time = expiry.strftime('%-I:%M %p')
|
time = expiry.strftime("%-I:%M %p")
|
||||||
expiry_delta = (expiry - current_time).seconds
|
expiry_delta = (expiry - current_time).seconds
|
||||||
expires_in = {'hours': expiry_delta//3600,
|
expires_in = {
|
||||||
'minutes': (expiry_delta//60) % 60}
|
"hours": expiry_delta // 3600,
|
||||||
if expires_in['hours'] == 0:
|
"minutes": (expiry_delta // 60) % 60,
|
||||||
|
}
|
||||||
|
if expires_in["hours"] == 0:
|
||||||
expires_in = f'{str(expires_in["minutes"])}m'
|
expires_in = f'{str(expires_in["minutes"])}m'
|
||||||
else:
|
else:
|
||||||
expires_in = (f'{str(expires_in["hours"])}h ' +
|
expires_in = (
|
||||||
f'{str(expires_in["minutes"])}m')
|
f'{str(expires_in["hours"])}h ' + f'{str(expires_in["minutes"])}m'
|
||||||
log.debug(f'{self.address}: Expires in {expires_in}')
|
)
|
||||||
return {'date': date, 'time': time, 'expires_in': expires_in}
|
log.debug(f"{self.address}: Expires in {expires_in}")
|
||||||
|
return {"date": date, "time": time, "expires_in": expires_in}
|
||||||
|
|
||||||
def construct_invite(self, invite):
|
def construct_invite(self, invite):
|
||||||
self.subject = config['invite_emails']['subject']
|
self.subject = config["invite_emails"]["subject"]
|
||||||
log.debug(f'{self.address}: Using subject {self.subject}')
|
log.debug(f"{self.address}: Using subject {self.subject}")
|
||||||
log.debug(f'{self.address}: Constructing email content')
|
log.debug(f"{self.address}: Constructing email content")
|
||||||
expiry = invite['expiry']
|
expiry = invite["expiry"]
|
||||||
expiry.replace(tzinfo=None)
|
expiry.replace(tzinfo=None)
|
||||||
pretty = self.pretty_time(expiry)
|
pretty = self.pretty_time(expiry)
|
||||||
email_message = config['email']['message']
|
email_message = config["email"]["message"]
|
||||||
invite_link = config['invite_emails']['url_base']
|
invite_link = config["invite_emails"]["url_base"]
|
||||||
invite_link += '/' + invite['code']
|
invite_link += "/" + invite["code"]
|
||||||
for key in ['text', 'html']:
|
for key in ["text", "html"]:
|
||||||
sp = Path(config['invite_emails']['email_' + key]) / '..'
|
sp = Path(config["invite_emails"]["email_" + key]) / ".."
|
||||||
sp = str(sp.resolve()) + '/'
|
sp = str(sp.resolve()) + "/"
|
||||||
template_loader = FileSystemLoader(searchpath=sp)
|
template_loader = FileSystemLoader(searchpath=sp)
|
||||||
template_env = Environment(loader=template_loader)
|
template_env = Environment(loader=template_loader)
|
||||||
fname = Path(config['invite_emails']['email_' + key]).name
|
fname = Path(config["invite_emails"]["email_" + key]).name
|
||||||
template = template_env.get_template(fname)
|
template = template_env.get_template(fname)
|
||||||
c = template.render(expiry_date=pretty['date'],
|
c = template.render(
|
||||||
expiry_time=pretty['time'],
|
expiry_date=pretty["date"],
|
||||||
expires_in=pretty['expires_in'],
|
expiry_time=pretty["time"],
|
||||||
invite_link=invite_link,
|
expires_in=pretty["expires_in"],
|
||||||
message=email_message)
|
invite_link=invite_link,
|
||||||
|
message=email_message,
|
||||||
|
)
|
||||||
self.content[key] = c
|
self.content[key] = c
|
||||||
log.info(f'{self.address}: {key} constructed')
|
log.info(f"{self.address}: {key} constructed")
|
||||||
|
|
||||||
def construct_reset(self, reset):
|
def construct_reset(self, reset):
|
||||||
self.subject = config['password_resets']['subject']
|
self.subject = config["password_resets"]["subject"]
|
||||||
log.debug(f'{self.address}: Using subject {self.subject}')
|
log.debug(f"{self.address}: Using subject {self.subject}")
|
||||||
log.debug(f'{self.address}: Constructing email content')
|
log.debug(f"{self.address}: Constructing email content")
|
||||||
try:
|
try:
|
||||||
expiry = date_parser.parse(reset['ExpirationDate'])
|
expiry = date_parser.parse(reset["ExpirationDate"])
|
||||||
expiry = expiry.replace(tzinfo=None)
|
expiry = expiry.replace(tzinfo=None)
|
||||||
except:
|
except:
|
||||||
log.error(f"{self.address}: Couldn't parse expiry time")
|
log.error(f"{self.address}: Couldn't parse expiry time")
|
||||||
return False
|
return False
|
||||||
current_time = datetime.datetime.now()
|
current_time = datetime.datetime.now()
|
||||||
if expiry >= current_time:
|
if expiry >= current_time:
|
||||||
log.debug(f'{self.address}: Invite valid')
|
log.debug(f"{self.address}: Invite valid")
|
||||||
pretty = self.pretty_time(expiry)
|
pretty = self.pretty_time(expiry)
|
||||||
email_message = config['email']['message']
|
email_message = config["email"]["message"]
|
||||||
for key in ['text', 'html']:
|
for key in ["text", "html"]:
|
||||||
sp = Path(config['password_resets']['email_' + key]) / '..'
|
sp = Path(config["password_resets"]["email_" + key]) / ".."
|
||||||
sp = str(sp.resolve()) + '/'
|
sp = str(sp.resolve()) + "/"
|
||||||
template_loader = FileSystemLoader(searchpath=sp)
|
template_loader = FileSystemLoader(searchpath=sp)
|
||||||
template_env = Environment(loader=template_loader)
|
template_env = Environment(loader=template_loader)
|
||||||
fname = Path(config['password_resets']['email_' + key]).name
|
fname = Path(config["password_resets"]["email_" + key]).name
|
||||||
template = template_env.get_template(fname)
|
template = template_env.get_template(fname)
|
||||||
c = template.render(username=reset['UserName'],
|
c = template.render(
|
||||||
expiry_date=pretty['date'],
|
username=reset["UserName"],
|
||||||
expiry_time=pretty['time'],
|
expiry_date=pretty["date"],
|
||||||
expires_in=pretty['expires_in'],
|
expiry_time=pretty["time"],
|
||||||
pin=reset['Pin'],
|
expires_in=pretty["expires_in"],
|
||||||
message=email_message)
|
pin=reset["Pin"],
|
||||||
|
message=email_message,
|
||||||
|
)
|
||||||
self.content[key] = c
|
self.content[key] = c
|
||||||
log.info(f'{self.address}: {key} constructed')
|
log.info(f"{self.address}: {key} constructed")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
err = ((f"{self.address}: " +
|
err = (
|
||||||
"Reset has reportedly already expired. " +
|
f"{self.address}: "
|
||||||
"Ensure timezones are correctly configured."))
|
+ "Reset has reportedly already expired. "
|
||||||
|
+ "Ensure timezones are correctly configured."
|
||||||
|
)
|
||||||
log.error(err)
|
log.error(err)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -110,71 +122,74 @@ class Email():
|
|||||||
class Mailgun(Email):
|
class Mailgun(Email):
|
||||||
def __init__(self, address):
|
def __init__(self, address):
|
||||||
super().__init__(address)
|
super().__init__(address)
|
||||||
self.api_url = config['mailgun']['api_url']
|
self.api_url = config["mailgun"]["api_url"]
|
||||||
self.api_key = config['mailgun']['api_key']
|
self.api_key = config["mailgun"]["api_key"]
|
||||||
self.from_mg = f'{self.from_name} <{self.from_address}>'
|
self.from_mg = f"{self.from_name} <{self.from_address}>"
|
||||||
|
|
||||||
def send(self):
|
def send(self):
|
||||||
response = requests.post(self.api_url,
|
response = requests.post(
|
||||||
auth=("api", self.api_key),
|
self.api_url,
|
||||||
data={"from": self.from_mg,
|
auth=("api", self.api_key),
|
||||||
"to": [self.address],
|
data={
|
||||||
"subject": self.subject,
|
"from": self.from_mg,
|
||||||
"text": self.content['text'],
|
"to": [self.address],
|
||||||
"html": self.content['html']})
|
"subject": self.subject,
|
||||||
|
"text": self.content["text"],
|
||||||
|
"html": self.content["html"],
|
||||||
|
},
|
||||||
|
)
|
||||||
if response.ok:
|
if response.ok:
|
||||||
log.info(f'{self.address}: Sent via mailgun.')
|
log.info(f"{self.address}: Sent via mailgun.")
|
||||||
return True
|
return True
|
||||||
log.debug(f'{self.address}: Mailgun: {response.status_code}')
|
log.debug(f"{self.address}: Mailgun: {response.status_code}")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class Smtp(Email):
|
class Smtp(Email):
|
||||||
def __init__(self, address):
|
def __init__(self, address):
|
||||||
super().__init__(address)
|
super().__init__(address)
|
||||||
self.server = config['smtp']['server']
|
self.server = config["smtp"]["server"]
|
||||||
self.password = config['smtp']['password']
|
self.password = config["smtp"]["password"]
|
||||||
try:
|
try:
|
||||||
self.port = int(config['smtp']['port'])
|
self.port = int(config["smtp"]["port"])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.port = 465
|
self.port = 465
|
||||||
log.debug(f'{self.address}: Defaulting to port {self.port}')
|
log.debug(f"{self.address}: Defaulting to port {self.port}")
|
||||||
|
|
||||||
def send(self):
|
def send(self):
|
||||||
message = MIMEMultipart("alternative")
|
message = MIMEMultipart("alternative")
|
||||||
message["Subject"] = self.subject
|
message["Subject"] = self.subject
|
||||||
message["From"] = self.from_address
|
message["From"] = self.from_address
|
||||||
message["To"] = self.address
|
message["To"] = self.address
|
||||||
text = MIMEText(self.content['text'], 'plain')
|
text = MIMEText(self.content["text"], "plain")
|
||||||
html = MIMEText(self.content['html'], 'html')
|
html = MIMEText(self.content["html"], "html")
|
||||||
message.attach(text)
|
message.attach(text)
|
||||||
message.attach(html)
|
message.attach(html)
|
||||||
try:
|
try:
|
||||||
if config['smtp']['encryption'] == 'ssl_tls':
|
if config["smtp"]["encryption"] == "ssl_tls":
|
||||||
self.context = ssl.create_default_context()
|
self.context = ssl.create_default_context()
|
||||||
with smtplib.SMTP_SSL(self.server,
|
with smtplib.SMTP_SSL(
|
||||||
self.port,
|
self.server, self.port, context=self.context
|
||||||
context=self.context) as server:
|
) as server:
|
||||||
server.ehlo()
|
server.ehlo()
|
||||||
server.login(self.from_address, self.password)
|
server.login(self.from_address, self.password)
|
||||||
server.sendmail(self.from_address,
|
server.sendmail(
|
||||||
self.address,
|
self.from_address, self.address, message.as_string()
|
||||||
message.as_string())
|
)
|
||||||
log.info(f'{self.address}: Sent via smtp (ssl/tls)')
|
log.info(f"{self.address}: Sent via smtp (ssl/tls)")
|
||||||
return True
|
return True
|
||||||
elif config['smtp']['encryption'] == 'starttls':
|
elif config["smtp"]["encryption"] == "starttls":
|
||||||
with smtplib.SMTP(self.server,
|
with smtplib.SMTP(self.server, self.port) as server:
|
||||||
self.port) as server:
|
|
||||||
server.ehlo()
|
server.ehlo()
|
||||||
server.starttls()
|
server.starttls()
|
||||||
server.login(self.from_address, self.password)
|
server.login(self.from_address, self.password)
|
||||||
server.sendmail(self.from_address,
|
server.sendmail(
|
||||||
self.address,
|
self.from_address, self.address, message.as_string()
|
||||||
message.as_string())
|
)
|
||||||
log.info(f'{self.address}: Sent via smtp (starttls)')
|
log.info(f"{self.address}: Sent via smtp (starttls)")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err = f'{self.address}: Failed to send via smtp: '
|
err = f"{self.address}: Failed to send via smtp: "
|
||||||
err += type(e).__name__
|
err += type(e).__name__
|
||||||
log.error(err)
|
log.error(err)
|
||||||
try:
|
try:
|
||||||
|
@ -2,35 +2,48 @@
|
|||||||
import requests
|
import requests
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
class Error(Exception):
|
class Error(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Jellyfin:
|
class Jellyfin:
|
||||||
"""
|
"""
|
||||||
Basic Jellyfin API client, providing account related function only.
|
Basic Jellyfin API client, providing account related function only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class UserExistsError(Error):
|
class UserExistsError(Error):
|
||||||
"""
|
"""
|
||||||
Thrown if a user already exists with the same name
|
Thrown if a user already exists with the same name
|
||||||
when creating an account.
|
when creating an account.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class UserNotFoundError(Error):
|
class UserNotFoundError(Error):
|
||||||
"""Thrown if account with specified user ID/name does not exist."""
|
"""Thrown if account with specified user ID/name does not exist."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class AuthenticationError(Error):
|
class AuthenticationError(Error):
|
||||||
"""Thrown if authentication with Jellyfin fails."""
|
"""Thrown if authentication with Jellyfin fails."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class AuthenticationRequiredError(Error):
|
class AuthenticationRequiredError(Error):
|
||||||
"""
|
"""
|
||||||
Thrown if privileged action is attempted without authentication.
|
Thrown if privileged action is attempted without authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class UnknownError(Error):
|
class UnknownError(Error):
|
||||||
"""
|
"""
|
||||||
Thrown if i've been too lazy to figure out an error's meaning.
|
Thrown if i've been too lazy to figure out an error's meaning.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __init__(self, server, client, version, device, deviceId):
|
def __init__(self, server, client, version, device, deviceId):
|
||||||
"""
|
"""
|
||||||
Initializes the Jellyfin object. All parameters except server
|
Initializes the Jellyfin object. All parameters except server
|
||||||
@ -58,17 +71,16 @@ class Jellyfin:
|
|||||||
self.auth += f"DeviceId={self.deviceId}, "
|
self.auth += f"DeviceId={self.deviceId}, "
|
||||||
self.auth += f"Version={self.version}"
|
self.auth += f"Version={self.version}"
|
||||||
self.header = {
|
self.header = {
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
"X-Application": f"{self.client}/{self.version}",
|
"X-Application": f"{self.client}/{self.version}",
|
||||||
"Accept-Charset": "UTF-8,*",
|
"Accept-Charset": "UTF-8,*",
|
||||||
"Accept-encoding": "gzip",
|
"Accept-encoding": "gzip",
|
||||||
"User-Agent": self.useragent,
|
"User-Agent": self.useragent,
|
||||||
"X-Emby-Authorization": self.auth
|
"X-Emby-Authorization": self.auth,
|
||||||
}
|
}
|
||||||
def getUsers(self, username: str = "all",
|
|
||||||
userId: str = "all",
|
def getUsers(self, username: str = "all", userId: str = "all", public: bool = True):
|
||||||
public: bool = True):
|
|
||||||
"""
|
"""
|
||||||
Returns details on user(s), such as ID, Name, Policy.
|
Returns details on user(s), such as ID, Name, Policy.
|
||||||
|
|
||||||
@ -81,19 +93,20 @@ class Jellyfin:
|
|||||||
"""
|
"""
|
||||||
if public is True:
|
if public is True:
|
||||||
if (time.time() - self.userCachePublicAge) >= self.timeout:
|
if (time.time() - self.userCachePublicAge) >= self.timeout:
|
||||||
response = requests.get(self.server+"/emby/Users/Public").json()
|
response = requests.get(self.server + "/emby/Users/Public").json()
|
||||||
self.userCachePublic = response
|
self.userCachePublic = response
|
||||||
self.userCachePublicAge = time.time()
|
self.userCachePublicAge = time.time()
|
||||||
else:
|
else:
|
||||||
response = self.userCachePublic
|
response = self.userCachePublic
|
||||||
elif (public is False and
|
elif (
|
||||||
hasattr(self, 'username') and
|
public is False and hasattr(self, "username") and hasattr(self, "password")
|
||||||
hasattr(self, 'password')):
|
):
|
||||||
if (time.time() - self.userCacheAge) >= self.timeout:
|
if (time.time() - self.userCacheAge) >= self.timeout:
|
||||||
response = requests.get(self.server+"/emby/Users",
|
response = requests.get(
|
||||||
headers=self.header,
|
self.server + "/emby/Users",
|
||||||
params={'Username': self.username,
|
headers=self.header,
|
||||||
'Pw': self.password})
|
params={"Username": self.username, "Pw": self.password},
|
||||||
|
)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
response = response.json()
|
response = response.json()
|
||||||
self.userCache = response
|
self.userCache = response
|
||||||
@ -113,7 +126,7 @@ class Jellyfin:
|
|||||||
elif id == "all":
|
elif id == "all":
|
||||||
match = False
|
match = False
|
||||||
for user in response:
|
for user in response:
|
||||||
if user['Name'] == username:
|
if user["Name"] == username:
|
||||||
match = True
|
match = True
|
||||||
return user
|
return user
|
||||||
if not match:
|
if not match:
|
||||||
@ -121,7 +134,7 @@ class Jellyfin:
|
|||||||
else:
|
else:
|
||||||
match = False
|
match = False
|
||||||
for user in response:
|
for user in response:
|
||||||
if user['Id'] == id:
|
if user["Id"] == id:
|
||||||
match = True
|
match = True
|
||||||
return user
|
return user
|
||||||
if not match:
|
if not match:
|
||||||
@ -136,24 +149,26 @@ class Jellyfin:
|
|||||||
"""
|
"""
|
||||||
self.username = username
|
self.username = username
|
||||||
self.password = password
|
self.password = password
|
||||||
response = requests.post(self.server+"/emby/Users/AuthenticateByName",
|
response = requests.post(
|
||||||
headers=self.header,
|
self.server + "/emby/Users/AuthenticateByName",
|
||||||
params={'Username': self.username,
|
headers=self.header,
|
||||||
'Pw': self.password})
|
params={"Username": self.username, "Pw": self.password},
|
||||||
|
)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
json = response.json()
|
json = response.json()
|
||||||
self.userId = json['User']['Id']
|
self.userId = json["User"]["Id"]
|
||||||
self.accessToken = json['AccessToken']
|
self.accessToken = json["AccessToken"]
|
||||||
self.auth = "MediaBrowser "
|
self.auth = "MediaBrowser "
|
||||||
self.auth += f"Client={self.client}, "
|
self.auth += f"Client={self.client}, "
|
||||||
self.auth += f"Device={self.device}, "
|
self.auth += f"Device={self.device}, "
|
||||||
self.auth += f"DeviceId={self.deviceId}, "
|
self.auth += f"DeviceId={self.deviceId}, "
|
||||||
self.auth += f"Version={self.version}"
|
self.auth += f"Version={self.version}"
|
||||||
self.auth += f", Token={self.accessToken}"
|
self.auth += f", Token={self.accessToken}"
|
||||||
self.header['X-Emby-Authorization'] = self.auth
|
self.header["X-Emby-Authorization"] = self.auth
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
raise self.AuthenticationError
|
raise self.AuthenticationError
|
||||||
|
|
||||||
def setPolicy(self, userId: str, policy: dict):
|
def setPolicy(self, userId: str, policy: dict):
|
||||||
"""
|
"""
|
||||||
Sets a user's policy (Admin rights, Library Access, etc.) by user ID.
|
Sets a user's policy (Admin rights, Library Access, etc.) by user ID.
|
||||||
@ -161,55 +176,64 @@ class Jellyfin:
|
|||||||
:param userId: ID of the user to modify.
|
:param userId: ID of the user to modify.
|
||||||
:param policy: User policy in dictionary form.
|
:param policy: User policy in dictionary form.
|
||||||
"""
|
"""
|
||||||
return requests.post(self.server+"/Users/"+userId+"/Policy",
|
return requests.post(
|
||||||
headers=self.header,
|
self.server + "/Users/" + userId + "/Policy",
|
||||||
params=policy)
|
headers=self.header,
|
||||||
|
params=policy,
|
||||||
|
)
|
||||||
|
|
||||||
def newUser(self, username: str, password: str):
|
def newUser(self, username: str, password: str):
|
||||||
for user in self.getUsers():
|
for user in self.getUsers():
|
||||||
if user['Name'] == username:
|
if user["Name"] == username:
|
||||||
raise self.UserExistsError
|
raise self.UserExistsError
|
||||||
response = requests.post(self.server+"/emby/Users/New",
|
response = requests.post(
|
||||||
headers=self.header,
|
self.server + "/emby/Users/New",
|
||||||
params={'Name': username,
|
headers=self.header,
|
||||||
'Password': password})
|
params={"Name": username, "Password": password},
|
||||||
|
)
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
if hasattr(self, "username") and hasattr(self, "password"):
|
||||||
self.authenticate(self.username, self.password)
|
self.authenticate(self.username, self.password)
|
||||||
return self.newUser(username, password)
|
return self.newUser(username, password)
|
||||||
else:
|
else:
|
||||||
raise self.AuthenticationRequiredError
|
raise self.AuthenticationRequiredError
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def getViewOrder(self, userId: str, public: bool = True):
|
def getViewOrder(self, userId: str, public: bool = True):
|
||||||
if not public:
|
if not public:
|
||||||
param = '?IncludeHidden=true'
|
param = "?IncludeHidden=true"
|
||||||
else:
|
else:
|
||||||
param = ''
|
param = ""
|
||||||
views = requests.get(self.server+"/Users/"+userId+"/Views"+param,
|
views = requests.get(
|
||||||
headers=self.header).json()['Items']
|
self.server + "/Users/" + userId + "/Views" + param, headers=self.header
|
||||||
|
).json()["Items"]
|
||||||
orderedViews = []
|
orderedViews = []
|
||||||
for library in views:
|
for library in views:
|
||||||
orderedViews.append(library['Id'])
|
orderedViews.append(library["Id"])
|
||||||
return orderedViews
|
return orderedViews
|
||||||
|
|
||||||
def setConfiguration(self, userId: str, configuration: dict):
|
def setConfiguration(self, userId: str, configuration: dict):
|
||||||
"""
|
"""
|
||||||
Sets a user's configuration (Settings the user can change themselves).
|
Sets a user's configuration (Settings the user can change themselves).
|
||||||
:param userId: ID of the user to modify.
|
:param userId: ID of the user to modify.
|
||||||
:param configuration: Configuration to write in dictionary form.
|
:param configuration: Configuration to write in dictionary form.
|
||||||
"""
|
"""
|
||||||
resp = requests.post(self.server+"/Users/"+userId+"/Configuration",
|
resp = requests.post(
|
||||||
headers=self.header,
|
self.server + "/Users/" + userId + "/Configuration",
|
||||||
params=configuration)
|
headers=self.header,
|
||||||
if (resp.status_code == 200 or
|
params=configuration,
|
||||||
resp.status_code == 204):
|
)
|
||||||
|
if resp.status_code == 200 or resp.status_code == 204:
|
||||||
return True
|
return True
|
||||||
elif resp.status_code == 401:
|
elif resp.status_code == 401:
|
||||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
if hasattr(self, "username") and hasattr(self, "password"):
|
||||||
self.authenticate(self.username, self.password)
|
self.authenticate(self.username, self.password)
|
||||||
return self.setConfiguration(userId, configuration)
|
return self.setConfiguration(userId, configuration)
|
||||||
else:
|
else:
|
||||||
raise self.AuthenticationRequiredError
|
raise self.AuthenticationRequiredError
|
||||||
else:
|
else:
|
||||||
raise self.UnknownError
|
raise self.UnknownError
|
||||||
|
|
||||||
def getConfiguration(self, username: str = "all", userId: str = "all"):
|
def getConfiguration(self, username: str = "all", userId: str = "all"):
|
||||||
"""
|
"""
|
||||||
Gets a user's Configuration. This can also be found in getUsers if
|
Gets a user's Configuration. This can also be found in getUsers if
|
||||||
@ -217,27 +241,36 @@ class Jellyfin:
|
|||||||
:param username: The user's username.
|
:param username: The user's username.
|
||||||
:param userId: The user's ID.
|
:param userId: The user's ID.
|
||||||
"""
|
"""
|
||||||
return self.getUsers(username=username,
|
return self.getUsers(username=username, userId=userId, public=False)[
|
||||||
userId=userId,
|
"Configuration"
|
||||||
public=False)['Configuration']
|
]
|
||||||
|
|
||||||
def getDisplayPreferences(self, userId: str):
|
def getDisplayPreferences(self, userId: str):
|
||||||
"""
|
"""
|
||||||
Gets a user's Display Preferences (Home layout).
|
Gets a user's Display Preferences (Home layout).
|
||||||
:param userId: The user's ID.
|
:param userId: The user's ID.
|
||||||
"""
|
"""
|
||||||
resp = requests.get((self.server+"/DisplayPreferences/usersettings" +
|
resp = requests.get(
|
||||||
"?userId="+userId+"&client=emby"),
|
(
|
||||||
headers=self.header)
|
self.server
|
||||||
|
+ "/DisplayPreferences/usersettings"
|
||||||
|
+ "?userId="
|
||||||
|
+ userId
|
||||||
|
+ "&client=emby"
|
||||||
|
),
|
||||||
|
headers=self.header,
|
||||||
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
elif resp.status_code == 401:
|
elif resp.status_code == 401:
|
||||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
if hasattr(self, "username") and hasattr(self, "password"):
|
||||||
self.authenticate(self.username, self.password)
|
self.authenticate(self.username, self.password)
|
||||||
return self.getDisplayPreferences(userId)
|
return self.getDisplayPreferences(userId)
|
||||||
else:
|
else:
|
||||||
raise self.AuthenticationRequiredError
|
raise self.AuthenticationRequiredError
|
||||||
else:
|
else:
|
||||||
raise self.UnknownError
|
raise self.UnknownError
|
||||||
|
|
||||||
def setDisplayPreferences(self, userId: str, preferences: dict):
|
def setDisplayPreferences(self, userId: str, preferences: dict):
|
||||||
"""
|
"""
|
||||||
Sets a user's Display Preferences (Home layout).
|
Sets a user's Display Preferences (Home layout).
|
||||||
@ -245,16 +278,22 @@ class Jellyfin:
|
|||||||
:param preferences: The preferences to set.
|
:param preferences: The preferences to set.
|
||||||
"""
|
"""
|
||||||
tempheader = self.header
|
tempheader = self.header
|
||||||
tempheader['Content-type'] = 'application/json'
|
tempheader["Content-type"] = "application/json"
|
||||||
resp = requests.post((self.server+"/DisplayPreferences/usersettings" +
|
resp = requests.post(
|
||||||
"?userId="+userId+"&client=emby"),
|
(
|
||||||
headers=tempheader,
|
self.server
|
||||||
json=preferences)
|
+ "/DisplayPreferences/usersettings"
|
||||||
if (resp.status_code == 200 or
|
+ "?userId="
|
||||||
resp.status_code == 204):
|
+ userId
|
||||||
|
+ "&client=emby"
|
||||||
|
),
|
||||||
|
headers=tempheader,
|
||||||
|
json=preferences,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200 or resp.status_code == 204:
|
||||||
return True
|
return True
|
||||||
elif resp.status_code == 401:
|
elif resp.status_code == 401:
|
||||||
if hasattr(self, 'username') and hasattr(self, 'password'):
|
if hasattr(self, "username") and hasattr(self, "password"):
|
||||||
self.authenticate(self.username, self.password)
|
self.authenticate(self.username, self.password)
|
||||||
return self.setDisplayPreferences(userId, preferences)
|
return self.setDisplayPreferences(userId, preferences)
|
||||||
else:
|
else:
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# from flask import g
|
|
||||||
|
|
||||||
from flask_httpauth import HTTPBasicAuth
|
from flask_httpauth import HTTPBasicAuth
|
||||||
from itsdangerous import (TimedJSONWebSignatureSerializer
|
from itsdangerous import (
|
||||||
as Serializer, BadSignature, SignatureExpired)
|
TimedJSONWebSignatureSerializer as Serializer,
|
||||||
|
BadSignature,
|
||||||
|
SignatureExpired,
|
||||||
|
)
|
||||||
from passlib.apps import custom_app_context as pwd_context
|
from passlib.apps import custom_app_context as pwd_context
|
||||||
import uuid
|
import uuid
|
||||||
from jellyfin_accounts import config, app, g
|
from jellyfin_accounts import config, app, g
|
||||||
@ -11,13 +13,16 @@ from jellyfin_accounts import auth_log as log
|
|||||||
from jellyfin_accounts.jf_api import Jellyfin
|
from jellyfin_accounts.jf_api import Jellyfin
|
||||||
from jellyfin_accounts.web_api import jf
|
from jellyfin_accounts.web_api import jf
|
||||||
|
|
||||||
auth_jf = Jellyfin(config['jellyfin']['server'],
|
auth_jf = Jellyfin(
|
||||||
config['jellyfin']['client'],
|
config["jellyfin"]["server"],
|
||||||
config['jellyfin']['version'],
|
config["jellyfin"]["client"],
|
||||||
config['jellyfin']['device'],
|
config["jellyfin"]["version"],
|
||||||
config['jellyfin']['device_id'] + '_authClient')
|
config["jellyfin"]["device"],
|
||||||
|
config["jellyfin"]["device_id"] + "_authClient",
|
||||||
|
)
|
||||||
|
|
||||||
class Account():
|
|
||||||
|
class Account:
|
||||||
def __init__(self, username=None, password=None):
|
def __init__(self, username=None, password=None):
|
||||||
self.username = username
|
self.username = username
|
||||||
if password is not None:
|
if password is not None:
|
||||||
@ -25,10 +30,12 @@ class Account():
|
|||||||
self.id = str(uuid.uuid4())
|
self.id = str(uuid.uuid4())
|
||||||
self.jf = False
|
self.jf = False
|
||||||
elif username is not None:
|
elif username is not None:
|
||||||
jf.authenticate(config['jellyfin']['username'],
|
jf.authenticate(
|
||||||
config['jellyfin']['password'])
|
config["jellyfin"]["username"], config["jellyfin"]["password"]
|
||||||
self.id = jf.getUsers(self.username, public=False)['Id']
|
)
|
||||||
|
self.id = jf.getUsers(self.username, public=False)["Id"]
|
||||||
self.jf = True
|
self.jf = True
|
||||||
|
|
||||||
def verify_password(self, password):
|
def verify_password(self, password):
|
||||||
if not self.jf:
|
if not self.jf:
|
||||||
return pwd_context.verify(password, self.password_hash)
|
return pwd_context.verify(password, self.password_hash)
|
||||||
@ -37,59 +44,60 @@ class Account():
|
|||||||
return auth_jf.authenticate(self.username, password)
|
return auth_jf.authenticate(self.username, password)
|
||||||
except Jellyfin.AuthenticationError:
|
except Jellyfin.AuthenticationError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def generate_token(self, expiration=1200):
|
def generate_token(self, expiration=1200):
|
||||||
s = Serializer(app.config['SECRET_KEY'], expires_in=expiration)
|
s = Serializer(app.config["SECRET_KEY"], expires_in=expiration)
|
||||||
log.debug(self.id)
|
log.debug(self.id)
|
||||||
return s.dumps({ 'id': self.id })
|
return s.dumps({"id": self.id})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify_token(token, accounts):
|
def verify_token(token, accounts):
|
||||||
log.debug(f'verifying token {token}')
|
log.debug(f"verifying token {token}")
|
||||||
s = Serializer(app.config['SECRET_KEY'])
|
s = Serializer(app.config["SECRET_KEY"])
|
||||||
try:
|
try:
|
||||||
data = s.loads(token)
|
data = s.loads(token)
|
||||||
except SignatureExpired:
|
except SignatureExpired:
|
||||||
return None
|
return None
|
||||||
except BadSignature:
|
except BadSignature:
|
||||||
return None
|
return None
|
||||||
if config.getboolean('ui', 'jellyfin_login'):
|
if config.getboolean("ui", "jellyfin_login"):
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
if data['id'] == accounts[account].id:
|
if data["id"] == accounts[account].id:
|
||||||
return account
|
return account
|
||||||
else:
|
else:
|
||||||
return accounts['adminAccount']
|
return accounts["adminAccount"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
auth = HTTPBasicAuth()
|
auth = HTTPBasicAuth()
|
||||||
|
|
||||||
accounts = {}
|
accounts = {}
|
||||||
|
|
||||||
if config.getboolean('ui', 'jellyfin_login'):
|
if config.getboolean("ui", "jellyfin_login"):
|
||||||
log.debug('Using jellyfin for admin authentication')
|
log.debug("Using jellyfin for admin authentication")
|
||||||
else:
|
else:
|
||||||
log.debug('Using configured login details for admin authentication')
|
log.debug("Using configured login details for admin authentication")
|
||||||
accounts['adminAccount'] = Account(config['ui']['username'],
|
accounts["adminAccount"] = Account(
|
||||||
config['ui']['password'])
|
config["ui"]["username"], config["ui"]["password"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@auth.verify_password
|
@auth.verify_password
|
||||||
def verify_password(username, password):
|
def verify_password(username, password):
|
||||||
user = None
|
user = None
|
||||||
verified = False
|
verified = False
|
||||||
log.debug('Verifying auth')
|
log.debug("Verifying auth")
|
||||||
if config.getboolean('ui', 'jellyfin_login'):
|
if config.getboolean("ui", "jellyfin_login"):
|
||||||
try:
|
try:
|
||||||
jf_user = jf.getUsers(username, public=False)
|
jf_user = jf.getUsers(username, public=False)
|
||||||
id = jf_user['Id']
|
id = jf_user["Id"]
|
||||||
user = accounts[id]
|
user = accounts[id]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
if config.getboolean('ui', 'admin_only'):
|
if config.getboolean("ui", "admin_only"):
|
||||||
if jf_user['Policy']['IsAdministrator']:
|
if jf_user["Policy"]["IsAdministrator"]:
|
||||||
user = Account(username)
|
user = Account(username)
|
||||||
accounts[id] = user
|
accounts[id] = user
|
||||||
else:
|
else:
|
||||||
log.debug(f'User {username} not admin.')
|
log.debug(f"User {username} not admin.")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
user = Account(username)
|
user = Account(username)
|
||||||
@ -99,11 +107,11 @@ def verify_password(username, password):
|
|||||||
if user:
|
if user:
|
||||||
verified = True
|
verified = True
|
||||||
if not user:
|
if not user:
|
||||||
log.debug(f'User {username} not found on Jellyfin')
|
log.debug(f"User {username} not found on Jellyfin")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
user = accounts['adminAccount']
|
user = accounts["adminAccount"]
|
||||||
verified = Account().verify_token(username, accounts)
|
verified = Account().verify_token(username, accounts)
|
||||||
if not verified:
|
if not verified:
|
||||||
if username == user.username and user.verify_password(password):
|
if username == user.username and user.verify_password(password):
|
||||||
g.user = user
|
g.user = user
|
||||||
@ -115,6 +123,3 @@ def verify_password(username, password):
|
|||||||
g.user = user
|
g.user = user
|
||||||
log.debug("HTTPAuth Allowed")
|
log.debug("HTTPAuth Allowed")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,7 +8,6 @@ from jellyfin_accounts import config, data_store
|
|||||||
from jellyfin_accounts import email_log as log
|
from jellyfin_accounts import email_log as log
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Watcher:
|
class Watcher:
|
||||||
def __init__(self, dir):
|
def __init__(self, dir):
|
||||||
self.observer = Observer()
|
self.observer = Observer()
|
||||||
@ -20,13 +19,13 @@ class Watcher:
|
|||||||
try:
|
try:
|
||||||
self.observer.start()
|
self.observer.start()
|
||||||
except NotADirectoryError:
|
except NotADirectoryError:
|
||||||
log.error(f'Directory {self.dir} does not exist')
|
log.error(f"Directory {self.dir} does not exist")
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
except:
|
except:
|
||||||
self.observer.stop()
|
self.observer.stop()
|
||||||
log.info('Watchdog stopped')
|
log.info("Watchdog stopped")
|
||||||
|
|
||||||
|
|
||||||
class Handler(FileSystemEventHandler):
|
class Handler(FileSystemEventHandler):
|
||||||
@ -34,33 +33,35 @@ class Handler(FileSystemEventHandler):
|
|||||||
def on_any_event(event):
|
def on_any_event(event):
|
||||||
if event.is_directory:
|
if event.is_directory:
|
||||||
return None
|
return None
|
||||||
elif (event.event_type == 'modified' and
|
elif event.event_type == "modified" and "passwordreset" in event.src_path:
|
||||||
'passwordreset' in event.src_path):
|
log.debug(f"Password reset file: {event.src_path}")
|
||||||
log.debug(f'Password reset file: {event.src_path}')
|
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
with open(event.src_path, 'r') as f:
|
with open(event.src_path, "r") as f:
|
||||||
reset = json.load(f)
|
reset = json.load(f)
|
||||||
log.info(f'New password reset for {reset["UserName"]}')
|
log.info(f'New password reset for {reset["UserName"]}')
|
||||||
try:
|
try:
|
||||||
id = jf.getUsers(reset['UserName'], public=False)['Id']
|
id = jf.getUsers(reset["UserName"], public=False)["Id"]
|
||||||
address = data_store.emails[id]
|
address = data_store.emails[id]
|
||||||
if address != '':
|
if address != "":
|
||||||
method = config['email']['method']
|
method = config["email"]["method"]
|
||||||
if method == 'mailgun':
|
if method == "mailgun":
|
||||||
email = Mailgun(address)
|
email = Mailgun(address)
|
||||||
elif method == 'smtp':
|
elif method == "smtp":
|
||||||
email = Smtp(address)
|
email = Smtp(address)
|
||||||
if email.construct_reset(reset):
|
if email.construct_reset(reset):
|
||||||
email.send()
|
email.send()
|
||||||
else:
|
else:
|
||||||
raise IndexError
|
raise IndexError
|
||||||
except (FileNotFoundError,
|
except (
|
||||||
json.decoder.JSONDecodeError,
|
FileNotFoundError,
|
||||||
IndexError) as e:
|
json.decoder.JSONDecodeError,
|
||||||
err = f'{address}: Failed: ' + type(e).__name__
|
IndexError,
|
||||||
|
) as e:
|
||||||
|
err = f"{address}: Failed: " + type(e).__name__
|
||||||
log.error(err)
|
log.error(err)
|
||||||
|
|
||||||
|
|
||||||
def start():
|
def start():
|
||||||
log.info(f'Monitoring {config["password_resets"]["watch_directory"]}')
|
log.info(f'Monitoring {config["password_resets"]["watch_directory"]}')
|
||||||
w = Watcher(config['password_resets']['watch_directory'])
|
w = Watcher(config["password_resets"]["watch_directory"])
|
||||||
w.run()
|
w.run()
|
||||||
|
@ -7,34 +7,36 @@ from jellyfin_accounts.web_api import resp
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
if first_run:
|
if first_run:
|
||||||
|
|
||||||
def tempJF(server):
|
def tempJF(server):
|
||||||
return Jellyfin(server,
|
return Jellyfin(
|
||||||
config['jellyfin']['client'],
|
server,
|
||||||
config['jellyfin']['version'],
|
config["jellyfin"]["client"],
|
||||||
config['jellyfin']['device'] + '_temp',
|
config["jellyfin"]["version"],
|
||||||
config['jellyfin']['device_id'] + '_temp')
|
config["jellyfin"]["device"] + "_temp",
|
||||||
|
config["jellyfin"]["device_id"] + "_temp",
|
||||||
|
)
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def page_not_found(e):
|
def page_not_found(e):
|
||||||
return render_template('404.html'), 404
|
return render_template("404.html"), 404
|
||||||
|
|
||||||
@app.route('/', methods=['GET', 'POST'])
|
@app.route("/", methods=["GET", "POST"])
|
||||||
def setup():
|
def setup():
|
||||||
return render_template('setup.html')
|
return render_template("setup.html")
|
||||||
|
|
||||||
@app.route('/<path:path>')
|
@app.route("/<path:path>")
|
||||||
def static_proxy(path):
|
def static_proxy(path):
|
||||||
if 'html' not in path:
|
if "html" not in path:
|
||||||
return app.send_static_file(path)
|
return app.send_static_file(path)
|
||||||
else:
|
else:
|
||||||
return render_template('404.html'), 404
|
return render_template("404.html"), 404
|
||||||
|
|
||||||
@app.route('/modifyConfig', methods=['POST'])
|
@app.route("/modifyConfig", methods=["POST"])
|
||||||
def modifyConfig():
|
def modifyConfig():
|
||||||
log.info('Config modification requested')
|
log.info("Config modification requested")
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
temp_config = RawConfigParser(comment_prefixes='/',
|
temp_config = RawConfigParser(comment_prefixes="/", allow_no_value=True)
|
||||||
allow_no_value=True)
|
|
||||||
temp_config.read(config_path)
|
temp_config.read(config_path)
|
||||||
for section in data:
|
for section in data:
|
||||||
if section in temp_config:
|
if section in temp_config:
|
||||||
@ -42,24 +44,23 @@ if first_run:
|
|||||||
if item in temp_config[section]:
|
if item in temp_config[section]:
|
||||||
temp_config[section][item] = data[section][item]
|
temp_config[section][item] = data[section][item]
|
||||||
data[section][item] = True
|
data[section][item] = True
|
||||||
log.debug(f'{section}/{item} modified')
|
log.debug(f"{section}/{item} modified")
|
||||||
else:
|
else:
|
||||||
data[section][item] = False
|
data[section][item] = False
|
||||||
log.debug(f'{section}/{item} does not exist in config')
|
log.debug(f"{section}/{item} does not exist in config")
|
||||||
with open(config_path, 'w') as config_file:
|
with open(config_path, "w") as config_file:
|
||||||
temp_config.write(config_file)
|
temp_config.write(config_file)
|
||||||
log.debug('Config written')
|
log.debug("Config written")
|
||||||
# ugly exit, sorry
|
# ugly exit, sorry
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
return resp()
|
return resp()
|
||||||
|
|
||||||
@app.route('/testJF', methods=['GET', 'POST'])
|
@app.route("/testJF", methods=["GET", "POST"])
|
||||||
def testJF():
|
def testJF():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
tempjf = tempJF(data['jfHost'])
|
tempjf = tempJF(data["jfHost"])
|
||||||
try:
|
try:
|
||||||
tempjf.authenticate(data['jfUser'],
|
tempjf.authenticate(data["jfUser"], data["jfPassword"])
|
||||||
data['jfPassword'])
|
|
||||||
tempjf.getUsers(public=False)
|
tempjf.getUsers(public=False)
|
||||||
return resp()
|
return resp()
|
||||||
except:
|
except:
|
||||||
|
@ -3,33 +3,39 @@ specials = ['[', '@', '_', '!', '#', '$', '%', '^', '&', '*', '(', ')',
|
|||||||
|
|
||||||
class PasswordValidator:
|
class PasswordValidator:
|
||||||
def __init__(self, min_length, upper, lower, number, special):
|
def __init__(self, min_length, upper, lower, number, special):
|
||||||
self.criteria = {'characters': int(min_length),
|
self.criteria = {
|
||||||
'uppercase characters': int(upper),
|
"characters": int(min_length),
|
||||||
'lowercase characters': int(lower),
|
"uppercase characters": int(upper),
|
||||||
'numbers': int(number),
|
"lowercase characters": int(lower),
|
||||||
'special characters': int(special)}
|
"numbers": int(number),
|
||||||
|
"special characters": int(special),
|
||||||
|
}
|
||||||
|
|
||||||
def validate(self, password):
|
def validate(self, password):
|
||||||
count = {'characters': 0,
|
count = {
|
||||||
'uppercase characters': 0,
|
"characters": 0,
|
||||||
'lowercase characters': 0,
|
"uppercase characters": 0,
|
||||||
'numbers': 0,
|
"lowercase characters": 0,
|
||||||
'special characters': 0}
|
"numbers": 0,
|
||||||
|
"special characters": 0,
|
||||||
|
}
|
||||||
for c in password:
|
for c in password:
|
||||||
count['characters'] += 1
|
count["characters"] += 1
|
||||||
if c.isupper():
|
if c.isupper():
|
||||||
count['uppercase characters'] += 1
|
count["uppercase characters"] += 1
|
||||||
elif c.islower():
|
elif c.islower():
|
||||||
count['lowercase characters'] += 1
|
count["lowercase characters"] += 1
|
||||||
elif c.isnumeric():
|
elif c.isnumeric():
|
||||||
count['numbers'] += 1
|
count["numbers"] += 1
|
||||||
elif c in specials:
|
elif c in specials:
|
||||||
count['special characters'] += 1
|
count["special characters"] += 1
|
||||||
for criterion in count:
|
for criterion in count:
|
||||||
if count[criterion] < self.criteria[criterion]:
|
if count[criterion] < self.criteria[criterion]:
|
||||||
count[criterion] = False
|
count[criterion] = False
|
||||||
else:
|
else:
|
||||||
count[criterion] = True
|
count[criterion] = True
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def getCriteria(self):
|
def getCriteria(self):
|
||||||
lines = {}
|
lines = {}
|
||||||
for criterion in self.criteria:
|
for criterion in self.criteria:
|
||||||
|
@ -8,63 +8,76 @@ from jellyfin_accounts.web_api import checkInvite, validator
|
|||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def page_not_found(e):
|
def page_not_found(e):
|
||||||
return render_template('404.html',
|
return (
|
||||||
css_href=css['href'],
|
render_template(
|
||||||
css_integrity=css['integrity'],
|
"404.html",
|
||||||
css_crossorigin=css['crossorigin'],
|
css_href=css["href"],
|
||||||
contactMessage=config['ui']['contact_message']), 404
|
css_integrity=css["integrity"],
|
||||||
|
css_crossorigin=css["crossorigin"],
|
||||||
|
contactMessage=config["ui"]["contact_message"],
|
||||||
|
),
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/', methods=['GET', 'POST'])
|
@app.route("/", methods=["GET", "POST"])
|
||||||
def admin():
|
def admin():
|
||||||
# return app.send_static_file('admin.html')
|
# return app.send_static_file('admin.html')
|
||||||
return render_template('admin.html',
|
return render_template(
|
||||||
css_href=css['href'],
|
"admin.html",
|
||||||
css_integrity=css['integrity'],
|
css_href=css["href"],
|
||||||
css_crossorigin=css['crossorigin'],
|
css_integrity=css["integrity"],
|
||||||
contactMessage='',
|
css_crossorigin=css["crossorigin"],
|
||||||
email_enabled=config.getboolean(
|
contactMessage="",
|
||||||
'invite_emails', 'enabled'))
|
email_enabled=config.getboolean("invite_emails", "enabled"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/<path:path>')
|
@app.route("/<path:path>")
|
||||||
def static_proxy(path):
|
def static_proxy(path):
|
||||||
if 'html' not in path:
|
if "html" not in path:
|
||||||
return app.send_static_file(path)
|
return app.send_static_file(path)
|
||||||
return render_template('404.html',
|
return (
|
||||||
css_href=css['href'],
|
render_template(
|
||||||
css_integrity=css['integrity'],
|
"404.html",
|
||||||
css_crossorigin=css['crossorigin'],
|
css_href=css["href"],
|
||||||
contactMessage=config['ui']['contact_message']), 404
|
css_integrity=css["integrity"],
|
||||||
|
css_crossorigin=css["crossorigin"],
|
||||||
|
contactMessage=config["ui"]["contact_message"],
|
||||||
|
),
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/invite/<path:path>')
|
@app.route("/invite/<path:path>")
|
||||||
def inviteProxy(path):
|
def inviteProxy(path):
|
||||||
if checkInvite(path):
|
if checkInvite(path):
|
||||||
log.info(f'Invite {path} used to request form')
|
log.info(f"Invite {path} used to request form")
|
||||||
try:
|
try:
|
||||||
email = data_store.invites[path]['email']
|
email = data_store.invites[path]["email"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
email = ''
|
email = ""
|
||||||
return render_template('form.html',
|
return render_template(
|
||||||
css_href=css['href'],
|
"form.html",
|
||||||
css_integrity=css['integrity'],
|
css_href=css["href"],
|
||||||
css_crossorigin=css['crossorigin'],
|
css_integrity=css["integrity"],
|
||||||
contactMessage=config['ui']['contact_message'],
|
css_crossorigin=css["crossorigin"],
|
||||||
helpMessage=config['ui']['help_message'],
|
contactMessage=config["ui"]["contact_message"],
|
||||||
successMessage=config['ui']['success_message'],
|
helpMessage=config["ui"]["help_message"],
|
||||||
jfLink=config['jellyfin']['public_server'],
|
successMessage=config["ui"]["success_message"],
|
||||||
validate=config.getboolean(
|
jfLink=config["jellyfin"]["public_server"],
|
||||||
'password_validation',
|
validate=config.getboolean("password_validation", "enabled"),
|
||||||
'enabled'),
|
requirements=validator.getCriteria(),
|
||||||
requirements=validator.getCriteria(),
|
email=email,
|
||||||
email=email)
|
)
|
||||||
elif 'admin.html' not in path and 'admin.html' not in path:
|
elif "admin.html" not in path and "admin.html" not in path:
|
||||||
return app.send_static_file(path)
|
return app.send_static_file(path)
|
||||||
else:
|
else:
|
||||||
log.debug('Attempted use of invalid invite')
|
log.debug("Attempted use of invalid invite")
|
||||||
return render_template('invalidCode.html',
|
return render_template(
|
||||||
css_href=css['href'],
|
"invalidCode.html",
|
||||||
css_integrity=css['integrity'],
|
css_href=css["href"],
|
||||||
css_crossorigin=css['crossorigin'],
|
css_integrity=css["integrity"],
|
||||||
contactMessage=config['ui']['contact_message'])
|
css_crossorigin=css["crossorigin"],
|
||||||
|
contactMessage=config["ui"]["contact_message"],
|
||||||
|
)
|
||||||
|
@ -8,27 +8,30 @@ from jellyfin_accounts import config, config_path, app, g, data_store
|
|||||||
from jellyfin_accounts import web_log as log
|
from jellyfin_accounts import web_log as log
|
||||||
from jellyfin_accounts.validate_password import PasswordValidator
|
from jellyfin_accounts.validate_password import PasswordValidator
|
||||||
|
|
||||||
|
|
||||||
def resp(success=True, code=500):
|
def resp(success=True, code=500):
|
||||||
if success:
|
if success:
|
||||||
r = jsonify({'success': True})
|
r = jsonify({"success": True})
|
||||||
if code == 500:
|
if code == 500:
|
||||||
r.status_code = 200
|
r.status_code = 200
|
||||||
else:
|
else:
|
||||||
r.status_code = code
|
r.status_code = code
|
||||||
else:
|
else:
|
||||||
r = jsonify({'success': False})
|
r = jsonify({"success": False})
|
||||||
r.status_code = code
|
r.status_code = code
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
def checkInvite(code, delete=False):
|
def checkInvite(code, delete=False):
|
||||||
current_time = datetime.datetime.now()
|
current_time = datetime.datetime.now()
|
||||||
invites = dict(data_store.invites)
|
invites = dict(data_store.invites)
|
||||||
match = False
|
match = False
|
||||||
for invite in invites:
|
for invite in invites:
|
||||||
expiry = datetime.datetime.strptime(invites[invite]['valid_till'],
|
expiry = datetime.datetime.strptime(
|
||||||
'%Y-%m-%dT%H:%M:%S.%f')
|
invites[invite]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
|
||||||
|
)
|
||||||
if current_time >= expiry:
|
if current_time >= expiry:
|
||||||
log.debug(f'Housekeeping: Deleting old invite {invite}')
|
log.debug(f"Housekeeping: Deleting old invite {invite}")
|
||||||
del data_store.invites[invite]
|
del data_store.invites[invite]
|
||||||
elif invite == code:
|
elif invite == code:
|
||||||
match = True
|
match = True
|
||||||
@ -36,34 +39,37 @@ def checkInvite(code, delete=False):
|
|||||||
del data_store.invites[code]
|
del data_store.invites[code]
|
||||||
return match
|
return match
|
||||||
|
|
||||||
jf = Jellyfin(config['jellyfin']['server'],
|
|
||||||
config['jellyfin']['client'],
|
jf = Jellyfin(
|
||||||
config['jellyfin']['version'],
|
config["jellyfin"]["server"],
|
||||||
config['jellyfin']['device'],
|
config["jellyfin"]["client"],
|
||||||
config['jellyfin']['device_id'])
|
config["jellyfin"]["version"],
|
||||||
|
config["jellyfin"]["device"],
|
||||||
|
config["jellyfin"]["device_id"],
|
||||||
|
)
|
||||||
|
|
||||||
from jellyfin_accounts.login import auth
|
from jellyfin_accounts.login import auth
|
||||||
|
|
||||||
jf_address = config['jellyfin']['server']
|
jf_address = config["jellyfin"]["server"]
|
||||||
success = False
|
success = False
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
try:
|
try:
|
||||||
jf.authenticate(config['jellyfin']['username'],
|
jf.authenticate(config["jellyfin"]["username"], config["jellyfin"]["password"])
|
||||||
config['jellyfin']['password'])
|
|
||||||
success = True
|
success = True
|
||||||
log.info(f'Successfully authenticated with {jf_address}')
|
log.info(f"Successfully authenticated with {jf_address}")
|
||||||
break
|
break
|
||||||
except Jellyfin.AuthenticationError:
|
except Jellyfin.AuthenticationError:
|
||||||
log.error(f'Failed to authenticate with {jf_address}, Retrying...')
|
log.error(f"Failed to authenticate with {jf_address}, Retrying...")
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
log.error('Could not authenticate after 3 tries.')
|
log.error("Could not authenticate after 3 tries.")
|
||||||
exit()
|
exit()
|
||||||
|
|
||||||
|
|
||||||
def switchToIds():
|
def switchToIds():
|
||||||
try:
|
try:
|
||||||
with open(config['files']['emails'], 'r') as f:
|
with open(config["files"]["emails"], "r") as f:
|
||||||
emails = json.load(f)
|
emails = json.load(f)
|
||||||
except (FileNotFoundError, json.decoder.JSONDecodeError):
|
except (FileNotFoundError, json.decoder.JSONDecodeError):
|
||||||
emails = {}
|
emails = {}
|
||||||
@ -72,220 +78,227 @@ def switchToIds():
|
|||||||
match = False
|
match = False
|
||||||
for key in emails:
|
for key in emails:
|
||||||
for user in users:
|
for user in users:
|
||||||
if user['Name'] == key:
|
if user["Name"] == key:
|
||||||
match = True
|
match = True
|
||||||
new_emails[user['Id']] = emails[key]
|
new_emails[user["Id"]] = emails[key]
|
||||||
elif user['Id'] == key:
|
elif user["Id"] == key:
|
||||||
new_emails[user['Id']] = emails[key]
|
new_emails[user["Id"]] = emails[key]
|
||||||
if match:
|
if match:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
email_file = Path(config['files']['emails']).name
|
|
||||||
log.info((f'{email_file} modified to use userID instead of ' +
|
email_file = Path(config["files"]["emails"]).name
|
||||||
'usernames. These will be used in future.'))
|
log.info(
|
||||||
|
(
|
||||||
|
f"{email_file} modified to use userID instead of "
|
||||||
|
+ "usernames. These will be used in future."
|
||||||
|
)
|
||||||
|
)
|
||||||
emails = new_emails
|
emails = new_emails
|
||||||
with open(config['files']['emails'], 'w') as f:
|
with open(config["files"]["emails"], "w") as f:
|
||||||
f.write(json.dumps(emails, indent=4))
|
f.write(json.dumps(emails, indent=4))
|
||||||
|
|
||||||
|
|
||||||
# Temporary, switches emails.json over from using Usernames to User IDs.
|
# Temporary, switches emails.json over from using Usernames to User IDs.
|
||||||
switchToIds()
|
switchToIds()
|
||||||
|
|
||||||
if config.getboolean('password_validation', 'enabled'):
|
if config.getboolean("password_validation", "enabled"):
|
||||||
validator = PasswordValidator(config['password_validation']['min_length'],
|
validator = PasswordValidator(
|
||||||
config['password_validation']['upper'],
|
config["password_validation"]["min_length"],
|
||||||
config['password_validation']['lower'],
|
config["password_validation"]["upper"],
|
||||||
config['password_validation']['number'],
|
config["password_validation"]["lower"],
|
||||||
config['password_validation']['special'])
|
config["password_validation"]["number"],
|
||||||
|
config["password_validation"]["special"],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
validator = PasswordValidator(0, 0, 0, 0, 0)
|
validator = PasswordValidator(0, 0, 0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/newUser', methods=['POST'])
|
@app.route("/newUser", methods=["POST"])
|
||||||
def newUser():
|
def newUser():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
log.debug('Attempted newUser')
|
log.debug("Attempted newUser")
|
||||||
if checkInvite(data['code']):
|
if checkInvite(data["code"]):
|
||||||
validation = validator.validate(data['password'])
|
validation = validator.validate(data["password"])
|
||||||
valid = True
|
valid = True
|
||||||
for criterion in validation:
|
for criterion in validation:
|
||||||
if validation[criterion] is False:
|
if validation[criterion] is False:
|
||||||
valid = False
|
valid = False
|
||||||
if valid:
|
if valid:
|
||||||
log.debug('User password valid')
|
log.debug("User password valid")
|
||||||
try:
|
try:
|
||||||
user = jf.newUser(data['username'],
|
user = jf.newUser(data["username"], data["password"])
|
||||||
data['password'])
|
|
||||||
except Jellyfin.UserExistsError:
|
except Jellyfin.UserExistsError:
|
||||||
error = f'User already exists named {data["username"]}'
|
error = f'User already exists named {data["username"]}'
|
||||||
log.debug(error)
|
log.debug(error)
|
||||||
return jsonify({'error': error})
|
return jsonify({"error": error})
|
||||||
except:
|
except:
|
||||||
return jsonify({'error': 'Unknown error'})
|
return jsonify({"error": "Unknown error"})
|
||||||
checkInvite(data['code'], delete=True)
|
checkInvite(data["code"], delete=True)
|
||||||
if user.status_code == 200:
|
if user.status_code == 200:
|
||||||
try:
|
try:
|
||||||
policy = data_store.user_template
|
policy = data_store.user_template
|
||||||
if policy != {}:
|
if policy != {}:
|
||||||
jf.setPolicy(user.json()['Id'], policy)
|
jf.setPolicy(user.json()["Id"], policy)
|
||||||
else:
|
else:
|
||||||
log.debug('user policy was blank')
|
log.debug("user policy was blank")
|
||||||
except:
|
except:
|
||||||
log.error('Failed to set new user policy')
|
log.error("Failed to set new user policy")
|
||||||
try:
|
try:
|
||||||
configuration = data_store.user_configuration
|
configuration = data_store.user_configuration
|
||||||
displayprefs = data_store.user_displayprefs
|
displayprefs = data_store.user_displayprefs
|
||||||
if configuration != {} and displayprefs != {}:
|
if configuration != {} and displayprefs != {}:
|
||||||
if jf.setConfiguration(user.json()['Id'],
|
if jf.setConfiguration(user.json()["Id"], configuration):
|
||||||
configuration):
|
jf.setDisplayPreferences(user.json()["Id"], displayprefs)
|
||||||
jf.setDisplayPreferences(user.json()['Id'],
|
log.debug("Set homescreen layout.")
|
||||||
displayprefs)
|
|
||||||
log.debug('Set homescreen layout.')
|
|
||||||
else:
|
else:
|
||||||
log.debug('user configuration and/or ' +
|
log.debug(
|
||||||
'displayprefs were blank')
|
"user configuration and/or " + "displayprefs were blank"
|
||||||
|
)
|
||||||
except:
|
except:
|
||||||
log.error('Failed to set new user homescreen layout')
|
log.error("Failed to set new user homescreen layout")
|
||||||
if config.getboolean('password_resets', 'enabled'):
|
if config.getboolean("password_resets", "enabled"):
|
||||||
data_store.emails[user.json()['Id']] = data['email']
|
data_store.emails[user.json()["Id"]] = data["email"]
|
||||||
log.debug('Email address stored')
|
log.debug("Email address stored")
|
||||||
log.info('New user created')
|
log.info("New user created")
|
||||||
else:
|
else:
|
||||||
log.error(f'New user creation failed: {user.status_code}')
|
log.error(f"New user creation failed: {user.status_code}")
|
||||||
return resp(False)
|
return resp(False)
|
||||||
else:
|
else:
|
||||||
log.debug('User password invalid')
|
log.debug("User password invalid")
|
||||||
return jsonify(validation)
|
return jsonify(validation)
|
||||||
else:
|
else:
|
||||||
log.debug('Attempted newUser unauthorized')
|
log.debug("Attempted newUser unauthorized")
|
||||||
return resp(False, code=401)
|
return resp(False, code=401)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/generateInvite', methods=['POST'])
|
@app.route("/generateInvite", methods=["POST"])
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def generateInvite():
|
def generateInvite():
|
||||||
current_time = datetime.datetime.now()
|
current_time = datetime.datetime.now()
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
delta = datetime.timedelta(hours=int(data['hours']),
|
delta = datetime.timedelta(hours=int(data["hours"]), minutes=int(data["minutes"]))
|
||||||
minutes=int(data['minutes']))
|
|
||||||
invite_code = secrets.token_urlsafe(16)
|
invite_code = secrets.token_urlsafe(16)
|
||||||
invite = {}
|
invite = {}
|
||||||
log.debug(f'Creating new invite: {invite_code}')
|
log.debug(f"Creating new invite: {invite_code}")
|
||||||
valid_till = current_time + delta
|
valid_till = current_time + delta
|
||||||
invite['valid_till'] = valid_till.strftime('%Y-%m-%dT%H:%M:%S.%f')
|
invite["valid_till"] = valid_till.strftime("%Y-%m-%dT%H:%M:%S.%f")
|
||||||
if 'email' in data and config.getboolean('invite_emails', 'enabled'):
|
if "email" in data and config.getboolean("invite_emails", "enabled"):
|
||||||
address = data['email']
|
address = data["email"]
|
||||||
invite['email'] = address
|
invite["email"] = address
|
||||||
log.info(f'Sending invite to {address}')
|
log.info(f"Sending invite to {address}")
|
||||||
method = config['email']['method']
|
method = config["email"]["method"]
|
||||||
if method == 'mailgun':
|
if method == "mailgun":
|
||||||
from jellyfin_accounts.email import Mailgun
|
from jellyfin_accounts.email import Mailgun
|
||||||
|
|
||||||
email = Mailgun(address)
|
email = Mailgun(address)
|
||||||
elif method == 'smtp':
|
elif method == "smtp":
|
||||||
from jellyfin_accounts.email import Smtp
|
from jellyfin_accounts.email import Smtp
|
||||||
|
|
||||||
email = Smtp(address)
|
email = Smtp(address)
|
||||||
email.construct_invite({'expiry': valid_till,
|
email.construct_invite({"expiry": valid_till, "code": invite_code})
|
||||||
'code': invite_code})
|
|
||||||
response = email.send()
|
response = email.send()
|
||||||
if response is False or type(response) != bool:
|
if response is False or type(response) != bool:
|
||||||
invite['email'] = f'Failed to send to {address}'
|
invite["email"] = f"Failed to send to {address}"
|
||||||
data_store.invites[invite_code] = invite
|
data_store.invites[invite_code] = invite
|
||||||
log.info(f'New invite created: {invite_code}')
|
log.info(f"New invite created: {invite_code}")
|
||||||
return resp()
|
return resp()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/getInvites', methods=['GET'])
|
@app.route("/getInvites", methods=["GET"])
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def getInvites():
|
def getInvites():
|
||||||
log.debug('Invites requested')
|
log.debug("Invites requested")
|
||||||
current_time = datetime.datetime.now()
|
current_time = datetime.datetime.now()
|
||||||
invites = dict(data_store.invites)
|
invites = dict(data_store.invites)
|
||||||
for code in invites:
|
for code in invites:
|
||||||
checkInvite(code)
|
checkInvite(code)
|
||||||
invites = dict(data_store.invites)
|
invites = dict(data_store.invites)
|
||||||
response = {'invites': []}
|
response = {"invites": []}
|
||||||
for code in invites:
|
for code in invites:
|
||||||
expiry = datetime.datetime.strptime(invites[code]['valid_till'],
|
expiry = datetime.datetime.strptime(
|
||||||
'%Y-%m-%dT%H:%M:%S.%f')
|
invites[code]["valid_till"], "%Y-%m-%dT%H:%M:%S.%f"
|
||||||
|
)
|
||||||
valid_for = expiry - current_time
|
valid_for = expiry - current_time
|
||||||
invite = {'code': code,
|
invite = {
|
||||||
'hours': valid_for.seconds//3600,
|
"code": code,
|
||||||
'minutes': (valid_for.seconds//60) % 60}
|
"hours": valid_for.seconds // 3600,
|
||||||
if 'email' in invites[code]:
|
"minutes": (valid_for.seconds // 60) % 60,
|
||||||
invite['email'] = invites[code]['email']
|
}
|
||||||
response['invites'].append(invite)
|
if "email" in invites[code]:
|
||||||
|
invite["email"] = invites[code]["email"]
|
||||||
|
response["invites"].append(invite)
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
@app.route('/deleteInvite', methods=['POST'])
|
|
||||||
|
@app.route("/deleteInvite", methods=["POST"])
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def deleteInvite():
|
def deleteInvite():
|
||||||
code = request.get_json()['code']
|
code = request.get_json()["code"]
|
||||||
invites = dict(data_store.invites)
|
invites = dict(data_store.invites)
|
||||||
if code in invites:
|
if code in invites:
|
||||||
del data_store.invites[code]
|
del data_store.invites[code]
|
||||||
log.info(f'Invite deleted: {code}')
|
log.info(f"Invite deleted: {code}")
|
||||||
return resp()
|
return resp()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/getToken')
|
@app.route("/getToken")
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def get_token():
|
def get_token():
|
||||||
token = g.user.generate_token()
|
token = g.user.generate_token()
|
||||||
return jsonify({'token': token.decode('ascii')})
|
return jsonify({"token": token.decode("ascii")})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/getUsers', methods=['GET'])
|
@app.route("/getUsers", methods=["GET"])
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def getUsers():
|
def getUsers():
|
||||||
log.debug('User and email list requested')
|
log.debug("User and email list requested")
|
||||||
response = {'users': []}
|
response = {"users": []}
|
||||||
users = jf.getUsers(public=False)
|
users = jf.getUsers(public=False)
|
||||||
emails = data_store.emails
|
emails = data_store.emails
|
||||||
for user in users:
|
for user in users:
|
||||||
entry = {'name': user['Name']}
|
entry = {"name": user["Name"]}
|
||||||
if user['Id'] in emails:
|
if user["Id"] in emails:
|
||||||
entry['email'] = emails[user['Id']]
|
entry["email"] = emails[user["Id"]]
|
||||||
response['users'].append(entry)
|
response["users"].append(entry)
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/modifyUsers', methods=['POST'])
|
@app.route("/modifyUsers", methods=["POST"])
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def modifyUsers():
|
def modifyUsers():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
log.debug('Email list modification requested')
|
log.debug("Email list modification requested")
|
||||||
for key in data:
|
for key in data:
|
||||||
uid = jf.getUsers(key, public=False)['Id']
|
uid = jf.getUsers(key, public=False)["Id"]
|
||||||
data_store.emails[uid] = data[key]
|
data_store.emails[uid] = data[key]
|
||||||
log.debug(f'Email for user "{key}" modified')
|
log.debug(f'Email for user "{key}" modified')
|
||||||
return resp()
|
return resp()
|
||||||
|
|
||||||
|
|
||||||
@app.route('/setDefaults', methods=['POST'])
|
@app.route("/setDefaults", methods=["POST"])
|
||||||
@auth.login_required
|
@auth.login_required
|
||||||
def setDefaults():
|
def setDefaults():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
username = data['username']
|
username = data["username"]
|
||||||
log.debug(f'Storing default settings from user {username}')
|
log.debug(f"Storing default settings from user {username}")
|
||||||
try:
|
try:
|
||||||
user = jf.getUsers(username=username,
|
user = jf.getUsers(username=username, public=False)
|
||||||
public=False)
|
|
||||||
except Jellyfin.UserNotFoundError:
|
except Jellyfin.UserNotFoundError:
|
||||||
log.error(f'Storing defaults failed: Couldn\'t find user {username}')
|
log.error(f"Storing defaults failed: Couldn't find user {username}")
|
||||||
return resp(False)
|
return resp(False)
|
||||||
uid = user['Id']
|
uid = user["Id"]
|
||||||
policy = user['Policy']
|
policy = user["Policy"]
|
||||||
data_store.user_template = policy
|
data_store.user_template = policy
|
||||||
if data['homescreen']:
|
if data["homescreen"]:
|
||||||
configuration = user['Configuration']
|
configuration = user["Configuration"]
|
||||||
try:
|
try:
|
||||||
displayprefs = jf.getDisplayPreferences(uid)
|
displayprefs = jf.getDisplayPreferences(uid)
|
||||||
data_store.user_configuration = configuration
|
data_store.user_configuration = configuration
|
||||||
data_store.user_displayprefs = displayprefs
|
data_store.user_displayprefs = displayprefs
|
||||||
except:
|
except:
|
||||||
log.error('Storing defaults failed: ' +
|
log.error("Storing defaults failed: " + "couldn't store homescreen layout")
|
||||||
'couldn\'t store homescreen layout')
|
|
||||||
return resp(False)
|
return resp(False)
|
||||||
return resp()
|
return resp()
|
||||||
|
|
||||||
import jellyfin_accounts.setup
|
|
||||||
|
|
||||||
|
|
||||||
|
import jellyfin_accounts.setup
|
||||||
|
246
poetry.lock
generated
246
poetry.lock
generated
@ -1,3 +1,45 @@
|
|||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||||
|
name = "appdirs"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
version = "1.4.4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "Classes Without Boilerplate"
|
||||||
|
name = "attrs"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||||
|
version = "19.3.0"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
|
||||||
|
dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
|
||||||
|
docs = ["sphinx", "zope.interface"]
|
||||||
|
tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "The uncompromising code formatter."
|
||||||
|
name = "black"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
version = "19.10b0"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
appdirs = "*"
|
||||||
|
attrs = ">=18.1.0"
|
||||||
|
click = ">=6.5"
|
||||||
|
pathspec = ">=0.6,<1"
|
||||||
|
regex = "*"
|
||||||
|
toml = ">=0.9.4"
|
||||||
|
typed-ast = ">=1.4.0"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
category = "main"
|
category = "main"
|
||||||
description = "Python package for providing Mozilla's CA Bundle."
|
description = "Python package for providing Mozilla's CA Bundle."
|
||||||
@ -33,18 +75,6 @@ optional = false
|
|||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||||
version = "7.1.2"
|
version = "7.1.2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
category = "main"
|
|
||||||
description = "Updated configparser from Python 3.8 for Python 2.6+."
|
|
||||||
name = "configparser"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
version = "5.0.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
|
|
||||||
testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-black-multipy", "pytest-cov"]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
category = "main"
|
category = "main"
|
||||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||||
@ -94,6 +124,14 @@ version = "3.3.0"
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
Flask = "*"
|
Flask = "*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "Lightweight in-process concurrent programming"
|
||||||
|
name = "greenlet"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
version = "0.4.16"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
category = "main"
|
category = "main"
|
||||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||||
@ -132,6 +170,25 @@ optional = false
|
|||||||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
|
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "MessagePack (de)serializer."
|
||||||
|
name = "msgpack"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "Transition packgage for pynvim"
|
||||||
|
name = "neovim"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
version = "0.3.1"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
pynvim = ">=0.3.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
category = "main"
|
category = "main"
|
||||||
description = "comprehensive password hashing framework supporting over 30 schemes"
|
description = "comprehensive password hashing framework supporting over 30 schemes"
|
||||||
@ -146,6 +203,14 @@ bcrypt = ["bcrypt (>=3.1.0)"]
|
|||||||
build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.0)"]
|
build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.0)"]
|
||||||
totp = ["cryptography"]
|
totp = ["cryptography"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "Utility library for gitignore style pattern matching of file paths."
|
||||||
|
name = "pathspec"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||||
|
version = "0.8.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
category = "main"
|
category = "main"
|
||||||
description = "File system general utilities"
|
description = "File system general utilities"
|
||||||
@ -162,6 +227,22 @@ optional = false
|
|||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||||
version = "2.20"
|
version = "2.20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "Python client to neovim"
|
||||||
|
name = "pynvim"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
version = "0.4.1"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
greenlet = "*"
|
||||||
|
msgpack = ">=0.5.0"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
pyuv = ["pyuv (>=1.0.0)"]
|
||||||
|
test = ["pytest (>=3.4.0)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
category = "main"
|
category = "main"
|
||||||
description = "Python wrapper module around the OpenSSL library"
|
description = "Python wrapper module around the OpenSSL library"
|
||||||
@ -197,6 +278,14 @@ optional = false
|
|||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
version = "2020.1"
|
version = "2020.1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "Alternative regular expression module, to replace re."
|
||||||
|
name = "regex"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
version = "2020.6.8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
category = "main"
|
category = "main"
|
||||||
description = "Python HTTP for Humans."
|
description = "Python HTTP for Humans."
|
||||||
@ -223,6 +312,22 @@ optional = false
|
|||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "Python Library for Tom's Obvious, Minimal Language"
|
||||||
|
name = "toml"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
version = "0.10.1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
category = "dev"
|
||||||
|
description = "a fork of Python 2 and 3 ast modules with type comment support"
|
||||||
|
name = "typed-ast"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
version = "1.4.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
category = "main"
|
category = "main"
|
||||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||||
@ -275,10 +380,22 @@ dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-
|
|||||||
watchdog = ["watchdog"]
|
watchdog = ["watchdog"]
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
content-hash = "fd63698aba27900fe4068b86c7042725a7210e647429dad462966febcf2047b9"
|
content-hash = "f07c7cafa4edc558a016b9b7742290d7f28579b4e350762d2afbdce21f71796b"
|
||||||
python-versions = "^3.6"
|
python-versions = "^3.6"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
|
appdirs = [
|
||||||
|
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
|
||||||
|
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
|
||||||
|
]
|
||||||
|
attrs = [
|
||||||
|
{file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
|
||||||
|
{file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
|
||||||
|
]
|
||||||
|
black = [
|
||||||
|
{file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
|
||||||
|
{file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
|
||||||
|
]
|
||||||
certifi = [
|
certifi = [
|
||||||
{file = "certifi-2020.4.5.2-py2.py3-none-any.whl", hash = "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc"},
|
{file = "certifi-2020.4.5.2-py2.py3-none-any.whl", hash = "sha256:9cd41137dc19af6a5e03b630eefe7d1f458d964d406342dd3edf625839b944cc"},
|
||||||
{file = "certifi-2020.4.5.2.tar.gz", hash = "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1"},
|
{file = "certifi-2020.4.5.2.tar.gz", hash = "sha256:5ad7e9a056d25ffa5082862e36f119f7f7cec6457fa07ee2f8c339814b80c9b1"},
|
||||||
@ -321,10 +438,6 @@ click = [
|
|||||||
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
|
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
|
||||||
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
|
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
|
||||||
]
|
]
|
||||||
configparser = [
|
|
||||||
{file = "configparser-5.0.0-py3-none-any.whl", hash = "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd"},
|
|
||||||
{file = "configparser-5.0.0.tar.gz", hash = "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1"},
|
|
||||||
]
|
|
||||||
cryptography = [
|
cryptography = [
|
||||||
{file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"},
|
{file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"},
|
||||||
{file = "cryptography-2.9.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b"},
|
{file = "cryptography-2.9.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b"},
|
||||||
@ -354,6 +467,25 @@ flask-httpauth = [
|
|||||||
{file = "Flask-HTTPAuth-3.3.0.tar.gz", hash = "sha256:6ef8b761332e780f9ff74d5f9056c2616f52babc1998b01d9f361a1e439e61b9"},
|
{file = "Flask-HTTPAuth-3.3.0.tar.gz", hash = "sha256:6ef8b761332e780f9ff74d5f9056c2616f52babc1998b01d9f361a1e439e61b9"},
|
||||||
{file = "Flask_HTTPAuth-3.3.0-py2.py3-none-any.whl", hash = "sha256:0149953720489407e51ec24bc2f86273597b7973d71cd51f9443bd0e2a89bd72"},
|
{file = "Flask_HTTPAuth-3.3.0-py2.py3-none-any.whl", hash = "sha256:0149953720489407e51ec24bc2f86273597b7973d71cd51f9443bd0e2a89bd72"},
|
||||||
]
|
]
|
||||||
|
greenlet = [
|
||||||
|
{file = "greenlet-0.4.16-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:80cb0380838bf4e48da6adedb0c7cd060c187bb4a75f67a5aa9ec33689b84872"},
|
||||||
|
{file = "greenlet-0.4.16-cp27-cp27m-win32.whl", hash = "sha256:df7de669cbf21de4b04a3ffc9920bc8426cab4c61365fa84d79bf97401a8bef7"},
|
||||||
|
{file = "greenlet-0.4.16-cp27-cp27m-win_amd64.whl", hash = "sha256:1429dc183b36ec972055e13250d96e174491559433eb3061691b446899b87384"},
|
||||||
|
{file = "greenlet-0.4.16-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5ea034d040e6ab1d2ae04ab05a3f37dbd719c4dee3804b13903d4cc794b1336e"},
|
||||||
|
{file = "greenlet-0.4.16-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c196a5394c56352e21cb7224739c6dd0075b69dd56f758505951d1d8d68cf8a9"},
|
||||||
|
{file = "greenlet-0.4.16-cp35-cp35m-win32.whl", hash = "sha256:1000038ba0ea9032948e2156a9c15f5686f36945e8f9906e6b8db49f358e7b52"},
|
||||||
|
{file = "greenlet-0.4.16-cp35-cp35m-win_amd64.whl", hash = "sha256:1b805231bfb7b2900a16638c3c8b45c694334c811f84463e52451e00c9412691"},
|
||||||
|
{file = "greenlet-0.4.16-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e5db19d4a7d41bbeb3dd89b49fc1bc7e6e515b51bbf32589c618655a0ebe0bf0"},
|
||||||
|
{file = "greenlet-0.4.16-cp36-cp36m-win32.whl", hash = "sha256:eac2a3f659d5f41d6bbfb6a97733bc7800ea5e906dc873732e00cebb98cec9e4"},
|
||||||
|
{file = "greenlet-0.4.16-cp36-cp36m-win_amd64.whl", hash = "sha256:7eed31f4efc8356e200568ba05ad645525f1fbd8674f1e5be61a493e715e3873"},
|
||||||
|
{file = "greenlet-0.4.16-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:682328aa576ec393c1872615bcb877cf32d800d4a2f150e1a5dc7e56644010b1"},
|
||||||
|
{file = "greenlet-0.4.16-cp37-cp37m-win32.whl", hash = "sha256:3a35e33902b2e6079949feed7a2dafa5ac6f019da97bd255842bb22de3c11bf5"},
|
||||||
|
{file = "greenlet-0.4.16-cp37-cp37m-win_amd64.whl", hash = "sha256:b0b2a984bbfc543d144d88caad6cc7ff4a71be77102014bd617bd88cfb038727"},
|
||||||
|
{file = "greenlet-0.4.16-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:d83c1d38658b0f81c282b41238092ed89d8f93c6e342224ab73fb39e16848721"},
|
||||||
|
{file = "greenlet-0.4.16-cp38-cp38-win32.whl", hash = "sha256:e695ac8c3efe124d998230b219eb51afb6ef10524a50b3c45109c4b77a8a3a92"},
|
||||||
|
{file = "greenlet-0.4.16-cp38-cp38-win_amd64.whl", hash = "sha256:133ba06bad4e5f2f8bf6a0ac434e0fd686df749a86b3478903b92ec3a9c0c90b"},
|
||||||
|
{file = "greenlet-0.4.16.tar.gz", hash = "sha256:6e06eac722676797e8fce4adb8ad3dc57a1bb3adfb0dd3fdf8306c055a38456c"},
|
||||||
|
]
|
||||||
idna = [
|
idna = [
|
||||||
{file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"},
|
{file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"},
|
||||||
{file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"},
|
{file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"},
|
||||||
@ -401,10 +533,37 @@ markupsafe = [
|
|||||||
{file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
|
{file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
|
||||||
{file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
|
{file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
|
||||||
]
|
]
|
||||||
|
msgpack = [
|
||||||
|
{file = "msgpack-1.0.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:cec8bf10981ed70998d98431cd814db0ecf3384e6b113366e7f36af71a0fca08"},
|
||||||
|
{file = "msgpack-1.0.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aa5c057eab4f40ec47ea6f5a9825846be2ff6bf34102c560bad5cad5a677c5be"},
|
||||||
|
{file = "msgpack-1.0.0-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:4233b7f86c1208190c78a525cd3828ca1623359ef48f78a6fea4b91bb995775a"},
|
||||||
|
{file = "msgpack-1.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b3758dfd3423e358bbb18a7cccd1c74228dffa7a697e5be6cb9535de625c0dbf"},
|
||||||
|
{file = "msgpack-1.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:25b3bc3190f3d9d965b818123b7752c5dfb953f0d774b454fd206c18fe384fb8"},
|
||||||
|
{file = "msgpack-1.0.0-cp36-cp36m-win32.whl", hash = "sha256:e7bbdd8e2b277b77782f3ce34734b0dfde6cbe94ddb74de8d733d603c7f9e2b1"},
|
||||||
|
{file = "msgpack-1.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5dba6d074fac9b24f29aaf1d2d032306c27f04187651511257e7831733293ec2"},
|
||||||
|
{file = "msgpack-1.0.0-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:908944e3f038bca67fcfedb7845c4a257c7749bf9818632586b53bcf06ba4b97"},
|
||||||
|
{file = "msgpack-1.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:db685187a415f51d6b937257474ca72199f393dad89534ebbdd7d7a3b000080e"},
|
||||||
|
{file = "msgpack-1.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ea41c9219c597f1d2bf6b374d951d310d58684b5de9dc4bd2976db9e1e22c140"},
|
||||||
|
{file = "msgpack-1.0.0-cp37-cp37m-win32.whl", hash = "sha256:e35b051077fc2f3ce12e7c6a34cf309680c63a842db3a0616ea6ed25ad20d272"},
|
||||||
|
{file = "msgpack-1.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:5bea44181fc8e18eed1d0cd76e355073f00ce232ff9653a0ae88cb7d9e643322"},
|
||||||
|
{file = "msgpack-1.0.0-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c901e8058dd6653307906c5f157f26ed09eb94a850dddd989621098d347926ab"},
|
||||||
|
{file = "msgpack-1.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:271b489499a43af001a2e42f42d876bb98ccaa7e20512ff37ca78c8e12e68f84"},
|
||||||
|
{file = "msgpack-1.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7a22c965588baeb07242cb561b63f309db27a07382825fc98aecaf0827c1538e"},
|
||||||
|
{file = "msgpack-1.0.0-cp38-cp38-win32.whl", hash = "sha256:002a0d813e1f7b60da599bdf969e632074f9eec1b96cbed8fb0973a63160a408"},
|
||||||
|
{file = "msgpack-1.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:39c54fdebf5fa4dda733369012c59e7d085ebdfe35b6cf648f09d16708f1be5d"},
|
||||||
|
{file = "msgpack-1.0.0.tar.gz", hash = "sha256:9534d5cc480d4aff720233411a1f765be90885750b07df772380b34c10ecb5c0"},
|
||||||
|
]
|
||||||
|
neovim = [
|
||||||
|
{file = "neovim-0.3.1.tar.gz", hash = "sha256:a6a0e7a5b4433bf4e6ddcbc5c5ff44170be7d84259d002b8e8d8fb4ee78af60f"},
|
||||||
|
]
|
||||||
passlib = [
|
passlib = [
|
||||||
{file = "passlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177"},
|
{file = "passlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:68c35c98a7968850e17f1b6892720764cc7eed0ef2b7cb3116a89a28e43fe177"},
|
||||||
{file = "passlib-1.7.2.tar.gz", hash = "sha256:8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8"},
|
{file = "passlib-1.7.2.tar.gz", hash = "sha256:8d666cef936198bc2ab47ee9b0410c94adf2ba798e5a84bf220be079ae7ab6a8"},
|
||||||
]
|
]
|
||||||
|
pathspec = [
|
||||||
|
{file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"},
|
||||||
|
{file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"},
|
||||||
|
]
|
||||||
pathtools = [
|
pathtools = [
|
||||||
{file = "pathtools-0.1.2.tar.gz", hash = "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"},
|
{file = "pathtools-0.1.2.tar.gz", hash = "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0"},
|
||||||
]
|
]
|
||||||
@ -412,6 +571,9 @@ pycparser = [
|
|||||||
{file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
|
{file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"},
|
||||||
{file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
|
{file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"},
|
||||||
]
|
]
|
||||||
|
pynvim = [
|
||||||
|
{file = "pynvim-0.4.1.tar.gz", hash = "sha256:55e918d664654cfa1c9889d3dbe7c63e9f338df5d49471663f78d54c85e84c58"},
|
||||||
|
]
|
||||||
pyopenssl = [
|
pyopenssl = [
|
||||||
{file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"},
|
{file = "pyOpenSSL-19.1.0-py2.py3-none-any.whl", hash = "sha256:621880965a720b8ece2f1b2f54ea2071966ab00e2970ad2ce11d596102063504"},
|
||||||
{file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"},
|
{file = "pyOpenSSL-19.1.0.tar.gz", hash = "sha256:9a24494b2602aaf402be5c9e30a0b82d4a5c67528fe8fb475e3f3bc00dd69507"},
|
||||||
@ -424,6 +586,29 @@ pytz = [
|
|||||||
{file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"},
|
{file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"},
|
||||||
{file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"},
|
{file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"},
|
||||||
]
|
]
|
||||||
|
regex = [
|
||||||
|
{file = "regex-2020.6.8-cp27-cp27m-win32.whl", hash = "sha256:fbff901c54c22425a5b809b914a3bfaf4b9570eee0e5ce8186ac71eb2025191c"},
|
||||||
|
{file = "regex-2020.6.8-cp27-cp27m-win_amd64.whl", hash = "sha256:112e34adf95e45158c597feea65d06a8124898bdeac975c9087fe71b572bd938"},
|
||||||
|
{file = "regex-2020.6.8-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:92d8a043a4241a710c1cf7593f5577fbb832cf6c3a00ff3fc1ff2052aff5dd89"},
|
||||||
|
{file = "regex-2020.6.8-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bae83f2a56ab30d5353b47f9b2a33e4aac4de9401fb582b55c42b132a8ac3868"},
|
||||||
|
{file = "regex-2020.6.8-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:b2ba0f78b3ef375114856cbdaa30559914d081c416b431f2437f83ce4f8b7f2f"},
|
||||||
|
{file = "regex-2020.6.8-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:95fa7726d073c87141f7bbfb04c284901f8328e2d430eeb71b8ffdd5742a5ded"},
|
||||||
|
{file = "regex-2020.6.8-cp36-cp36m-win32.whl", hash = "sha256:e3cdc9423808f7e1bb9c2e0bdb1c9dc37b0607b30d646ff6faf0d4e41ee8fee3"},
|
||||||
|
{file = "regex-2020.6.8-cp36-cp36m-win_amd64.whl", hash = "sha256:c78e66a922de1c95a208e4ec02e2e5cf0bb83a36ceececc10a72841e53fbf2bd"},
|
||||||
|
{file = "regex-2020.6.8-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:08997a37b221a3e27d68ffb601e45abfb0093d39ee770e4257bd2f5115e8cb0a"},
|
||||||
|
{file = "regex-2020.6.8-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2f6f211633ee8d3f7706953e9d3edc7ce63a1d6aad0be5dcee1ece127eea13ae"},
|
||||||
|
{file = "regex-2020.6.8-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:55b4c25cbb3b29f8d5e63aeed27b49fa0f8476b0d4e1b3171d85db891938cc3a"},
|
||||||
|
{file = "regex-2020.6.8-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:89cda1a5d3e33ec9e231ece7307afc101b5217523d55ef4dc7fb2abd6de71ba3"},
|
||||||
|
{file = "regex-2020.6.8-cp37-cp37m-win32.whl", hash = "sha256:690f858d9a94d903cf5cada62ce069b5d93b313d7d05456dbcd99420856562d9"},
|
||||||
|
{file = "regex-2020.6.8-cp37-cp37m-win_amd64.whl", hash = "sha256:1700419d8a18c26ff396b3b06ace315b5f2a6e780dad387e4c48717a12a22c29"},
|
||||||
|
{file = "regex-2020.6.8-cp38-cp38-manylinux1_i686.whl", hash = "sha256:654cb773b2792e50151f0e22be0f2b6e1c3a04c5328ff1d9d59c0398d37ef610"},
|
||||||
|
{file = "regex-2020.6.8-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:52e1b4bef02f4040b2fd547357a170fc1146e60ab310cdbdd098db86e929b387"},
|
||||||
|
{file = "regex-2020.6.8-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:cf59bbf282b627130f5ba68b7fa3abdb96372b24b66bdf72a4920e8153fc7910"},
|
||||||
|
{file = "regex-2020.6.8-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:5aaa5928b039ae440d775acea11d01e42ff26e1561c0ffcd3d805750973c6baf"},
|
||||||
|
{file = "regex-2020.6.8-cp38-cp38-win32.whl", hash = "sha256:97712e0d0af05febd8ab63d2ef0ab2d0cd9deddf4476f7aa153f76feef4b2754"},
|
||||||
|
{file = "regex-2020.6.8-cp38-cp38-win_amd64.whl", hash = "sha256:6ad8663c17db4c5ef438141f99e291c4d4edfeaacc0ce28b5bba2b0bf273d9b5"},
|
||||||
|
{file = "regex-2020.6.8.tar.gz", hash = "sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac"},
|
||||||
|
]
|
||||||
requests = [
|
requests = [
|
||||||
{file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"},
|
{file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"},
|
||||||
{file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"},
|
{file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"},
|
||||||
@ -432,6 +617,33 @@ six = [
|
|||||||
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
|
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
|
||||||
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
|
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
|
||||||
]
|
]
|
||||||
|
toml = [
|
||||||
|
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
|
||||||
|
{file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
|
||||||
|
]
|
||||||
|
typed-ast = [
|
||||||
|
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
|
||||||
|
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
|
||||||
|
{file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
|
||||||
|
{file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
|
||||||
|
{file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
|
||||||
|
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
|
||||||
|
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
|
||||||
|
{file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
|
||||||
|
{file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
|
||||||
|
{file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
|
||||||
|
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
|
||||||
|
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
|
||||||
|
{file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
|
||||||
|
{file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
|
||||||
|
{file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
|
||||||
|
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
|
||||||
|
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
|
||||||
|
{file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
|
||||||
|
{file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
|
||||||
|
{file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
|
||||||
|
{file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
|
||||||
|
]
|
||||||
urllib3 = [
|
urllib3 = [
|
||||||
{file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"},
|
{file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"},
|
||||||
{file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"},
|
{file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"},
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "jellyfin-accounts"
|
name = "jellyfin-accounts"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
description = "A simple account management system for Jellyfin"
|
description = "A simple account management system for Jellyfin"
|
||||||
authors = ["Harvey Tindall <harveyltindall@gmail.com>"]
|
authors = ["Harvey Tindall <harveyltindall@gmail.com>"]
|
||||||
@ -28,10 +28,11 @@ passlib = "^1.7.2"
|
|||||||
pytz = "^2020.1"
|
pytz = "^2020.1"
|
||||||
python-dateutil = "^2.8.1"
|
python-dateutil = "^2.8.1"
|
||||||
watchdog = "^0.10.2"
|
watchdog = "^0.10.2"
|
||||||
configparser = "^5.0.0"
|
|
||||||
waitress = "^1.4.3"
|
waitress = "^1.4.3"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
|
neovim = "^0.3.1"
|
||||||
|
black = "^19.10b0"
|
||||||
|
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
|
Loading…
Reference in New Issue
Block a user