2022-10-06 11:44:59 +02:00
|
|
|
#!/usr/bin/env python
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
#
|
2022-10-08 16:49:45 +02:00
|
|
|
import sys, os, base64, toml
|
2022-10-09 18:09:32 +02:00
|
|
|
import http.client, ssl
|
2022-10-06 11:44:59 +02:00
|
|
|
import xml.etree.ElementTree as ET
|
|
|
|
from flask import Flask, render_template, request, make_response, jsonify
|
|
|
|
from waitress import serve
|
2022-11-03 12:48:12 +01:00
|
|
|
import gettext
|
2022-10-06 11:44:59 +02:00
|
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
2022-11-03 12:48:12 +01:00
|
|
|
# ~ _ = gettext.gettext
|
|
|
|
|
|
|
|
LOCALE = os.getenv('LANG', 'en')
|
|
|
|
|
|
|
|
_ = gettext.translation('template', localedir='locales', languages=[LOCALE]).gettext
|
|
|
|
|
|
|
|
queue_msgs = [ _("No items"),
|
|
|
|
_("No files queued."),
|
|
|
|
]
|
|
|
|
|
2022-10-08 16:49:45 +02:00
|
|
|
# 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)
|
2022-10-19 18:39:23 +02:00
|
|
|
if app.config.from_file(os.path.expanduser( location + "pilpil-server.toml"), load=toml.load, silent=True):
|
2022-11-03 12:48:12 +01:00
|
|
|
print( _("Found configuration file in {}").format( os.path.expanduser(location) ) )
|
2022-10-08 16:49:45 +02:00
|
|
|
# ~ app.config.from_file(os.path.expanduser("~/.config/videopi.toml"), load=toml.load, silent=True)
|
|
|
|
|
|
|
|
###
|
2022-10-06 11:44:59 +02:00
|
|
|
|
|
|
|
hosts_available, hosts_unavailable = [],[]
|
|
|
|
|
|
|
|
# Map vlc cmds
|
|
|
|
# See https://github.com/videolan/vlc/blob/1336447566c0190c42a1926464fa1ad2e59adc4f/share/lua/http/requests/README.txt
|
2022-10-25 12:53:05 +02:00
|
|
|
cmd_player = {
|
2022-10-06 11:44:59 +02:00
|
|
|
"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://~"
|
|
|
|
# System commands :
|
2022-10-25 12:53:05 +02:00
|
|
|
# ~ "rssi" : "rssi",
|
|
|
|
# ~ "blink" : "blink",
|
|
|
|
# ~ "poweroff" : "poweroff",
|
|
|
|
# ~ "reboot" : "reboot",
|
2022-10-06 11:44:59 +02:00
|
|
|
|
|
|
|
}
|
|
|
|
|
2022-10-25 12:53:05 +02:00
|
|
|
cmd_server = ["blink", "reboot", "poweroff", "rssi"]
|
|
|
|
|
2022-10-08 16:49:45 +02:00
|
|
|
# Set configuration
|
|
|
|
|
|
|
|
DEBUG = app.config['DEFAULT']['DEBUG']
|
|
|
|
media_folder_remote = app.config['DEFAULT']['media_folder_remote']
|
2022-10-14 13:27:01 +02:00
|
|
|
media_folder_local = os.path.expanduser(app.config['DEFAULT']['media_folder_local'])
|
|
|
|
media_exts = app.config['DEFAULT']['media_exts']
|
2022-10-08 16:49:45 +02:00
|
|
|
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']
|
2022-10-09 18:09:32 +02:00
|
|
|
useSSL = app.config['DEFAULT']['useSSL']
|
2022-10-18 17:16:00 +02:00
|
|
|
CAfile = app.config['DEFAULT']['CAfile']
|
2022-10-14 13:27:01 +02:00
|
|
|
sync_facility = app.config['DEFAULT']['sync_facility']
|
|
|
|
|
|
|
|
headers = {"Authorization":"Basic " + auth}
|
2022-10-08 16:49:45 +02:00
|
|
|
|
2022-10-06 11:44:59 +02:00
|
|
|
# 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)
|
2022-10-09 18:09:32 +02:00
|
|
|
if useSSL:
|
2022-10-18 17:16:00 +02:00
|
|
|
sslcontext = ssl.create_default_context()
|
2022-10-25 18:43:20 +02:00
|
|
|
# ~ 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
|
2022-10-18 17:16:00 +02:00
|
|
|
s = sslcontext.wrap_socket(s, server_hostname=host_l)
|
2022-10-06 11:44:59 +02:00
|
|
|
try:
|
|
|
|
s.settimeout(3.0)
|
|
|
|
s.connect((host_l, port))
|
|
|
|
if DEBUG:
|
2022-11-03 12:48:12 +01:00
|
|
|
print( _("Port {} reachable").format(str(port)) )
|
2022-10-06 11:44:59 +02:00
|
|
|
return 1
|
|
|
|
except (socket.error, socket.timeout) as e:
|
|
|
|
if DEBUG:
|
2022-11-03 12:48:12 +01:00
|
|
|
print( _("Error on connection to {} : {} : {} ").format(host_l, str(port), e) )
|
2022-10-06 11:44:59 +02:00
|
|
|
return 0
|
|
|
|
finally:
|
|
|
|
s.close()
|
|
|
|
|
|
|
|
def checkHosts(host_l):
|
|
|
|
hostdown, hostup = [], []
|
|
|
|
hosts_number = str(len(host_l))
|
|
|
|
for lhost in host_l:
|
2022-10-09 18:09:32 +02:00
|
|
|
if not isup(lhost, port):
|
2022-10-06 11:44:59 +02:00
|
|
|
hostdown.append(lhost)
|
|
|
|
else:
|
|
|
|
hostup.append(lhost)
|
|
|
|
if DEBUG:
|
2022-11-03 12:48:12 +01:00
|
|
|
print( _("{} of {} hosts found.").format(str(len(hostup)), hosts_number))
|
2022-10-06 11:44:59 +02:00
|
|
|
return hostup, hostdown
|
|
|
|
|
|
|
|
# File utilities
|
|
|
|
|
|
|
|
def listMediaFiles(folder):
|
2022-10-19 14:54:58 +02:00
|
|
|
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 []
|
2022-10-06 11:44:59 +02:00
|
|
|
|
2022-10-14 13:27:01 +02:00
|
|
|
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") }
|
2022-10-25 12:53:05 +02:00
|
|
|
if DEBUG:
|
|
|
|
print(files)
|
2022-10-18 19:54:18 +02:00
|
|
|
resp = requests.post(url, files=files, headers=headers, verify=CAfile)
|
2022-10-25 12:53:05 +02:00
|
|
|
if DEBUG:
|
|
|
|
print(resp.text)
|
2022-10-14 13:27:01 +02:00
|
|
|
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)
|
2022-11-03 12:48:12 +01:00
|
|
|
return _("{} files uploaded.").format(str(transfer_ok))
|
2022-10-14 13:27:01 +02:00
|
|
|
|
|
|
|
# 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
|
|
|
|
|
2022-10-06 11:44:59 +02:00
|
|
|
# /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"
|
2022-10-25 12:53:05 +02:00
|
|
|
elif arg0 in cmd_server:
|
|
|
|
req = "/" + str(arg0)
|
2022-10-09 18:09:32 +02:00
|
|
|
portl = cmd_port
|
2022-10-06 11:44:59 +02:00
|
|
|
elif arg0 != "status" :
|
2022-10-25 12:53:05 +02:00
|
|
|
req = req + "?command=" + cmd_player[arg0]
|
2022-10-06 11:44:59 +02:00
|
|
|
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") :
|
2022-10-08 16:49:45 +02:00
|
|
|
req = req + "&input=file://" + media_folder_remote + "/" + arg1
|
2022-10-06 11:44:59 +02:00
|
|
|
# Send request
|
2022-10-09 18:09:32 +02:00
|
|
|
if useSSL:
|
2022-10-18 17:16:00 +02:00
|
|
|
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
|
2022-10-09 18:09:32 +02:00
|
|
|
conn = http.client.HTTPSConnection( host + ":" + str(portl), timeout=3, context = sslcontext )
|
|
|
|
else:
|
|
|
|
conn = http.client.HTTPConnection( host + ":" + str(portl), timeout=3 )
|
2022-10-06 11:44:59 +02:00
|
|
|
try:
|
2022-10-14 13:27:01 +02:00
|
|
|
conn.request( "GET", req, headers = headers )
|
2022-10-22 14:22:38 +02:00
|
|
|
resp = conn.getresponse()
|
2022-10-25 12:53:05 +02:00
|
|
|
data = resp.read()
|
2022-10-22 14:22:38 +02:00
|
|
|
except http.client.HTTPException:
|
2022-11-03 12:48:12 +01:00
|
|
|
print( _("Connection to {} timed out").format(host) )
|
|
|
|
return _("Connection to {} timed out").format(host)
|
2022-10-06 11:44:59 +02:00
|
|
|
except:
|
2022-11-03 12:48:12 +01:00
|
|
|
return _("Error while connecting to {}:{}").format(host, str(portl))
|
2022-10-22 14:22:38 +02:00
|
|
|
finally:
|
|
|
|
conn.close()
|
2022-10-06 11:44:59 +02:00
|
|
|
# Parse response
|
2022-10-25 12:53:05 +02:00
|
|
|
# ~ data = resp.read()
|
2022-10-06 11:44:59 +02:00
|
|
|
|
|
|
|
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)
|
2022-10-08 16:49:45 +02:00
|
|
|
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
|
2022-10-06 11:44:59 +02:00
|
|
|
response_dict = {
|
|
|
|
'host': host,
|
|
|
|
'file': filename,
|
|
|
|
'time': cur_time_fmtd,
|
|
|
|
'leng': cur_length_fmtd,
|
2022-10-08 16:49:45 +02:00
|
|
|
'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,
|
2022-10-06 11:44:59 +02:00
|
|
|
}
|
|
|
|
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") ) )
|
2022-10-06 20:08:04 +02:00
|
|
|
item_info = item.get("id") + ";" + item.get("name") + ";" + sec2min( int( item.get("duration") ) ) + ";"
|
|
|
|
if "current" in item.keys():
|
|
|
|
item_info += item.get("current")
|
2022-10-06 11:44:59 +02:00
|
|
|
item_list.append(item_info)
|
|
|
|
|
|
|
|
playlist_overview = {
|
|
|
|
"host" : host,
|
|
|
|
"leng" : str(len(playlist)),
|
|
|
|
"duration" : sec2min(playlist_duration),
|
|
|
|
"items" : item_list
|
|
|
|
}
|
|
|
|
return playlist_overview
|
|
|
|
|
2022-11-03 12:48:12 +01:00
|
|
|
status_message = _("Idle")
|
2022-10-06 11:44:59 +02:00
|
|
|
|
|
|
|
@app.route("/")
|
|
|
|
def main():
|
|
|
|
global hosts
|
2022-11-03 12:48:12 +01:00
|
|
|
status_message = _("Searching network for live hosts...")
|
2022-10-06 11:44:59 +02:00
|
|
|
# ~ hosts_available, hosts_unavailable = checkHosts(hosts)
|
|
|
|
templateData = {
|
|
|
|
'hosts' : hosts,
|
2022-11-03 12:48:12 +01:00
|
|
|
'status_message' : status_message,
|
|
|
|
'queue_msgs' : queue_msgs
|
2022-10-06 11:44:59 +02:00
|
|
|
}
|
|
|
|
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():
|
2022-10-14 13:27:01 +02:00
|
|
|
files = listMediaFiles(media_folder_local)
|
2022-10-06 11:44:59 +02:00
|
|
|
return files;
|
|
|
|
|
2022-10-14 13:27:01 +02:00
|
|
|
|
|
|
|
@app.route("/sync/<host>")
|
|
|
|
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;
|
|
|
|
|
2022-10-06 11:44:59 +02:00
|
|
|
|
|
|
|
@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"
|
2022-10-25 12:53:05 +02:00
|
|
|
if (arg0 not in cmd_player) and (arg0 not in cmd_server):
|
2022-11-03 12:48:12 +01:00
|
|
|
status_message = "<p>{}</p>".format(_("Wrong command"))
|
2022-10-06 11:44:59 +02:00
|
|
|
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:
|
2022-11-03 12:48:12 +01:00
|
|
|
status_message = "<p>{}</p>".format("Host is not reachable")
|
2022-10-06 11:44:59 +02:00
|
|
|
else:
|
|
|
|
status_message = sendCommand(host, arg0, arg1, arg2)
|
|
|
|
if DEBUG:
|
|
|
|
print(status_message)
|
|
|
|
return status_message
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
2022-10-14 13:27:01 +02:00
|
|
|
# ~ app.run()
|
2022-10-25 12:53:05 +02:00
|
|
|
serve(app, host='127.0.0.1', port=8080)
|