commit d321726d62fb11be3f9958b7b50e2381fd3bccfd Author: Harvey Tindall Date: Sat Apr 11 15:20:25 2020 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc7b66a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +notes.md +MANIFEST.in +dist/ +build/ +*.egg-info/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dc91e2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Harvey Tindall + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e8ee6f --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# jellyfin-accounts +A simple, web-based invite system for [Jellyfin](https://github.com/jellyfin/jellyfin). +* Uses a basic python jellyfin API client for communication with the server. +* Uses [Flask](https://github.com/pallets/flask), [HTTPAuth](https://github.com/miguelgrinberg/Flask-HTTPAuth), [itsdangerous](https://github.com/pallets/itsdangerous), and [Waitress](https://github.com/Pylons/waitress) +* Frontend uses [Bootstrap](https://getbootstrap.com), [jQuery](https://jquery.com) and [jQuery-serialize-object](https://github.com/macek/jquery-serialize-object) +## Screenshots +

+ +

+ +## Install +``` +git clone https://github.com/hrfee/jellyfin-accounts.git +cd jellyfin-accounts +python3 setup.py install +``` + +### Usage +* Passing no arguments will run the server +``` +usage: jf-accounts [-h] [-c CONFIG] [-d DATA] [--host HOST] [-p PORT] [-g] + +jellyfin-accounts + +optional arguments: + -h, --help show this help message and exit + -c CONFIG, --config CONFIG + specifies path to configuration file. + -d DATA, --data DATA specifies directory to store data in. defaults to + ~/.jf-accounts. + --host HOST address to host web ui on. + -p PORT, --port PORT port to host web ui on. + -g, --get_policy tool to grab a JF users policy (access, perms, etc.) + and output as json to be used as a user template. +``` +### Setup +#### Policy template +* You may want to restrict from accessing certain libraries (e.g 4K Movies), or display their account on the login screen by default. Jellyfin stores these settings as a user's policy. +* Make a temporary account and change its settings, then run `jf-accounts --get_policy`. Choose your user, and the policy will be stored at the location you set in `user_template`, and used for all subsequent new accounts. + +### Configuration +On first run, the default configuration is copied to `~/.jf-accounts/config.ini`. +``` +; It is reccommended to create a limited admin account for this program. +[jellyfin] +username = username +password = password +; Server will also be used in the invite form, so make sure it's publicly accessible. +server = https://jellyf.in:443 +client = jf-accounts +version = 0.1 +device = jf-accounts +device_id = jf-accounts-0.1 + +[ui] +host = 127.0.0.1 +port = 8056 +username = your username +password = your password +debug = false +; Enable to store request email address and store. Useful for sending password reset emails. +emails_enabled = false + +; Displayed at the bottom of all pages except admin. +contact_message = Need help? contact me. +; Displayed at top of form page. +help_message = Enter your details to create an account. +; Displayed when an account is created. +success_message = Your account has been created. Click below to continue to Jellyfin. + + +[files] +; When the below paths are left blank, files are stored in ~/.jf-accounts/. + +; Path to store valid invites. +invites = +; Path to store emails in JSON +emails = +; Path to the user policy template. Can be acquired with get-template. +user_template = +``` + +### Todo +* Fix pip install (possible related to using `data_files` over `package_data`?) +* Properly integrate with a janky password reset email system i've written. +* Improve `generateInvites` in admin.js to refresh each invite's data, instead of deleting and recreating them. +* Fix weird alignment of invite codes and the generate button (I know, i'm very new to web development) diff --git a/data/config-default.ini b/data/config-default.ini new file mode 100644 index 0000000..25bfeb7 --- /dev/null +++ b/data/config-default.ini @@ -0,0 +1,39 @@ +[jellyfin] +; It is reccommended to create a limited admin account for this program. +username = username +password = password +; Server will also be used in the invite form, so make sure it's publicly accessible. +server = https://jellyf.in:443 +client = jf-accounts +version = 0.1 +device = jf-accounts +device_id = jf-accounts-0.1 + +[ui] +host = 127.0.0.1 +port = 8056 +username = your username +password = your password +debug = false +; Enable to store request email address and store. Useful for sending password reset emails. +emails_enabled = false + +; Displayed at the bottom of all pages except admin. +contact_message = Need help? contact me. +; Displayed at top of form page. +help_message = Enter your details to create an account. +; Displayed when an account is created. +success_message = Your account has been created. Click below to continue to Jellyfin. + + +[files] +; When the below paths are left blank, files are stored in ~/.jf-accounts/. + +; Path to store valid invites. +invites = +; Path to store emails in JSON +emails = +; Path to the user policy template. Can be acquired with get-template. +user_template = + + diff --git a/data/static/admin.js b/data/static/admin.js new file mode 100644 index 0000000..96225b5 --- /dev/null +++ b/data/static/admin.js @@ -0,0 +1,173 @@ +function addItem(invite) { + var links = document.getElementById('invites'); + var listItem = document.createElement('li'); + listItem.classList.add('list-group-item'); + listItem.classList.add('align-middle'); + var listCode = document.createElement('div'); + listCode.classList.add('float-left'); + var codeLink = document.createElement('a'); + codeLink.appendChild(document.createTextNode(invite[0])); + listCode.appendChild(codeLink); + listItem.appendChild(listCode); + var listRight = document.createElement('div'); + listRight.classList.add('float-right'); + listRight.appendChild(document.createTextNode(invite[1])); + if (invite[2] == 0) { + var inviteCode = window.location.href + 'invite/' + invite[0]; + codeLink.href = inviteCode; + listCode.appendChild(document.createTextNode(" ")); + var codeCopy = document.createElement('i'); + codeCopy.onclick = function(){toClipboard(inviteCode)}; + codeCopy.classList.add('fa'); + codeCopy.classList.add('fa-clipboard'); + listCode.appendChild(codeCopy); + var listDelete = document.createElement('button'); + listDelete.onclick = function(){deleteInvite(invite[0])}; + listDelete.classList.add('btn'); + listDelete.classList.add('btn-outline-danger'); + listDelete.appendChild(document.createTextNode('Delete')); + listRight.appendChild(listDelete); + }; + listItem.appendChild(listRight); + links.appendChild(listItem); +}; +function generateInvites(empty = false) { + document.getElementById('invites').textContent = ''; + if (empty === false) { + $.ajax('/getInvites', { + type : 'GET', + dataType : 'json', + contentType: 'json', + xhrFields : { + withCredentials: true + }, + beforeSend : function (xhr) { + xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":")); + }, + data: { get_param: 'value' }, + complete: function(response) { + var data = JSON.parse(response['responseText']); + if (data['invites'].length == 0) { + addItem(["None", "", "1"]); + } else { + data['invites'].forEach(function(item) { + var i = ["", "", "0"]; + i[0] = item['code']; + if (item['hours'] == 0) { + i[1] = item['minutes'] + 'm'; + } else if (item['minutes'] == 0) { + i[1] = item['hours'] + 'h'; + } else { + i[1] = item['hours'] + 'h ' + item['minutes'] + 'm'; + } + i[1] = "Expires in " + i[1] + " "; + addItem(i) + }); + } + } + }); + } else if (empty === true) { + addItem(["None", "", "1"]); + }; +}; +function deleteInvite(code) { + var send = JSON.stringify({ "code": code }); + $.ajax('/deleteInvite', { + data : send, + contentType : 'application/json', + type : 'POST', + xhrFields : { + withCredentials: true + }, + beforeSend : function (xhr) { + xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":")); + }, + success: function() { generateInvites(); }, + }); +}; +function addOptions(le, sel) { + for (v = 0; v <= le; v++) { + var opt = document.createElement('option'); + opt.appendChild(document.createTextNode(v)) + opt.value = v + sel.appendChild(opt) + } +}; +function toClipboard(str) { + const el = document.createElement('textarea'); + el.value = str; + el.setAttribute('readonly', ''); + el.style.position = 'absolute'; + el.style.left = '-9999px'; + document.body.appendChild(el); + const selected = + document.getSelection().rangeCount > 0 + ? document.getSelection().getRangeAt(0) + : false; + el.select(); + document.execCommand('copy'); + document.body.removeChild(el); + if (selected) { + document.getSelection().removeAllRanges(); + document.getSelection().addRange(selected); + } +}; +$("form#inviteForm").submit(function() { + var send = $("form#inviteForm").serializeJSON(); + $.ajax('/generateInvite', { + data : send, + contentType : 'application/json', + type : 'POST', + xhrFields : { + withCredentials: true + }, + beforeSend : function (xhr) { + xhr.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":")); + }, + success: function() { generateInvites(); }, + + }); + return false; +}); +$("form#loginForm").submit(function() { + window.token = ""; + var details = $("form#loginForm").serializeObject(); + $.ajax('/getToken', { + type : 'GET', + dataType : 'json', + contentType: 'json', + xhrFields : { + withCredentials: true + }, + beforeSend : function (xhr) { + xhr.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password'])); + }, + data: { get_param: 'value' }, + complete: function(data) { + if (data['status'] == 401) { + var formBody = document.getElementById('formBody'); + var wrongPassword = document.createElement('div'); + wrongPassword.classList.add('alert'); + wrongPassword.classList.add('alert-danger'); + wrongPassword.setAttribute('role', 'alert'); + wrongPassword.appendChild(document.createTextNode('Incorrect username or password.')); + formBody.appendChild(wrongPassword); + } else { + window.token = JSON.parse(data['responseText'])['token']; + generateInvites(); + var interval = setInterval(function() { generateInvites(); }, 60 * 1000); + var hour = document.getElementById('hours'); + addOptions(24, hour); + hour.selected = "0"; + var minutes = document.getElementById('minutes'); + addOptions(59, minutes); + minutes.selected = "30"; + $('#login').modal('hide'); + } + } + }); + return false; +}); +generateInvites(empty = true); +$("#login").modal('show'); + diff --git a/data/templates/404.html b/data/templates/404.html new file mode 100644 index 0000000..e84af4d --- /dev/null +++ b/data/templates/404.html @@ -0,0 +1,26 @@ + + + + + +Create Jellyfin Account + + + + + + + + +
+

Page not found.

+

+ {{ contactMessage }} +

+
+ + diff --git a/data/templates/admin.html b/data/templates/admin.html new file mode 100644 index 0000000..624acda --- /dev/null +++ b/data/templates/admin.html @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + Admin + + + +

+ Accounts admin +

+
+ Current Invites +
+
+
+
+ Generate Invite +
+
+
+
+ + + + + +
+
+
+
+
+
+

{{ contactMessage }}

+
+ + + diff --git a/data/templates/form.html b/data/templates/form.html new file mode 100644 index 0000000..eac866b --- /dev/null +++ b/data/templates/form.html @@ -0,0 +1,90 @@ + + + + + + + + + + + + + Create Jellyfin Account + + + +
+

+ Create Account +

+

{{ helpMessage }}

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+

{{ contactMessage }}

+
+ + + diff --git a/data/templates/invalidCode.html b/data/templates/invalidCode.html new file mode 100644 index 0000000..118a60b --- /dev/null +++ b/data/templates/invalidCode.html @@ -0,0 +1,25 @@ + + + + + +Create Jellyfin Account + + + + + + + + +
+

Invalid Code.

+

The above code is either incorrect, or has expired.

+

{{ contactMessage }}

+
+ + diff --git a/images/admin.png b/images/admin.png new file mode 100644 index 0000000..2814462 Binary files /dev/null and b/images/admin.png differ diff --git a/images/create.png b/images/create.png new file mode 100644 index 0000000..485646b Binary files /dev/null and b/images/create.png differ diff --git a/jellyfin_accounts/__init__.py b/jellyfin_accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jellyfin_accounts/jf_api.py b/jellyfin_accounts/jf_api.py new file mode 100644 index 0000000..0176f2c --- /dev/null +++ b/jellyfin_accounts/jf_api.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import requests + + +class Error(Exception): + pass + +class Jellyfin: + class UserExistsError(Error): + pass + class UserNotFoundError(Error): + pass + class AuthenticationError(Error): + pass + class AuthenticationRequiredError(Error): + pass + def __init__(self, server, client, version, device, deviceId): + self.server = server + self.client = client + self.version = version + self.device = device + self.deviceId = deviceId + self.useragent = f"{self.client}/{self.version}" + self.auth = "MediaBrowser " + self.auth += f"Client={self.client}, " + self.auth += f"Device={self.device}, " + self.auth += f"DeviceId={self.deviceId}, " + self.auth += f"Version={self.version}" + self.header = { + "Accept": "application/json", + "Content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Application": f"{self.client}/{self.version}", + "Accept-Charset": "UTF-8,*", + "Accept-encoding": "gzip", + "User-Agent": self.useragent, + "X-Emby-Authorization": self.auth + } + def getUsers(self, username="all"): + response = requests.get(self.server+"/emby/Users/Public").json() + if username == "all": + return response + else: + match = False + for user in response: + if user['Name'] == username: + match = True + return user + if not match: + raise self.UserNotFoundError + def authenticate(self, username, password): + self.username = username + self.password = password + response = requests.post(self.server+"/emby/Users/AuthenticateByName", + headers=self.header, + params={'Username': self.username, + 'Pw': self.password}) + if response.status_code == 200: + json = response.json() + self.userId = json['User']['Id'] + self.accessToken = json['AccessToken'] + self.auth += f", Token={self.accessToken}" + self.header['X-Emby-Authorization'] = self.auth + return True + else: + raise self.AuthenticationError + def setPolicy(self, userId, policy): + return requests.post(self.server+"/Users/"+userId+"/Policy", + headers=self.header, + params=policy) + def newUser(self, username, password): + for user in self.getUsers(): + if user['Name'] == username: + raise self.UserExistsError + response = requests.post(self.server+"/emby/Users/New", + headers=self.header, + params={'Name': username, + 'Password': password}) + if response.status_code == 401: + raise self.AuthenticationRequiredError + return response + +# template user's policies should be copied to each new account. diff --git a/jellyfin_accounts/login.py b/jellyfin_accounts/login.py new file mode 100644 index 0000000..7a213a1 --- /dev/null +++ b/jellyfin_accounts/login.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# from flask import g + +from flask_httpauth import HTTPBasicAuth +from itsdangerous import (TimedJSONWebSignatureSerializer + as Serializer, BadSignature, SignatureExpired) +from passlib.apps import custom_app_context as pwd_context +import uuid +from __main__ import config, app, g + + +class Account(): + def __init__(self, username, password): + self.username = username + self.password_hash = pwd_context.hash(password) + self.id = str(uuid.uuid4()) + def verify_password(self, password): + return pwd_context.verify(password, self.password_hash) + def generate_token(self, expiration=1200): + s = Serializer(app.config['SECRET_KEY'], expires_in=expiration) + return s.dumps({ 'id': self.id }) + + @staticmethod + def verify_token(token, account): + s = Serializer(app.config['SECRET_KEY']) + try: + data = s.loads(token) + except SignatureExpired: + return None + except BadSignature: + return None + if data['id'] == account.id: + return account + +auth = HTTPBasicAuth() + + +adminAccount = Account(config['ui']['username'], config['ui']['password']) + + +@auth.verify_password +def verify_password(username, password): + user = adminAccount.verify_token(username, adminAccount) + if not user: + if username == adminAccount.username and adminAccount.verify_password(password): + g.user = adminAccount + print(g) + return True + else: + return False + g.user = adminAccount + return True + + + diff --git a/jellyfin_accounts/web.py b/jellyfin_accounts/web.py new file mode 100644 index 0000000..f8a9059 --- /dev/null +++ b/jellyfin_accounts/web.py @@ -0,0 +1,41 @@ +from pathlib import Path +from flask import Flask, send_from_directory, render_template +from __main__ import config, app, g + + +@app.errorhandler(404) +def page_not_found(e): + return render_template('404.html', + contactMessage=config['ui']['contact_message']), 404 + + +@app.route('/', methods=['GET', 'POST']) +def admin(): + # return app.send_static_file('admin.html') + return render_template('admin.html', + contactMessage='') + + +@app.route('/') +def static_proxy(path): + if 'form.html' not in path and 'admin.html' not in path: + return app.send_static_file(path) + return render_template('404.html', + contactMessage=config['ui']['contact_message']), 404 + +from jellyfin_accounts.web_api import checkInvite + + +@app.route('/invite/') +def inviteProxy(path): + if checkInvite(path): + return render_template('form.html', + contactMessage=config['ui']['contact_message'], + helpMessage=config['ui']['help_message'], + successMessage=config['ui']['success_message'], + jfLink=config['jellyfin']['server']) + elif 'admin.html' not in path and 'admin.html' not in path: + return app.send_static_file(path) + else: + return render_template('invalidCode.html', + contactMessage=config['ui']['contact_message']) diff --git a/jellyfin_accounts/web_api.py b/jellyfin_accounts/web_api.py new file mode 100644 index 0000000..6135198 --- /dev/null +++ b/jellyfin_accounts/web_api.py @@ -0,0 +1,150 @@ +from flask import request, jsonify +from jellyfin_accounts.jf_api import Jellyfin +import json +import datetime +import secrets +from __main__ import config, app, g +from jellyfin_accounts.login import auth + +def resp(success=True, code=500): + if success: + r = jsonify({'success': True}) + r.status_code = 200 + else: + r = jsonify({'success': False}) + r.status_code = code + return r + + +def checkInvite(code, delete=False): + current_time = datetime.datetime.now() + try: + with open(config['files']['invites'], 'r') as f: + invites = json.load(f) + except (FileNotFoundError, json.decoder.JSONDecodeError): + invites = {'invites': []} + valid = False + for index, i in enumerate(invites['invites']): + expiry = datetime.datetime.strptime(i['valid_till'], + '%Y-%m-%dT%H:%M:%S.%f') + if current_time >= expiry: + del invites['invites'][index] + else: + if i['code'] == code: + valid = True + if delete: + del invites['invites'][index] + with open(config['files']['invites'], 'w') as f: + f.write(json.dumps(invites, indent=4, default=str)) + return valid + + +jf = Jellyfin(config['jellyfin']['server'], + config['jellyfin']['client'], + config['jellyfin']['version'], + config['jellyfin']['device'], + config['jellyfin']['device_id']) + +jf.authenticate(config['jellyfin']['username'], + config['jellyfin']['password']) + + +@app.route('/newUser', methods=['GET', 'POST']) +def newUser(): + data = request.get_json() + if checkInvite(data['code'], delete=True): + user = jf.newUser(data['username'], data['password']) + if user.status_code == 200: + try: + with open(config['files']['user_template'], 'r') as f: + default_policy = json.load(f) + jf.setPolicy(user.json()['Id'], default_policy) + except: + pass + if config['ui']['emails_enabled'] == 'true': + try: + with open(config['files']['emails'], 'r') as f: + emails = json.load(f) + except (FileNotFoundError, json.decoder.JSONDecodeError): + emails = {} + emails[data['username']] = data['email'] + with open(config['files']['emails'], 'w') as f: + f.write(json.dumps(emails, indent=4)) + return resp() + else: + return resp(False) + else: + return resp(False, code=401) + + +@app.route('/generateInvite', methods=['GET', 'POST']) +@auth.login_required +def generateInvite(): + current_time = datetime.datetime.now() + data = request.get_json() + delta = datetime.timedelta(hours=int(data['hours']), + minutes=int(data['minutes'])) + invite = {'code': secrets.token_urlsafe(16)} + invite['valid_till'] = (current_time + + delta).strftime('%Y-%m-%dT%H:%M:%S.%f') + try: + with open(config['files']['invites'], 'r') as f: + invites = json.load(f) + except (FileNotFoundError, json.decoder.JSONDecodeError): + invites = {'invites': []} + invites['invites'].append(invite) + with open(config['files']['invites'], 'w') as f: + f.write(json.dumps(invites, indent=4, default=str)) + return resp() + + +@app.route('/getInvites', methods=['GET']) +@auth.login_required +def getInvites(): + current_time = datetime.datetime.now() + try: + with open(config['files']['invites'], 'r') as f: + invites = json.load(f) + except (FileNotFoundError, json.decoder.JSONDecodeError): + invites = {'invites': []} + response = {'invites': []} + for index, i in enumerate(invites['invites']): + expiry = datetime.datetime.strptime(i['valid_till'], '%Y-%m-%dT%H:%M:%S.%f') + if current_time >= expiry: + del invites['invites'][index] + else: + valid_for = expiry - current_time + response['invites'].append({ + 'code': i['code'], + 'hours': valid_for.seconds//3600, + 'minutes': (valid_for.seconds//60) % 60}) + with open(config['files']['invites'], 'w') as f: + f.write(json.dumps(invites, indent=4, default=str)) + return jsonify(response) + + +@app.route('/deleteInvite', methods=['POST']) +@auth.login_required +def deleteInvite(): + code = request.get_json()['code'] + try: + with open(config['files']['invites'], 'r') as f: + invites = json.load(f) + except (FileNotFoundError, json.decoder.JSONDecodeError): + invites = {'invites': []} + for index, i in enumerate(invites['invites']): + if i['code'] == code: + del invites['invites'][index] + with open(config['files']['invites'], 'w') as f: + f.write(json.dumps(invites, indent=4, default=str)) + return resp() + + +@app.route('/getToken') +@auth.login_required +def get_token(): + token = g.user.generate_token() + return jsonify({'token': token.decode('ascii')}) + + + diff --git a/jf-accounts b/jf-accounts new file mode 100755 index 0000000..3a5acba --- /dev/null +++ b/jf-accounts @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 + +import secrets +import configparser +import shutil +import argparse +from pathlib import Path +from flask import Flask, g + +parser = argparse.ArgumentParser(description="jellyfin-accounts") + +parser.add_argument("-c", "--config", + help="specifies path to configuration file.") +parser.add_argument("-d", "--data", + 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("-p", "--port", + help="port to host web ui on.") +parser.add_argument("-g", "--get_policy", + help=("tool to grab a JF users " + + "policy (access, perms, etc.) and " + + "output as json to be used as a user template."), + action='store_true') + +args, leftovers = parser.parse_known_args() + +if args.data is not None: + data_dir = Path(args.data) +else: + data_dir = Path.home() / '.jf-accounts' + +local_dir = (Path(__file__).parents[2] / 'data').resolve() + +if not data_dir.exists(): + Path.mkdir(data_dir) + print(f'Config dir not found, so created at {str(data_dir)}') + if args.config is None: + config_path = data_dir / 'config.ini' + shutil.copy(str(local_dir / 'config-default.ini'), + str(config_path)) + print("Edit the configuration and restart.") + raise SystemExit + else: + config_path = Path(args.config) + print(f'config.ini can be found at {str(config_path)}') +else: + config_path = data_dir / 'config.ini' + +config = configparser.ConfigParser() +config.read(config_path) + +if args.host is not None: + config['ui']['host'] = args.host +if args.port is not None: + config['ui']['port'] = args.port + +for key in config['files']: + if config['files'][key] == '': + config['files'][key] = str(data_dir / (key + '.json')) + +if args.get_policy: + import json + from jellyfin_accounts.jf_api import Jellyfin + jf = Jellyfin(config['jellyfin']['server'], + config['jellyfin']['client'], + config['jellyfin']['version'], + config['jellyfin']['device'], + config['jellyfin']['device_id']) + # No auth needed. + print("Make sure the user is publicly visible!") + users = jf.getUsers() + for index, user in enumerate(users): + print(f'{index+1}) {user["Name"]}') + success = False + while not success: + try: + policy = users[int(input(">: "))-1]['Policy'] + success = True + except (ValueError, IndexError): + pass + with open(config['files']['user_template'], 'w') as f: + f.write(json.dumps(policy, indent=4)) + print(f'Policy written to "{config["files"]["user_template"]}".') + print('In future, this policy will be copied to all new users.') +else: + app = Flask(__name__, root_path=str(local_dir)) + app.config['DEBUG'] = config.getboolean('ui', 'debug') + app.config['SECRET_KEY'] = secrets.token_urlsafe(16) + + if __name__ == '__main__': + import jellyfin_accounts.web_api + import jellyfin_accounts.web + print(jellyfin_accounts.web.__file__) + from waitress import serve + serve(app, + host=config['ui']['host'], + port=int(config['ui']['port'])) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5f920f8 --- /dev/null +++ b/setup.py @@ -0,0 +1,42 @@ +from setuptools import find_packages, setup + +with open('README.md', 'r') as f: + long_description = f.read() + +setup( + name='jellyfin-accounts', + version='0.1', + scripts=['jf-accounts'], + author="Harvey Tindall", + author_email="hrfee@protonmail.ch", + description="A simple invite system for Jellyfin", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/hrfee/jellyfin-accounts", + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + packages=find_packages(), + # include_package_data=True, + data_files=[('data', ['data/config-default.ini']), + ('data/static', ['data/static/admin.js']), + ('data/templates', [ + 'data/templates/404.html', + 'data/templates/invalidCode.html', + 'data/templates/admin.html', + 'data/templates/form.html'])], + zip_safe=False, + install_requires=[ + 'flask', + 'flask_httpauth', + 'requests', + 'itsdangerous', + 'passlib', + 'secrets', + 'configparser', + 'waitress', + ], +) +