#!/usr/bin/env python # -*- coding: utf-8 -*- # import sys, os, base64, toml import http.client, ssl import xml.etree.ElementTree as ET from flask import Flask, render_template, request, make_response, jsonify from waitress import serve import gettext app = Flask(__name__) # ~ _ = gettext.gettext LOCALE = os.getenv('LANG', 'en') _ = gettext.translation('template', localedir='locales', languages=[LOCALE]).gettext queue_msgs = [ _("No items"), _("No files queued."), ] # Load config defaults, then look for other config files 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 # ~ app.config.from_file("videopi.toml", load=toml.load, silent=True) 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) ) ) # ~ app.config.from_file(os.path.expanduser("~/.config/videopi.toml"), load=toml.load, silent=True) ### hosts_available, hosts_unavailable = [],[] # Map vlc cmds # See https://github.com/videolan/vlc/blob/1336447566c0190c42a1926464fa1ad2e59adc4f/share/lua/http/requests/README.txt cmd_player = { "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://~" # System commands : # ~ "rssi" : "rssi", # ~ "blink" : "blink", # ~ "poweroff" : "poweroff", # ~ "reboot" : "reboot", } cmd_server = ["blink", "reboot", "poweroff", "rssi"] # Set 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} # Network/link utilities # https://www.metageek.com/training/resources/understanding-rssi/ def isup(host_l, port): global DEBUG import socket s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) if useSSL: sslcontext = ssl.create_default_context() # ~ if os.path.exists(CAfile): # ~ sslcontext.load_verify_locations(cafile=CAfile) # ~ else: # Dont validate cert, we juts want to see if host is live sslcontext.check_hostname = False sslcontext.verify_mode = ssl.CERT_NONE s = sslcontext.wrap_socket(s, server_hostname=host_l) try: s.settimeout(3.0) s.connect((host_l, port)) if DEBUG: print( _("Port {} reachable").format(str(port)) ) return 1 except (socket.error, socket.timeout) as e: if DEBUG: print( _("Error on connection to {} : {} : {} ").format(host_l, str(port), e) ) return 0 finally: s.close() def checkHosts(host_l): hostdown, hostup = [], [] hosts_number = str(len(host_l)) for lhost in host_l: if not isup(lhost, port): hostdown.append(lhost) else: hostup.append(lhost) if DEBUG: print( _("{} of {} hosts found.").format(str(len(hostup)), hosts_number)) return hostup, hostdown # File utilities def listMediaFiles(folder): 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 httpUpload(filename, hostl, trailing_slash=1): import requests url = "https://" + hostl + ":" + str(cmd_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 syncMediaFolder(media_folder_local, media_folder_remote, hostl, sync_facility=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 += "/" if sync_facility == "http": media_list = listMediaFiles(media_folder_local) transfer_ok = 0 for media in media_list: transfer_ok += httpUpload(media, hostl, trailing_slash) return _("{} files uploaded.").format(str(transfer_ok)) # Check sync utility exists elif which(sync_facility): from shutil import whichdb # Build subprocess arg list accroding to facility 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, hostl + ":" + media_folder_remote]) if sync_facility == "scp": media_list = listMediaFiles(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( hostl + ":" + 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 writeM3U(m3u_content : str, host : str): filename = host.replace(".", "_") + ".m3u" fd = open(filename, "w") fd.write(m3u_content) fd.close() return 1 # String utilities def escapeStr(uri): return uri.replace(" ", "%20") def sec2min(duration): return('%02d:%02d' % (duration / 60, duration % 60)) # VLC lua utilities def sendCommand(host, arg0, arg1, arg2): 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=" + escapeStr(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(): global hosts status_message = _("Searching network for live hosts...") # ~ hosts_available, hosts_unavailable = checkHosts(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 = checkHosts(hosts) hosts_status = [hosts_available, hosts_unavailable] return hosts_status @app.route("/browse") def browse(): files = listMediaFiles(media_folder_local) return files; @app.route("/sync/") def sync(host): if host == "all": for hostl in hosts_available: size = syncMediaFolder(media_folder_local, media_folder_remote, hostl) else: size = syncMediaFolder(media_folder_local, media_folder_remote, host) 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")) elif host == "all": resp = [] for hostl in hosts_available: resp.append( sendCommand(hostl, arg0, arg1, arg2) ) status_message = resp elif host not in hosts_available: status_message = "

{}

".format("Host is not reachable") else: status_message = sendCommand(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)