Compare commits

..

20 commits

Author SHA1 Message Date
9c7c33ffc1 Added minor TODO 2026-04-14 19:45:52 -04:00
8a5534482b Add versionNum Object, pages for search
This will be tweaked a lot more but i really need to go study for something instead of doing this
2026-04-14 12:53:26 -04:00
151ed839c3 updated gitignore for linux 2026-03-31 13:45:30 -04:00
1518fdec8f Add decription to password option 2026-03-31 13:42:09 -04:00
bec0e301b2 Add checks for no database, print error 2026-03-31 12:43:27 -04:00
d4a0702a8a Add eventlet to requirements
this is kind of a half measure, since eventlet is out of date and i have to do monkey_patch

all in due time for the stable release will i find a new server to use
2026-03-31 12:41:25 -04:00
89bf77304c Update Readme for accuracy
Making sure everything in the readme is relevant to the modern version
this includes removing references to mp3s (now audio files) and to a seperate web client
2026-03-30 18:37:09 -04:00
1230ce60b3 Adding files i forgot last time (new to command line git, sorry 2026-03-30 09:36:49 -04:00
2d70c5306e Cleanup: Move JS Files Locally
Moved remote JS files to local, (all under MIT liscense (i hope))

Added version number, plans for semantic versioning and better 'stable' releases
2026-03-30 09:34:25 -04:00
ff09e518b8 Minor updates 2026-03-23 09:30:59 -04:00
b31982153f Added getting, starting on RESTful 2026-03-19 13:08:20 -04:00
0099f17d34 update the total song length from the socket messages 2026-03-08 13:27:17 -04:00
f95ecc78d4 Multiple fixes
- Fixed pwa location in manifest.json
- made database generator check the .env for location rather than runtime attribute
- fixed dark mode preference based on cookie/browser preference
- changed to socketio.sleep for compatibility
2026-03-06 17:05:32 -05:00
419eed375c Changes to adjust for no longer having an ipbox 2026-03-05 13:40:21 -05:00
71228d8ccc Update webbyBits.py 2026-03-05 11:43:04 -05:00
e14484835e Get rid of cors, add static favicon 2026-03-04 19:55:47 -05:00
da16c9bd27 Merge branch 'main' into flask-only 2026-03-04 19:51:48 -05:00
f6e0049229 Adjusted for not needing to set an ip anymore 2026-03-04 19:50:57 -05:00
66b4ce33bf so many moves 2026-03-04 16:03:15 -05:00
4d3c707301 Moved Static Client 2026-03-04 15:58:16 -05:00
27 changed files with 296 additions and 333 deletions

2
.gitignore vendored
View file

@ -1,4 +1,4 @@
server/sound/ Server/sound/
*.db *.db
start.bat start.bat
.env .env

View file

@ -1,134 +0,0 @@
function Pop() {
// var cssRuleFile = "/src/css/style.css"; // will be the link once this css file became available online
var cssRuleFile = "https://cookieconsent.popupsmart.com/src/css/style.css"; // will be the link once this css file became available online
let lnk = document.createElement("link");
lnk.setAttribute("rel", "stylesheet");
lnk.setAttribute("type", "text/css");
lnk.setAttribute("href", cssRuleFile);
document.getElementsByTagName("head")[0].appendChild(lnk);
let styl = "undefined";
var conDivObj;
var fadeInTime = 10; // If needed could be served as an customizable option to the user
var fadeOutTime = 10;
let cookie = {
name: "cookieconsent_status",
path: "/",
expiryDays: 365 * 24 * 60 * 60 * 5000,
};
let content = {
/// Add a field for link color
message:
"This website uses cookies to ensure you get the best experience on our website.",
btnText: "Got it!",
mode: " banner bottom",
theme: " theme-classic",
palette: " palette1",
link: "Learn more",
href: "https://www.cookiesandyou.com",
target: "_blank",
};
let createPopUp = function () {
console.log(content);
if (typeof conDivObj === "undefined") {
conDivObj = document.createElement("DIV");
conDivObj.style.opacity = 0;
conDivObj.setAttribute("id", "spopupCont");
}
conDivObj.innerHTML =
'<div id="poper" class="window ' +
content.mode +
content.theme +
content.palette +
'"><span id="msg" class="message">' +
content.message +
'<a id="plcy-lnk" class="policylink" href="' +
content.href +
'"' +
" target=" +
content.target +
">" +
content.link +
'</a></span><div id="btn" class="compliance"><a id="cookie-btn" class="spopupbtnok" >' +
content.btnText +
'</a></div><span class="credit"><a href="https://popupsmart.com" target="_blank"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 60 60" fill="currentColor"><path id="popupsmart" d="M56.627,12.279a22.441,22.441,0,0,0-9.549-9.074c-4.122-2.088-8.951-3.2-15.722-3.2H28.644c-6.769,0-11.6,1.112-15.72,3.2a22.425,22.425,0,0,0-9.551,9.072C1.174,16.191,0,20.783,0,27.214v5.578c0,6.434,1.173,11.024,3.373,14.934A22.412,22.412,0,0,0,12.924,56.8c4.12,2.094,8.949,3.206,15.72,3.206h2.711c6.771,0,11.6-1.112,15.72-3.206a22.427,22.427,0,0,0,9.551-9.072c2.2-3.91,3.373-8.5,3.373-14.934V27.216C60,20.78,58.827,16.19,56.627,12.279ZM30,45.006c-.237,0-.473-.005-.708-.015l-.211-.012c-.14-.008-.28-.019-.419-.031-.123-.011-.245-.022-.367-.036l-.191-.024a14.979,14.979,0,0,1-2.672-.59V44.3a14.861,14.861,0,0,1-6.294-3.955,1.406,1.406,0,1,0-2.036,1.94,17.648,17.648,0,0,0,8.33,4.944v.354a5.214,5.214,0,1,1-10.428,0V30.046c0-.013,0-.026,0-.039a15,15,0,1,1,15,15Z" transform="translate(0 -0.005)"></path></svg><span>Powered by Popupsmart</span></a></span></div>';
document.body.appendChild(conDivObj);
fadeIn(conDivObj);
document
.getElementById("cookie-btn")
.addEventListener("click", function () {
saveCookie();
fadeOut(conDivObj);
});
};
let fadeOut = function (element) {
var op = 1;
var timer = setInterval(function () {
if (op <= 0.1) {
clearInterval(timer);
conDivObj.parentElement.removeChild(conDivObj);
}
element.style.opacity = op;
element.style.filter = "alpha(opacity=" + op * 100 + ")";
op -= op * 0.1;
}, fadeOutTime);
};
let fadeIn = function (element) {
var op = 0.1;
var timer = setInterval(function () {
if (op >= 1) {
clearInterval(timer);
}
element.style.opacity = op;
element.style.filter = "alpha(opacity=" + op * 100 + ")";
op += op * 0.1;
}, fadeInTime);
};
let checkCookie = function (key) {
var keyValue = document.cookie.match("(^|;) ?" + key + "=([^;]*)(;|$)");
return keyValue ? true : false;
};
let saveCookie = function () {
var expires = new Date();
expires.setTime(expires.getTime() + cookie.expiryDays);
document.cookie =
cookie.name +
"=" +
"ok" +
";expires=" +
expires.toUTCString() +
"path=" +
cookie.path;
};
this.init = function (param) {
if (checkCookie(cookie.name)) return;
if (typeof param === "object") {
if ("ButtonText" in param) content.btnText = param.ButtonText;
if ("Mode" in param) content.mode = " " + param.Mode;
if ("Theme" in param) content.theme = " " + param.Theme;
if ("Palette" in param) content.palette = " " + param.Palette;
if ("Message" in param) content.message = param.Message;
if ("LinkText" in param) content.link = param.LinkText;
if ("Location" in param) content.href = param.Location;
if ("Target" in param) content.target = param.Target;
if ("Time" in param)
setTimeout(function () {
createPopUp();
}, param.Time * 1000);
else createPopUp();
}
};
}
window.start = new Pop();

Binary file not shown.

View file

@ -11,18 +11,18 @@ parser=argparse.ArgumentParser(description="Options for the generation of the so
# parser.add_argument('-k','--apikey', help='String: LastFM api key', default="") # parser.add_argument('-k','--apikey', help='String: LastFM api key', default="")
parser.add_argument('-m', '--mode', help='new/update: Remake database or update current', default= "update") parser.add_argument('-m', '--mode', help='new/update: Remake database or update current', default= "update")
parser.add_argument('-a', '--art', help="True/False: Add art to the database using LastFm (takes minimum 0.25s per song)", default="True") parser.add_argument('-a', '--art', help="True/False: Add art to the database using LastFm (takes minimum 0.25s per song)", default="True")
parser.add_argument('-d','--directory',help="Directory of the song files", default="./sound/") # parser.add_argument('-d','--directory',help="Directory of the song files", default="./sound/")
args = parser.parse_args() args = parser.parse_args()
dotenv.load_dotenv() dotenv.load_dotenv()
apikeylastfm = os.getenv("API_KEY") apikeylastfm = os.getenv("API_KEY")
soundLocation = os.getenv("DIRECTORY") soundLocation = os.getenv("DIRECTORY")
# apikeylastfm = args.apikey # apikeylastfm = args.apikey
if args.directory[-1] == "/" or args.directory[-1] == "\\": if soundLocation[-1] == "/" or soundLocation[-1] == "\\":
soundLocation = args.directory soundLocation = soundLocation
elif "/" in args.directory: elif "/" in soundLocation:
soundLocation = args.directory + "/" soundLocation = soundLocation + "/"
else: else:
soundLocation = args.directory + "\\" soundLocation = soundLocation + "\\"
songFiles = os.listdir(soundLocation) songFiles = os.listdir(soundLocation)
fileOfDB = sql.connect("songDatabase.db") fileOfDB = sql.connect("songDatabase.db")

9
Server/static/ext/sha256.min.js vendored Normal file

File diff suppressed because one or more lines are too long

7
Server/static/ext/socket.io.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Before After
Before After

View file

@ -2,7 +2,7 @@
"name": "Jukebox Remote", "name": "Jukebox Remote",
"short_name": "Jukebox Remote", "short_name": "Jukebox Remote",
"description": "Controller for the PartyJukebox server app.", "description": "Controller for the PartyJukebox server app.",
"start_url": "index.html", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#eeeeee", "background_color": "#eeeeee",
"theme_color": "#eeeeee", "theme_color": "#eeeeee",

View file

@ -1,5 +1,5 @@
// set all the global stuff // set all the global stuff
let ip; let ip = URL.parse(document.URL).host;
let alertTime = 2; let alertTime = 2;
let adminPass = ""; let adminPass = "";
let justSkipped = false; let justSkipped = false;
@ -15,7 +15,7 @@ let currentlyPlaying = false;
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
let darkmodetemp = getCookie("darkmode"); let darkmodetemp = getCookie("darkmode");
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && darkmodetemp === undefined) {
darkmodetemp = "true"; darkmodetemp = "true";
} }
if(darkmodetemp === "") { if(darkmodetemp === "") {
@ -40,14 +40,42 @@ async function alertText(text="Song Added!") {
alertbox.innerHTML = "" alertbox.innerHTML = ""
} }
} }
async function getFromServer(source,headersIn={},secure = false, password=adminPass) {
try {
let href = "";
if(secure) {
href = "https://"+ip+"/" + source;
} else {
href = "http://"+ip+"/" + source;
}
headersIn["Jukebox-Auth"] = password;
headersIn["Accept"] = "application/json";
let response = await fetch(href,{
method:"GET",
headers:headersIn,
})
let data = await response.json();
if (response.status == ERR_NO_ADMIN) {
alertText("Error: Admin restricted action")
} else if(!response.ok){
throw new Error(data.error);
}
// we add some information from the response just in case it is needed
data["ok"] = response.ok;
data["status"] = response.status;
// console.log(data);
return await data;
} catch(e) {
alertText(e);
}
}
// a lot of this is kinda waffly because i was trying to get // a lot of this is kinda waffly because i was trying to get
// it to return the right stuff and javascript is asyrcronouse (boo) // it to return the right stuff and javascript is asyrcronouse (boo)
async function getFromServer(bodyInfo, source="", secure=false, password=adminPass) { async function postFromServer(bodyInfo, source="", secure=false, password=adminPass) {
try{ try{
if (bodyInfo != null) {
// the currently set password is always included in every request
bodyInfo["password"] = password;
}
let href = ""; let href = "";
if(secure) { if(secure) {
href = "https://"+ip+"/" + source; href = "https://"+ip+"/" + source;
@ -58,7 +86,8 @@ async function getFromServer(bodyInfo, source="", secure=false, password=adminPa
method: "POST", method: "POST",
body: JSON.stringify(bodyInfo), body: JSON.stringify(bodyInfo),
headers: { headers: {
"Content-type": "application/json; charset=UTF-8" "Content-type": "application/json; charset=UTF-8",
"Jukebox-Auth": password
} }
}); });
@ -81,7 +110,7 @@ async function getFromServer(bodyInfo, source="", secure=false, password=adminPa
// console.log("error print here:"); // console.log("error print here:");
// console.log(e); // console.log(e);
if (e.toString().includes("TypeError: Failed to fetch")){ if (e.toString().includes("TypeError: Failed to fetch")){
alertText("Error: Can't Connect to Server (is the ip set?)") alertText("Error: Can't Connect to Server")
} else { } else {
alertText(e); alertText(e);
} }
@ -111,12 +140,12 @@ function getCookie(cname) {
// also someone who likes things not being dumb more than me would have separated the client and server buttons // also someone who likes things not being dumb more than me would have separated the client and server buttons
async function controlButton(buttonType) { async function controlButton(buttonType) {
if (buttonType == "pp") { // Play-Pause button if (buttonType == "pp") { // Play-Pause button
let result = await getFromServer({control: "play-pause"}, "controls"); let result = await postFromServer({control: "play-pause"}, "controls");
// console.log(result); // console.log(result);
currentlyPlaying = result["data"]["playingState"]; currentlyPlaying = result["data"]["playingState"];
} else if (buttonType == "sk") { // Skip button } else if (buttonType == "sk") { // Skip button
// clearInterval(playlistTimeTimer); // clearInterval(playlistTimeTimer);
let returnCode = await getFromServer({control: "skip"}, "controls"); let returnCode = await postFromServer({control: "skip"}, "controls");
// console.log(returnCode["ok"]) // console.log(returnCode["ok"])
if(returnCode["ok"]) { if(returnCode["ok"]) {
if (document.getElementById("playlist-mode").style.display == "block") { if (document.getElementById("playlist-mode").style.display == "block") {
@ -149,7 +178,7 @@ async function controlButton(buttonType) {
document.getElementById("settings-mode").style.display = "block"; document.getElementById("settings-mode").style.display = "block";
checkSettings() checkSettings()
} else if (buttonType == "pm") { //Partymode toggle (in settings) } else if (buttonType == "pm") { //Partymode toggle (in settings)
let response = await getFromServer({setting: "partymode-toggle"}, "settings") let response = await postFromServer({setting: "partymode-toggle"}, "settings")
if(response.ok) { if(response.ok) {
justChangedSetting = true; justChangedSetting = true;
checkSettings(); checkSettings();
@ -169,11 +198,17 @@ function searchSongsEnter(e) {
} }
} }
async function searchSongs(searchTerm){ async function searchSongs(searchTerm,page=-1){
document.getElementById("songlist").innerHTML = "" document.getElementById("songlist").innerHTML = ""
let fetchResults = await getFromServer({search:searchTerm},"search").then(); let fetchResults = await getFromServer("search?query="+searchTerm+"&page="+page);
let searchResults = fetchResults.data; let searchResults = fetchResults.data.songsobj;
//generate the visual song list //generate the visual song list
// let x = document.createElement("button")
// x.addEventListener("click",()=>{
// searchSongs(searchTerm,page+1);
// })
// x.textContent = "Next Page"
// document.getElementById("songlist-mode").appendChild(x)
for(var fileName in searchResults) { for(var fileName in searchResults) {
let currentSongInJSON = searchResults[fileName] let currentSongInJSON = searchResults[fileName]
let newItem = document.createElement("div"); let newItem = document.createElement("div");
@ -187,12 +222,14 @@ async function searchSongs(searchTerm){
} }
image.src = currentSongInJSON["art"]; image.src = currentSongInJSON["art"];
} catch(err){ } catch(err){
image.src = "./images/placeholder.png"; image.src = "/static/images/placeholder.png";
} }
image.id = String(fileName)+" image"; image.id = String(fileName)+" image";
let head3 = document.createElement("h3"); let head3 = document.createElement("h3");
head3.id = fileName;
head3.innerText = currentSongInJSON["title"]; head3.innerText = currentSongInJSON["title"];
let head4 = document.createElement("h4"); let head4 = document.createElement("h4");
head4.id = fileName;
head4.innerText = currentSongInJSON["artist"]; head4.innerText = currentSongInJSON["artist"];
newItem.appendChild(image); newItem.appendChild(image);
newItem.appendChild(head3); newItem.appendChild(head3);
@ -210,6 +247,8 @@ async function searchSongs(searchTerm){
if (JSON.stringify(searchResults)==JSON.stringify({})) { if (JSON.stringify(searchResults)==JSON.stringify({})) {
//display error if no results //display error if no results
document.getElementById("songlist").innerHTML = "<h1>We might not have that one...</h1>"; document.getElementById("songlist").innerHTML = "<h1>We might not have that one...</h1>";
} else {
} }
} }
@ -226,40 +265,8 @@ function alertTimeSet(time) {
alertText("Alerts stay on screen for " + alertTime.toString() + " seconds") alertText("Alerts stay on screen for " + alertTime.toString() + " seconds")
} }
function ipSetEnter(e){
if (e.key == "Enter") {
e.preventDefault();
// why on gosh's green earth am i sending a value here?
// im gonna get rid of all these individual "enter" dectectors and do something
// like i did for the keyboard selection of elements
// basically just if(e==click || e.key == enter)
ipSetter(document.getElementById("iptextbox").value)
}
}
function ipSetter(){
ipBox = document.getElementById("iptextbox").value
if (ipBox == "") {
alertText("Your IP is set to "+ip)
} else {
if (ipBox.includes(":")) {
port = ipBox.slice(ipBox.indexOf(":")+1)
ip = ipBox;
document.cookie = "ip="+ip+"; path=/;"
alertText("Your IP is now set to "+ip.slice(0, ipBox.indexOf(":"))+" at port "+port)
} else {
ip = ipBox + ":19054"
document.cookie = "ip="+ip+"; path=/;"
alertText("Your IP is now set to "+ipBox+" at port 19054 (Default)")
}
}
// anytime the server ip changes the qrcode should change to use it
qrCodeGenerate()
}
function qrCodeGenerate() { function qrCodeGenerate() {
let tempURL = "http://" + document.location.href.split("/")[2] + "/?ip=" + ip; let tempURL = "http://" + URL.parse(document.location.href).host
document.getElementById("qrcode").innerHTML = ""; document.getElementById("qrcode").innerHTML = "";
// get the current foreground and background // get the current foreground and background
let dark = window.getComputedStyle(document.body).getPropertyValue("--text-color"); let dark = window.getComputedStyle(document.body).getPropertyValue("--text-color");
@ -317,12 +324,12 @@ async function updateSingleSetting(data) {
async function checkSettings(skipServer=false) { async function checkSettings(skipServer=false) {
//check client stuff first so if the server doesn't exist it can still be changed and seen //check client stuff first so if the server doesn't exist it can still be changed and seen
if (ip.slice(-5)=="19054") { // if (ip.slice(-5)=="19054") {
// don't show the port if it is the default // // don't show the port if it is the default
document.getElementById("iptextbox").value = ip.slice(0,-6) // document.getElementById("iptextbox").value = ip.slice(0,-6)
} else { // } else {
document.getElementById("iptextbox").value = ip; // document.getElementById("iptextbox").value = ip;
} // }
qrCodeGenerate() qrCodeGenerate()
document.getElementById("alerttimetextbox").value = alertTime document.getElementById("alerttimetextbox").value = alertTime
partyButtonState = document.getElementById("partymode-button").innerHTML; partyButtonState = document.getElementById("partymode-button").innerHTML;
@ -334,7 +341,7 @@ async function checkSettings(skipServer=false) {
} }
} }
//ping the server here //ping the server here
data = await getFromServer({setting: "getsettings"}, "settings"); data = await getFromServer("settings");
x = data["data"]; x = data["data"];
if (!(skipServer) || partyButtonState=="N/A") { if (!(skipServer) || partyButtonState=="N/A") {
if (x["partymode"] == false) { if (x["partymode"] == false) {
@ -372,7 +379,7 @@ async function addToPlaylist(songObject) {
} }
image.src = songObject[newItem.id]["art"]; image.src = songObject[newItem.id]["art"];
} catch(err){ } catch(err){
image.src = "./images/placeholder.png"; image.src = "static/images/placeholder.png";
} }
image.id = String(songObject[newItem.id])+" image"; image.id = String(songObject[newItem.id])+" image";
let head3 = document.createElement("h3"); let head3 = document.createElement("h3");
@ -431,7 +438,7 @@ async function skipInPlaylist() {
async function generateVisualPlaylist(conditions="") { async function generateVisualPlaylist(conditions="") {
document.getElementById("playlist").innerHTML = "<h1 id=\"playlist-alert\"></h1>"; document.getElementById("playlist").innerHTML = "<h1 id=\"playlist-alert\"></h1>";
data = await getFromServer(null, "playlist"); data = await getFromServer("playlist");
playlist = data["data"]["playlist"]; playlist = data["data"]["playlist"];
currentlyPlaying = data["data"]["playingState"] currentlyPlaying = data["data"]["playingState"]
playlist = Object.values(playlist).map(obj => { playlist = Object.values(playlist).map(obj => {
@ -462,7 +469,7 @@ async function generateVisualPlaylist(conditions="") {
} }
image.src = playlist[i]["art"]; image.src = playlist[i]["art"];
} catch(err){ } catch(err){
image.src = "./images/placeholder.png"; image.src = "/static/images/placeholder.png";
} }
image.id = String(fileName)+" image"; image.id = String(fileName)+" image";
let head3 = document.createElement("h3"); let head3 = document.createElement("h3");
@ -509,9 +516,9 @@ async function generateVisualPlaylist(conditions="") {
} }
async function submitSong(songid) { async function submitSong(songid) {
let returncode = await getFromServer({song: songid}, "songadd"); let returncode = await postFromServer({song: songid}, "songadd");
if(returncode["status"] === ERR_NO_ADMIN) { if(returncode["status"] === ERR_NO_ADMIN) {
// right now the error is alerted in getFromServer, maybe will change that // right now the error is alerted in postFromServer, maybe will change that
} else if(returncode["status"]!==200) { } else if(returncode["status"]!==200) {
alertText("That song's already in the queue! Hang on!") alertText("That song's already in the queue! Hang on!")
} else { } else {
@ -521,6 +528,7 @@ async function submitSong(songid) {
function checkWhatSongWasClicked(e) { function checkWhatSongWasClicked(e) {
if(e.type == "click" || e.key == "Enter") { if(e.type == "click" || e.key == "Enter") {
itemId = e.srcElement.id; itemId = e.srcElement.id;
// console.log(e.srcElement);
if ((itemId.length-itemId.lastIndexOf("image") == 5) && itemId.lastIndexOf("image")!=-1) { if ((itemId.length-itemId.lastIndexOf("image") == 5) && itemId.lastIndexOf("image")!=-1) {
itemId = itemId.slice(0,-6) itemId = itemId.slice(0,-6)
} }
@ -584,7 +592,7 @@ async function submitPerms(e) {
tempData["PM"] = document.getElementById("partymodesettingcheckbox").checked; tempData["PM"] = document.getElementById("partymodesettingcheckbox").checked;
tempData["VOL"] = document.getElementById("volumechangesettingcheckbox").checked; tempData["VOL"] = document.getElementById("volumechangesettingcheckbox").checked;
tempData["DUP"] = document.getElementById("duplicateallowesettingcheckbox").checked; tempData["DUP"] = document.getElementById("duplicateallowesettingcheckbox").checked;
let returncode = await getFromServer({"setting":"perms","admin":tempData},"settings"); let returncode = await postFromServer({"setting":"perms","admin":tempData},"settings");
if (!(returncode["ok"])) { if (!(returncode["ok"])) {
// if you aren't allowed to check the box then toggle it again // if you aren't allowed to check the box then toggle it again
// its not perfect if you spam click, but it gets the point across to the user // its not perfect if you spam click, but it gets the point across to the user
@ -596,8 +604,8 @@ async function submitPerms(e) {
} }
async function clearPlaylist() { async function clearPlaylist() {
let returncode = await getFromServer({control:"clear"},"controls"); let returncode = await postFromServer({control:"clear"},"controls");
if(returncode == ERR_NO_ADMIN || returncode == null) { if(returncode["status"] === ERR_NO_ADMIN || returncode == null) {
// alertText("Admin Restricted ") // alertText("Admin Restricted ")
// there's an admin restrict alert built into getFromServer // there's an admin restrict alert built into getFromServer
} else { } else {
@ -618,8 +626,8 @@ document.getElementById("playlist-mode").style.display = "none";
document.getElementById("settings-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "none";
document.getElementById("volumerange").onchange = async function(e) { document.getElementById("volumerange").onchange = async function(e) {
// there is no reason for this not to be a defined function // there is no reason for this not to be a defined function
// FIX THIS // TODO: FIX THIS
let returnValue = await getFromServer({setting:"volume",level:e.target.value}, "settings") let returnValue = await postFromServer({setting:"volume",level:e.target.value}, "settings")
if (returnValue["status"] == ERR_NO_ADMIN) { if (returnValue["status"] == ERR_NO_ADMIN) {
// alertText("Error: Admin restricted action"); // alertText("Error: Admin restricted action");
// there's an admin restrict alert built into getFromServer // there's an admin restrict alert built into getFromServer
@ -648,7 +656,7 @@ document.getElementById("search-button").addEventListener('click', function(){co
document.getElementById("skip-button").addEventListener('click',function(){controlButton("sk")}); document.getElementById("skip-button").addEventListener('click',function(){controlButton("sk")});
document.getElementById("go-search").addEventListener('click', function(){searchSongs(document.getElementById("songsearch").value)}) document.getElementById("go-search").addEventListener('click', function(){searchSongs(document.getElementById("songsearch").value)})
document.getElementById("songsearch").addEventListener('keydown', function(e){searchSongsEnter(e)}); document.getElementById("songsearch").addEventListener('keydown', function(e){searchSongsEnter(e)});
document.getElementById("iptextbox").addEventListener('keydown', function(e){ipSetEnter(e)}); // document.getElementById("iptextbox").addEventListener('keydown', function(e){ipSetEnter(e)});
document.getElementById("alerttimetextbox").addEventListener('keydown', function(e){alertTimeEnter(e)}); document.getElementById("alerttimetextbox").addEventListener('keydown', function(e){alertTimeEnter(e)});
document.getElementById("adminpasswordbox").addEventListener('keydown',function(e){adminPassEnter(e)}); document.getElementById("adminpasswordbox").addEventListener('keydown',function(e){adminPassEnter(e)});
document.getElementById("admincheckholder").addEventListener('click',function(e){submitPerms(e)}); document.getElementById("admincheckholder").addEventListener('click',function(e){submitPerms(e)});
@ -664,20 +672,6 @@ document.getElementById("songlist").addEventListener('click', function(e){checkW
// let tempWidth = document.getElementById('controls').clientWidth; // let tempWidth = document.getElementById('controls').clientWidth;
// document.getElementById("controls").style.marginLeft = "-"+String(parseInt(tempWidth/2))+"px"; // document.getElementById("controls").style.marginLeft = "-"+String(parseInt(tempWidth/2))+"px";
//for my use case (my immediate family), they dont know how to set an ip
//using this allows the creator of the link for, a qr code for example, to set the ip before distributing the code, and it would all work smoothly
//example (http://192.168.1.100:8000/?ip=192.168.1.100:19054 sets the ip to the same host at the default port)
//the port must be set manually using this method, but only has to be done once for the url that ends up being shared
//tries the url first, then the cookie, then the default
ip = params.get("ip")
if (ip == null || ip=="") {
ip=getCookie("ip")
}
if (ip==null || ip==""){
ip = ""
}
// saving the cookies (don't tell the EU) // saving the cookies (don't tell the EU)
document.cookie = "ip="+ip+"; path=/;" document.cookie = "ip="+ip+"; path=/;"
@ -707,7 +701,8 @@ socket.on("timeUpdate", function(data) {
// console.log("recieved data from timeUpdate"); // console.log("recieved data from timeUpdate");
// console.log(data); // console.log(data);
playlistElapsedSeconds = data["elapsedTime"]; playlistElapsedSeconds = data["elapsedTime"];
currentlyPlaying = data["playingState"] currentlyPlaying = data["playingState"];
playlistSongLength = data["songLength"]
}); });
socket.on("skipSong",() => { socket.on("skipSong",() => {

View file

@ -235,7 +235,6 @@ h4 {
.versionNumber { .versionNumber {
font-size: 11px; font-size: 11px;
font-style: italic;
text-align: left; text-align: left;
width: 80%; width: 80%;
} }

View file

@ -3,17 +3,15 @@
<head> <head>
<title>Jukebox Controller</title> <title>Jukebox Controller</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="/static/styles.css">
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="/static/manifest.json"/>
<link rel="shortcut icon" href="/static/favicon.ico">
<meta charset="utf-8"> <meta charset="utf-8">
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"></script> <script src="/static/ext/sha256.min.js"></script>
<!-- above allows use of sha256() on http --> <!-- above allows use of sha256() on http -->
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script> <script src="/static/ext/socket.io.min.js"></script>
</head> </head>
<body id="test-body"> <body id="test-body">
<!--Cookie Popup(does it matter if im not tracking them? i have no idea)-->
<script type="text/javascript" src="/ext/popper.js"></script><script> window.start.init({Palette:"palette2",Mode:"floating left",Theme:"classic",LinkText:" Learn More!",Message:"This website uses cookies to save necessary data to your device, and no tracking is performed.",Time:"0",})</script>
<div class="intro"> <div class="intro">
<h1 id="title">Jukebox Remote</h1> <h1 id="title">Jukebox Remote</h1>
<p>Add songs to the shared playlist below!</p> <p>Add songs to the shared playlist below!</p>
@ -63,11 +61,11 @@ changes visibility with JS-->
<p class="italic">Opposite of light mode</p> <p class="italic">Opposite of light mode</p>
<button title="darkmode-button" id="darkmode-button">Off</button> <button title="darkmode-button" id="darkmode-button">Off</button>
</div> </div>
<div class="item"> <!-- <div class="item">
<h2 for="iptextbox">Server IP:</h2> <h2 for="iptextbox">Server IP:</h2>
<p class="italic">IP of the device running the song server</p> <p class="italic">IP of the device running the song server</p>
<input title="iptextbox" style="width:200px" type="text" id="iptextbox" enterkeyhint="Done"> <input title="iptextbox" style="width:200px" type="text" id="iptextbox" enterkeyhint="Done">
</div> </div> -->
<div class="item"> <div class="item">
<h2 for="alerttimetextbox">Alert Time:</h2> <h2 for="alerttimetextbox">Alert Time:</h2>
<p class="italic">How long alerts stay on screen for (seconds)</p> <p class="italic">How long alerts stay on screen for (seconds)</p>
@ -114,20 +112,21 @@ changes visibility with JS-->
<p class="italic" >Wipe the playlist, except the currently playing song. With PartyMode enabled, a second song will be added back randomly</p> <p class="italic" >Wipe the playlist, except the currently playing song. With PartyMode enabled, a second song will be added back randomly</p>
<button id="clear-button">Clear Playlist</button> <button id="clear-button">Clear Playlist</button>
</div> </div>
<p class="versionNumber">PartyJukebox is under an <a href="https://github.com/kristy-fournier/PartyJukebox/blob/main/LICENSE.md" target="_blank">AGPLV3</a> liscense. You can access the source code <a href=https://github.com/kristy-fournier/PartyJukebox target="_blank">here</a>.</p> <p class="versionNumber italic">PartyJukebox is under an <a href="https://github.com/kristy-fournier/PartyJukebox/blob/main/LICENSE.md" target="_blank">AGPLV3</a> liscense. You can access the source code <a href=https://github.com/kristy-fournier/PartyJukebox target="_blank">here</a>.</p>
<p class="versionNumber italic">Release {{ REL_VER_NUM }}</p>
</div> </div>
</div> </div>
<!--All the buttons are down here but settings is just doing its own thing--> <!--All the buttons are down here but settings is just doing its own thing-->
<div id="controls" class="controls"> <div id="controls" class="controls">
<img tabindex=0 class="control-button" id="playlist-button" src="./images/playlist.png" alt="Playlist"></img> <img tabindex=0 class="control-button" id="playlist-button" src="/static/images/playlist.png" alt="Playlist"></img>
<img tabindex=0 class="control-button" id="play-pause-button" src="./images/play-pause.png" alt="Play pause"></img> <img tabindex=0 class="control-button" id="play-pause-button" src="/static/images/play-pause.png" alt="Play pause"></img>
<img tabindex=0 class="control-button" id="skip-button" src="./images/skip.png" alt="Skip"></img> <img tabindex=0 class="control-button" id="skip-button" src="/static/images/skip.png" alt="Skip"></img>
<img tabindex=0 class="control-button" id="search-button" src="./images/search.png" alt="Search"></img> <img tabindex=0 class="control-button" id="search-button" src="/static/images/search.png" alt="Search"></img>
</div> </div>
<div class="settings-button-holder"> <div class="settings-button-holder">
<img tabindex=0 class="settings-button control-button" id="settings-button" src="./images/settings.png" alt="settings"></img> <img tabindex=0 class="settings-button control-button" id="settings-button" src="/static/images/settings.png" alt="settings"></img>
</div> </div>
<script src="/ext/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="/static/ext/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="scripts.js"></script> <script src="/static/scripts.js"></script>
</body> </body>
</html> </html>

67
Server/versionNum.py Normal file
View file

@ -0,0 +1,67 @@
from __future__ import annotations
class VersionNumber:
def __init__(self,major:int,minor:int,patch:int,extra:str):
self.major = major
self.minor = minor
self.patch = patch
self.extra = extra
# From String like "x.y.z-extra"
@staticmethod
def fromString(verString:str) -> VersionNumber:
numList = verString.split(".")
major = int(numList[0])
minor = int(numList[1])
patch = int(numList[2].split("-")[0])
extra = numList[2].split("-")[1]
return VersionNumber(major,minor,patch,extra)
def clone(verNumIn:VersionNumber) -> VersionNumber:
return VersionNumber(verNumIn.major,verNumIn.minor,verNumIn.patch,verNumIn.extra)
def __str__(self) -> str:
returnStr = f"v{self.major}.{self.minor}.{self.patch}"
if(self.extra):
returnStr += f"-{self.extra}"
return returnStr
def __eq__(self,comp) -> bool:
if type(comp) == VersionNumber:
return self.major == comp.major and self.minor == comp.minor and self.patch == comp.patch and self.extra == comp.extra
elif type(comp) == str:
return self == VersionNumber.fromString(comp)
elif type(comp) == type(None):
return False
else:
raise TypeError
def __gt__(self,comp):
if type(comp) == VersionNumber:
if(self.major > comp.major):
return True
elif(self.major == comp.major and self.minor > comp.minor):
return True
elif(self.major == comp.major and self.minor == comp.minor and self.patch > comp.patch):
return True
return False
elif type(comp) == str:
return self > VersionNumber.fromString(comp)
else:
raise TypeError
def __ge__(self,comp):
return self > comp or self == comp
if __name__ == "__main__":
x = VersionNumber(1,2,4,"alpha")
y = VersionNumber.fromString("1.2.3-beta")
z = VersionNumber.clone(x)
print(x)
print(y)
print(z)
print(f"X == Y: {x==y}")
print(f"X > Y: {x>y}")
print(f"Y < X: {y<x}")
print(f"Z >= Y: {z>=y}")
print(f"Z <= Y: {z<=y}")

View file

@ -1,56 +1,59 @@
import eventlet # import eventlet
eventlet.monkey_patch() # eventlet.monkey_patch()
from flask import Flask from flask import Flask
from flask import request from flask import request,render_template
from flask_cors import CORS
from flask_socketio import SocketIO from flask_socketio import SocketIO
import sqlite3 as sql import sqlite3 as sql
import vlc,threading,time,random,argparse,dotenv,os,hashlib,string import vlc,threading,random,argparse,dotenv,os,hashlib,string,getpass
from versionNum import VersionNumber
# So i'm famously bad at following Semantic versioning, we're gonna see how this goes
REL_VER_NUM = VersionNumber(0,0,2,"alpha")
# Argparse Stuff # Argparse Stuff
parser=argparse.ArgumentParser(description="Options for the Webby Bits") parser=argparse.ArgumentParser(description="Options for the Webby Bits")
# parser.add_argument('-p','--port',help="Port to host on, not the same as the web (client) port",default='19054') parser.add_argument('-a','--admin',help="Set as True to be prompted to enter an AdminPassword",default=False)
parser.add_argument('-a','--admin',help="Add an admin password to be used in the client. DO NOT use a password you use elsewhere",default="")
args = parser.parse_args() args = parser.parse_args()
dotenv.load_dotenv() dotenv.load_dotenv()
portTheUserPicked=os.getenv("SERVER_PORT") portTheUserPicked=os.getenv("SERVER_PORT")
ERR_NO_ADMIN = ({"error":"no-admin","data":None},401) ERR_NO_ADMIN = ({"error":"no-admin","data":None},401)
ERR_200 = ({"error":"OK","data":None},200) ERR_200 = ({"error":"OK","data":None},200)
ERR_MISSING_ARGS = ({"error":"Request missing required arguments","data":None}),400 ERR_MISSING_ARGS = ({"error":"Request missing required arguments","data":None},400)
if args.admin: if bool(args.admin) and args.admin.lower() != "false":
ADMIN_PASS = hashlib.sha256(bytes(args.admin,'utf-8')).hexdigest() ADMIN_PASS = hashlib.sha256(bytes(getpass.getpass("Enter AdminPass: "),'utf-8')).hexdigest()
else: else:
tempPass = ''.join(random.choices(string.ascii_letters + string.digits +"?"+"!",k=20)) tempPass = ''.join(random.choices(string.ascii_letters + string.digits +"?"+"!",k=20))
print("No adminPass was set, the auto generated one is: "+tempPass) print("No adminPass was set, the auto generated one is: "+tempPass)
ADMIN_PASS = hashlib.sha256(bytes(tempPass,'utf-8')).hexdigest() ADMIN_PASS = hashlib.sha256(bytes(tempPass,'utf-8')).hexdigest()
# True = everyone, False = admin only. Change in client while in use. # True = everyone, False = admin only. Change in client while in use.
# play-pause,skip,addsong,partymode,volume in order # play-pause,skip,addsong,partymode,volume,add duplicates in order
controlPerms = { controlPerms = {
"PP":True, "PP":True,
"SK":True, "SK":True,
"AS":True, "AS":True,
"PM":True, "PM":True,
"VOL":True, "VOL":True,
"DUP":True # Not implemented, allow duplicate songs in queue "DUP":True
} }
fileofDB = sql.connect("songDatabase.db") fileofDB = sql.connect("songDatabase.db")
songDatabase = fileofDB.cursor() songDatabase = fileofDB.cursor()
#song directory #song directory
songDatabase.execute("SELECT * FROM meta WHERE id='songDirectory';") try:
soundLocation = songDatabase.fetchall()[0][1] songDatabase.execute("SELECT * FROM meta WHERE id='songDirectory';")
soundLocation = songDatabase.fetchall()[0][1]
except sql.OperationalError:
print("No Database Found, try running databaseGenerator.py")
os._exit(1)
if soundLocation[-1] == "/" or soundLocation[-1] == "\\": if soundLocation[-1] == "/" or soundLocation[-1] == "\\":
pass pass
elif "/" in soundLocation: elif "/" in soundLocation:
soundLocation += "/" soundLocation += "/"
else: else:
soundLocation += "\\" soundLocation += "\\"
#Create Virtual table for searching
#I'm not sure why i don't do this in the databaseGenerator, but it also takes like 3 seconds so i'm not messing with it rn
#Initializing all the global stuff #Initializing all the global stuff
random.seed() random.seed()
global partyMode global partyMode
@ -66,10 +69,7 @@ player = vlcInstance.media_player_new()
# for client side volume to work as well as possible, set system volume to 100 and control in app # for client side volume to work as well as possible, set system volume to 100 and control in app
player.audio_set_volume(100) player.audio_set_volume(100)
app = Flask(__name__) app = Flask(__name__)
# because you are POSTing from another domain to this one, you need CORS socketio = SocketIO(app)
CORS(app)
# Replace the star with the frontend domain if you dislike being hacked
socketio = SocketIO(app, cors_allowed_origins="*")
def queueSong(song): def queueSong(song):
with playlistLock: with playlistLock:
@ -105,7 +105,7 @@ def playQueuedSongs():
counter+=1 counter+=1
if(counter > 2): if(counter > 2):
playingState = str(player.get_state()) == "State.Playing" playingState = str(player.get_state()) == "State.Playing"
socketio.emit('timeUpdate',{"elapsedTime":player.get_time()/1000,"playingState":playingState}) socketio.emit('timeUpdate',{"elapsedTime":player.get_time()/1000,"playingState":playingState,"songLength":player.get_length()/1000})
counter = 0 counter = 0
playerState = str(player.get_state()) playerState = str(player.get_state())
endStates = ["State.Ended","State.Stopped","State.NothingSpecial"] endStates = ["State.Ended","State.Stopped","State.NothingSpecial"]
@ -140,12 +140,16 @@ def playQueuedSongs():
# check for new songs every second # check for new songs every second
# I just didn't want to eat too much processing looping # I just didn't want to eat too much processing looping
# this also has another useful affect that skips get "queued" to only 1 per second, that way somebody usually can't skip twice accidentally # this also has another useful affect that skips get "queued" to only 1 per second, that way somebody usually can't skip twice accidentally
time.sleep(1) socketio.sleep(1)
@socketio.on("connect") @socketio.on("connect")
def handleConnect(): def handleConnect():
pass pass
@app.route("/",methods=['GET'])
def returnStaticFile():
return render_template("index.html",REL_VER_NUM=str(REL_VER_NUM))
@app.route("/controls", methods=['POST']) @app.route("/controls", methods=['POST'])
def playerControls(): def playerControls():
# recieve control inputs (play/pause and skip) from the webUI # recieve control inputs (play/pause and skip) from the webUI
@ -154,7 +158,7 @@ def playerControls():
recieveData=request.get_json(force=True) recieveData=request.get_json(force=True)
try: try:
if recieveData["control"] == "play-pause": if recieveData["control"] == "play-pause":
if ADMIN_PASS == recieveData['password'] or controlPerms["PP"]: if ADMIN_PASS == request.headers["Jukebox-Auth"] or controlPerms["PP"]:
playingState = str(player.get_state())=="State.Playing" playingState = str(player.get_state())=="State.Playing"
player.pause() player.pause()
return {"error":"ok","data":{"playingState":not(playingState)}},200 return {"error":"ok","data":{"playingState":not(playingState)}},200
@ -162,14 +166,14 @@ def playerControls():
playingState = str(player.get_state())=="State.Playing" playingState = str(player.get_state())=="State.Playing"
return {"error":"Admin Restricted Action","data":{"playingState":playingState}},401 return {"error":"Admin Restricted Action","data":{"playingState":playingState}},401
elif recieveData["control"] == "skip": elif recieveData["control"] == "skip":
if ADMIN_PASS == recieveData['password'] or controlPerms["SK"]: if ADMIN_PASS == request.headers["Jukebox-Auth"] or controlPerms["SK"]:
skipNow = True skipNow = True
return ERR_200 return ERR_200
else: else:
return ERR_NO_ADMIN return ERR_NO_ADMIN
# Maybe i should have put this next one in the "settings" section # Maybe i should have put this next one in the "settings" section
elif recieveData["control"] == "clear": elif recieveData["control"] == "clear":
if ADMIN_PASS == recieveData['password']: # this is only ever allowed with the adminpassword if ADMIN_PASS == request.headers["Jukebox-Auth"]: # this is only ever allowed with the adminpassword
with playlistLock: with playlistLock:
playlist.clear() playlist.clear()
return ERR_200 return ERR_200
@ -180,16 +184,19 @@ def playerControls():
except KeyError: except KeyError:
return ERR_MISSING_ARGS return ERR_MISSING_ARGS
@app.route("/settings", methods=['POST']) @app.route("/settings", methods=['POST','GET'])
def settingsControl(): def settingsControl():
global controlPerms global controlPerms
# set the volume and partymode # set the volume and partymode
global partyMode global partyMode
global player global player
if (request.method == 'GET'):
return {"error":"ok","data":{"partymode":partyMode,"volume":player.audio_get_volume(),"admin":controlPerms}},200
elif (request.method == 'POST'):
recieveData = request.get_json(force=True) recieveData = request.get_json(force=True)
try: try:
if recieveData["setting"] == "volume": if recieveData["setting"] == "volume":
if ADMIN_PASS == recieveData['password'] or controlPerms["VOL"]: if ADMIN_PASS == request.headers["Jukebox-Auth"] or controlPerms["VOL"]:
volumeLevel = int(recieveData["level"]) volumeLevel = int(recieveData["level"])
if(volumeLevel <= 100 and volumeLevel >= 0): if(volumeLevel <= 100 and volumeLevel >= 0):
volumePassed = player.audio_set_volume(volumeLevel) volumePassed = player.audio_set_volume(volumeLevel)
@ -202,7 +209,7 @@ def settingsControl():
else: else:
return ERR_NO_ADMIN return ERR_NO_ADMIN
elif recieveData["setting"] == "partymode-toggle": elif recieveData["setting"] == "partymode-toggle":
if ADMIN_PASS == recieveData['password'] or controlPerms["PM"]: if ADMIN_PASS == request.headers["Jukebox-Auth"] or controlPerms["PM"]:
partyMode = not(partyMode) partyMode = not(partyMode)
partyModeStr = "On" if partyMode else "Off" partyModeStr = "On" if partyMode else "Off"
socketio.emit("settingsChange",{"settingToChange":"partymode","newData":partyModeStr}) socketio.emit("settingsChange",{"settingToChange":"partymode","newData":partyModeStr})
@ -210,35 +217,42 @@ def settingsControl():
else: else:
return ERR_NO_ADMIN return ERR_NO_ADMIN
elif recieveData["setting"] == "perms": elif recieveData["setting"] == "perms":
if ADMIN_PASS == recieveData["password"]: if ADMIN_PASS == request.headers["Jukebox-Auth"]:
controlPerms = recieveData["admin"] controlPerms = recieveData["admin"]
# print(recieveData["admin"]) # print(recieveData["admin"])
socketio.emit("settingsChange",{"settingToChange":"perms","newData":controlPerms}) socketio.emit("settingsChange",{"settingToChange":"perms","newData":controlPerms})
return ERR_200 return ERR_200
else: else:
return ERR_NO_ADMIN return ERR_NO_ADMIN
elif recieveData["setting"] == "getsettings":
# probably should have made this a different request type or something but it works
return {"error":"ok","data":{"partymode":partyMode,"volume":player.audio_get_volume(),"admin":controlPerms}},200
else: else:
return {"error":"Not a valid setting","data":None},400 return {"error":"Not a valid setting","data":None},400
except: except KeyError as e:
return ERR_MISSING_ARGS print(f"Error: {e}")
return {"error":"Incorrect Data Sent","data":None},400
@app.route("/search", methods=['POST']) @app.route("/search", methods=['GET'])
def searchSongDB(): def searchSongDB():
recieveData=request.get_json(force=True) recieveData = request.args.get("query")
page = int(request.args.get("page"))
if not(page):
page = 1
fileofDB = sql.connect("songDatabase.db") fileofDB = sql.connect("songDatabase.db")
songDatabase = fileofDB.cursor() songDatabase = fileofDB.cursor()
try: try:
results = [] results = []
# print(recieveData["search"]) # print(recieveData["search"])
if (recieveData['search'] == ""): if (recieveData == None or recieveData == ""):
songDatabase.execute("SELECT * FROM virtualSongs") songDatabase.execute("SELECT * FROM virtualSongs")
results = songDatabase.fetchall() results = songDatabase.fetchall()
else: else:
songDatabase.execute("SELECT * FROM virtualSongs WHERE virtualSongs MATCH ?",['"' + recieveData['search']+'"']) songDatabase.execute("SELECT * FROM virtualSongs WHERE virtualSongs MATCH ?",['"' + recieveData +'"'])
results = songDatabase.fetchall() results = songDatabase.fetchall()
pages = (len(results)//20)+1
if(page>0):
# Numbers <0 use old rendering
inBound = 20*(page-1)
outBound = 20*page
results = results[inBound:outBound]
tempdata = {} tempdata = {}
# this is a temporary solution so i dont have to change the client # this is a temporary solution so i dont have to change the client
for i in results: for i in results:
@ -251,10 +265,7 @@ def searchSongDB():
} }
fileofDB.close() fileofDB.close()
return {"error":"ok","data":tempdata},200 return {"error":"ok","data":{"songsobj":tempdata,"pages":pages}},200
except KeyError:
fileofDB.close()
return ERR_MISSING_ARGS
except sql.OperationalError as e: except sql.OperationalError as e:
print(e) print(e)
fileofDB.close() fileofDB.close()
@ -265,9 +276,9 @@ def searchSongDB():
def songadd(): def songadd():
recieveData=request.get_json(force=True) recieveData=request.get_json(force=True)
try: try:
if (ADMIN_PASS == recieveData['password']) or controlPerms["AS"]: if (ADMIN_PASS == request.headers["Jukebox-Auth"]) or controlPerms["AS"]:
# Password exists and is correct, or it's not restricted # Password exists and is correct, or it's not restricted
if not(controlPerms["DUP"]) and (recieveData['song'] in playlist) and not(ADMIN_PASS == recieveData['password']): if not(controlPerms["DUP"]) and (recieveData['song'] in playlist) and not(ADMIN_PASS == request.headers["Jukebox-Auth"]):
return {"error":"This song is already in the queue, hang on!","data":None},409 return {"error":"This song is already in the queue, hang on!","data":None},409
else: else:
queueSong(recieveData['song']) queueSong(recieveData['song'])
@ -279,7 +290,7 @@ def songadd():
print(e) print(e)
return ERR_MISSING_ARGS return ERR_MISSING_ARGS
@app.route("/playlist", methods=["POST"]) @app.route("/playlist", methods=["GET"])
def getPlaylist(): def getPlaylist():
global songNext global songNext
fileofDB = sql.connect("songDatabase.db") fileofDB = sql.connect("songDatabase.db")
@ -324,5 +335,6 @@ if __name__ == "__main__":
queueThread = threading.Thread(target=playQueuedSongs) queueThread = threading.Thread(target=playQueuedSongs)
queueThread.daemon = True queueThread.daemon = True
queueThread.start() queueThread.start()
print(f"PartyJukebox {REL_VER_NUM} running on port {portTheUserPicked}")
socketio.run(app=app,host='0.0.0.0', port=portTheUserPicked) socketio.run(app=app,host='0.0.0.0', port=portTheUserPicked)

View file

@ -4,13 +4,12 @@
You can use `--help` on any of the python files to see all the properties that can be changed at runtime You can use `--help` on any of the python files to see all the properties that can be changed at runtime
## Purpose ## Purpose
The **Party Jukebox** is a program that allows many people to add music, skip songs, play, and pause from any web device to the same device and playlist. \ The **Party Jukebox** is a program that allows many people to add music, skip songs, play, and pause from any web device to the same device and playlist. \
This was created for a personal use case for parties, and is a simple, (mostly) functional solution to have a collective playlist for local mp3 files. \ This was created for a personal use case for parties, and is a simple solution to have a collective playlist for local audio files. \
The main advantage compared to doing something similar using Spotify is that you can limit the songs that can be played to your selection. Songs can be chosen, but only from a list. The main advantage compared to doing something similar using Spotify or another streaming service is that you can limit the songs that can be played to your selection. Songs can be chosen, but only from a list.
## Basic Setup ## Basic Setup
### Client Setup: ### Client Setup:
The client is a web application that can be hosted on any server, it need not be the same device running the music player. The client is a web application hosted by the flask app running the audio player.
* If the app is being setup for a large group, you can distribute the url (via QR code, for example) with `?ip=YOURSERVERHOSTNAME:19054` set as an attribute after the url. * You can add `?darkmode=(true/false)` to the client URL to set the default colour scheme, but this will be overwritten by the user's saved choice if they change it themselves
* You can also add `?darkmode=(true/false)` to set the default colour scheme, but this will be overwritten by the users saved choice in the cookie if they change it themselves
### Server Setup: ### Server Setup:
**Pre-setup:** If you want the songs to have art associated with them, it is all hosted on and retrieved from LastFM, and you will need to sign up for a developer app, and put your key in the database generator \ **Pre-setup:** If you want the songs to have art associated with them, it is all hosted on and retrieved from LastFM, and you will need to sign up for a developer app, and put your key in the database generator \
\ \
@ -23,18 +22,20 @@ webbyBits.py
.env .env
``` ```
1. Place mp3 files in the `sound/` folder 1. Place audio files in the `sound/` folder
- Supports flac, mp3, and wav files
2. Rename `example.env` to `.env` and... 2. Rename `example.env` to `.env` and...
- Set the location where your audio files are (Default: `./sound/`) - Set the location where your audio files are (Default: `./sound/`)
- Set the LastFM API key (Optional) - Set the LastFM API key (Optional)
- Change the port of the webbybits server (Default: `19054` ) - Change the port of the app (Default: `19054` )
3. Run `databaseGenerator.py` (Will try to use LastFM API key) 3. Run `databaseGenerator.py` (Will try to use LastFM API key)
* *The `databaseGenerator.py` will index all mp3 files, and save the information to `songDatabase.db`* * *The `databaseGenerator.py` will index all audio files, and save the information to `songDatabase.db`*
* *If getting images, this process may take a long time with a large amount of mp3 files* * *If getting images, this process may take a long time with a large amount of audio files*
4. Run `webbyBits.py` 4. Run `webbyBits.py`
* *The port can be customized by editing the `.env` file* * *The port can be customized by editing the `.env` file*
* *You can add an admin password at runtime with* `-a AdminPass` *as an atribute* * *You can add an admin password at runtime with* `-a True` *as an atribute*
* ***NOTE: Do not reuse ANY password for this, it is hashed but 100% unsecure. The best option is just a random string you write down once*** * ***NOTE: Do not reuse ANY password for this, it is hashed but 100% unsecure. The best option is just a random string you write down once***
* You will be prompted in console for a password to be used
* If this attribute isn't included a random string will be generated as the admin password * If this attribute isn't included a random string will be generated as the admin password
* This is intended for protecting certain features for small closed events, not for public security * This is intended for protecting certain features for small closed events, not for public security
@ -45,8 +46,8 @@ Read on for specific information on each piece of the app.
## Details ## Details
These are specific details on each section of the app, and how to use them These are specific details on each section of the app, and how to use them
### Server: ### Server:
- `sound/` contains all mp3 files by default - `sound/` contains all audio files by default
- `databaseGenerator.py` scans through mp3 files and gets information about them - `databaseGenerator.py` scans through audio files and gets information about them
- `Filename, Title, Artist, Art, Length` are all saved - `Filename, Title, Artist, Art, Length` are all saved
- *If the title and artist are not in the file metadata, it looks for a format of* `TITLE_ARTIST.mp3` *then of* `ARTIST - TITLE.mp3` *and otherwise defaults to the file name as the title, and no artist* - *If the title and artist are not in the file metadata, it looks for a format of* `TITLE_ARTIST.mp3` *then of* `ARTIST - TITLE.mp3` *and otherwise defaults to the file name as the title, and no artist*
- Art is retrieved from LastFM - Art is retrieved from LastFM
@ -97,14 +98,15 @@ From left to right:
The exact process of the password's plaintext scope is as follows The exact process of the password's plaintext scope is as follows
- On the server, you type in the password on the server in the console, the python script takes that plaintext, hashes it, then stores that hash as a variable. The plaintext is also technically a variable, but it's not accessed after that initial hashing. (It's also going to be visible in your console history) - On the server, you type in the password on the server in the console, the python script takes that input directly, hashes it, then stores that hash as a variable. The plaintext could be in memory, but it's not accessible in the code after that initial hashing. If you typed your own password, it won't be visible in the console history after it is typed.
- On the client, you type in the password and press enter. A function reads the value of the password box, saves the hash of that password to a variable, and sends it with all your requests. The plaintext is still stored in the inputbox, but if you delete it and don't press enter on the box again, the hash will be stored without keeping the plaintext. (I may change this behaviour so this box auto-clears when enter is pressed, maybe) - On the client, you type in the password and press enter. A function reads the value of the password box, saves the hash of that password to a variable, and sends it with all your requests. The plaintext is still stored in the input box, but if you delete it and don't press enter on the box again, the hash will be stored without keeping the plaintext. (I may change this behaviour so this box auto-clears when enter is pressed)
None of this is "secure", but it's better than sending plaintext passwords, which is what I was doing before. Hypothetically somebody who intercepted your packet where you sent the password can't get back the original plaintext, just the hash. None of this is "secure", but it's better than sending plaintext passwords, which is what I was doing before. Hypothetically somebody who intercepted any packets with the password can't get back the original plaintext, just the hash.
## External Credits ## External Credits
- QR Code Generator: JS file found [here](https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js) - QR Code Generator: JS file found [here](https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js)
- Cookie Popup: JS file found [here](https://cookieconsent.popupsmart.com/src/js/popper.js) - Socket.io: JS file found [here](https://cdn.socket.io/4.7.5/socket.io.min.js)
- SHA256 over http: JS file found [here](https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js)
*See `LICENSE.md` for redistribution and editing details.* *See `LICENSE.md` for redistribution and editing details.*

View file

@ -5,6 +5,7 @@ charset-normalizer==3.4.4
click==8.3.1 click==8.3.1
colorama==0.4.6 colorama==0.4.6
dotenv==0.9.9 dotenv==0.9.9
eventlet
Flask==3.1.2 Flask==3.1.2
flask-cors==6.0.2 flask-cors==6.0.2
Flask-SocketIO==5.6.0 Flask-SocketIO==5.6.0

View file

@ -1,14 +1,20 @@
# Wishlist # Wishlist
*Features I would like to add, will be completed in any order* *Features I would like to add, will be completed in any order*
- [ ] Pages of song results, to avoid very long pages and large data being sent
- [ ] RESTful design
- [ ] Change all necesary POSTs to GET,DELETE (probably not PUT)
- [ ] Write an api layout
- Despite the fact this isn't necesary (nobody but the app should access any url but root) it's probably still a good idea. It's going to be very simple anyway
- [ ] Loading indicator while awaiting server stuff - [ ] Loading indicator while awaiting server stuff
- [ ] Refactoring existing code - [ ] Refactoring existing code
- [x] Remove old comments - [x] Remove old comments
- [ ] Update the SQL -> Server -> Client pipeline when searching and building playlist - [ ] Update the SQL -> Server -> Client pipeline when searching and building playlist
- Moving to mongo might make more sense to maintain a dictionary/JSON like format, but that needs a server
- [ ] Verify all if-else sequences are correct and not redundant - [ ] Verify all if-else sequences are correct and not redundant
- [ ] Security Updates - [ ] Security Updates
- [x] `.env` file for the api keys and other runtime info to be set, rather than in the `.py` files - [x] `.env` file for the api keys and other runtime info to be set, rather than in the `.py` files
- [x] Hashing rather than plaintext sending passwords (that way at least the password text itself isn't transmitted over the network) - [x] Hashing rather than plaintext sending passwords (that way at least the password text itself isn't transmitted over the network)
- [ ] Actually use SSL, for posting (CORS seems like an issue) - [ ] Actually use TLS, for posting
- [ ] Accessibility - [ ] Accessibility
- [ ] Better use of semantic HTML tags - [ ] Better use of semantic HTML tags
- [ ] Full keyboard control (tab, enter to select, tab between control buttons) - [ ] Full keyboard control (tab, enter to select, tab between control buttons)