#!/usr/bin/env python # pilpil-client 0.1 # abelliqueux 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=", #"command" : "?command=", #"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']['vlc_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'] vlc_port = app.config['DEFAULT']['vlc_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'] http_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/ def send_HTTP_request(listed_host, port, time_out=3, request_="/"): ''' Send a http request with http auth ''' if useSSL: conn = http.client.HTTPSConnection( listed_host + ":" + str(port), timeout=time_out, context = sslcontext ) else: conn = http.client.HTTPConnection( listed_host + ":" + str(port), timeout=time_out ) try: if debug: print(request_ + " - " + str(http_headers)) conn.request("GET", request_, headers=http_headers) resp = conn.getresponse() data = resp.read() if debug: print(_("{} reachable on {}").format(str(listed_host), str(port))) print("Data length:" + str(len(data))) return data except Exception as e: if debug: print(_("Error on connection to {} : {} : {} ").format(listed_host, str(port), e)) return 0 finally: conn.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 send_HTTP_request(local_host, vlc_port, time_out=1): hosts_up.append(local_host) else: hosts_down.append(local_host) if debug: print( _("{} of {} hosts found.").format(str(len(hosts_up)), hosts_number)) return hosts_up, hosts_down 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=http_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 def get_meta_data(host, xml_data, request_="status", m3u_=0): ''' Parse XML response from pilpil-client instance and return a dict of metadata according to request type. ''' # Basic metadata media_infos = { 'host': host, 'status': 0 } if request_ == "list": # Return current instance's playlist return get_playlist(host, xml_data, m3u_) elif request_ == "status": # Return current instance's status ( currently playing, state, time length, etc. ) if xml_data.findall("./information/category/"): for leaf in xml_data.findall("./information/category/"): if leaf.get("name") == "filename": filename = leaf.text else: filename = "N/A" cur_length = int(xml_data.find('length').text) cur_time = int(xml_data.find('time').text) cur_length_fmtd = sec2min(cur_length) cur_time_fmtd = sec2min(cur_time) cur_id = int(xml_data.find('currentplid').text) cur_pos = xml_data.find('position').text cur_loop = xml_data.find('loop').text cur_repeat = xml_data.find('repeat').text media_infos.update({ 'status' : 1, 'file': filename, 'time': cur_time_fmtd, 'leng': cur_length_fmtd, 'pos': cur_pos, 'loop': cur_loop, 'repeat': cur_repeat, 'id': cur_id, }) elif request_ == "rssi": # # Return current instance's wifi signal quality print(xml_data) if xml_data.findall("rssi"): media_infos.update({ 'status': 1, 'rssi' : xml_data.find('rssi').text }) return media_infos def get_playlist(host, xml_data, m3u=0): playlist = [] item_list = [] playlist_duration = 0 if xml_data.find("./node") and xml_data.find("./node").get('name') == "Playlist": playlist = xml_data.findall("./node/leaf") content_format = "{};{};{};" if m3u: m3u_hdr = "#EXTM3U\n" m3u_prefix = "#EXTINF:" m3u_playlist = m3u_hdr # M3U file building m3u_format = "{}{}, {}\n{}\n" m3u_content = m3u_hdr for item in playlist: # item info if m3u: m3u_content += m3u_format.format(m3u_prefix, item.get("duration"), item.get("name"), item.get("uri")) item_info = content_format.format(item.get("id"), item.get("name"), sec2min(int(item.get("duration")))) # Add cursor to currently playing element if "current" in item.keys(): item_info += item.get("current") item_list.append(item_info) # Compute playlist length playlist_duration += int(item.get("duration")) if debug: if m3u: print(m3u_content) playlist_overview = { 'host' : host, 'status': 1, 'leng' : str(len(playlist)), 'duration' : sec2min(playlist_duration), 'items' : item_list } if debug: print(playlist_overview) return playlist_overview def send_pilpil_command(host, arg0, arg1, arg2): ''' Builds a pilpil request according to args, send it and return parsed result. ''' port_ = vlc_port # Build request # # Default request HTTP_request = "/requests/status.xml" if arg0 == "list" : # Get playlist HTTP_request = "/requests/playlist.xml" elif arg0 in cmd_server: # Switching to cmd server HTTP_request = "/" + str(arg0) port_ = cmd_port elif arg0 != "status" : # Build request for VLC command HTTP_request = HTTP_request + "?command=" + cmd_player[arg0] if arg1 != "null" : if (arg0 == "play") or (arg0 == "delete") or (arg0 == "sort") or (arg0 == "move"): # Add 'id' url parameter HTTP_request = HTTP_request + "&id=" + arg1 if (arg0 == "sort") or (arg0 == "move") : # Add 'val' url parameter # 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 HTTP_request = HTTP_request + "&val=" + escape_str(arg2) elif arg0 == "seek" : HTTP_request = HTTP_request + "&val=" + arg1 elif (arg0 == "enqueue") or (arg0 == "add") : # Add 'input' url parameter HTTP_request = HTTP_request + "&input=file://" + media_folder_remote + "/" + arg1 # Send request and get data response data = send_HTTP_request(host, port_, time_out=3, request_=HTTP_request) if debug: print(str(host) + " - data length :" + str(len(data))) if not data: print("No data was received.") return 0 # Parse xml data xml = ET.fromstring(data) # Process parsed data and return dict metadata = get_meta_data(host, xml, arg0) if debug: print("Metadata:" + str(metadata)) return metadata # 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 [] # /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 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)) 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) return [hosts_available, hosts_unavailable] @app.route("/browse") def browse(): return list_media_files(media_folder_local) @app.route("/sync/") def sync(host): # TODO : Add feedback for transfer in GUI size = 0 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("///", defaults = { "arg1": "null", "arg2": "null" }) @app.route("////", defaults = { "arg2": "null" }) @app.route("////") def action(host, arg0, arg1, arg2): status_message = "Idle" if (arg0 not in cmd_player) and (arg0 not in cmd_server): status_message = "

{}

".format(_("Wrong command")) return status_message if host == "all": # Send request to all available hosts resp = [] for hostl in hosts_available: resp.append(send_pilpil_command(hostl, arg0, arg1, arg2)) status_message = resp elif host not in hosts_available: status_message = "

{}

".format("Host is not reachable") else: # Send request to specified host status_message = send_pilpil_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)