pilpil-server/app.py

453 lines
15 KiB
Python
Executable File

#!/usr/bin/env python
# pilpil-client 0.1
# abelliqueux <contact@arthus.net>
import base64
from flask import Flask, render_template, request, make_response, jsonify
import gettext
import http.client
import os
import socket
import ssl
import subprocess
import requests
from shutil import which
import sys
import toml
import xml.etree.ElementTree as ET
from waitress import serve
# l10n
LOCALE = os.getenv('LANG', 'en')
_ = gettext.translation('template', localedir='locales', languages=[LOCALE]).gettext
app = Flask(__name__)
app.config.from_file("defaults.toml", load=toml.load)
config_locations = ["./", "~/.", "~/.config/"]
for location in config_locations:
# Optional config files, ~ is expanded to $HOME on *nix, %USERPROFILE% on windows
if app.config.from_file(os.path.expanduser( location + "pilpil-server.toml"), load=toml.load, silent=True):
print( _("Found configuration file in {}").format( os.path.expanduser(location) ) )
###
hosts_available, hosts_unavailable = [],[]
queue_msgs = [
_("No items"),
_("No files queued.")
]
cmd_player = {
# Map vlc http url parameters to pilpil-server urls
# See https://github.com/videolan/vlc/blob/1336447566c0190c42a1926464fa1ad2e59adc4f/share/lua/http/requests/README.txt
"play" : "pl_play",
"resume" : "pl_forceresume",
"pause" : "pl_forcepause",
"tpause" : "pl_pause",
"previous" : "pl_previous",
"next" : "pl_next",
"stop" : "pl_stop",
"enqueue" : "in_enqueue",
"add" : "in_play",
"clear" : "pl_empty",
"delete" : "pl_delete",
"loop" : "pl_loop",
"repeat" : "pl_repeat",
"random" : "pl_random",
"move" : "pl_move",
"sort" : "pl_sort",
"seek" : "seek",
"sync" : "sync",
"status" : "status.xml",
"list" : "playlist.xml",
#"volume" : "volume",
#"ratio" : "aspectratio",
#"dir" : "?dir=<uri>",
#"command" : "?command=<cmd>",
#"key" : "key=",
#"browse" : "browse.xml?uri=file://~"
}
cmd_server = [
# Map pilpil-client http url parameters to pilpil-server urls
"blink",
"reboot",
"poweroff",
"rssi"
]
# Configuration
debug = app.config['DEFAULT']['debug']
media_folder_remote = app.config['DEFAULT']['media_folder_remote']
media_folder_local = os.path.expanduser(app.config['DEFAULT']['media_folder_local'])
media_exts = app.config['DEFAULT']['media_exts']
auth = str(base64.b64encode(str(":" + app.config['DEFAULT']['auth']).encode('utf-8')), 'utf-8')
cmd_auth = str(base64.b64encode(str(":" + app.config['DEFAULT']['cmd_auth']).encode('utf-8')), 'utf-8')
hosts = app.config['DEFAULT']['hosts']
port = app.config['DEFAULT']['port']
cmd_port = app.config['DEFAULT']['cmd_port']
useSSL = app.config['DEFAULT']['useSSL']
CAfile = app.config['DEFAULT']['CAfile']
sync_facility = app.config['DEFAULT']['sync_facility']
headers = {"Authorization":"Basic " + auth}
# SSl context creation should be out of class
sslcontext = ssl.create_default_context()
if os.path.exists(CAfile):
sslcontext.load_verify_locations(cafile=CAfile)
else:
sslcontext.check_hostname = False
sslcontext.verify_mode = ssl.CERT_NONE
class PilpilClient:
def __init__(self):
pass
def __init_connection(self):
pass
def __send_request(self):
pass
def isup(self):
pass
def rssi(self):
pass
def status(self):
pass
def playlist(self):
pass
def command(self, cmd):
pass
def __str__(self):
pass
def __del__(self):
pass
# Network/link utilities
# https://www.metageek.com/training/resources/understanding-rssi/
# Rewrite using HTTPConnection
def isup(listed_host, port):
'''
Check listed_host is up and listening on port by opening a socket
Return 1 if successfull.
'''
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
if useSSL:
# ~ sslcontext = ssl.create_default_context()
# Disable cert validation, we just want to know if host is live
sslcontext.check_hostname = False
sslcontext.verify_mode = ssl.CERT_NONE
s = sslcontext.wrap_socket(s, server_hostname=listed_host)
try:
s.settimeout(3.0)
s.connect((listed_host, port))
if debug:
print(_("Port {} reachable on {}").format(str(port), str(listed_host)))
return 1
except (socket.error, socket.timeout) as e:
if debug:
print(_("Error on connection to {} : {} : {} ").format(listed_host, str(port), e))
return 0
finally:
s.close()
def check_hosts(host_list):
'''
Check hosts in a host list are up and build then return two lists with up/down hosts.
'''
hosts_up, hosts_down = [], []
hosts_number = str(len(host_list))
for local_host in host_list:
if not isup(local_host, port):
hosts_down.append(local_host)
else:
hosts_up.append(local_host)
if debug:
print( _("{} of {} hosts found.").format(str(len(hosts_up)), hosts_number))
return hosts_up, hosts_down
# File utilities
def list_media_files(folder):
'''
List files in folder which extension is allowed (exists in media_exts).
'''
if os.path.exists(folder):
files = os.listdir(folder);
medias = []
for fd in files:
if len(fd.split('.')) > 1:
if fd.split('.')[1] in media_exts:
medias.append(fd)
return medias
else:
return []
def HTTP_upload(filename, host_local, port, trailing_slash=1):
'''
Build HTTP file upload request and send it.
'''
url = "https://" + host_local + ":" + str(port) + "/upload"
if not trailing_slash:
filename = "/" + filename
files = { "file":( filename, open( media_folder_local + filename, "rb"), "multipart/form-data") }
if debug:
print(files)
resp = requests.post(url, files=files, headers=headers, verify=CAfile)
if debug:
print(resp.text)
if resp.ok:
return 1
else:
return 0
def sync_media_folder(media_folder_local, media_folder_remote, host_local, port, sync_facility=sync_facility):
'''
Sync media_folder_local with media_folder_remote using sync_facility
'''
trailing_slash = 1
# Check for trailing / and add it if missing
if media_folder_local[-1:] != "/":
media_folder_local += "/"
trailing_slash = 0
if media_folder_remote[-1:] != "/":
media_folder_remote += "/"
#Using http_upload
if sync_facility == "http":
media_list = list_media_files(media_folder_local)
transfer_ok = 0
for media in media_list:
transfer_ok += HTTP_upload(media, host_local, port, trailing_slash)
return _("{} files uploaded.").format(str(transfer_ok))
elif which(sync_facility):
# Build subprocess arg list accroding to sync_facility
# Using Rsync
if sync_facility == "rsync":
scrape_str = "total size is "
sync_args = [sync_facility, "-zharm", "--include='*/'"]
for media_type in media_exts:
sync_args.append( "--include='*." + media_type + "'" )
sync_args.extend(["--exclude='*'", media_folder_local, host_local + ":" + media_folder_remote])
# Using scp
if sync_facility == "scp":
media_list = list_media_files(media_folder_local)
sync_args = [sync_facility, "-Crp", "-o IdentitiesOnly=yes"]
for media in media_list:
sync_args.append( media_folder_local + media )
sync_args.append( host_local + ":" + media_folder_remote )
sync_proc = subprocess.run( sync_args , capture_output=True)
if len(sync_proc.stdout):
scrape_index = str(sync_proc.stdout).index(scrape_str)+len(scrape_str)
total_size = str(sync_proc.stdout)[ scrape_index: ].split(" ")[0][:-1]
else:
total_size = "N/A";
return total_size
# /requests/status.xml?command=in_enqueue&input=file:///home/pi/tst1.mp4
def write_M3U(m3u_content : str, host : str):
'''
Write a M3U file named host.m3u from m3u_content
'''
filename = host.replace(".", "_") + ".m3u"
fd = open(filename, "w")
fd.write(m3u_content)
fd.close()
return 1
# String utilities
def escape_str(uri):
'''
Replace spaces with %20 for http urls
'''
return uri.replace(" ", "%20")
def sec2min(duration):
'''
Convert seconds to min:sec format.
'''
return('%02d:%02d' % (duration / 60, duration % 60))
# VLC lua utilities
# TODO : make that a class
def send_command(host, arg0, arg1, arg2):
'''
Build a http request according to args, send it and return parsed result.
'''
portl = port
# Build request
req = "/requests/status.xml"
if arg0 == "list" :
req = "/requests/playlist.xml"
elif arg0 in cmd_server:
req = "/" + str(arg0)
portl = cmd_port
elif arg0 != "status" :
req = req + "?command=" + cmd_player[arg0]
if arg1 != "null" :
if (arg0 == "play") or (arg0 == "delete") or (arg0 == "sort") or (arg0 == "move"):
req = req + "&id=" + arg1
if (arg0 == "sort") or (arg0 == "move") :
# val possible values : id, title, title nodes first, artist, genre, random, duration, title numeric, album
# https://github.com/videolan/vlc/blob/3.0.17.4/modules/lua/libs/playlist.c#L353-L362
req = req + "&val=" + escape_str(arg2)
elif arg0 == "seek" :
req = req + "&val=" + arg1
elif (arg0 == "enqueue") or (arg0 == "add") :
req = req + "&input=file://" + media_folder_remote + "/" + arg1
# Send request
if useSSL:
# ~ sslcontext = ssl.create_default_context()
# ~ if os.path.exists(CAfile):
# ~ sslcontext.load_verify_locations(cafile=CAfile)
# ~ else:
# ~ sslcontext.check_hostname = False
# ~ sslcontext.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection( host + ":" + str(portl), timeout=3, context = sslcontext )
else:
conn = http.client.HTTPConnection( host + ":" + str(portl), timeout=3 )
try:
conn.request( "GET", req, headers = headers )
resp = conn.getresponse()
data = resp.read()
except http.client.HTTPException:
print( _("Connection to {} timed out").format(host) )
return _("Connection to {} timed out").format(host)
except:
return _("Error while connecting to {}:{}").format(host, str(portl))
finally:
conn.close()
# Parse response
# ~ data = resp.read()
if arg0 == "rssi":
if debug:
print(data)
response_dict = {
'host': host,
'rssi': str(data, 'UTF-8')
}
return response_dict
xml = ET.fromstring(data)
if arg0 != "list" :
meta = xml.findall("./information/category/")
if meta:
for leaf in meta:
if leaf.get("name") == "filename":
filename = leaf.text
else:
filename = "N/A"
cur_length = int(xml.find('length').text)
cur_time = int(xml.find('time').text)
cur_length_fmtd = sec2min(cur_length)
cur_time_fmtd = sec2min(cur_time)
cur_id = int(xml.find('currentplid').text)
cur_pos = xml.find('position').text
cur_loop = xml.find('loop').text
cur_repeat = xml.find('repeat').text
response_dict = {
'host': host,
'file': filename,
'time': cur_time_fmtd,
'leng': cur_length_fmtd,
'pos': cur_pos,
'loop': cur_loop,
'repeat': cur_repeat,
# ~ 'pos': xml.find('position').text,
# ~ 'loop': xml.find('loop').text,
# ~ 'repeat': xml.find('repeat').text,
'id': cur_id,
}
return response_dict
else:
# Build M3U file from playlist
playlist = []
playlist_duration = 0
m3u_hdr = "#EXTM3U\n"
m3u_prefix = "#EXTINF:"
item_list = []
if xml.find("./node") and xml.find("./node").get('name') == "Playlist":
m3u_content = m3u_hdr
playlist = xml.findall("./node/leaf")
#item_list = []
for item in playlist:
m3u_content += m3u_prefix + item.get("duration") + ", " + item.get("name") + "\n" + item.get("uri") + "\n"
playlist_duration += int(item.get("duration"))
# ~ item_info = item.get("id") + " : " + item.get("name") + " - " + sec2min( int( item.get("duration") ) )
item_info = item.get("id") + ";" + item.get("name") + ";" + sec2min( int( item.get("duration") ) ) + ";"
if "current" in item.keys():
item_info += item.get("current")
item_list.append(item_info)
playlist_overview = {
"host" : host,
"leng" : str(len(playlist)),
"duration" : sec2min(playlist_duration),
"items" : item_list
}
return playlist_overview
status_message = _("Idle")
@app.route("/")
def main():
status_message = _("Searching network for live hosts...")
templateData = {
'hosts' : hosts,
'status_message' : status_message,
'queue_msgs' : queue_msgs
}
return render_template('main.html', **templateData)
@app.route("/scan")
def scan():
global hosts_available, hosts_unavailable
hosts_available, hosts_unavailable = check_hosts(hosts)
hosts_status = [hosts_available, hosts_unavailable]
return hosts_status
@app.route("/browse")
def browse():
files = list_media_files(media_folder_local)
return files;
@app.route("/sync/<host>")
def sync(host):
if host == "all":
for hostl in hosts_available:
size = sync_media_folder(media_folder_local, media_folder_remote, hostl, cmd_port)
else:
size = sync_media_folder(media_folder_local, media_folder_remote, host, cmd_port)
return size;
@app.route("/<host>/<arg0>/", defaults = { "arg1": "null", "arg2": "null" })
@app.route("/<host>/<arg0>/<arg1>/", defaults = { "arg2": "null" })
@app.route("/<host>/<arg0>/<arg1>/<arg2>")
def action(host, arg0, arg1, arg2):
status_message = "Idle"
if (arg0 not in cmd_player) and (arg0 not in cmd_server):
status_message = "<p>{}</p>".format(_("Wrong command"))
elif host == "all":
resp = []
for hostl in hosts_available:
resp.append( send_command(hostl, arg0, arg1, arg2) )
status_message = resp
elif host not in hosts_available:
status_message = "<p>{}</p>".format("Host is not reachable")
else:
status_message = send_command(host, arg0, arg1, arg2)
if debug:
print(status_message)
return status_message
if __name__ == '__main__':
# ~ app.run()
serve(app, host='127.0.0.1', port=8080)