#!/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 requests # ~ import socket import ssl import subprocess from shutil import which import sys # ~ import threading import toml from urllib.parse import quote, unquote 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", "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", "sync" ] # Configuration debug = app.config['DEFAULT']['debug'] media_folder_remote = os.path.expanduser(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 current_upload = 0 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 ''' total_size = 0 transfer_ok = 0 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) for media in media_list: current_upload = media.strip("/") if debug: print("Uploading " + str(media)) if HTTP_upload(media, host_local, port, trailing_slash): if debug: print("File size : " + str(os.stat(media_folder_local + media).st_size)) total_size += os.stat(media_folder_local + media).st_size transfer_ok += 1 # ~ return _("{} files uploaded.").format(str(transfer_ok)) # ~ return _("{} files uploaded.").format(str(transfer_ok)) # Using system cmd 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] if debug: print("Transferred size : " + str(total_size)) 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 if debug: print(xml_data) if xml_data.findall("rssi"): media_infos.update({ 'status': 1, 'rssi' : xml_data.find('rssi').text }) elif request_ == "browse": host_medias = {} for media in xml_data: media_name = media.attrib["name"] if len(media_name.split('.')) > 1: if media_name.split('.')[1] in media_exts: host_medias[media_name] = { "name": media_name, "uri": media.attrib["uri"], "size": round(int(media.attrib["size"])/1000000, 2)} media_infos = {host : host_medias} if debug: print(media_infos) 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"): print("HAHAHAH") 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 == "browse": if debug: print("Brosing {}".format(media_folder_remote)) # Browse remote media folder media_folder_remote_URL = quote(media_folder_remote) HTTP_request = "/requests/browse.xml?uri=file://{}".format(media_folder_remote_URL) if debug: print("requesting url {} to {}:{}".format(HTTP_request, host, port_)) 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://" + quote(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_local_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_local") def browse(): return list_local_media_files(media_folder_local) @app.route("/sync/") def sync(host): # TODO : Add feedback for transfer in GUI size = 0 if host == "status": return str(current_upload) elif host == "all": for hostl in hosts_available: size += sync_media_folder(media_folder_local, media_folder_remote, hostl, cmd_port) # ~ th = threading.Thread(target=blink_pi, args=(16,)) # ~ th.start() else: size = sync_media_folder(media_folder_local, media_folder_remote, host, cmd_port) return str(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 responses = [] for hostl in hosts_available: responses.append(send_pilpil_command(hostl, arg0, arg1, arg2)) status_message = responses 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)