Media enqueuing UI

This commit is contained in:
ABelliqueux 2022-11-12 18:17:25 +01:00
parent 9bcb04890b
commit 82a67286ea
8 changed files with 253 additions and 129 deletions

97
app.py
View File

@ -7,16 +7,19 @@ from flask import Flask, render_template, request, make_response, jsonify
import gettext
import http.client
import os
import socket
# ~ import requests
# ~ import socket
import ssl
import subprocess
import requests
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
@ -30,8 +33,6 @@ for location in config_locations:
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"),
@ -57,7 +58,6 @@ cmd_player = {
"move" : "pl_move",
"sort" : "pl_sort",
"seek" : "seek",
"sync" : "sync",
"status" : "status.xml",
"list" : "playlist.xml",
#"volume" : "volume",
@ -65,19 +65,20 @@ cmd_player = {
#"dir" : "?dir=<uri>",
#"command" : "?command=<cmd>",
#"key" : "key=",
#"browse" : "browse.xml?uri=file://~"
"browse" : "browse.xml?uri=file://~"
}
cmd_server = [
# Map pilpil-client http url parameters to pilpil-server urls
"blink",
"reboot",
"poweroff",
"rssi"
"rssi",
"sync"
]
# Configuration
debug = app.config['DEFAULT']['debug']
media_folder_remote = app.config['DEFAULT']['media_folder_remote']
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')
@ -98,6 +99,7 @@ else:
sslcontext.check_hostname = False
sslcontext.verify_mode = ssl.CERT_NONE
current_upload = 0
class PilpilClient:
def __init__(self):
@ -190,6 +192,8 @@ def sync_media_folder(media_folder_local, media_folder_remote, host_local, port,
'''
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:] != "/":
@ -200,10 +204,18 @@ def sync_media_folder(media_folder_local, media_folder_remote, host_local, port,
#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))
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
@ -224,9 +236,9 @@ def sync_media_folder(media_folder_local, media_folder_remote, host_local, port,
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
if debug:
print("Transferred size : " + str(total_size))
return total_size
def get_meta_data(host, xml_data, request_="status", m3u_=0):
'''
@ -239,7 +251,7 @@ def get_meta_data(host, xml_data, request_="status", m3u_=0):
}
if request_ == "list":
# Return current instance's playlist
return get_playlist(host, xml_data, m3u_)
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/"):
@ -267,13 +279,27 @@ def get_meta_data(host, xml_data, request_="status", m3u_=0):
'id': cur_id,
})
elif request_ == "rssi":
# # Return current instance's wifi signal quality
print(xml_data)
# 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
@ -282,7 +308,8 @@ def get_playlist(host, xml_data, m3u=0):
item_list = []
playlist_duration = 0
if xml_data.find("./node") and xml_data.find("./node").get('name') == "Playlist":
if xml_data.find("./node") and xml_data.find("./node").get('name') == _("Playlist"):
print("HAHAHAH")
playlist = xml_data.findall("./node/leaf")
content_format = "{};{};{};"
@ -329,14 +356,22 @@ def send_pilpil_command(host, arg0, arg1, arg2):
#
# Default request
HTTP_request = "/requests/status.xml"
if arg0 == "list" :
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" :
elif arg0 != "status":
# Build request for VLC command
HTTP_request = HTTP_request + "?command=" + cmd_player[arg0]
if arg1 != "null" :
@ -352,7 +387,7 @@ def send_pilpil_command(host, arg0, arg1, arg2):
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
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)
@ -371,7 +406,7 @@ def send_pilpil_command(host, arg0, arg1, arg2):
return metadata
# Utilities
def list_media_files(folder):
def list_local_media_files(folder):
'''
List files in folder which extension is allowed (exists in media_exts).
'''
@ -426,20 +461,24 @@ def scan():
hosts_available, hosts_unavailable = check_hosts(hosts)
return [hosts_available, hosts_unavailable]
@app.route("/browse")
@app.route("/browse_local")
def browse():
return list_media_files(media_folder_local)
return list_local_media_files(media_folder_local)
@app.route("/sync/<host>")
def sync(host):
# TODO : Add feedback for transfer in GUI
size = 0
if host == "all":
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 size;
return str(size)
@app.route("/<host>/<arg0>/", defaults = { "arg1": "null", "arg2": "null" })
@app.route("/<host>/<arg0>/<arg1>/", defaults = { "arg2": "null" })
@ -453,10 +492,10 @@ def action(host, arg0, arg1, arg2):
if host == "all":
# Send request to all available hosts
resp = []
responses = []
for hostl in hosts_available:
resp.append(send_pilpil_command(hostl, arg0, arg1, arg2))
status_message = resp
responses.append(send_pilpil_command(hostl, arg0, arg1, arg2))
status_message = responses
elif host not in hosts_available:
status_message = "<p>{}</p>".format("Host is not reachable")
else:

View File

@ -32,7 +32,7 @@ sha256 : 0fe3fe76d0e56e445124fa20646fa8b3d8c59568786b3ebc8a96d83d92f203e3
md5 : dee7af70135994169cab4f073ee51905
sha256 : ec3e17fc9b41f8c5181484e9866be2d1d92cab8403210e3d22f4f689edd4cfde
* Switch to rpi os Bullseye
* Switch to rpi os Bullseye arm64
* Switch to user 'pil', pw 'pilpoil'
* client config file parsing ( look for 'pilpil-client.toml' in ./, ~/., ~/.config/)
* Add media folder sync (scp, rsync, http upload)
@ -70,6 +70,7 @@ sha256 :
* setup : Generate/install http auth secret at setup
* setup : Add dry, help flags
* FR localisation for server, client and install script
* pep8ification
# OTHER:
@ -77,7 +78,7 @@ sha256 :
# TODO :
* ? python sanitization
* ~ python sanitization
* ~ Test with several rpis
* ? Scripts hotspot linux/win/mac
* ? Config sync

Binary file not shown.

View File

@ -54,6 +54,10 @@ msgstr "Erreur pendant la connexion à {}:{}"
msgid "{} reachable on {}"
msgstr "{} accessible sur {}"
#: app.py:311
msgid "Playlist"
msgstr "Liste de lecture"
#: app.py:331
msgid "Idle"
msgstr "Innocupé"

View File

@ -1,16 +1,17 @@
[DEFAULT]
debug = 1
useSSL = true
useSSL = false
CAfile = "selfCA.crt"
# scp, rsync, http
sync_facility = "http"
media_folder_local = "../medias"
media_folder_remote = "/home/pil/Videos"
media_folder_remote = "~/Vidéos"
media_exts = ["mp4", "avi", "mkv"]
vlc_auth = "secret"
# OnNlY3JldA==
cmd_auth = "secret"
hosts = ["10.42.0.10", "10.42.0.11"]
#hosts = ["10.42.0.10", "10.42.0.11"]
hosts = ["127.0.0.1"]
# VLC http LUA port
vlc_port = 8887
# Clients cmd port

View File

@ -5,15 +5,17 @@ const tl_drag_attr = {id:"tl_drag", draggable:"true", ondragstart:"drag(event, t
// Config
var timeline_color_cursor = "#FF8839";
var timeline_color_bg = "#2EB8E6";
var scanInterval = 3000;
var status_all = "Searching network for live hosts..."
// Global vars
var src_id = "";
var medias_status = {};
var fileButtons = [];
function updatePlaylist(){
var new_list = [];
var media_count = document.getElementById("timeline").children.length;
for (i=media_count,l=0;i>l;i--) {
//~ new_list.push(document.getElementById("timeline").children[i].children[0].getAttribute("media_id"));
toMove = document.getElementById("timeline").children[i-1].children[0].getAttribute("media_id");
console.log(toMove);
sendCmd("/all/move/" + toMove + "/1");
@ -21,10 +23,10 @@ function updatePlaylist(){
}
function findTargetIndex(element, index, array, thisArg){
if (element == this) {
return index+1;
}
return 0;
if (element == this) {
return index+1;
}
return 0;
};
function moveElements(target) {
@ -47,26 +49,25 @@ function moveElements(target) {
};
function allowDrop(ev) {
ev.preventDefault();
drop_id = ev.dataTransfer.getData("text");
ev.preventDefault();
drop_id = ev.dataTransfer.getData("text");
};
function drag(ev, source) {
src_id = ev.target.parentElement;
ev.dataTransfer.setData("text", ev.target.id);
src_id = ev.target.parentElement;
ev.dataTransfer.setData("text", ev.target.id);
};
function drop(ev, target) {
ev.preventDefault();
var data = ev.dataTransfer.getData("text");
if (src_id.id != target.id) {
ev.preventDefault();
var data = ev.dataTransfer.getData("text");
if (src_id.id != target.id) {
dropTarget = moveElements(target);
if (dropTarget) {
dropTarget.appendChild(document.getElementById(data));
updatePlaylist();
}
}
}
}
};
function adjustTl() {
@ -88,36 +89,45 @@ function addAttr(id, attr, val , child=-1) {
};
function addElement(type, attr, meta = 0, j = 0){
var elem = document.createElement(type);
var keys_array = Object.keys(attr);
for (i=0, l=keys_array.length;i<l;i++) {
var att = document.createAttribute(keys_array[i]);
if(!i){
att.value = Object.values(attr)[i]+j;
} else {
att.value = Object.values(attr)[i];
var elem = document.createElement(type);
var keys_array = Object.keys(attr);
for (i=0, l=keys_array.length;i<l;i++) {
var att = document.createAttribute(keys_array[i]);
if(!i){
att.value = Object.values(attr)[i]+j;
} else {
att.value = Object.values(attr)[i];
}
elem.setAttributeNode(att);
}
elem.setAttributeNode(att);
}
// Set playlist id attribute
if (meta) {
att = document.createAttribute("media_id");
att.value = meta[0];
elem.setAttributeNode(att);
}
// Get filename
elem.innerText = meta[1];
return elem;
// Set playlist id attribute
if (meta) {
att = document.createAttribute("media_id");
att.value = meta[0];
elem.setAttributeNode(att);
}
// Get filename
elem.innerText = meta[1];
return elem;
};
var scanInterval = 3000;
// Bouttons de commande
addEventListener("DOMContentLoaded", function() {
sendCmd("/scan");
sendCmd("/browse");
sendCmd("/browse_local");
sendCmd("/all/rssi");
sendCmd("/all/list");
sendCmd("/all/browse");
adjustTl();
// Get filename when selected in table
//~ for (var i=0, l=fileButtons.length; i<l; i++) {
//~ var selected_file = fileButtons[i];
//~ // Sur un click
//~ selected_file.addEventListener("click", function(event) {
//~ // On intercepte le signal
//~ event.preventDefault();
//~ }
//~ }
// Tous les elements avec la classe ".command"
var commandButtons = document.querySelectorAll(".command");
for (var i=0, l=commandButtons.length; i<l; i++) {
@ -133,14 +143,17 @@ addEventListener("DOMContentLoaded", function() {
if ( !confirm("Êtes vous certain de vouloir effectuer cette action ?") ) {
return 0;
}
} else if ( command == "/scan" ) {
document.getElementById("status_all").innerHTML = "Searching network for live hosts...";
document.getElementById("status_all").innerHTML = status_all;
} else if ( command.indexOf("/sort") > -1 ){
if (command.indexOf('/1/') > -1 ) {
clickedButton.value = clickedButton.value.replace('/1/','/0/')
} else {
clickedButton.value = clickedButton.value.replace('/0/','/1/')
}
} else if ( command.indexOf("/move") > -1 ) {
const test_array = [21,19,20];
for (i=test_array.length, l=0;i>l;i--){
@ -155,7 +168,11 @@ addEventListener("DOMContentLoaded", function() {
var request = new XMLHttpRequest();
if ( command == "/scan" ) {
request.onload = sendCmd(command);
}
} else if ( command == "/sync/all" ) {
status_all = "Syncing files..."
request.onload = sendCmd("/sync/status");
};
// On construit la commande
request.open("GET", command, true);
// et on l'envoie
@ -175,15 +192,6 @@ function parseResult(command, infos_array) {
document.getElementById("status_"+infos_array[i].host).innerHTML = infos_array[i].file + " <br/> " + infos_array[i].time + " / " + infos_array[i].leng;
medias_status[infos_array[i].id] = infos_array[i].pos;
}
// Find currently playing element
//~ var pl_length = document.getElementById("timeline").getAttribute("length");
//~ for (j=0,k=pl_length;j<k;j++){
//~ if (document.getElementById("timeline").children[j].children[0].getAttribute('media_id') == infos_array[i].id ) {
//~ addAttr(document.getElementById("timeline").children[j].children[0].id, "pos", infos_array[i].pos);
//~ }
//~ };
//~ document.getElementById("timeline").children[0].children[0].hasAttribute('media_id')
// Toggle loop indicator
if (infos_array[i].loop == "true") {
document.getElementById("loop_ind_" + infos_array[i].host).style.backgroundColor = "#78E738"
} else {
@ -201,7 +209,7 @@ function parseResult(command, infos_array) {
for (var i = 0, l=infos_array.length; i<l; i++) {
// Fill Playlist infos
document.getElementById("playlist_"+infos_array[i].host).innerHTML = infos_array[i].leng + " item(s) in playlist - " + infos_array[i].duration;
//~ document.getElementById("playlist_"+infos_array[i].host).innerHTML = infos_array[i].leng + " item(s) in playlist - " + infos_array[i].duration;
// Build html table and timeline
var items_array = Array.from(infos_array[i].items);
//console.log(items_array.length);
@ -212,20 +220,20 @@ function parseResult(command, infos_array) {
};
break;
}
var html_table = "<table>" +
"<tr>" +
"<th>Id</th>" +
"<th>Filename</th>" +
"<th>Duration</th>" +
"</tr>";
//~ var html_table = "<table>" +
//~ "<tr>" +
//~ "<th>Id</th>" +
//~ "<th>Filename</th>" +
//~ "<th>Duration</th>" +
//~ "</tr>";
for (var j = 0, k=items_array.length; j<k; j++) {
// Table
item_meta = items_array[j].split(';');
html_table += "<tr>" +
"<td>" + item_meta[0] + "</td>" +
"<td>" + item_meta[1] + "</td>" +
"<td>" + item_meta[2] + "</td>" +
"</tr>" ;
//~ html_table += "<tr>" +
//~ "<td>" + item_meta[0] + "</td>" +
//~ "<td>" + item_meta[1] + "</td>" +
//~ "<td>" + item_meta[2] + "</td>" +
//~ "</tr>" ;
// Timeline
var child_node = addElement("div", tl_drag_attr, item_meta, j);
var len = document.getElementById("timeline").children.length;
@ -248,8 +256,8 @@ function parseResult(command, infos_array) {
document.getElementById(tl_cont_attr.id + j).children[0].style.background = "linear-gradient(90deg," + timeline_color_bg + " " + pos1 + ", " + timeline_color_cursor + " " + pos + ", " + timeline_color_bg + " " + pos + ")";
}
}
html_table += "</table>";
document.getElementById("playlist_"+infos_array[i].host).innerHTML += html_table;
//~ html_table += "</table>";
//~ document.getElementById("playlist_"+infos_array[i].host).innerHTML += html_table;
};
break;
case "/scan":
@ -263,11 +271,13 @@ function parseResult(command, infos_array) {
}
if (host_up.length) {
scanInterval = 10000;
document.getElementById("status_all").innerHTML = "Scan intarvel set to " + scanInterval;
//~ document.getElementById("status_all").innerHTML = "Scan interval set to " + scanInterval;
status_all = "Scan interval set to " + scanInterval;
}
document.getElementById("status_all").innerHTML = host_up.length + " client(s).";
//~ document.getElementById("status_all").innerHTML = host_up.length + " client(s).";
status_all = host_up.length + " client(s).";
break;
case "/browse":
case "/browse_local":
var html_table = "<table>" +
"<tr>" +
"<th>Filename</th>" +
@ -299,6 +309,47 @@ function parseResult(command, infos_array) {
};
};
break;
case "/all/browse":
for (var i=0, l=infos_array.length; i<l; i++) {
hosts = Object.keys(infos_array[i]);
//~ console.log(keys);
//~ var html_table = "<table id='file_sel_" + hosts + "'>" +
//~ "<tr>" +
//~ "<th>Filename</th>" +
//"<th>Size</th>" +
//~ "</tr>";
for ( var j=0, k=hosts.length;j<k;j++ ) {
keys_ = Object.keys(infos_array[i][hosts[j]]);
//~ console.log(infos_array[i][hosts[j]]);
for ( var m=0, n=keys_.length;m<n;m++ ) {
item_meta = infos_array[i][hosts[j]][keys_[m]];
//~ console.log(item_meta);
//~ html_table += "<tr>" +
//~ "<td class='.file_selection'>" + item_meta["name"] + "</td>" +
//"<td>" + item_meta["size"] + "</td>" +
//~ "</tr>" ;
tr = document.createElement("tr");
td = document.createElement("td");
tr.appendChild(td);
tr.setAttribute("class", "file_selection");
tr.host = hosts[j];
td.innerText = item_meta["name"];
tr.addEventListener("click", enqueueFile, false)
document.getElementById("file_sel_"+hosts).appendChild(tr);
};
}
//~ html_table += "</table>";
//~ document.getElementById("playlist_"+hosts).innerHTML += html_table;
//~ fileButtons = document.querySelectorAll('.file_selection');
//~ console.log(fileButtons);
//~ for (var i=fileButtons.length; i>0; i--) {
//~ fileButtons[i].addEventListener('click', selectFile, false);
//~ }
}
break;
case "/sync/status":
status_all = infos_array;
break;
}; // End switch case
};
@ -321,8 +372,20 @@ function sendCmd(command) {
request.send();
};
setInterval( sendCmd, 500, "/all/status");
setInterval( sendCmd, 1000, "/all/list");
setInterval( sendCmd, scanInterval, "/scan");
setInterval( sendCmd, 20000, "/all/rssi");
function enqueueFile(evt) {
//console.log(evt.currentTarget.innerText + evt.currentTarget.host);
sendCmd("/" + evt.currentTarget.host + "/enqueue/" + evt.currentTarget.innerText);
};
function updateStatusAll() {
document.getElementById("status_all").innerHTML = status_all;
};
setInterval(sendCmd, 500, "/all/status");
setInterval(sendCmd, 1000, "/all/list");
//~ setInterval(sendCmd, 3000, "/all/browse");
setInterval(sendCmd, scanInterval, "/scan");
setInterval(sendCmd, 20000, "/all/rssi");
setInterval(updateStatusAll, 1000);

View File

@ -1,7 +1,7 @@
body {color:#fff;background-color:#666;margin:0;}
div {box-sizing: border-box;}
h2 {margin:0;}
table {background-color: #555;margin:.5em;max-width:100%;font-size:.9em}
table {background-color: #555;margin-left:.5em;max-width:100%;font-size:.9em}
td {padding: 0;}
th {background-color: #aaa;}
tr:nth-child(2n+1) {background-color: #888;}
@ -87,9 +87,9 @@ tr:nth-child(2n+1) {background-color: #888;}
}
.btn_txt {display: block;font-size: small;}
/*Right column*/
.right_col {width: 79.9%;display: inline-block;}
.right_col {width: 71%;display: inline-block;}
/*Left column*/
.left_col {width: 20%;display: inline-block;float: left;clear: left;}
.left_col {width: 28%;display: inline-block;float: left;clear: left;}
.left_col button {
width: 2em;
height: 2em;
@ -109,30 +109,38 @@ tr:nth-child(2n+1) {background-color: #888;}
}
.left_col button:hover .btn_txt {display:block;}
.col_1 {
width: 40%;
float: left;
padding: 3% 0 0 5%;
}
width: 40%;
float: left;
padding: 3% 0 0 5%;
}
.col_2 {
width: 60%;
display: inline-block;
clear: right;
width: 59%;
display: inline-block;
clear: right;
height: 170px;
}
.col_2 div {
overflow-y: scroll;
height: 170px;
}
.indicator {
display:inline-block;
background-color: #C32600;
margin: 0 0 0 5%;
padding: 0.3em;
display:inline-block;
background-color: #C32600;
margin: 0 0 0 5%;
padding: 0.3em;
}
.file_sel {
width: 100%;
margin: 0;
}
.wl_indicator {
display: inline-block;
background-color: #bbb;
vertical-align: bottom;
margin: 0 1px;
padding: 0;
height: .5em;
width: 5%;
display: inline-block;
background-color: #bbb;
vertical-align: bottom;
margin: 0 1px;
padding: 0;
height: .5em;
width: 5%;
}
#wl_0 {height:.5em;}
#wl_1 {height:.65em;}

View File

@ -56,7 +56,15 @@
<p id="repeat_ind_{{ host }}" class="indicator">Repeat</p>
</div>
<div class="col_2">
<p id="playlist_{{ host }}">{{queue_msgs[1]}}</p>
<div>{{queue_msgs[1]}}
<div id="playlist_{{ host }}" class="table_cont">
<table id="file_sel_{{ host }}">
<tr>
<th>Filename</th>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="right_col">