From a291e4626aef4588325ac476e5d8f99718b09108 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sat, 12 Jul 2025 22:09:18 -0400 Subject: [PATCH 01/69] added options --- Client/index.html | 21 +++++++++++++++++++-- Client/scripts.js | 8 ++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Client/index.html b/Client/index.html index 6012ac4..23420b7 100644 --- a/Client/index.html +++ b/Client/index.html @@ -81,13 +81,30 @@ changes visibility with JS-->

Volume of the music

-
+

Share the remote:

-

Version 1.0.2

+

Admin Settings

+

Note: Admin password must have been set from the server

+
+

Admin Password:

+

Enter to use admin restricted functions

+ +
+
+

Fine action control:

+

A check means that action is avalible to everyone

+
+
+
+
+
+
+
+

PartyJukebox is under an AGPLV3 liscense. You can access the source code here.

diff --git a/Client/scripts.js b/Client/scripts.js index a63ee01..10bbd5f 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -192,6 +192,14 @@ async function checkSettings(skipServer=false) { qrCodeGenerate() document.getElementById("alerttimetextbox").value = alertTime partyButtonState = document.getElementById("partymode-button").innerHTML; + checksforadmin = document.getElementById("admincheckholder") + // temporary + for (let i in checksforadmin.children) { + if (i.type == "checkbox") { + i.checked = true; + } + } + //ping the server here x = await getFromServer({setting: "getsettings"}, "settings"); if (!(skipServer) || partyButtonState=="N/A") { if (x["partymode"] == false) { From 26e8e937852a72198c10c11773a0e616e4db5018 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:31:02 -0400 Subject: [PATCH 02/69] Fixed adminperms labels --- Client/index.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Client/index.html b/Client/index.html index 23420b7..7d14a89 100644 --- a/Client/index.html +++ b/Client/index.html @@ -98,11 +98,11 @@ changes visibility with JS-->

Fine action control:

A check means that action is avalible to everyone

-
-
-
-
-
+
+
+
+
+

PartyJukebox is under an AGPLV3 liscense. You can access the source code here.

From ea0380e3eb4c16c9dd00213f3c46688f1eb737d4 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:31:22 -0400 Subject: [PATCH 03/69] changed a for loop to fix for testing --- Client/scripts.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 10bbd5f..7ee24de 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -192,11 +192,11 @@ async function checkSettings(skipServer=false) { qrCodeGenerate() document.getElementById("alerttimetextbox").value = alertTime partyButtonState = document.getElementById("partymode-button").innerHTML; - checksforadmin = document.getElementById("admincheckholder") + let nodeList = document.getElementById("admincheckholder").children // temporary - for (let i in checksforadmin.children) { - if (i.type == "checkbox") { - i.checked = true; + for (let i=0; i Date: Fri, 18 Jul 2025 18:31:46 -0400 Subject: [PATCH 04/69] Added base of password system and check for adding a song --- Server/webbyBits.py | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 614e1b4..d8fe771 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -8,7 +8,20 @@ parser=argparse.ArgumentParser(description="Options for the Webby Bits") # this is no longer needed assuming my file works correctly with the generator # parser.add_argument('-d','--directory',help="Directory of the song files (make sure this matches the directory used for the databaseGenerator)", default="./sound/") parser.add_argument('-p','--port',help="Port to host on, not the same as the web (client) port",default='19054') -portTheUserPicked=parser.parse_args().port +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() +portTheUserPicked=args.port +ADMIN_PASS = args.admin +if not(ADMIN_PASS): + ADMIN_PASS = None +# True = everyone, False = admin only. Change in client while in use. +controlPerms = { + "PP":True, + "SK":True, + "AS":True, + "PM":True, + "VOL":True +} fileofDB = sql.connect("songDatabase.db") songDatabase = fileofDB.cursor() @@ -119,9 +132,16 @@ def settingsControl(): elif recieveData["setting"] == "partymode-toggle": partyMode = not(partyMode) return "200" + elif recieveData["setting"] == "perms": + if ADMIN_PASS == recieveData["password"] and ADMIN_PASS: + controlPerms = recieveData["admin"] + return "200" + else: + return "401" + elif recieveData["setting"] == "getsettings": # probably should have made this a different request type or something but it works - x = {"partymode":partyMode,"volume":player.audio_get_volume()} + x = {"partymode":partyMode,"volume":player.audio_get_volume(),"admin":controlPerms} return x else: return "400" @@ -154,8 +174,17 @@ def searchSongDB(): @app.route("/songadd", methods=["POST"]) def songadd(): recieveData=request.get_json(force=True) - queueSong(recieveData['song']) - return "200" + if ADMIN_PASS and ADMIN_PASS == recieveData['password']: + # Pass exists and is correct + queueSong(recieveData['song']) + return "200" + elif ADMIN_PASS and not(controlPerms["AS"]): + # Pass exists, or this action isn't restricted + return "401" + else: + # No pass + queueSong(recieveData['song']) + return "200" @app.route("/playlist", methods=["POST"]) def getPlaylist(): global songNext From f41255e45653934e43a3017185d0da9983d3c0a0 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 18 Jul 2025 19:21:21 -0400 Subject: [PATCH 05/69] added server side password verification to everything that needs it --- Server/webbyBits.py | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/Server/webbyBits.py b/Server/webbyBits.py index d8fe771..22a84c5 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -16,11 +16,11 @@ if not(ADMIN_PASS): ADMIN_PASS = None # True = everyone, False = admin only. Change in client while in use. controlPerms = { - "PP":True, - "SK":True, - "AS":True, - "PM":True, - "VOL":True + "PP":True, #done + "SK":True, #done + "AS":True, #done + "PM":True, #done + "VOL":True #done } fileofDB = sql.connect("songDatabase.db") @@ -111,34 +111,48 @@ def playerControls(): recieveData=request.get_json(force=True) if recieveData["control"] != None: if recieveData["control"] == "play-pause": - player.pause() - return "200" + if ADMIN_PASS == recieveData['password'] or not(ADMIN_PASS) or controlPerms["PP"]: + player.pause() + return "200" + else: + return "401" elif recieveData["control"] == "skip": - skipNow = True - # print(str(player.get_state())) - return "200" + if ADMIN_PASS == recieveData['password'] or not(ADMIN_PASS) or controlPerms["SK"]: + skipNow = True + return "200" + else: + return "401" else: return "400" @app.route("/settings", methods=['POST']) def settingsControl(): + global controlPerms # set the volume and partymode global partyMode global player recieveData = request.get_json(force=True) if recieveData["setting"] == "volume": - volumePassed = player.audio_set_volume(int(recieveData["level"])) - return {"volumePassed":volumePassed} + if ADMIN_PASS == recieveData['password'] or not(ADMIN_PASS) or controlPerms["VOL"]: + volumePassed = player.audio_set_volume(int(recieveData["level"])) + return {"volumePassed":volumePassed} + else: + return "401" elif recieveData["setting"] == "partymode-toggle": - partyMode = not(partyMode) - return "200" + if ADMIN_PASS == recieveData['password'] or not(ADMIN_PASS) or controlPerms["PM"]: + partyMode = not(partyMode) + return "200" + else: + return "401" elif recieveData["setting"] == "perms": + print(ADMIN_PASS) + print(recieveData["password"]) if ADMIN_PASS == recieveData["password"] and ADMIN_PASS: + #if an adminpass doesn't exist these perms can never be changed controlPerms = recieveData["admin"] return "200" else: return "401" - elif recieveData["setting"] == "getsettings": # probably should have made this a different request type or something but it works x = {"partymode":partyMode,"volume":player.audio_get_volume(),"admin":controlPerms} From 1910b30acc32ea6979ed8a171b4d01df8e73e1fe Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 18 Jul 2025 20:13:18 -0400 Subject: [PATCH 06/69] everything but the auto-updating settings should be working and also skipping isnt working on the playlist page --- Client/index.html | 4 ++-- Client/scripts.js | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/Client/index.html b/Client/index.html index 7d14a89..8bd1d0a 100644 --- a/Client/index.html +++ b/Client/index.html @@ -92,7 +92,7 @@ changes visibility with JS-->

Admin Password:

Enter to use admin restricted functions

- +

Fine action control:

@@ -101,7 +101,7 @@ changes visibility with JS-->


-
+

diff --git a/Client/scripts.js b/Client/scripts.js index 7ee24de..07d5d73 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -1,5 +1,6 @@ -let ip -let alertTime = 2 +let ip; +let alertTime = 2; +let adminPass = ""; async function alertText(text="Song Added!") { alertbox = document.getElementById("alert"); alertbox.innerHTML = text; @@ -10,8 +11,11 @@ async function alertText(text="Song Added!") { } // a lot of this is kinda waffly because i was trying to get // it to return the right stuff and javascript is asyrcronouse (boo) -async function getFromServer(bodyInfo, source="") { +async function getFromServer(bodyInfo, source="",password=adminPass) { try{ + if (bodyInfo != null) { + bodyInfo["password"] = password; + } const response = await fetch("http://"+ip+"/"+source, { method: "POST", body: JSON.stringify(bodyInfo), @@ -20,10 +24,15 @@ async function getFromServer(bodyInfo, source="") { } }); const data = await response.json(); + if (data == "401") { + alertText("error: Admin restricted action") + } return await data; } catch(e) { if (e == "TypeError: Failed to fetch"){ alertText("error: Can't Connect to Server (is the ip set?)") + } else if(e == "") { + } else { alertText("error: " + e) } @@ -309,6 +318,22 @@ function toggleDark(e) { } } +function adminPassEnter(e) { + if (e.key == "Enter") { + e.preventDefault(); + adminPass=document.getElementById("adminpasswordbox").value + alertText("Admin Password Updated") + } +} +function submitPerms() { + let tempData = {} + tempData["PP"] = document.getElementById("playpausesettingcheckbox").checked + tempData["SK"] = document.getElementById("skipsongsettingcheckbox").checked + tempData["AS"] = document.getElementById("addsongsettingcheckbox").checked + tempData["PM"] = document.getElementById("partymodesettingcheckbox").checked + tempData["VOL"] = document.getElementById("partymodesettingcheckbox").checked + getFromServer({"setting":"perms","admin":tempData},"settings") +} let optionslist = [] @@ -344,6 +369,8 @@ document.getElementById("go-search").addEventListener('click', function(){search document.getElementById("songsearch").addEventListener('keydown', function(e){searchSongsEnter(e)}); document.getElementById("iptextbox").addEventListener('keydown', function(e){ipSetEnter(e)}); document.getElementById("alerttimetextbox").addEventListener('keydown', function(e){alertTimeEnter(e)}); +document.getElementById("adminpasswordbox").addEventListener('keydown',function(e){adminPassEnter(e)}); +document.getElementById("admincheckholder").addEventListener('click',function(){submitPerms()}); document.getElementById("partymode-button").addEventListener('click',function(){controlButton("pm")}) //sets the fact that clicking a song needs to return its id to the function to find it document.getElementById("songlist").addEventListener('click', function(e){checkWhatSongWasClicked(e)}); From 36c2286b41a12f0735554e419a9a5cc9a3809ccc Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:32:14 -0400 Subject: [PATCH 07/69] Update readme.md --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index e63bcbd..59f555b 100644 --- a/readme.md +++ b/readme.md @@ -65,6 +65,7 @@ From left to right: - The currently playing song is identified, and has the duration listed - The play-pause button toggles playing - The skip button goes to the next track + - *No "previous" button is a design decision (It's a feature not a bug)* - The search button opens the search screen (pictured) - The settings button (top right) opens the settings menu - Server IP allows you to change the ip that the site connects to From 1733a485b4ab874750e219f4db9626c073adc922 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:26:12 -0400 Subject: [PATCH 08/69] 99% done also added some new comments and notes about future design changes --- Client/scripts.js | 33 ++++++++++++++++++++++++++------- Server/webbyBits.py | 25 ++++++++++++++----------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 07d5d73..2f8130d 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -222,6 +222,16 @@ async function checkSettings(skipServer=false) { document.getElementById("partymode-button").innerHTML = "Off"; } document.getElementById("volumerange").value = parseInt(x["volume"]) + + // do the admin checkboxes here + // seemingly i almost finished it last time, dunno why i stopped here + // like as far as i can tell this is the last step + let currentAdminPerms = x["admin"]; + document.getElementById("addsongsettingcheckbox").checked = currentAdminPerms["AS"]; + document.getElementById("skipsongsettingcheckbox").checked = currentAdminPerms["SK"]; + document.getElementById("playpausesettingcheckbox").checked = currentAdminPerms["PP"]; + document.getElementById("partymodesettingcheckbox").checked = currentAdminPerms["PM"]; + document.getElementById("volumechangesettingcheckbox").checked = currentAdminPerms["VOL"]; } async function generateVisualPlaylist(conditions="") { @@ -325,14 +335,20 @@ function adminPassEnter(e) { alertText("Admin Password Updated") } } -function submitPerms() { +async function submitPerms() { let tempData = {} - tempData["PP"] = document.getElementById("playpausesettingcheckbox").checked - tempData["SK"] = document.getElementById("skipsongsettingcheckbox").checked - tempData["AS"] = document.getElementById("addsongsettingcheckbox").checked - tempData["PM"] = document.getElementById("partymodesettingcheckbox").checked - tempData["VOL"] = document.getElementById("partymodesettingcheckbox").checked - getFromServer({"setting":"perms","admin":tempData},"settings") + tempData["PP"] = document.getElementById("playpausesettingcheckbox").checked; + tempData["SK"] = document.getElementById("skipsongsettingcheckbox").checked; + tempData["AS"] = document.getElementById("addsongsettingcheckbox").checked; + tempData["PM"] = document.getElementById("partymodesettingcheckbox").checked; + tempData["VOL"] = document.getElementById("partymodesettingcheckbox").checked; + let returncode = await getFromServer({"setting":"perms","admin":tempData},"settings"); + if (returncode === "401") { + // just so that the checkboxes don't change if you click without the + // (I know i could do this better but this is good enough for now) + // okay this actually doesn't work but i have to go eat dinner + checkSettings(); + } } let optionslist = [] @@ -350,6 +366,8 @@ document.getElementById("settings-mode").style.display = "none"; document.getElementById("volumerange").onchange = async function() { let returnValue = await getFromServer({setting:"volume",level:this.value}, "settings") if (returnValue["volumePassed"] !=0) { + // i forgot about this, i had to do this because it confused the crap out of me one time + // vlc doesn't let you change the volume of nothing, which makes sense if you think about it alertText("Nothing is playing") document.getElementById("volumerange").value = -1 } @@ -378,6 +396,7 @@ document.getElementById("songlist").addEventListener('click', function(e){checkW let tempWidth = document.getElementById('controls').clientWidth; document.getElementById("controls").style.marginLeft = "-"+String(parseInt(tempWidth/2))+"px"; // document.getElementById("darkmode-button").addEventListener('click',function(){toggleDark()}) + //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) diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 22a84c5..fb81d9f 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -10,7 +10,11 @@ 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="Add an admin password to be used in the client. DO NOT use a password you use elsewhere",default="") args = parser.parse_args() + + portTheUserPicked=args.port +#Just a note that the return code "401" as of now is used to mean "you don't have the password" +#This is not great design, and the whole "returning string codes" thing is something to add to the todo list ADMIN_PASS = args.admin if not(ADMIN_PASS): ADMIN_PASS = None @@ -36,7 +40,7 @@ elif "/" in soundLocation: soundLocation += "/" else: soundLocation += "\\" -print(soundLocation) +#print(soundLocation) #Create Virtual table for searching songDatabase.execute("DROP TABLE virtualSongs;") songDatabase.execute("CREATE VIRTUAL TABLE virtualSongs USING fts5(filename, title, artist, art, length);") @@ -58,7 +62,7 @@ player = fakeplayer.media_player_new() # for client side volume to work as well as possible, set system volume to 100 and control in app player.audio_set_volume(100) app = Flask(__name__) -# because you are posting from another domain to this one, you need CORS +# because you are POSTing from another domain to this one, you need CORS CORS(app) def queueSong(song): @@ -124,6 +128,8 @@ def playerControls(): return "401" else: return "400" + else: + return "400" @app.route("/settings", methods=['POST']) def settingsControl(): @@ -145,8 +151,8 @@ def settingsControl(): else: return "401" elif recieveData["setting"] == "perms": - print(ADMIN_PASS) - print(recieveData["password"]) + # print(ADMIN_PASS) + # print(recieveData["password"]) if ADMIN_PASS == recieveData["password"] and ADMIN_PASS: #if an adminpass doesn't exist these perms can never be changed controlPerms = recieveData["admin"] @@ -188,17 +194,14 @@ def searchSongDB(): @app.route("/songadd", methods=["POST"]) def songadd(): recieveData=request.get_json(force=True) - if ADMIN_PASS and ADMIN_PASS == recieveData['password']: - # Pass exists and is correct + if (ADMIN_PASS and ADMIN_PASS == recieveData['password']): + # Pass exists and is correct, or it's not restricted queueSong(recieveData['song']) return "200" - elif ADMIN_PASS and not(controlPerms["AS"]): + else: # Pass exists, or this action isn't restricted return "401" - else: - # No pass - queueSong(recieveData['song']) - return "200" + @app.route("/playlist", methods=["POST"]) def getPlaylist(): global songNext From 76971ea75e6b8e76b54594f77f387da383ce5e62 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 3 Oct 2025 18:51:12 -0400 Subject: [PATCH 09/69] Done adding admin password Just gotta do documentation --- Client/scripts.js | 30 +++++++++++++++++------------- Client/styles.css | 8 ++++++++ Server/webbyBits.py | 18 ++++++++++-------- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 2f8130d..02ac217 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -1,6 +1,8 @@ let ip; let alertTime = 2; let adminPass = ""; +const ERR_NO_ADMIN = "401"; // gonna use this later to refactor + async function alertText(text="Song Added!") { alertbox = document.getElementById("alert"); alertbox.innerHTML = text; @@ -25,12 +27,12 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { }); const data = await response.json(); if (data == "401") { - alertText("error: Admin restricted action") + alertText("Error: Admin restricted action") } return await data; } catch(e) { if (e == "TypeError: Failed to fetch"){ - alertText("error: Can't Connect to Server (is the ip set?)") + alertText("Error: Can't Connect to Server (is the ip set?)") } else if(e == "") { } else { @@ -224,8 +226,6 @@ async function checkSettings(skipServer=false) { document.getElementById("volumerange").value = parseInt(x["volume"]) // do the admin checkboxes here - // seemingly i almost finished it last time, dunno why i stopped here - // like as far as i can tell this is the last step let currentAdminPerms = x["admin"]; document.getElementById("addsongsettingcheckbox").checked = currentAdminPerms["AS"]; document.getElementById("skipsongsettingcheckbox").checked = currentAdminPerms["SK"]; @@ -302,8 +302,12 @@ async function generateVisualPlaylist(conditions="") { } async function submitSong(songid) { - getFromServer({song: songid}, "songadd") - alertText("Added to Queue") + let returncode = await getFromServer({song: songid}, "songadd"); + if(returncode == ERR_NO_ADMIN) { + // right now the error is alerted in getFromServer, maybe will change that + } else { + alertText("Added to Queue"); + } } function checkWhatSongWasClicked(e) { itemId = e.srcElement.id; @@ -335,7 +339,7 @@ function adminPassEnter(e) { alertText("Admin Password Updated") } } -async function submitPerms() { +async function submitPerms(e) { let tempData = {} tempData["PP"] = document.getElementById("playpausesettingcheckbox").checked; tempData["SK"] = document.getElementById("skipsongsettingcheckbox").checked; @@ -343,11 +347,11 @@ async function submitPerms() { tempData["PM"] = document.getElementById("partymodesettingcheckbox").checked; tempData["VOL"] = document.getElementById("partymodesettingcheckbox").checked; let returncode = await getFromServer({"setting":"perms","admin":tempData},"settings"); - if (returncode === "401") { - // just so that the checkboxes don't change if you click without the - // (I know i could do this better but this is good enough for now) - // okay this actually doesn't work but i have to go eat dinner - checkSettings(); + if (returncode == ERR_NO_ADMIN) { + // 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 + let clickedBox = e.srcElement; + clickedBox.checked = !clickedBox.checked; } } @@ -388,7 +392,7 @@ document.getElementById("songsearch").addEventListener('keydown', function(e){se document.getElementById("iptextbox").addEventListener('keydown', function(e){ipSetEnter(e)}); document.getElementById("alerttimetextbox").addEventListener('keydown', function(e){alertTimeEnter(e)}); document.getElementById("adminpasswordbox").addEventListener('keydown',function(e){adminPassEnter(e)}); -document.getElementById("admincheckholder").addEventListener('click',function(){submitPerms()}); +document.getElementById("admincheckholder").addEventListener('click',function(e){submitPerms(e)}); document.getElementById("partymode-button").addEventListener('click',function(){controlButton("pm")}) //sets the fact that clicking a song needs to return its id to the function to find it document.getElementById("songlist").addEventListener('click', function(e){checkWhatSongWasClicked(e)}); diff --git a/Client/styles.css b/Client/styles.css index 803c5df..a6c4cdf 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -177,6 +177,14 @@ h4 { border-bottom: 0; } +.settings > .item > h2 { + margin-bottom: 4px; +} + +.settings > .item > p { + margin-top: 0px +} + .versionNumber { font-size: 8px; font-style: italic; diff --git a/Server/webbyBits.py b/Server/webbyBits.py index fb81d9f..c10a04b 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -13,8 +13,10 @@ args = parser.parse_args() portTheUserPicked=args.port -#Just a note that the return code "401" as of now is used to mean "you don't have the password" -#This is not great design, and the whole "returning string codes" thing is something to add to the todo list +# Just a note that the return code "401" as of now is used to mean "you don't have the password" +# This is not great design, and the whole "returning string codes" thing is something to add to the todo list +# I mean returning 200 when no return is necesary i think is fine but we'll see +ERR_NO_ADMIN = "401" ADMIN_PASS = args.admin if not(ADMIN_PASS): ADMIN_PASS = None @@ -119,13 +121,13 @@ def playerControls(): player.pause() return "200" else: - return "401" + return ERR_NO_ADMIN elif recieveData["control"] == "skip": if ADMIN_PASS == recieveData['password'] or not(ADMIN_PASS) or controlPerms["SK"]: skipNow = True return "200" else: - return "401" + return ERR_NO_ADMIN else: return "400" else: @@ -143,13 +145,13 @@ def settingsControl(): volumePassed = player.audio_set_volume(int(recieveData["level"])) return {"volumePassed":volumePassed} else: - return "401" + return ERR_NO_ADMIN elif recieveData["setting"] == "partymode-toggle": if ADMIN_PASS == recieveData['password'] or not(ADMIN_PASS) or controlPerms["PM"]: partyMode = not(partyMode) return "200" else: - return "401" + return ERR_NO_ADMIN elif recieveData["setting"] == "perms": # print(ADMIN_PASS) # print(recieveData["password"]) @@ -158,7 +160,7 @@ def settingsControl(): controlPerms = recieveData["admin"] return "200" else: - return "401" + return ERR_NO_ADMIN elif recieveData["setting"] == "getsettings": # probably should have made this a different request type or something but it works x = {"partymode":partyMode,"volume":player.audio_get_volume(),"admin":controlPerms} @@ -200,7 +202,7 @@ def songadd(): return "200" else: # Pass exists, or this action isn't restricted - return "401" + return ERR_NO_ADMIN @app.route("/playlist", methods=["POST"]) def getPlaylist(): From 189cafd08a30d83fe429f78d5b993117051bcb49 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 3 Oct 2025 19:03:49 -0400 Subject: [PATCH 10/69] Readme update Should be good to be pushed to main now --- Client/scripts.js | 2 +- readme.md | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Client/scripts.js b/Client/scripts.js index 02ac217..46d1a54 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -347,7 +347,7 @@ async function submitPerms(e) { tempData["PM"] = document.getElementById("partymodesettingcheckbox").checked; tempData["VOL"] = document.getElementById("partymodesettingcheckbox").checked; let returncode = await getFromServer({"setting":"perms","admin":tempData},"settings"); - if (returncode == ERR_NO_ADMIN) { + if (returncode == ERR_NO_ADMIN || returncode == null) { // 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 let clickedBox = e.srcElement; diff --git a/readme.md b/readme.md index e63bcbd..0fa7206 100644 --- a/readme.md +++ b/readme.md @@ -28,6 +28,7 @@ webbyBits.py * *If getting images, this process may take a long time with a large amount of mp3 files* 4. Run `webbyBits.py` * *The port can be customized at runtime using* `-p portNumber` *as an atribute* + * *You can add an admin password at runtime with* `-a AdminPass` *as an atribute* You can now connect with the client and use the app as normal. \ *Make sure you have turned down/off any other apps that might make noise or notification sounds* \ @@ -57,6 +58,15 @@ These are specific details on each section of the app, and how to use them - Uses port 19054 by default - `--port (port)` changes the port for that run - The default port can be changed in the file + - Running with `--admin (admin password)` sets an admin password for moderation on the client + - Anyone who knows the admin password can enter it on the client and change the abilities of any non-admin users (for example to limit skipping) + - The total set of features that can be restricted is + - Skip track + - Play-pause toggle + - Add track + - Partymode toggle + - Change volume + - When this argument is left out (or empty string) the admin features aren't used, and everyone can do everything ### Client: ![image](./Screenshot_MAIN.png) \ From d24aca7f3948f624d01dd1bf8daa685bea56ff3a Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 5 Oct 2025 09:02:48 -0400 Subject: [PATCH 11/69] made very clear passwords are not secure --- readme.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/readme.md b/readme.md index 0fa7206..9a79055 100644 --- a/readme.md +++ b/readme.md @@ -29,6 +29,7 @@ webbyBits.py 4. Run `webbyBits.py` * *The port can be customized at runtime using* `-p portNumber` *as an atribute* * *You can add an admin password at runtime with* `-a AdminPass` *as an atribute* + * ***NOTE: Do not reuse ANY password for this, it is 100% unsecure. The best option is just a random string you write down once*** You can now connect with the client and use the app as normal. \ *Make sure you have turned down/off any other apps that might make noise or notification sounds* \ @@ -59,6 +60,7 @@ These are specific details on each section of the app, and how to use them - `--port (port)` changes the port for that run - The default port can be changed in the file - Running with `--admin (admin password)` sets an admin password for moderation on the client + - ***Note: Do not reuse a password, consider this like making whatever this string is public, no security is guaranteed*** - Anyone who knows the admin password can enter it on the client and change the abilities of any non-admin users (for example to limit skipping) - The total set of features that can be restricted is - Skip track From 86fde793056e12928479aa7186870c85a8eba337 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 5 Oct 2025 09:04:24 -0400 Subject: [PATCH 12/69] make VERY CLEAR the password feature is not secure --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index 9a79055..a54546e 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,7 @@ webbyBits.py * *The port can be customized at runtime using* `-p portNumber` *as an atribute* * *You can add an admin password at runtime with* `-a AdminPass` *as an atribute* * ***NOTE: Do not reuse ANY password for this, it is 100% unsecure. The best option is just a random string you write down once*** + * This is intended for protecting certain features for small closed events, not for public security You can now connect with the client and use the app as normal. \ *Make sure you have turned down/off any other apps that might make noise or notification sounds* \ From d72320aae48ba31f0849fef2b1b2f2fd2de613df Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 5 Oct 2025 23:27:04 -0400 Subject: [PATCH 13/69] Renamed some dumb variables, started wishlist --- Server/webbyBits.py | 20 ++++++++------------ wishlist.md | 12 ++++++++++++ 2 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 wishlist.md diff --git a/Server/webbyBits.py b/Server/webbyBits.py index c10a04b..a70fb93 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -11,7 +11,6 @@ parser.add_argument('-p','--port',help="Port to host on, not the same as the web 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() - portTheUserPicked=args.port # Just a note that the return code "401" as of now is used to mean "you don't have the password" # This is not great design, and the whole "returning string codes" thing is something to add to the todo list @@ -59,8 +58,8 @@ songNext = None skipNow = False playlist = [] playlistLock = threading.Lock() -fakeplayer = vlc.Instance() -player = fakeplayer.media_player_new() +vlcInstance = vlc.Instance() +player = vlcInstance.media_player_new() # for client side volume to work as well as possible, set system volume to 100 and control in app player.audio_set_volume(100) app = Flask(__name__) @@ -77,17 +76,17 @@ def playQueuedSongs(): global partyMode while True: with playlistLock: - z = str(player.get_state()) - - if playlist and (z == "State.Ended" or z== "State.Stopped" or z == "State.NothingSpecial" or skipNow == True): + playerState = str(player.get_state()) + endStates = ["State.Ended","State.Stopped","State.NothingSpecial"] + if playlist and (playerState in endStates or skipNow == True): # New song is in the queue and (the previous song is over or skip has been pressed) player.stop() skipNow = False songNext = playlist.pop(0) - media = fakeplayer.media_new(soundLocation+songNext) + media = vlcInstance.media_new(soundLocation+songNext) player.set_media(media) player.play() - elif (skipNow==True or (z == "State.Ended" or z == "State.NothingSpecial" or z=="State.Stopped")): + elif (skipNow==True or (playerState in endStates)): # skip was pressed and there are no new songs skipNow=False songNext = None @@ -112,7 +111,6 @@ queueThread.start() def playerControls(): # recieve control inputs (play/pause and skip) from the webUI global skipNow - global media global partyMode recieveData=request.get_json(force=True) if recieveData["control"] != None: @@ -153,8 +151,6 @@ def settingsControl(): else: return ERR_NO_ADMIN elif recieveData["setting"] == "perms": - # print(ADMIN_PASS) - # print(recieveData["password"]) if ADMIN_PASS == recieveData["password"] and ADMIN_PASS: #if an adminpass doesn't exist these perms can never be changed controlPerms = recieveData["admin"] @@ -201,7 +197,7 @@ def songadd(): queueSong(recieveData['song']) return "200" else: - # Pass exists, or this action isn't restricted + # Pass exists, and the action is restricted return ERR_NO_ADMIN @app.route("/playlist", methods=["POST"]) diff --git a/wishlist.md b/wishlist.md new file mode 100644 index 0000000..722cc86 --- /dev/null +++ b/wishlist.md @@ -0,0 +1,12 @@ +## Wishlist +*Features I would like to add, will be completed in any order* +- [x] Admin password + * Allows restricting certain features and changing permissions on the fly on the client +- [ ] Refactoring existing code + - [ ] Update the SQL -> Server -> Client pipeline when searching and building playlist +- [ ] Secure Password + * Actually use SSL for stuff that should be using it +- [ ] GUI update for client + - [ ] Google material design?? + - [ ] Dark mode? + - [ ] New Icons \ No newline at end of file From e08e9cbcca1110d618d00d41cf6b9e99335951ac Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:47:58 -0400 Subject: [PATCH 14/69] Fixed a bunch of bugs to make adminpass complete also added a bunch of comments and a new item design (css) that i'm probably going to undo --- Client/scripts.js | 32 +++++++++++++++++++------------- Client/styles.css | 8 ++++++-- Server/webbyBits.py | 7 +++++-- wishlist.md | 9 ++++++++- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 46d1a54..3db547d 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -1,3 +1,4 @@ +// set all the global stuff let ip; let alertTime = 2; let adminPass = ""; @@ -26,7 +27,11 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { } }); const data = await response.json(); - if (data == "401") { + if (data == ERR_NO_ADMIN) { + // im suprised i didn't comment on this already but this is kinda lame desing + // its not wrong but you know + // it is easy which i like + // and it overrides any other non-async alerts which is nice alertText("Error: Admin restricted action") } return await data; @@ -36,14 +41,13 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { } else if(e == "") { } else { - alertText("error: " + e) + alertText("Error: " + e) } const response=null; return response; } } - //cookie reader is taken from internet because cookies ae too complicated for me //i still understand how it works though promise just i see no reason to write this from scratch function getCookie(cname) { @@ -63,34 +67,34 @@ function getCookie(cname) { } //someone more organised than me would have set all these html elements to variables so they dont have to get them 50 times async function controlButton(buttonType) { - if (buttonType == "pp") { + if (buttonType == "pp") { // Play-Pause button getFromServer({control: "play-pause"}, "controls") - } else if (buttonType == "sk") { + } else if (buttonType == "sk") { // Skip button getFromServer({control: "skip"}, "controls") if (document.getElementById("playlist-mode").style.display == "block") { generateVisualPlaylist("skip-button"); } - } else if (buttonType == "pl") { + } else if (buttonType == "pl") { // Playlist button document.getElementById("songlist").innerHTML = ""; document.getElementById("playlist").innerHTML = "

"; document.getElementById("playlist-mode").style.display = "block"; document.getElementById("songlist-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "none"; generateVisualPlaylist(); - } else if (buttonType == "se") { + } else if (buttonType == "se") { //SearchMode button document.getElementById("songlist").innerHTML = "

Search to find songs!

"; document.getElementById("playlist").innerHTML = ""; document.getElementById("playlist-mode").style.display = "none"; document.getElementById("songlist-mode").style.display = "block"; document.getElementById("settings-mode").style.display = "none"; - } else if (buttonType == "st") { + } else if (buttonType == "st") { //Settings button document.getElementById("songlist").innerHTML = ""; document.getElementById("playlist").innerHTML = ""; document.getElementById("playlist-mode").style.display = "none"; document.getElementById("songlist-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "block"; checkSettings() - } else if (buttonType = "pm") { + } else if (buttonType = "pm") { //Partymode toggle (in settings) await getFromServer({setting: "partymode-toggle"}, "settings") checkSettings(true) } @@ -105,7 +109,6 @@ function searchSongsEnter(e) { } async function searchSongs(searchTerm){ - let optionslist = [] document.getElementById("songlist").innerHTML = "" searchResults = await getFromServer({search:searchTerm},"search").then() //generate the visual song list @@ -345,7 +348,7 @@ async function submitPerms(e) { tempData["SK"] = document.getElementById("skipsongsettingcheckbox").checked; tempData["AS"] = document.getElementById("addsongsettingcheckbox").checked; tempData["PM"] = document.getElementById("partymodesettingcheckbox").checked; - tempData["VOL"] = document.getElementById("partymodesettingcheckbox").checked; + tempData["VOL"] = document.getElementById("volumechangesettingcheckbox").checked; let returncode = await getFromServer({"setting":"perms","admin":tempData},"settings"); if (returncode == ERR_NO_ADMIN || returncode == null) { // if you aren't allowed to check the box then toggle it again @@ -366,10 +369,13 @@ document.addEventListener('keydown', function(e){ }}) document.getElementById("playlist-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "none"; -//.ontouch for mobile?? document.getElementById("volumerange").onchange = async function() { + // there is no reason for this not to be a defined function + // FIX THIS let returnValue = await getFromServer({setting:"volume",level:this.value}, "settings") - if (returnValue["volumePassed"] !=0) { + if (returnValue == ERR_NO_ADMIN) { + alertText("Error: Admin restricted action"); + } else if (returnValue["volumePassed"] !=0) { // i forgot about this, i had to do this because it confused the crap out of me one time // vlc doesn't let you change the volume of nothing, which makes sense if you think about it alertText("Nothing is playing") diff --git a/Client/styles.css b/Client/styles.css index a6c4cdf..fdd3d09 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -72,6 +72,11 @@ h4 { text-align: center; } +.item { + /* Only actually applies to playlist and search because settings item has "inherit" bg-colour */ + background-color: #DDDDDD; +} + /* Songlist stuff */ .songlist { width: 80%; @@ -87,7 +92,6 @@ h4 { max-width: 150px; margin: 5px auto; min-width: 75px; - background-color: inherit; } .songlist > .item > img{ @@ -160,7 +164,7 @@ h4 { .settings > .item { margin-left: 10%; width:fit-content; - + background-color: inherit; } .settings > .item:not(:last-child) { diff --git a/Server/webbyBits.py b/Server/webbyBits.py index a70fb93..590ccad 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -20,6 +20,9 @@ ADMIN_PASS = args.admin if not(ADMIN_PASS): ADMIN_PASS = None # True = everyone, False = admin only. Change in client while in use. +"""PP,SK,AS,PM,VOL all set to True or False +False is admin only +True is all users""" controlPerms = { "PP":True, #done "SK":True, #done @@ -192,12 +195,12 @@ def searchSongDB(): @app.route("/songadd", methods=["POST"]) def songadd(): recieveData=request.get_json(force=True) - if (ADMIN_PASS and ADMIN_PASS == recieveData['password']): + if (ADMIN_PASS and ADMIN_PASS == recieveData['password']) or controlPerms["AS"]: # Pass exists and is correct, or it's not restricted queueSong(recieveData['song']) return "200" else: - # Pass exists, and the action is restricted + # the pass is incorrect (technically a pass not existing falls into the above case because controlPerms is never changed) return ERR_NO_ADMIN @app.route("/playlist", methods=["POST"]) diff --git a/wishlist.md b/wishlist.md index 722cc86..75ec375 100644 --- a/wishlist.md +++ b/wishlist.md @@ -4,9 +4,16 @@ * Allows restricting certain features and changing permissions on the fly on the client - [ ] Refactoring existing code - [ ] Update the SQL -> Server -> Client pipeline when searching and building playlist + - [ ] Verify all if-else sequences are correct and not redundant + - [ ] Remove old comments - [ ] Secure Password * Actually use SSL for stuff that should be using it - [ ] GUI update for client - [ ] Google material design?? - [ ] Dark mode? - - [ ] New Icons \ No newline at end of file + - [ ] New Icons +- [ ] "Credit" system so each client can only add a set number of songs + - Based on time period, number in queue, other possible ideas for credits + - Without a login system there's no easy way to give credits to specific clients (and a login is beyond scope of what I want to do) + - Potentially a "redemption code" system, which can be tracked client side + - All of this is also very hackable without a server-side login. \ No newline at end of file From dcfe7115fa4259cad268ee336d9a46f9d37396ff Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:36:06 -0400 Subject: [PATCH 15/69] Playlist items text is spaced not stupidly Playlist Items look like not garbage now I gave them a giant margin and forgot about it. should be good now the item holders have padding instead for the edges --- Client/styles.css | 3 ++- Server/databaseGenerator.py | 2 -- wishlist.md | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Client/styles.css b/Client/styles.css index fdd3d09..ffe8a7d 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -136,6 +136,7 @@ h4 { } .playlist > .item > .text { + padding: 3px; display: inline-block; margin: 0px 3px; } @@ -150,7 +151,7 @@ h4 { } .playlist > .item > .text > * { - margin:5% 2px; + margin:2px; } /* settings stuff */ diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index e83cbc3..8a9b714 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -108,6 +108,4 @@ for i in songFiles: # each "song" is stored as a dictionary/JSON entry following the format seen in the readME songDatabase.execute(f"INSERT INTO songs (filename, title, artist, art, length) VALUES (?,?,?,?,?)",(i,title,artist,image,length)) - - fileOfDB.commit() diff --git a/wishlist.md b/wishlist.md index 75ec375..bb3161d 100644 --- a/wishlist.md +++ b/wishlist.md @@ -9,6 +9,7 @@ - [ ] Secure Password * Actually use SSL for stuff that should be using it - [ ] GUI update for client + - [x] Playlist items look cleaner - [ ] Google material design?? - [ ] Dark mode? - [ ] New Icons @@ -16,4 +17,4 @@ - Based on time period, number in queue, other possible ideas for credits - Without a login system there's no easy way to give credits to specific clients (and a login is beyond scope of what I want to do) - Potentially a "redemption code" system, which can be tracked client side - - All of this is also very hackable without a server-side login. \ No newline at end of file + - All of this is very hackable without a server-side login. \ No newline at end of file From b20f0ecad0bb5b59a372e50a97b1d5923b578a8f Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:28:49 -0400 Subject: [PATCH 16/69] Client is now 100% hosted on the included files no more web references (so you don't need internet access on a closed network) --- Client/ext/popper.js | 134 +++++++++++++++++++++++++++++++++++++++ Client/ext/qrcode.min.js | 1 + Client/index.html | 16 ++--- readme.md | 4 +- 4 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 Client/ext/popper.js create mode 100644 Client/ext/qrcode.min.js diff --git a/Client/ext/popper.js b/Client/ext/popper.js new file mode 100644 index 0000000..19a42b9 --- /dev/null +++ b/Client/ext/popper.js @@ -0,0 +1,134 @@ +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 = + '
' + + content.message + + '" + + content.link + + 'Powered by Popupsmart
'; + + 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(); diff --git a/Client/ext/qrcode.min.js b/Client/ext/qrcode.min.js new file mode 100644 index 0000000..993e88f --- /dev/null +++ b/Client/ext/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/Client/index.html b/Client/index.html index 8bd1d0a..6ee33ca 100644 --- a/Client/index.html +++ b/Client/index.html @@ -8,7 +8,7 @@ - +

Jukebox Remote

@@ -27,7 +27,7 @@ changes visibility with JS--> These are generated using javascript for search
- +

Song title

Artist

@@ -83,8 +83,6 @@ changes visibility with JS-->

Share the remote:

-

Admin Settings

@@ -111,13 +109,13 @@ changes visibility with JS--> settings
- playlist - play pause - skip - search + Playlist + Play pause + Skip + Search
- + \ No newline at end of file diff --git a/readme.md b/readme.md index 54cdf7c..6fbe6ed 100644 --- a/readme.md +++ b/readme.md @@ -52,7 +52,6 @@ These are specific details on each section of the app, and how to use them - Running with `--directory (directoryOfmp3s)` allows for sound files to be in a different place - Default `"./sound/"` - _This setting might be kinda iffy on Linux. You're on Linux just go and edit it if you have issues_ - - ~~__Make certain you only use forward slashes in your directory, even on Windows__~~ I think this should be fine now i'll check later - `songDatabase.db` stores all the information about each song in a SQLite database with tables `songs` and `meta` - `webbyBits.py` imports the database, runs all music playing, and accepts all commands from clients - Searches return matching songs @@ -88,4 +87,7 @@ From left to right: - *Because the volume can be controlled in the client, for best usage set your device volume as high as possible and turn it down using this slider* - QR code to allow others to connect to and use the Remote +## External Credits + - 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) *See `LICENSE.md` for redistribution details. \ No newline at end of file From 86e3e9cce89c489c4a6f4a0c9be505008d787064 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:30:01 -0400 Subject: [PATCH 17/69] Rearranged and removed comments --- Server/databaseGenerator.py | 7 +++++-- Server/webbyBits.py | 16 ++++------------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 8a9b714..f9e4782 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -59,9 +59,12 @@ if args.art.lower() == "true" and not(args.apikey == ""): else: print("ETA "+ str(x) + " seconds") +# will be used soon +validFormats = [".mp3",".flac",".wav"] + for i in songFiles: if i[-4:].lower() != ".mp3": - # skip any non-mp3's (like directories or cover art) + # skip any non music files (like directories or cover art) continue try: # get the metadata @@ -105,7 +108,7 @@ for i in songFiles: if len(songFiles) != 1: index = (songFiles.index(i))%4 print("\r" + str(loading[index] + str(math.floor((songFiles.index(i)/(len(songFiles)-1))*100))+ "%"), end='', flush=True) - # each "song" is stored as a dictionary/JSON entry following the format seen in the readME + # each "song" is stored as a SQLite entry following the format seen in the readME songDatabase.execute(f"INSERT INTO songs (filename, title, artist, art, length) VALUES (?,?,?,?,?)",(i,title,artist,image,length)) fileOfDB.commit() diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 590ccad..07d0332 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -5,8 +5,6 @@ import sqlite3 as sql import vlc,threading,time,random, argparse # Argparse Stuff parser=argparse.ArgumentParser(description="Options for the Webby Bits") -# this is no longer needed assuming my file works correctly with the generator -# parser.add_argument('-d','--directory',help="Directory of the song files (make sure this matches the directory used for the databaseGenerator)", default="./sound/") 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="Add an admin password to be used in the client. DO NOT use a password you use elsewhere",default="") args = parser.parse_args() @@ -20,9 +18,7 @@ ADMIN_PASS = args.admin if not(ADMIN_PASS): ADMIN_PASS = None # True = everyone, False = admin only. Change in client while in use. -"""PP,SK,AS,PM,VOL all set to True or False -False is admin only -True is all users""" +# play-pause,skip,addsong,partymode,volume in order controlPerms = { "PP":True, #done "SK":True, #done @@ -37,14 +33,12 @@ songDatabase = fileofDB.cursor() #song directory songDatabase.execute("SELECT * FROM meta WHERE id='songDirectory';") soundLocation = songDatabase.fetchall()[0][1] - if soundLocation[-1] == "/" or soundLocation[-1] == "\\": pass elif "/" in soundLocation: soundLocation += "/" else: soundLocation += "\\" -#print(soundLocation) #Create Virtual table for searching songDatabase.execute("DROP TABLE virtualSongs;") songDatabase.execute("CREATE VIRTUAL TABLE virtualSongs USING fts5(filename, title, artist, art, length);") @@ -180,7 +174,7 @@ def searchSongDB(): songDatabase.execute("SELECT * FROM virtualSongs WHERE virtualSongs MATCH ?",[recieveData['search']]) results = songDatabase.fetchall() tempdata = {} - # this is a temporary solution so i dont have to change the + # this is a temporary solution so i dont have to change the client for i in results: tempdata[i[0]] = { "title": i[1], @@ -188,7 +182,6 @@ def searchSongDB(): "art": i[3], "length": i[4] } - # print(tempData) fileofDB.close() return tempdata @@ -196,11 +189,11 @@ def searchSongDB(): def songadd(): recieveData=request.get_json(force=True) if (ADMIN_PASS and ADMIN_PASS == recieveData['password']) or controlPerms["AS"]: - # Pass exists and is correct, or it's not restricted + # Password exists and is correct, or it's not restricted queueSong(recieveData['song']) return "200" else: - # the pass is incorrect (technically a pass not existing falls into the above case because controlPerms is never changed) + # the password is incorrect (technically a password not existing falls into the above case because controlPerms is never changed) return ERR_NO_ADMIN @app.route("/playlist", methods=["POST"]) @@ -234,7 +227,6 @@ def getPlaylist(): "length": result[4] } tempPlaylist.append({i:k}) - # print(tempPlaylist) fileofDB.close() return tempPlaylist From faac93b1f6f2754d9c4ac0ffecfbae5c4b8647f3 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 10 Oct 2025 18:39:56 -0400 Subject: [PATCH 18/69] flac and wav support --- Client/scripts.js | 11 ++++++++--- Server/databaseGenerator.py | 20 +++++++++++++++----- wishlist.md | 2 +- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 3db547d..f0fea6c 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -3,7 +3,7 @@ let ip; let alertTime = 2; let adminPass = ""; const ERR_NO_ADMIN = "401"; // gonna use this later to refactor - +const VALID_FILE_EXT = ["mp3","flac","wav"] async function alertText(text="Song Added!") { alertbox = document.getElementById("alert"); alertbox.innerHTML = text; @@ -41,7 +41,7 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { } else if(e == "") { } else { - alertText("Error: " + e) + alertText("Error: " + e); } const response=null; return response; @@ -320,7 +320,9 @@ function checkWhatSongWasClicked(e) { //i feel like later kristy won't apreciate this //one of my files was "file.MP3" so it didn't work //windows be like - if (itemId.slice(-4).toLowerCase() == ".mp3") { + let filenameSep = itemId.split('.') + + if (VALID_FILE_EXT.includes(filenameSep[filenameSep.length-1].toLowerCase())) { submitSong(itemId); } } @@ -388,6 +390,8 @@ document.getElementById("volumerange").onchange = async function() { } } +//bit of a cheat code for clearing the alerts when they don't clear normally +document.getElementById("title").addEventListener('click',function(){document.getElementById("alert").innerHTML = ""}) document.getElementById("settings-button").addEventListener('click',function(){controlButton("st")}); document.getElementById("play-pause-button").addEventListener('click', function(){controlButton("pp")}); document.getElementById("playlist-button").addEventListener('click', function(){controlButton("pl")}); @@ -402,6 +406,7 @@ document.getElementById("admincheckholder").addEventListener('click',function(e) document.getElementById("partymode-button").addEventListener('click',function(){controlButton("pm")}) //sets the fact that clicking a song needs to return its id to the function to find it document.getElementById("songlist").addEventListener('click', function(e){checkWhatSongWasClicked(e)}); + //makes the controls look mostly normal on all screens, best solution i could find, idk man let tempWidth = document.getElementById('controls').clientWidth; document.getElementById("controls").style.marginLeft = "-"+String(parseInt(tempWidth/2))+"px"; diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index f9e4782..e8c1915 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -1,6 +1,8 @@ import os from mutagen.easyid3 import EasyID3 from mutagen.mp3 import MP3 +import mutagen.flac +import mutagen.wave import sqlite3 as sql import requests, ast, time, math, argparse @@ -60,15 +62,23 @@ if args.art.lower() == "true" and not(args.apikey == ""): print("ETA "+ str(x) + " seconds") # will be used soon -validFormats = [".mp3",".flac",".wav"] +validFormats = ["mp3","flac","wav"] for i in songFiles: - if i[-4:].lower() != ".mp3": + global song + extension = i.split(".") + extension = extension[len(extension)-1] + if not(extension.lower() in validFormats): # skip any non music files (like directories or cover art) continue try: - # get the metadata - song = EasyID3(soundLocation+i) + if(extension.lower() == "mp3"): + # get the metadata + song = EasyID3(soundLocation+i) + elif(extension.lower() == "flac"): + song = mutagen.flac.FLAC(soundLocation+i) + elif(extension.lower() in ["wav","wave"]): + song = mutagen.wave.WAVE(soundLocation+i) title = song['title'][0] artist = song['artist'][0] except: @@ -102,7 +112,7 @@ for i in songFiles: else: image=None try: - length = math.ceil(MP3(soundLocation+i).info.length) + length = math.ceil(song.info.length) except: length = 0 if len(songFiles) != 1: diff --git a/wishlist.md b/wishlist.md index bb3161d..c207fcf 100644 --- a/wishlist.md +++ b/wishlist.md @@ -5,7 +5,7 @@ - [ ] Refactoring existing code - [ ] Update the SQL -> Server -> Client pipeline when searching and building playlist - [ ] Verify all if-else sequences are correct and not redundant - - [ ] Remove old comments + - [x] Remove old comments - [ ] Secure Password * Actually use SSL for stuff that should be using it - [ ] GUI update for client From 6ece2d3ea122a83db4a06b5fa2ee0bf4130d2cc4 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:32:19 -0400 Subject: [PATCH 19/69] refactored, fixed mp3 length bug, added lossless tag to database --- Client/scripts.js | 6 +++++- Server/databaseGenerator.py | 34 +++++++++++++++++++++++----------- Server/webbyBits.py | 25 +++++++++++++++---------- readme.md | 5 +++-- 4 files changed, 46 insertions(+), 24 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index f0fea6c..dd8870e 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -3,7 +3,8 @@ let ip; let alertTime = 2; let adminPass = ""; const ERR_NO_ADMIN = "401"; // gonna use this later to refactor -const VALID_FILE_EXT = ["mp3","flac","wav"] +const VALID_FILE_EXT = ["mp3","flac","wav"]; + async function alertText(text="Song Added!") { alertbox = document.getElementById("alert"); alertbox.innerHTML = text; @@ -17,6 +18,7 @@ async function alertText(text="Song Added!") { async function getFromServer(bodyInfo, source="",password=adminPass) { try{ if (bodyInfo != null) { + // the currently set password is always included in every request bodyInfo["password"] = password; } const response = await fetch("http://"+ip+"/"+source, { @@ -142,6 +144,7 @@ async function searchSongs(searchTerm){ document.getElementById("songlist").innerHTML = "

We might not have that one...

"; } } + function alertTimeEnter(e){ if (e.key == "Enter") { e.preventDefault(); @@ -178,6 +181,7 @@ function ipSetter(){ 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() } diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index e8c1915..7c879cb 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -35,7 +35,7 @@ except: songDatabase.execute("UPDATE meta SET data = ? WHERE id = 'songDirectory'", (soundLocation,)) if args.mode.lower() == "update": #Create if not exists - songDatabase.execute("CREATE TABLE IF NOT EXISTS songs (filename TEXT PRIMARY KEY, title TEXT, artist TEXT, art TEXT, length INTEGER);") + songDatabase.execute("CREATE TABLE IF NOT EXISTS songs (filename TEXT PRIMARY KEY, title TEXT, artist TEXT, art TEXT, length INTEGER, lossless INTEGER);") songDatabase.execute("SELECT filename FROM songs;") dBfilelist = songDatabase.fetchall() dBfilelistSet = set() @@ -50,7 +50,7 @@ if args.mode.lower() == "update": print("new songs: " + ", ".join(songFiles)) elif args.mode.lower()=="new": songDatabase.execute("DROP TABLE IF EXISTS songs;") - songDatabase.execute("CREATE TABLE songs (filename TEXT PRIMARY KEY, title TEXT, artist TEXT, art TEXT, length INTEGER);") + songDatabase.execute("CREATE TABLE songs (filename TEXT PRIMARY KEY, title TEXT, artist TEXT, art TEXT, length INTEGER, lossless INTEGER);") else: raise ValueError("Must be \"new\" or \"update\"") @@ -65,37 +65,43 @@ if args.art.lower() == "true" and not(args.apikey == ""): validFormats = ["mp3","flac","wav"] for i in songFiles: + # songFiles is the list of filenames, so i is the filename of each song global song - extension = i.split(".") - extension = extension[len(extension)-1] + filenamesplit = i.split(".") + extension = filenamesplit[len(filenamesplit)-1] + lossless = 0 # sqlite doesn't have booleans. what is this, C? if not(extension.lower() in validFormats): # skip any non music files (like directories or cover art) continue try: + print(extension) + # get the metadata if(extension.lower() == "mp3"): - # get the metadata song = EasyID3(soundLocation+i) elif(extension.lower() == "flac"): song = mutagen.flac.FLAC(soundLocation+i) + lossless = 1 elif(extension.lower() in ["wav","wave"]): + # Im actually pretty sure waves can't have metadata, but whatevz song = mutagen.wave.WAVE(soundLocation+i) + lossless = 1 title = song['title'][0] artist = song['artist'][0] except: if "_" in i: - # if metadata is missing, try to use file name following title_artist.mp3 + # if metadata is missing, try to use file name following "title_artist.mp3" song = i.split("_") title = song[0] artist = song[1].split(".")[0] elif "-" in i: - # if there's no underscore, try artist - title.mp3 + # if there's no underscore, try "artist - title.mp3" song = i.split("-") title = song[1].split(".")[0] artist = song[0] title = title.strip() artist = artist.strip() else: - #if the file is not formatted with an underscore, the title is the file name + #if the file is not formatted with an underscore or hyphen, the title is the file name title = i artist = None if args.art.lower() == "true" and not(args.apikey == ""): @@ -112,13 +118,19 @@ for i in songFiles: else: image=None try: - length = math.ceil(song.info.length) + if extension.lower() in ['flac','wave','wav']: + length = math.ceil(song.info.length) + elif extension.lower() == "mp3": + # for some reason ID3 and mutagen.mp3 get different info + # artist and title are in id3() and length is in mp3() + # I dunno why + length = MP3(soundLocation+i).info.length except: length = 0 if len(songFiles) != 1: index = (songFiles.index(i))%4 print("\r" + str(loading[index] + str(math.floor((songFiles.index(i)/(len(songFiles)-1))*100))+ "%"), end='', flush=True) - # each "song" is stored as a SQLite entry following the format seen in the readME - songDatabase.execute(f"INSERT INTO songs (filename, title, artist, art, length) VALUES (?,?,?,?,?)",(i,title,artist,image,length)) + # each "song" is stored as a SQLite entry following the format seen below + songDatabase.execute(f"INSERT INTO songs (filename, title, artist, art, length, lossless) VALUES (?,?,?,?,?,?)",(i,title,artist,image,length,lossless)) fileOfDB.commit() diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 07d0332..9e38d3b 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -40,11 +40,13 @@ elif "/" in soundLocation: else: 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 songDatabase.execute("DROP TABLE virtualSongs;") -songDatabase.execute("CREATE VIRTUAL TABLE virtualSongs USING fts5(filename, title, artist, art, length);") +songDatabase.execute("CREATE VIRTUAL TABLE virtualSongs USING fts5(filename, title, artist, art, length, lossless);") songDatabase.execute("INSERT INTO virtualSongs SELECT * FROM songs;") fileofDB.commit() fileofDB.close() + #Initializing all the global stuff random.seed() global partyMode @@ -66,6 +68,7 @@ CORS(app) def queueSong(song): with playlistLock: playlist.append(song) + # this is a loop that plays the songs and checks for playlist changes, skips, ect. def playQueuedSongs(): global skipNow @@ -94,15 +97,11 @@ def playQueuedSongs(): songDatabase.execute("SELECT * FROM songs ORDER BY RANDOM() LIMIT 1;") result = songDatabase.fetchall() # adds the random songs for party mode - # the above 2 means this only applies if (a song is playing (or paused)) and the queue is empty + # the above 2 means this only applies if (a song is playing or paused) and (the queue is empty) playlist.append(result[0][0]) # check for new songs every second # I just didn't want to eat too much processing looping time.sleep(1) -# start the media player thread -queueThread = threading.Thread(target=playQueuedSongs) -queueThread.daemon = True -queueThread.start() @app.route("/controls", methods=['POST']) def playerControls(): @@ -112,13 +111,13 @@ def playerControls(): recieveData=request.get_json(force=True) if recieveData["control"] != None: if recieveData["control"] == "play-pause": - if ADMIN_PASS == recieveData['password'] or not(ADMIN_PASS) or controlPerms["PP"]: + if ADMIN_PASS == recieveData['password'] or controlPerms["PP"]: player.pause() return "200" else: return ERR_NO_ADMIN elif recieveData["control"] == "skip": - if ADMIN_PASS == recieveData['password'] or not(ADMIN_PASS) or controlPerms["SK"]: + if ADMIN_PASS == recieveData['password'] or controlPerms["SK"]: skipNow = True return "200" else: @@ -136,13 +135,13 @@ def settingsControl(): global player recieveData = request.get_json(force=True) if recieveData["setting"] == "volume": - if ADMIN_PASS == recieveData['password'] or not(ADMIN_PASS) or controlPerms["VOL"]: + if ADMIN_PASS == recieveData['password'] or controlPerms["VOL"]: volumePassed = player.audio_set_volume(int(recieveData["level"])) return {"volumePassed":volumePassed} else: return ERR_NO_ADMIN elif recieveData["setting"] == "partymode-toggle": - if ADMIN_PASS == recieveData['password'] or not(ADMIN_PASS) or controlPerms["PM"]: + if ADMIN_PASS == recieveData['password'] or controlPerms["PM"]: partyMode = not(partyMode) return "200" else: @@ -231,5 +230,11 @@ def getPlaylist(): return tempPlaylist if __name__ == "__main__": + # There's not really a whole lot of point to a main function for something like this, you'd never use any of these methods + # elsewhere, but its just good practice i guess + # start the media player thread + queueThread = threading.Thread(target=playQueuedSongs) + queueThread.daemon = True + queueThread.start() app.run(host='0.0.0.0', port=portTheUserPicked) \ No newline at end of file diff --git a/readme.md b/readme.md index 6fbe6ed..e894e44 100644 --- a/readme.md +++ b/readme.md @@ -65,7 +65,7 @@ These are specific details on each section of the app, and how to use them - The total set of features that can be restricted is - Skip track - Play-pause toggle - - Add track + - Add track to queue - Partymode toggle - Change volume - When this argument is left out (or empty string) the admin features aren't used, and everyone can do everything @@ -90,4 +90,5 @@ From left to right: ## External Credits - 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) -*See `LICENSE.md` for redistribution details. \ No newline at end of file + +*See `LICENSE.md` for redistribution and editing details.* \ No newline at end of file From 7958b4e8bd62ea51de41cdaaa5645407e18c3da2 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:44:10 -0400 Subject: [PATCH 20/69] made the search items not have text go out of the box --- Client/scripts.js | 4 ++++ Client/styles.css | 1 + 2 files changed, 5 insertions(+) diff --git a/Client/scripts.js b/Client/scripts.js index dd8870e..6149c98 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -330,6 +330,7 @@ function checkWhatSongWasClicked(e) { submitSong(itemId); } } + function toggleDark(e) { let x = document.getElementById("test-body").classList if (!(x.contains("dark-mode"))) { @@ -341,6 +342,7 @@ function toggleDark(e) { } } + function adminPassEnter(e) { if (e.key == "Enter") { e.preventDefault(); @@ -348,6 +350,7 @@ function adminPassEnter(e) { alertText("Admin Password Updated") } } + async function submitPerms(e) { let tempData = {} tempData["PP"] = document.getElementById("playpausesettingcheckbox").checked; @@ -430,6 +433,7 @@ if (ip == null || ip=="") { if (ip==null || ip==""){ ip = "" } +// saving the cookies (don't tell the EU) document.cookie = "ip="+ip+"; path=/;" alertTime = getCookie("alertTime") diff --git a/Client/styles.css b/Client/styles.css index ffe8a7d..b3ab859 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -102,6 +102,7 @@ h4 { .songlist > .item > h3, .songlist > .item > h4{ margin-left: 2px; margin-right: 2px; + word-wrap: break-word; } .searchbox-holder { From 758f39963606f2a54c9d75af575a36962b1bc41c Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:49:02 -0400 Subject: [PATCH 21/69] tab to songs and playlist items this actually isnt accessible, but it is something to think about. I don't personally know anyone who needs tab navigation but i know it's good to have and use --- Client/scripts.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Client/scripts.js b/Client/scripts.js index 6149c98..19d367f 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -119,6 +119,7 @@ async function searchSongs(searchTerm){ let newItem = document.createElement("div"); newItem.className = "item"; newItem.id = fileName; + newItem.tabIndex = 0; let image = document.createElement("img"); try { if (currentSongInJSON["art"] == null) { @@ -263,6 +264,7 @@ async function generateVisualPlaylist(conditions="") { let newItem = document.createElement("div"); newItem.className = "item"; newItem.id = fileName; + newItem.tabIndex = 0; let image = document.createElement("img"); try { if (playlist[i]["art"] == null) { From d33ee77693cf6fb089c7383ecf6b646469eba0bc Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 15:56:55 -0500 Subject: [PATCH 22/69] Moved the creation of the virtual table to the databasegenerator.py --- Server/databaseGenerator.py | 5 ++++- Server/webbyBits.py | 5 ----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 7c879cb..e758efb 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -74,7 +74,6 @@ for i in songFiles: # skip any non music files (like directories or cover art) continue try: - print(extension) # get the metadata if(extension.lower() == "mp3"): song = EasyID3(soundLocation+i) @@ -133,4 +132,8 @@ for i in songFiles: # each "song" is stored as a SQLite entry following the format seen below songDatabase.execute(f"INSERT INTO songs (filename, title, artist, art, length, lossless) VALUES (?,?,?,?,?,?)",(i,title,artist,image,length,lossless)) +songDatabase.execute("DROP TABLE IF EXISTS virtualSongs;") +songDatabase.execute("CREATE VIRTUAL TABLE virtualSongs USING fts5(filename, title, artist, art, length, lossless);") +songDatabase.execute("INSERT INTO virtualSongs SELECT * FROM songs;") fileOfDB.commit() +fileOfDB.close() \ No newline at end of file diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 9e38d3b..795ff4e 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -41,11 +41,6 @@ else: 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 -songDatabase.execute("DROP TABLE virtualSongs;") -songDatabase.execute("CREATE VIRTUAL TABLE virtualSongs USING fts5(filename, title, artist, art, length, lossless);") -songDatabase.execute("INSERT INTO virtualSongs SELECT * FROM songs;") -fileofDB.commit() -fileofDB.close() #Initializing all the global stuff random.seed() From ab09058c3d49a88a60c384161422cd3c21786111 Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 15:57:35 -0500 Subject: [PATCH 23/69] Dark mode is working to a level where i am happy with it Note: i did ask gemini for some help on this, but understand and still wrote everything out --- Client/index.html | 6 +++--- Client/scripts.js | 2 +- Client/styles.css | 51 +++++++++++++++++++++++++++++++++++------------ 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/Client/index.html b/Client/index.html index 6ee33ca..f89e974 100644 --- a/Client/index.html +++ b/Client/index.html @@ -53,13 +53,13 @@ changes visibility with JS-->

Client Settings (Saved to device)

- +

Server IP:

IP of the device running the song server

@@ -107,7 +107,7 @@ changes visibility with JS-->
- settings + settings
Playlist Play pause diff --git a/Client/scripts.js b/Client/scripts.js index 19d367f..5712e60 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -419,7 +419,7 @@ document.getElementById("songlist").addEventListener('click', function(e){checkW //makes the controls look mostly normal on all screens, best solution i could find, idk man let tempWidth = document.getElementById('controls').clientWidth; document.getElementById("controls").style.marginLeft = "-"+String(parseInt(tempWidth/2))+"px"; -// document.getElementById("darkmode-button").addEventListener('click',function(){toggleDark()}) +document.getElementById("darkmode-button").addEventListener('click',function(){toggleDark()}) //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 diff --git a/Client/styles.css b/Client/styles.css index b3ab859..c1d35a9 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -1,20 +1,46 @@ /* testing */ .dark-mode { - background-color: #333333; - color:#ffffff; + --bg-main: #333333; + --bg-item: #3f3f3f; + --bg-inputs: #2a2a2a; + --text-color: #ffffff; /* -webkit-filter:invert(100%); filter:progid:DXImageTransform.Microsoft.BasicImage(invert='1'); */ } +.dark-mode .control-button { + filter: invert(100%) brightness(0.9) +} + +:root { + --bg-main: #eeeeee; + --bg-item: #dddddd; + --bg-inputs: #ffffff; + --text-color: #000000; + color: var(--text-color); +} + +/* In hindsight i should have just used p's with classes to decide size, but whatever +Should probably fix that at some point, this is like the least accessible site ever */ +h1,h2,h3,h4,h5,p,input,button,label { + color: var(--text-color); +} + +input, button { + background-color: var(--bg-inputs); +} + /* Things that are always visible */ + body { - background-color: #EEEEEE; + background-color: var(--bg-main); } * { font-family: 'arial'; } + .italic { font-style: italic; } @@ -35,7 +61,7 @@ h4 { left: 50%; bottom: 0; margin: 0 auto; - background-color:inherit; + background-color:var(--bg-main); } .alert { @@ -44,7 +70,7 @@ h4 { width: 100%; text-align: center; z-index: 1000; - background-color: #EEEEEEd6; + background-color: color-mix(in srgb, var(--bg-main), transparent 16%); } .settings-button { @@ -54,13 +80,12 @@ h4 { top:0; right:0; margin: 3px; - background:inherit; /* This is a circle background for the circle settings button So it can display over other text and such */ border-radius: 50%; } -.control-button{ +.controls > .control-button{ width:20%; max-width: 110px; margin: auto 2%; @@ -74,7 +99,7 @@ h4 { .item { /* Only actually applies to playlist and search because settings item has "inherit" bg-colour */ - background-color: #DDDDDD; + background-color: var(--bg-item); } /* Songlist stuff */ @@ -87,7 +112,7 @@ h4 { } .songlist > .item{ - border: 1px solid #333333; + border: 1px solid var(--bg-item); width:30%; max-width: 150px; margin: 5px auto; @@ -128,7 +153,7 @@ h4 { } .playlist > .item{ - border: 1px solid #333333; + border: 1px solid var(--bg-item); display: flex; max-width: 50em; min-width: 200px; @@ -166,12 +191,12 @@ h4 { .settings > .item { margin-left: 10%; width:fit-content; - background-color: inherit; + background-color: var(--bg-main); } .settings > .item:not(:last-child) { padding-bottom: 10px; - border-bottom: 1px solid #333333; + border-bottom: 1px solid var(--bg-item); } .settings > .item.no-line { @@ -192,7 +217,7 @@ h4 { } .versionNumber { - font-size: 8px; + font-size: 11px; font-style: italic; text-align: left; width: 80%; From ded406abcde6d205382f93ea2fd858f397dda7ec Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 16:00:47 -0500 Subject: [PATCH 24/69] update wishlist --- .gitignore | 3 ++- wishlist.md | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 49c6f73..3920035 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ server/sound/ *.db -start.bat \ No newline at end of file +start.bat +.env \ No newline at end of file diff --git a/wishlist.md b/wishlist.md index c207fcf..6898fbf 100644 --- a/wishlist.md +++ b/wishlist.md @@ -6,12 +6,14 @@ - [ ] Update the SQL -> Server -> Client pipeline when searching and building playlist - [ ] Verify all if-else sequences are correct and not redundant - [x] Remove old comments -- [ ] Secure Password - * Actually use SSL for stuff that should be using it +- [ ] Security Updates + - [ ] `.env` file for the api keys and other runtime info to be set, rather than in the `.py` files + - [ ] Hashing rather than plaintext sending (that way at least the password text itself stays private) + - [ ] Actually use SSL, for posting (CORS seems like an issue) - [ ] GUI update for client - [x] Playlist items look cleaner - - [ ] Google material design?? - - [ ] Dark mode? + - [x] Dark mode + - [ ] Google material design (Not sure I want this anymore) - [ ] New Icons - [ ] "Credit" system so each client can only add a set number of songs - Based on time period, number in queue, other possible ideas for credits From 838789e687a0d843c359c90bdc664eea74947532 Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 16:03:29 -0500 Subject: [PATCH 25/69] I lied i added a bit to the wishlist --- wishlist.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wishlist.md b/wishlist.md index 6898fbf..d745d7e 100644 --- a/wishlist.md +++ b/wishlist.md @@ -10,6 +10,9 @@ - [ ] `.env` file for the api keys and other runtime info to be set, rather than in the `.py` files - [ ] Hashing rather than plaintext sending (that way at least the password text itself stays private) - [ ] Actually use SSL, for posting (CORS seems like an issue) +- [ ] Accessibility + - [ ] Better use of semantic HTML tags + - [ ] Full keyboard control (tab, enter to select, tab between control buttons) - [ ] GUI update for client - [x] Playlist items look cleaner - [x] Dark mode From 0cd6b4ce2e5bb74d0e0f91f5c6023a75273473fc Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 16:46:01 -0500 Subject: [PATCH 26/69] Keep dark mode state in a cookie im gonna make this suck less later --- Client/scripts.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 5712e60..52b8dda 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -5,6 +5,14 @@ let adminPass = ""; const ERR_NO_ADMIN = "401"; // gonna use this later to refactor const VALID_FILE_EXT = ["mp3","flac","wav"]; +let darkmodetemp = getCookie("darkmode"); +if (darkmodetemp === "true") { + // i know this is gonna cause weird blinking + // maybe the dark mode function should be loaded before any content, would that work? + // NEW JS FILE ????? exciting stuff + toggleDark("None"); +} + async function alertText(text="Song Added!") { alertbox = document.getElementById("alert"); alertbox.innerHTML = text; @@ -336,10 +344,12 @@ function checkWhatSongWasClicked(e) { function toggleDark(e) { let x = document.getElementById("test-body").classList if (!(x.contains("dark-mode"))) { - document.getElementById("darkmode-button").innerHTML = "On" + document.cookie = "darkmode=true; path=/;"; + document.getElementById("darkmode-button").innerHTML = "On"; x.add("dark-mode"); } else { - document.getElementById("darkmode-button").innerHTML = "Off" + document.cookie = "darkmode=false; path=/;"; + document.getElementById("darkmode-button").innerHTML = "Off"; x.remove("dark-mode"); } @@ -435,6 +445,7 @@ if (ip == null || ip=="") { if (ip==null || ip==""){ ip = "" } + // saving the cookies (don't tell the EU) document.cookie = "ip="+ip+"; path=/;" From 5772edf88a619440ab3aff28a4bb99b4cc0c6827 Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 16:49:04 -0500 Subject: [PATCH 27/69] Using dotenv, refactored some stuff, made making database slightly faster with art --- Server/databaseGenerator.py | 28 +++++++++++++++------------- Server/example.env | 3 +++ Server/webbyBits.py | 21 ++++++++++----------- wishlist.md | 4 ++-- 4 files changed, 30 insertions(+), 26 deletions(-) create mode 100644 Server/example.env diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index e758efb..99e2fe5 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -4,26 +4,27 @@ from mutagen.mp3 import MP3 import mutagen.flac import mutagen.wave import sqlite3 as sql -import requests, ast, time, math, argparse +import requests, ast, time, math, argparse, dotenv loading = ["-","\\","|","/"] parser=argparse.ArgumentParser(description="Options for the generation of the song database") -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('-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/") args = parser.parse_args() -apikeylastfm = args.apikey +dotenv.load_dotenv() +apikeylastfm = os.getenv("API_KEY") +soundLocation = os.getenv("DIRECTORY") +# apikeylastfm = args.apikey if args.directory[-1] == "/" or args.directory[-1] == "\\": soundLocation = args.directory elif "/" in args.directory: soundLocation = args.directory + "/" else: soundLocation = args.directory + "\\" -# if you want to set the api key/sound directory permenantly for your setup just uncomment the next line -# apikeylastfm = "KeyHere" -# soundLocation = "directoryHere" + songFiles = os.listdir(soundLocation) fileOfDB = sql.connect("songDatabase.db") songDatabase = fileOfDB.cursor() @@ -54,12 +55,12 @@ elif args.mode.lower()=="new": else: raise ValueError("Must be \"new\" or \"update\"") -if args.art.lower() == "true" and not(args.apikey == ""): - x = len(songFiles)*0.25 - if x > 60: - print("ETA "+ str(x/60) + " minutes") +if args.art.lower() == "true" and not(apikeylastfm == ""): + eta = len(songFiles)*0.25 + if eta > 60: + print(f"ETA {eta/60:.2f} minutes") else: - print("ETA "+ str(x) + " seconds") + print(f"ETA {eta} seconds") # will be used soon validFormats = ["mp3","flac","wav"] @@ -103,7 +104,8 @@ for i in songFiles: #if the file is not formatted with an underscore or hyphen, the title is the file name title = i artist = None - if args.art.lower() == "true" and not(args.apikey == ""): + if args.art.lower() == "true" and not(apikeylastfm == "") and artist: + # and artist just means anything that only has the x.mp3 title won't bother to check since it'll never exist on last fm try: # get the images from last fm, try 2 different sizes image = ast.literal_eval(requests.post(url="http://ws.audioscrobbler.com/2.0/?method=track.getInfo&api_key="+apikeylastfm+"&artist="+artist+"&track="+title+"&format=json").text)["track"]["album"]["image"][2]["#text"] @@ -111,7 +113,7 @@ for i in songFiles: image = ast.literal_eval(requests.post(url="http://ws.audioscrobbler.com/2.0/?method=track.getInfo&api_key="+apikeylastfm+"&artist="+artist+"&track="+title+"&format=json").text)["track"]["album"]["image"][1]["#text"] if image == "": image = None - time.sleep(0.25) + time.sleep(0.01) except: image=None else: diff --git a/Server/example.env b/Server/example.env new file mode 100644 index 0000000..98fc312 --- /dev/null +++ b/Server/example.env @@ -0,0 +1,3 @@ +API_KEY= +DIRECTORY=./sound +SERVER_PORT=19054 \ No newline at end of file diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 795ff4e..7e684b6 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -2,14 +2,14 @@ from flask import Flask from flask import request from flask_cors import CORS import sqlite3 as sql -import vlc,threading,time,random, argparse +import vlc,threading,time,random,argparse,dotenv,os # Argparse Stuff 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('-p','--port',help="Port to host on, not the same as the web (client) port",default='19054') 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() - -portTheUserPicked=args.port +dotenv.load_dotenv() +portTheUserPicked=os.getenv("SERVER_PORT") # Just a note that the return code "401" as of now is used to mean "you don't have the password" # This is not great design, and the whole "returning string codes" thing is something to add to the todo list # I mean returning 200 when no return is necesary i think is fine but we'll see @@ -20,11 +20,11 @@ if not(ADMIN_PASS): # True = everyone, False = admin only. Change in client while in use. # play-pause,skip,addsong,partymode,volume in order controlPerms = { - "PP":True, #done - "SK":True, #done - "AS":True, #done - "PM":True, #done - "VOL":True #done + "PP":True, + "SK":True, + "AS":True, + "PM":True, + "VOL":True } fileofDB = sql.connect("songDatabase.db") @@ -150,8 +150,7 @@ def settingsControl(): return ERR_NO_ADMIN elif recieveData["setting"] == "getsettings": # probably should have made this a different request type or something but it works - x = {"partymode":partyMode,"volume":player.audio_get_volume(),"admin":controlPerms} - return x + return {"partymode":partyMode,"volume":player.audio_get_volume(),"admin":controlPerms} else: return "400" diff --git a/wishlist.md b/wishlist.md index d745d7e..1b20f9b 100644 --- a/wishlist.md +++ b/wishlist.md @@ -7,8 +7,8 @@ - [ ] Verify all if-else sequences are correct and not redundant - [x] Remove old comments - [ ] Security Updates - - [ ] `.env` file for the api keys and other runtime info to be set, rather than in the `.py` files - - [ ] Hashing rather than plaintext sending (that way at least the password text itself stays private) + - [x] `.env` file for the api keys and other runtime info to be set, rather than in the `.py` files + - [ ] 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) - [ ] Accessibility - [ ] Better use of semantic HTML tags From ae428239a760fe84b08283b5a8058e309d2891ed Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 18:28:40 -0500 Subject: [PATCH 28/69] Start of updating icons --- Client/images/play-pause-old.png | Bin 0 -> 30330 bytes Client/images/play-pause.png | Bin 30330 -> 22779 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Client/images/play-pause-old.png diff --git a/Client/images/play-pause-old.png b/Client/images/play-pause-old.png new file mode 100644 index 0000000000000000000000000000000000000000..3796891ba51bcd000fd8f04aeecbb470be8754c8 GIT binary patch literal 30330 zcmeFY_d8r+*9M9RGejE_z4s8kcZ28=5)niTB6{yNj26*C^pcSvh#o{8(Pd;1EqZ74 zZZOK(-uHXYujd~)xh_|>vG+4;uVD0n6{WlNv$f5% zGY|TD`uaXn9C5P?gBAr@-X(F`uyUOBku8wz6JFkB)ph}Qv5>zU+P`Z(Z0VNh)i}Mg zTe_L$GMmkfF@J`CrTTcAzEN+jzjfSPrE6+1EkQ(Y_x{LHkI7)bydNR&87rP@7>FE~ zO$q;_x)K)xPvHN4i@~8F=kRjD1O5P9vf$Axg~8fzSv|o2yIli4bLW585@9&t;5*P2 z$bW7HK1~{GB>dmC6Amuqf&kXX`JZpO{S@v=_P^_YICvb#B%ow%mjB5CU1$2=brmkY z2AZCnE&cywPz{5j3CLOCu1Ybl@Bq>OBPB!z zbNd>%*Y8MJl%Vrr(T9!$gZNXk3YvJ|> zVQgW~suZADooK)cWcVj3Df~#@S*O`%3O{xzw`h4>!oAyrQPYp9cm9-WKx5sp<&IFm zc53wLVS@A4i?(FQ8+VA7ZH@@9Of4uf10ajfGOfa!G;gKsoF0ZZihw6u7AYvQh@^SmWKchRa=t=q2 z3je57ZZC44>()Tb6FJ}?JErjS$q^sYKup(q0#>&eF@Mm|pwN!b{GC{kWw*QC;RyBH z((*IN;)&9!9s$^_bxaY#ji|E>L9P*X{$88K$E>>gM$EijRI8X zqL<^J*C|H&Xk?CB(0*dhiRt60di${*syM>45}co1hpZ7BiaL_pYpO8))VGjCXpO&i z)I&@>9-YpN#aj?RPFm?q|G4kR+9lET*YWu61g4eX35(wZnH#@r$PomGZJ!7a2p93$ zN((bibC}lfo_in9GsZvcDL-_ch92Q>Tm<$=eLVPj^|6*t=a2I|#?+TiHq|!s4t+}% z4xe6Ei07}v#Y=*`bI6%>^w4Oc1WI}@lkA=6Lam_sySr-i)*6SB?$n;iuB=MXP-51Q zd^WszPt_w&Lia!t0|Hi))4-%?(eo+M<^8}to>QCHiL@W`=QP%C|5~&uJ{PWkPc5w* zhaY|Bwhhc#9Gn;|XM=a<-3YYb+#!yM!{bRaXYOUk69&RUqB2oVXB|jPD1BKuvDS{Y zt5$TO_GsVEI@jMXg5@5{qcvK3s^i{V{i9f{32*C=xt1}#bxm`#?7`U?4U;F29j

{C<(D;YPEN&GE%-6u-CWNO1@_a3amhY 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

Z<0?lSd?1nQ^V-0UwpQRaWB?0KO%R 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 literal 0 HcmV?d00001 diff --git a/Client/images/play-pause.png b/Client/images/play-pause.png index 3796891ba51bcd000fd8f04aeecbb470be8754c8..89fa08170bec3fe177a3705fe2553f5274db6cd9 100644 GIT binary patch literal 22779 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 H=@|Kc=`{pa literal 30330 zcmeFY_d8r+*9M9RGejE_z4s8kcZ28=5)niTB6{yNj26*C^pcSvh#o{8(Pd;1EqZ74 zZZOK(-uHXYujd~)xh_|>vG+4;uVD0n6{WlNv$f5% zGY|TD`uaXn9C5P?gBAr@-X(F`uyUOBku8wz6JFkB)ph}Qv5>zU+P`Z(Z0VNh)i}Mg zTe_L$GMmkfF@J`CrTTcAzEN+jzjfSPrE6+1EkQ(Y_x{LHkI7)bydNR&87rP@7>FE~ zO$q;_x)K)xPvHN4i@~8F=kRjD1O5P9vf$Axg~8fzSv|o2yIli4bLW585@9&t;5*P2 z$bW7HK1~{GB>dmC6Amuqf&kXX`JZpO{S@v=_P^_YICvb#B%ow%mjB5CU1$2=brmkY z2AZCnE&cywPz{5j3CLOCu1Ybl@Bq>OBPB!z zbNd>%*Y8MJl%Vrr(T9!$gZNXk3YvJ|> zVQgW~suZADooK)cWcVj3Df~#@S*O`%3O{xzw`h4>!oAyrQPYp9cm9-WKx5sp<&IFm zc53wLVS@A4i?(FQ8+VA7ZH@@9Of4uf10ajfGOfa!G;gKsoF0ZZihw6u7AYvQh@^SmWKchRa=t=q2 z3je57ZZC44>()Tb6FJ}?JErjS$q^sYKup(q0#>&eF@Mm|pwN!b{GC{kWw*QC;RyBH z((*IN;)&9!9s$^_bxaY#ji|E>L9P*X{$88K$E>>gM$EijRI8X zqL<^J*C|H&Xk?CB(0*dhiRt60di${*syM>45}co1hpZ7BiaL_pYpO8))VGjCXpO&i z)I&@>9-YpN#aj?RPFm?q|G4kR+9lET*YWu61g4eX35(wZnH#@r$PomGZJ!7a2p93$ zN((bibC}lfo_in9GsZvcDL-_ch92Q>Tm<$=eLVPj^|6*t=a2I|#?+TiHq|!s4t+}% z4xe6Ei07}v#Y=*`bI6%>^w4Oc1WI}@lkA=6Lam_sySr-i)*6SB?$n;iuB=MXP-51Q zd^WszPt_w&Lia!t0|Hi))4-%?(eo+M<^8}to>QCHiL@W`=QP%C|5~&uJ{PWkPc5w* zhaY|Bwhhc#9Gn;|XM=a<-3YYb+#!yM!{bRaXYOUk69&RUqB2oVXB|jPD1BKuvDS{Y zt5$TO_GsVEI@jMXg5@5{qcvK3s^i{V{i9f{32*C=xt1}#bxm`#?7`U?4U;F29j

{C<(D;YPEN&GE%-6u-CWNO1@_a3amhY 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

Z<0?lSd?1nQ^V-0UwpQRaWB?0KO%R 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 From 37945ccced3eee0280949fde2156cfd487cd74b5 Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 18:36:41 -0500 Subject: [PATCH 29/69] updated example.env also rearranged imports and wishlist --- Server/databaseGenerator.py | 3 +-- Server/example.env | 6 +++++- wishlist.md | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 99e2fe5..ebf8263 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -1,10 +1,9 @@ -import os from mutagen.easyid3 import EasyID3 from mutagen.mp3 import MP3 import mutagen.flac import mutagen.wave import sqlite3 as sql -import requests, ast, time, math, argparse, dotenv +import requests, ast, time, math, argparse, dotenv, os loading = ["-","\\","|","/"] diff --git a/Server/example.env b/Server/example.env index 98fc312..8167158 100644 --- a/Server/example.env +++ b/Server/example.env @@ -1,3 +1,7 @@ API_KEY= DIRECTORY=./sound -SERVER_PORT=19054 \ No newline at end of file +SERVER_PORT=19054 + +#API_KEY = a lastfm api key +#Directory = either a relative or concrete directory, default is subdirectory "sound", should work on unix and windows +#server_port= the port for the webbybits.py flask server \ No newline at end of file diff --git a/wishlist.md b/wishlist.md index 1b20f9b..02df8c5 100644 --- a/wishlist.md +++ b/wishlist.md @@ -3,9 +3,9 @@ - [x] Admin password * Allows restricting certain features and changing permissions on the fly on the client - [ ] Refactoring existing code + - [x] Remove old comments - [ ] Update the SQL -> Server -> Client pipeline when searching and building playlist - [ ] Verify all if-else sequences are correct and not redundant - - [x] Remove old comments - [ ] Security Updates - [x] `.env` file for the api keys and other runtime info to be set, rather than in the `.py` files - [ ] Hashing rather than plaintext sending passwords (that way at least the password text itself isn't transmitted over the network) @@ -16,8 +16,8 @@ - [ ] GUI update for client - [x] Playlist items look cleaner - [x] Dark mode - - [ ] Google material design (Not sure I want this anymore) - [ ] New Icons + - [ ] Google material design (Not sure I want this anymore) - [ ] "Credit" system so each client can only add a set number of songs - Based on time period, number in queue, other possible ideas for credits - Without a login system there's no easy way to give credits to specific clients (and a login is beyond scope of what I want to do) From 4f3941785243c295072a56e687b9b5954a912ec8 Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 19:27:49 -0500 Subject: [PATCH 30/69] added hashing, updated parts of readme to explain env file also added some tabby stuff, but its not done yet --- Client/index.html | 12 ++++----- Client/scripts.js | 60 +++++++++++++++++++++++++++++++++------------ Server/webbyBits.py | 4 +-- readme.md | 25 ++++++++++--------- wishlist.md | 2 +- 5 files changed, 65 insertions(+), 38 deletions(-) diff --git a/Client/index.html b/Client/index.html index f89e974..975e2de 100644 --- a/Client/index.html +++ b/Client/index.html @@ -59,7 +59,6 @@ changes visibility with JS-->

Opposite of light mode

-

Server IP:

IP of the device running the song server

@@ -107,14 +106,13 @@ changes visibility with JS-->
- settings
- Playlist - Play pause - Skip - Search - + Playlist + Play pause + Skip + Search
+ settings diff --git a/Client/scripts.js b/Client/scripts.js index 52b8dda..e3f4e07 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -5,7 +5,12 @@ let adminPass = ""; const ERR_NO_ADMIN = "401"; // gonna use this later to refactor const VALID_FILE_EXT = ["mp3","flac","wav"]; +const params = new URLSearchParams(location.search); + let darkmodetemp = getCookie("darkmode"); +darkmodetemp = params.get("darkmode") +if(darkmodetemp === "") { +} if (darkmodetemp === "true") { // i know this is gonna cause weird blinking // maybe the dark mode function should be loaded before any content, would that work? @@ -29,6 +34,7 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { // the currently set password is always included in every request bodyInfo["password"] = password; } + // console.log(bodyInfo); const response = await fetch("http://"+ip+"/"+source, { method: "POST", body: JSON.stringify(bodyInfo), @@ -327,18 +333,20 @@ async function submitSong(songid) { } } function checkWhatSongWasClicked(e) { - itemId = e.srcElement.id; - if ((itemId.length-itemId.lastIndexOf("image") == 5) && itemId.lastIndexOf("image")!=-1) { - itemId = itemId.slice(0,-6) + if(e.type == "click" || e.key == "Enter") { + itemId = e.srcElement.id; + if ((itemId.length-itemId.lastIndexOf("image") == 5) && itemId.lastIndexOf("image")!=-1) { + itemId = itemId.slice(0,-6) + } + //i feel like later kristy won't apreciate this + //one of my files was "file.MP3" so it didn't work + //windows be like + let filenameSep = itemId.split('.') + + if (VALID_FILE_EXT.includes(filenameSep[filenameSep.length-1].toLowerCase())) { + submitSong(itemId); + } } - //i feel like later kristy won't apreciate this - //one of my files was "file.MP3" so it didn't work - //windows be like - let filenameSep = itemId.split('.') - - if (VALID_FILE_EXT.includes(filenameSep[filenameSep.length-1].toLowerCase())) { - submitSong(itemId); - } } function toggleDark(e) { @@ -355,11 +363,31 @@ function toggleDark(e) { } -function adminPassEnter(e) { +async function sha256(message) { + // Encode the message as UTF-8 + const msgBuffer = new TextEncoder().encode(message); + + // Hash the message + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); + + // Convert ArrayBuffer to hex string + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + + return hashHex; +} + +async function adminPassEnter(e) { if (e.key == "Enter") { - e.preventDefault(); - adminPass=document.getElementById("adminpasswordbox").value - alertText("Admin Password Updated") + e.preventDefault(); + let enteredpass = document.getElementById("adminpasswordbox").value; + if(enteredpass === "") { + adminPass = ""; // an empty pass is technically meant to represent not having one + // this isn't stritly necesarry but i dont wanna break anything that might depend on this being true + } else { + adminPass= await sha256(document.getElementById("adminpasswordbox").value); + } + alertText("Admin Password Updated"); } } @@ -424,6 +452,7 @@ document.getElementById("adminpasswordbox").addEventListener('keydown',function( document.getElementById("admincheckholder").addEventListener('click',function(e){submitPerms(e)}); document.getElementById("partymode-button").addEventListener('click',function(){controlButton("pm")}) //sets the fact that clicking a song needs to return its id to the function to find it +document.getElementById("songlist").addEventListener('keydown', function(e){checkWhatSongWasClicked(e)}); document.getElementById("songlist").addEventListener('click', function(e){checkWhatSongWasClicked(e)}); //makes the controls look mostly normal on all screens, best solution i could find, idk man @@ -435,7 +464,6 @@ document.getElementById("darkmode-button").addEventListener('click',function(){t //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 -let params = new URLSearchParams(location.search); //tries the url first, then the cookie, then the default ip = params.get("ip") diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 7e684b6..236f903 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -2,7 +2,7 @@ from flask import Flask from flask import request from flask_cors import CORS import sqlite3 as sql -import vlc,threading,time,random,argparse,dotenv,os +import vlc,threading,time,random,argparse,dotenv,os,hashlib # Argparse Stuff 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') @@ -14,7 +14,7 @@ portTheUserPicked=os.getenv("SERVER_PORT") # This is not great design, and the whole "returning string codes" thing is something to add to the todo list # I mean returning 200 when no return is necesary i think is fine but we'll see ERR_NO_ADMIN = "401" -ADMIN_PASS = args.admin +ADMIN_PASS = hashlib.sha256(bytes(args.admin,'utf-8')).hexdigest() if not(ADMIN_PASS): ADMIN_PASS = None # True = everyone, False = admin only. Change in client while in use. diff --git a/readme.md b/readme.md index e894e44..76a6477 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,7 @@ The main advantage compared to doing something similar using Spotify is that you ### 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. * 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 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: **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 \ \ @@ -22,14 +23,17 @@ webbyBits.py ``` 1. Place mp3 files in the `sound/` folder -2. Open `databaseGenerator.py` and put your LastFM API key in at the top or at runtime using `-k APIKey` (*optional*) -3. Run `databaseGenerator.py` +2. Rename `example.env` to `.env` and... + - Set the location where your audio files are (Default: `./sound/`) + - Set the LastFM API key (Optional) + - Change the port of the webbybits server ("Default ) +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`* * *If getting images, this process may take a long time with a large amount of mp3 files* 4. Run `webbyBits.py` - * *The port can be customized at runtime using* `-p portNumber` *as an atribute* + * *The port can be customized by editing the `.env` file* * *You can add an admin password at runtime with* `-a AdminPass` *as an atribute* - * ***NOTE: Do not reuse ANY password for this, it is 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*** * This is intended for protecting certain features for small closed events, not for public security You can now connect with the client and use the app as normal. \ @@ -42,25 +46,21 @@ These are specific details on each section of the app, and how to use them - `sound/` contains all mp3 files by default - `databaseGenerator.py` scans through mp3 files and gets information about them - `Filename, Title, Artist, Art, Length` are all saved - - *If the title and artist are not in the mp3 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 - Running with `--mode (update/new)` either updates the current database and adds new songs/removes deleted songs, or recreates the entire database (update is default, and is faster in art mode) - Running with `--art (True/False)` retrieves art from LastFM or doesn't (True is default) - *Can only generate one song / 0.25 seconds, to avoid pinging the LastFM server too much* - - Running with `--apikey (KEYhere)` sets the LastFM key for that run - - If this is set to an empty string (Default) the app runs in non-art mode - - Running with `--directory (directoryOfmp3s)` allows for sound files to be in a different place - - Default `"./sound/"` - - _This setting might be kinda iffy on Linux. You're on Linux just go and edit it if you have issues_ + - Directory to index for music files can be set in the `.env` file - `songDatabase.db` stores all the information about each song in a SQLite database with tables `songs` and `meta` - `webbyBits.py` imports the database, runs all music playing, and accepts all commands from clients - Searches return matching songs - Accepts Play-Pause and Skip commands - Uses port 19054 by default - - `--port (port)` changes the port for that run + - Can be changed in the `.env` file - The default port can be changed in the file - Running with `--admin (admin password)` sets an admin password for moderation on the client - - ***Note: Do not reuse a password, consider this like making whatever this string is public, no security is guaranteed*** + - ***Note: Do not reuse a password, the password is hashed before being sent over the network, but I still wouldn't bet my house on it, no security is guaranteed*** - Anyone who knows the admin password can enter it on the client and change the abilities of any non-admin users (for example to limit skipping) - The total set of features that can be restricted is - Skip track @@ -80,6 +80,7 @@ From left to right: - *No "previous" button is a design decision (It's a feature not a bug)* - The search button opens the search screen (pictured) - The settings button (top right) opens the settings menu + - Dark mode sets a dark mode and stores a cookie to keep you in dark mode after refreshing - Server IP allows you to change the ip that the site connects to - Alert time changes how long error/confirmation messages are shown for (Default 2s) - Party Mode adds new songs to the queue when the queue has only 1 song in it diff --git a/wishlist.md b/wishlist.md index 02df8c5..bd525f7 100644 --- a/wishlist.md +++ b/wishlist.md @@ -8,7 +8,7 @@ - [ ] Verify all if-else sequences are correct and not redundant - [ ] Security Updates - [x] `.env` file for the api keys and other runtime info to be set, rather than in the `.py` files - - [ ] 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) - [ ] Accessibility - [ ] Better use of semantic HTML tags From 85257808a3240c1fe10b2c3498bde3897f9fcf6a Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 20:02:35 -0500 Subject: [PATCH 31/69] Updates to readme, fixed darkmode cookie bug --- Client/index.html | 2 +- Client/scripts.js | 6 +++++- readme.md | 12 +++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Client/index.html b/Client/index.html index 975e2de..54502b8 100644 --- a/Client/index.html +++ b/Client/index.html @@ -89,7 +89,7 @@ changes visibility with JS-->

Admin Password:

Enter to use admin restricted functions

- +

Fine action control:

diff --git a/Client/scripts.js b/Client/scripts.js index e3f4e07..ba710ba 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -8,13 +8,17 @@ const VALID_FILE_EXT = ["mp3","flac","wav"]; const params = new URLSearchParams(location.search); let darkmodetemp = getCookie("darkmode"); -darkmodetemp = params.get("darkmode") if(darkmodetemp === "") { + darkmodetemp = params.get("darkmode") } if (darkmodetemp === "true") { // i know this is gonna cause weird blinking // maybe the dark mode function should be loaded before any content, would that work? // NEW JS FILE ????? exciting stuff + // im thinking a few new js files + // you know like good design separating stuff + // yeah but i need the getCookie function in both the darkmode.js and this one, so im gonna make a + // getcookie.js toggleDark("None"); } diff --git a/readme.md b/readme.md index 76a6477..e1a753f 100644 --- a/readme.md +++ b/readme.md @@ -92,4 +92,14 @@ From left to right: - 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) -*See `LICENSE.md` for redistribution and editing details.* \ No newline at end of file +*See `LICENSE.md` for redistribution and editing details.* + +### A quick note on the password feature + +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 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) + +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. \ No newline at end of file From 0dba7fd8cfdfe21da3cf8ddfdcecb7d3337e9163 Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 20:05:39 -0500 Subject: [PATCH 32/69] Added to wishlist websockets for playlist to send updates without full refresh --- wishlist.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/wishlist.md b/wishlist.md index bd525f7..9fb170c 100644 --- a/wishlist.md +++ b/wishlist.md @@ -22,4 +22,8 @@ - Based on time period, number in queue, other possible ideas for credits - Without a login system there's no easy way to give credits to specific clients (and a login is beyond scope of what I want to do) - Potentially a "redemption code" system, which can be tracked client side - - All of this is very hackable without a server-side login. \ No newline at end of file + - All of this is very hackable without a server-side login. +- [ ] Websockets / some method of updating the time remaining to any client on the playlist screen + * currently the screen just grabs the "elapsed time" once when it is loaded + * websockets can re-update clients + * not actually sure if i can CORS-socket but we're sure gonna try \ No newline at end of file From 6a04ac30f5aab99c0e933f363adbc1d2d296d214 Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Wed, 21 Jan 2026 23:45:14 -0500 Subject: [PATCH 33/69] slight layout update, mostly relevant for mobile --- Client/index.html | 4 +++- Client/styles.css | 14 +++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Client/index.html b/Client/index.html index 54502b8..c0a018b 100644 --- a/Client/index.html +++ b/Client/index.html @@ -22,7 +22,7 @@ changes visibility with JS-->
-

Search to find songs!

+

Search to find songs!

Skip Search
+
settings +
diff --git a/Client/styles.css b/Client/styles.css index c1d35a9..024e988 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -73,18 +73,25 @@ h4 { background-color: color-mix(in srgb, var(--bg-main), transparent 16%); } -.settings-button { - width: 15%; +.settings-button-holder { + width:15%; max-width: 90px; position:fixed; top:0; right:0; margin: 3px; + background-color: var(--bg-main); /* This is a circle background for the circle settings button So it can display over other text and such */ border-radius: 50%; } +.settings-button { + width: 100%; + + +} + .controls > .control-button{ width:20%; max-width: 110px; @@ -105,7 +112,7 @@ h4 { /* Songlist stuff */ .songlist { width: 80%; - min-width: 300px; + min-width: 400px; margin:auto auto 150px; display: flex; flex-wrap: wrap; @@ -139,6 +146,7 @@ h4 { .searchbox { width: 65%; margin: 1px; + margin-bottom: 16px; } .go-search { width: 20%; From 1e1eac4aa4605a645d6710f6edb605c40838ae6d Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Thu, 22 Jan 2026 00:10:59 -0500 Subject: [PATCH 34/69] Added a "clear playlist" admin function on the client --- Client/index.html | 5 +++++ Client/scripts.js | 18 ++++++++++++++++-- Server/webbyBits.py | 7 +++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Client/index.html b/Client/index.html index c0a018b..7172540 100644 --- a/Client/index.html +++ b/Client/index.html @@ -102,6 +102,11 @@ changes visibility with JS-->
+
+

Clear the playlist

+

Wipe the playlist, except the currently playing song*

+ +

PartyJukebox is under an AGPLV3 liscense. You can access the source code here.

diff --git a/Client/scripts.js b/Client/scripts.js index ba710ba..6034398 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -411,6 +411,16 @@ async function submitPerms(e) { } } +async function clearPlaylist() { + let returncode = await getFromServer({control:"clear"},"controls"); + if(returncode == ERR_NO_ADMIN || returncode == null) { + // alertText("Admin Restricted ") + // there's an admin restrict alert built into getFromServer + } else { + alertText("Playlist Cleared!"); + } +} + let optionslist = [] //sets all de stuff for buttons @@ -427,7 +437,8 @@ document.getElementById("volumerange").onchange = async function() { // FIX THIS let returnValue = await getFromServer({setting:"volume",level:this.value}, "settings") if (returnValue == ERR_NO_ADMIN) { - alertText("Error: Admin restricted action"); + // alertText("Error: Admin restricted action"); + // there's an admin restrict alert built into getFromServer } else if (returnValue["volumePassed"] !=0) { // i forgot about this, i had to do this because it confused the crap out of me one time // vlc doesn't let you change the volume of nothing, which makes sense if you think about it @@ -441,6 +452,8 @@ document.getElementById("volumerange").onchange = async function() { } } + + //bit of a cheat code for clearing the alerts when they don't clear normally document.getElementById("title").addEventListener('click',function(){document.getElementById("alert").innerHTML = ""}) document.getElementById("settings-button").addEventListener('click',function(){controlButton("st")}); @@ -455,6 +468,8 @@ document.getElementById("alerttimetextbox").addEventListener('keydown', function document.getElementById("adminpasswordbox").addEventListener('keydown',function(e){adminPassEnter(e)}); document.getElementById("admincheckholder").addEventListener('click',function(e){submitPerms(e)}); document.getElementById("partymode-button").addEventListener('click',function(){controlButton("pm")}) +document.getElementById("darkmode-button").addEventListener('click',function(){toggleDark()}) +document.getElementById("clear-button").addEventListener('click',function(){clearPlaylist()}) //sets the fact that clicking a song needs to return its id to the function to find it document.getElementById("songlist").addEventListener('keydown', function(e){checkWhatSongWasClicked(e)}); document.getElementById("songlist").addEventListener('click', function(e){checkWhatSongWasClicked(e)}); @@ -462,7 +477,6 @@ document.getElementById("songlist").addEventListener('click', function(e){checkW //makes the controls look mostly normal on all screens, best solution i could find, idk man let tempWidth = document.getElementById('controls').clientWidth; document.getElementById("controls").style.marginLeft = "-"+String(parseInt(tempWidth/2))+"px"; -document.getElementById("darkmode-button").addEventListener('click',function(){toggleDark()}) //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 diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 236f903..d3f1faf 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -117,6 +117,13 @@ def playerControls(): return "200" else: return ERR_NO_ADMIN + elif recieveData["control"] == "clear": + if ADMIN_PASS == recieveData['password']: # this is only ever allowed with the adminpassword + with playlistLock: + playlist.clear() + return "200" + else: + return ERR_NO_ADMIN else: return "400" else: From bcd6807a3432cce3f3877a03a9b27b0335f238ae Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Thu, 22 Jan 2026 00:31:53 -0500 Subject: [PATCH 35/69] Duplicate song queue adds can be blocked --- Client/scripts.js | 6 +++++- Server/webbyBits.py | 24 +++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 6034398..826709f 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -332,7 +332,11 @@ async function submitSong(songid) { let returncode = await getFromServer({song: songid}, "songadd"); if(returncode == ERR_NO_ADMIN) { // right now the error is alerted in getFromServer, maybe will change that - } else { + } + else if(returncode["error"]=="song-in-queue") { + alertText("That song's about to play! Hang on!") + } + else { alertText("Added to Queue"); } } diff --git a/Server/webbyBits.py b/Server/webbyBits.py index d3f1faf..130131b 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -2,7 +2,7 @@ from flask import Flask from flask import request from flask_cors import CORS import sqlite3 as sql -import vlc,threading,time,random,argparse,dotenv,os,hashlib +import vlc,threading,time,random,argparse,dotenv,os,hashlib,string # Argparse Stuff 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') @@ -14,9 +14,13 @@ portTheUserPicked=os.getenv("SERVER_PORT") # This is not great design, and the whole "returning string codes" thing is something to add to the todo list # I mean returning 200 when no return is necesary i think is fine but we'll see ERR_NO_ADMIN = "401" -ADMIN_PASS = hashlib.sha256(bytes(args.admin,'utf-8')).hexdigest() -if not(ADMIN_PASS): - ADMIN_PASS = None +if args.admin: + ADMIN_PASS = hashlib.sha256(bytes(args.admin,'utf-8')).hexdigest() +else: + tempPass = ''.join(random.choices(string.ascii_letters + string.digits +"?"+"!",k=20)) + print("No adminPass was set, the auto generated one is: "+tempPass) + ADMIN_PASS = hashlib.sha256(bytes(tempPass,'utf-8')).hexdigest() + # True = everyone, False = admin only. Change in client while in use. # play-pause,skip,addsong,partymode,volume in order controlPerms = { @@ -189,9 +193,15 @@ def searchSongDB(): def songadd(): recieveData=request.get_json(force=True) if (ADMIN_PASS and ADMIN_PASS == recieveData['password']) or controlPerms["AS"]: - # Password exists and is correct, or it's not restricted - queueSong(recieveData['song']) - return "200" + # Password exists and is correct, or it's not restricted + # if (recieveData['song'] in playlist): + # return {"error":"song-in-queue"} + # else: + # Right now the above is disabled since i want to make it optional first + # probably with a checkbox like the other admin controls + if True: + queueSong(recieveData['song']) + return "200" else: # the password is incorrect (technically a password not existing falls into the above case because controlPerms is never changed) return ERR_NO_ADMIN From 347feae50aa2e03e793cb3595f08a5b1b5a0a5fb Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:57:03 -0500 Subject: [PATCH 36/69] added to requirements.txt, updated readme --- readme.md | 17 +++++++++-------- requirements.txt | 1 + 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/readme.md b/readme.md index e1a753f..0bf9c42 100644 --- a/readme.md +++ b/readme.md @@ -26,7 +26,7 @@ webbyBits.py 2. Rename `example.env` to `.env` and... - Set the location where your audio files are (Default: `./sound/`) - Set the LastFM API key (Optional) - - Change the port of the webbybits server ("Default ) + - Change the port of the webbybits server (Default: `19054` ) 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`* * *If getting images, this process may take a long time with a large amount of mp3 files* @@ -34,6 +34,7 @@ webbyBits.py * *The port can be customized by editing the `.env` file* * *You can add an admin password at runtime with* `-a AdminPass` *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*** + * 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 You can now connect with the client and use the app as normal. \ @@ -88,12 +89,6 @@ From left to right: - *Because the volume can be controlled in the client, for best usage set your device volume as high as possible and turn it down using this slider* - QR code to allow others to connect to and use the Remote -## External Credits - - 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) - -*See `LICENSE.md` for redistribution and editing details.* - ### A quick note on the password feature The exact process of the password's plaintext scope is as follows @@ -102,4 +97,10 @@ The exact process of the password's plaintext scope is as follows - 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) -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. \ No newline at end of file +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. + +## External Credits + - 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) + +*See `LICENSE.md` for redistribution and editing details.* diff --git a/requirements.txt b/requirements.txt index 73c4c0a..ca37329 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ certifi==2024.2.2 charset-normalizer==3.3.2 click==8.1.7 colorama==0.4.6 +dotenv Flask==3.0.3 Flask-Cors==4.0.1 idna==3.7 From 9bdac82f10ef6cb7ab0283b964c22b47654d8a5e Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Sun, 25 Jan 2026 19:04:02 -0500 Subject: [PATCH 37/69] In the thick of changing the responses to requests --- Client/index.html | 3 ++- Client/scripts.js | 31 ++++++++++++++++++++----------- Client/styles.css | 7 +++++++ Server/webbyBits.py | 33 +++++++++++++++++---------------- 4 files changed, 46 insertions(+), 28 deletions(-) diff --git a/Client/index.html b/Client/index.html index 7172540..428fb2d 100644 --- a/Client/index.html +++ b/Client/index.html @@ -5,6 +5,7 @@ + @@ -107,7 +108,7 @@ changes visibility with JS-->

Wipe the playlist, except the currently playing song*

-

PartyJukebox is under an AGPLV3 liscense. You can access the source code here.

+

PartyJukebox is under an AGPLV3 liscense. You can access the source code here.

diff --git a/Client/scripts.js b/Client/scripts.js index 826709f..1d34c71 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -2,7 +2,7 @@ let ip; let alertTime = 2; let adminPass = ""; -const ERR_NO_ADMIN = "401"; // gonna use this later to refactor +const ERR_NO_ADMIN = 401; // gonna use this later to refactor const VALID_FILE_EXT = ["mp3","flac","wav"]; const params = new URLSearchParams(location.search); @@ -46,8 +46,9 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { "Content-type": "application/json; charset=UTF-8" } }); + const data = await response.json(); - if (data == ERR_NO_ADMIN) { + if (response.status == ERR_NO_ADMIN) { // im suprised i didn't comment on this already but this is kinda lame desing // its not wrong but you know // it is easy which i like @@ -56,9 +57,11 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { } return await data; } catch(e) { + console.log("error print here:"); + console.log(e); if (e == "TypeError: Failed to fetch"){ alertText("Error: Can't Connect to Server (is the ip set?)") - } else if(e == "") { + } else if(e === "") { } else { alertText("Error: " + e); @@ -155,6 +158,13 @@ async function searchSongs(searchTerm){ newItem.appendChild(image); newItem.appendChild(head3); newItem.appendChild(head4); + // I like this concept but i'm leaving it out for now + // if(currentSongInJSON.lossless === 1) { + // let losslesstag = document.createElement("p"); + // losslesstag.textContent = "Ⓛ"; + // losslesstag.classList.add("lossless-tag"); + // newItem.appendChild(losslesstag); + // } document.getElementById("songlist").appendChild(newItem); } @@ -301,7 +311,7 @@ async function generateVisualPlaylist(conditions="") { let timeLeft =document.createElement("h5"); timeLeft.style.fontWeight = 100; try { - if (i == 0) { + if (i == 0) { // Only the first song in the loop gets a time head5.innerHTML="Playing"; if ((conditions != "skip-button")) { let mins = Math.floor(playlist[i]["time"]/60); @@ -313,7 +323,7 @@ async function generateVisualPlaylist(conditions="") { } }catch(err){ // i dont know why there's a try catch here but i'm leaving it i dont want to break something - console.log(err) + console.error(err) } let textdiv = document.createElement("div") textdiv.className="text" @@ -332,11 +342,9 @@ async function submitSong(songid) { let returncode = await getFromServer({song: songid}, "songadd"); if(returncode == ERR_NO_ADMIN) { // right now the error is alerted in getFromServer, maybe will change that - } - else if(returncode["error"]=="song-in-queue") { + } else if(returncode["error"]=="song-in-queue") { alertText("That song's about to play! Hang on!") - } - else { + } else { alertText("Added to Queue"); } } @@ -346,11 +354,10 @@ function checkWhatSongWasClicked(e) { if ((itemId.length-itemId.lastIndexOf("image") == 5) && itemId.lastIndexOf("image")!=-1) { itemId = itemId.slice(0,-6) } + let filenameSep = itemId.split('.') //i feel like later kristy won't apreciate this //one of my files was "file.MP3" so it didn't work //windows be like - let filenameSep = itemId.split('.') - if (VALID_FILE_EXT.includes(filenameSep[filenameSep.length-1].toLowerCase())) { submitSong(itemId); } @@ -443,6 +450,8 @@ document.getElementById("volumerange").onchange = async function() { if (returnValue == ERR_NO_ADMIN) { // alertText("Error: Admin restricted action"); // there's an admin restrict alert built into getFromServer + // i wanna put the volume slider back to where it was but idk a good way to keep the previous volume + checkSettings(false); } else if (returnValue["volumePassed"] !=0) { // i forgot about this, i had to do this because it confused the crap out of me one time // vlc doesn't let you change the volume of nothing, which makes sense if you think about it diff --git a/Client/styles.css b/Client/styles.css index 024e988..21f7b7c 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -134,6 +134,7 @@ h4 { .songlist > .item > h3, .songlist > .item > h4{ margin-left: 2px; margin-right: 2px; + margin: 5px; word-wrap: break-word; } @@ -152,6 +153,12 @@ h4 { width: 20%; min-width: 50px; } + +.lossless-tag { + width:16px; + padding: 1px; + margin-left: auto; +} /* playlist mode stuff */ .playlist { diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 130131b..056d23f 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -10,10 +10,9 @@ parser.add_argument('-a','--admin',help="Add an admin password to be used in the args = parser.parse_args() dotenv.load_dotenv() portTheUserPicked=os.getenv("SERVER_PORT") -# Just a note that the return code "401" as of now is used to mean "you don't have the password" -# This is not great design, and the whole "returning string codes" thing is something to add to the todo list -# I mean returning 200 when no return is necesary i think is fine but we'll see -ERR_NO_ADMIN = "401" + +ERR_NO_ADMIN = ({"error":"no-admin"},401) +ERR_200 = ({"error":"OK"},200) if args.admin: ADMIN_PASS = hashlib.sha256(bytes(args.admin,'utf-8')).hexdigest() else: @@ -99,7 +98,8 @@ def playQueuedSongs(): # the above 2 means this only applies if (a song is playing or paused) and (the queue is empty) playlist.append(result[0][0]) # 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 time.sleep(1) @app.route("/controls", methods=['POST']) @@ -112,26 +112,27 @@ def playerControls(): if recieveData["control"] == "play-pause": if ADMIN_PASS == recieveData['password'] or controlPerms["PP"]: player.pause() - return "200" + return ERR_200 else: return ERR_NO_ADMIN elif recieveData["control"] == "skip": if ADMIN_PASS == recieveData['password'] or controlPerms["SK"]: skipNow = True - return "200" + return ERR_200 else: return ERR_NO_ADMIN + # Maybe i should have put this next one in the "settings" section elif recieveData["control"] == "clear": if ADMIN_PASS == recieveData['password']: # this is only ever allowed with the adminpassword with playlistLock: playlist.clear() - return "200" + return ERR_200 else: return ERR_NO_ADMIN else: - return "400" + return {"error":"Not a valid control"},400 else: - return "400" + return {"error":"No control sent"},400 @app.route("/settings", methods=['POST']) def settingsControl(): @@ -149,14 +150,13 @@ def settingsControl(): elif recieveData["setting"] == "partymode-toggle": if ADMIN_PASS == recieveData['password'] or controlPerms["PM"]: partyMode = not(partyMode) - return "200" + return ERR_200 else: return ERR_NO_ADMIN elif recieveData["setting"] == "perms": - if ADMIN_PASS == recieveData["password"] and ADMIN_PASS: - #if an adminpass doesn't exist these perms can never be changed + if ADMIN_PASS == recieveData["password"]: controlPerms = recieveData["admin"] - return "200" + return ERR_200 else: return ERR_NO_ADMIN elif recieveData["setting"] == "getsettings": @@ -184,7 +184,8 @@ def searchSongDB(): "title": i[1], "artist": i[2], "art": i[3], - "length": i[4] + "length": i[4], + "lossless":i[5] } fileofDB.close() return tempdata @@ -201,7 +202,7 @@ def songadd(): # probably with a checkbox like the other admin controls if True: queueSong(recieveData['song']) - return "200" + return ERR_200 else: # the password is incorrect (technically a password not existing falls into the above case because controlPerms is never changed) return ERR_NO_ADMIN From 0b64a6f29721b4033ba5d3fb441a3b138c82eeb0 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:09:26 -0500 Subject: [PATCH 38/69] Okay the backend should be done so im committing now im gonna fix the frontend next --- Client/scripts.js | 7 +++---- Server/webbyBits.py | 27 ++++++++++++++++----------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 1d34c71..8f556be 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -59,10 +59,8 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { } catch(e) { console.log("error print here:"); console.log(e); - if (e == "TypeError: Failed to fetch"){ + if (e.contains("TypeError: Failed to fetch")){ alertText("Error: Can't Connect to Server (is the ip set?)") - } else if(e === "") { - } else { alertText("Error: " + e); } @@ -133,7 +131,8 @@ function searchSongsEnter(e) { async function searchSongs(searchTerm){ document.getElementById("songlist").innerHTML = "" - searchResults = await getFromServer({search:searchTerm},"search").then() + let fetchResults = await getFromServer({search:searchTerm},"search").then(); + let searchResults = fetchResults.data; //generate the visual song list for(var fileName in searchResults) { let currentSongInJSON = searchResults[fileName] diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 056d23f..65cd09a 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -11,8 +11,8 @@ args = parser.parse_args() dotenv.load_dotenv() portTheUserPicked=os.getenv("SERVER_PORT") -ERR_NO_ADMIN = ({"error":"no-admin"},401) -ERR_200 = ({"error":"OK"},200) +ERR_NO_ADMIN = ({"error":"no-admin","data":None},401) +ERR_200 = ({"error":"OK","data":None},200) if args.admin: ADMIN_PASS = hashlib.sha256(bytes(args.admin,'utf-8')).hexdigest() else: @@ -130,9 +130,9 @@ def playerControls(): else: return ERR_NO_ADMIN else: - return {"error":"Not a valid control"},400 + return {"error":"Not a valid control","data":None},400 else: - return {"error":"No control sent"},400 + return {"error":"No control sent","data":None},400 @app.route("/settings", methods=['POST']) def settingsControl(): @@ -143,8 +143,11 @@ def settingsControl(): recieveData = request.get_json(force=True) if recieveData["setting"] == "volume": if ADMIN_PASS == recieveData['password'] or controlPerms["VOL"]: - volumePassed = player.audio_set_volume(int(recieveData["level"])) - return {"volumePassed":volumePassed} + if(recieveData["level"] <= 100 and recieveData["level"] >= 0): + volumePassed = player.audio_set_volume(int(recieveData["level"])) + return {"error":"ok","data":{"volumePassed":volumePassed}},200 + else: + return {"error":"Invalid volume level","data":None} else: return ERR_NO_ADMIN elif recieveData["setting"] == "partymode-toggle": @@ -161,9 +164,9 @@ def settingsControl(): return ERR_NO_ADMIN elif recieveData["setting"] == "getsettings": # probably should have made this a different request type or something but it works - return {"partymode":partyMode,"volume":player.audio_get_volume(),"admin":controlPerms} + return {"error":"ok","data":{"partymode":partyMode,"volume":player.audio_get_volume(),"admin":controlPerms}},200 else: - return "400" + return {"error":"Not a valid setting","data":None},400 @app.route("/search", methods=['POST']) def searchSongDB(): @@ -188,12 +191,13 @@ def searchSongDB(): "lossless":i[5] } fileofDB.close() - return tempdata + + return {"error":"ok","data":tempdata},200 @app.route("/songadd", methods=["POST"]) def songadd(): recieveData=request.get_json(force=True) - if (ADMIN_PASS and ADMIN_PASS == recieveData['password']) or controlPerms["AS"]: + if (ADMIN_PASS == recieveData['password']) or controlPerms["AS"]: # Password exists and is correct, or it's not restricted # if (recieveData['song'] in playlist): # return {"error":"song-in-queue"} @@ -239,7 +243,8 @@ def getPlaylist(): } tempPlaylist.append({i:k}) fileofDB.close() - return tempPlaylist + + return {"error":"ok","data":tempPlaylist} if __name__ == "__main__": # There's not really a whole lot of point to a main function for something like this, you'd never use any of these methods From 00550cca852fe7433a8257aa9de7686238ff8ab1 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:39:17 -0500 Subject: [PATCH 39/69] I got distracted and broke something but the responses should be good now --- Client/scripts.js | 25 +++++++++++++++++-------- Server/webbyBits.py | 9 +++++---- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 8f556be..9ecb8a8 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -2,7 +2,7 @@ let ip; let alertTime = 2; let adminPass = ""; -const ERR_NO_ADMIN = 401; // gonna use this later to refactor +const ERR_NO_ADMIN = 401; const VALID_FILE_EXT = ["mp3","flac","wav"]; const params = new URLSearchParams(location.search); @@ -54,12 +54,15 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { // it is easy which i like // and it overrides any other non-async alerts which is nice alertText("Error: Admin restricted action") + } else if(response.status !== 200){ + alertText("Error: "+data.error); } + data["status"] = response.status; return await data; } catch(e) { console.log("error print here:"); console.log(e); - if (e.contains("TypeError: Failed to fetch")){ + if (e == "TypeError: Failed to fetch"){ alertText("Error: Can't Connect to Server (is the ip set?)") } else { alertText("Error: " + e); @@ -87,6 +90,8 @@ function getCookie(cname) { return ""; } //someone more organised than me would have set all these html elements to variables so they dont have to get them 50 times +// also someone who likes things not being dumb more than me would have separated the client and server buttons +let timer = null; async function controlButton(buttonType) { if (buttonType == "pp") { // Play-Pause button getFromServer({control: "play-pause"}, "controls") @@ -101,6 +106,9 @@ async function controlButton(buttonType) { document.getElementById("playlist-mode").style.display = "block"; document.getElementById("songlist-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "none"; + timer = setInterval(() => { + + }) generateVisualPlaylist(); } else if (buttonType == "se") { //SearchMode button document.getElementById("songlist").innerHTML = "

Search to find songs!

"; @@ -246,7 +254,8 @@ async function checkSettings(skipServer=false) { } } //ping the server here - x = await getFromServer({setting: "getsettings"}, "settings"); + data = await getFromServer({setting: "getsettings"}, "settings"); + x = data["data"]; if (!(skipServer) || partyButtonState=="N/A") { if (x["partymode"] == false) { document.getElementById("partymode-button").innerHTML = "Off"; @@ -271,7 +280,8 @@ async function checkSettings(skipServer=false) { async function generateVisualPlaylist(conditions="") { document.getElementById("playlist").innerHTML = "

"; - playlist = await getFromServer(null, "playlist"); + data = await getFromServer(null, "playlist"); + playlist = data["data"]; playlist = Object.values(playlist).map(obj => { const filename = Object.keys(obj)[0]; // Get the filename const songData = obj[filename]; // Get the song metadata @@ -446,18 +456,17 @@ document.getElementById("volumerange").onchange = async function() { // there is no reason for this not to be a defined function // FIX THIS let returnValue = await getFromServer({setting:"volume",level:this.value}, "settings") - if (returnValue == ERR_NO_ADMIN) { + if (returnValue["status"] == ERR_NO_ADMIN) { // alertText("Error: Admin restricted action"); // there's an admin restrict alert built into getFromServer // i wanna put the volume slider back to where it was but idk a good way to keep the previous volume checkSettings(false); - } else if (returnValue["volumePassed"] !=0) { + } else if (returnValue["data"]["volumePassed"] !=0) { // i forgot about this, i had to do this because it confused the crap out of me one time // vlc doesn't let you change the volume of nothing, which makes sense if you think about it alertText("Nothing is playing") document.getElementById("volumerange").value = -1 - } - else if (this.value == 0) { + } else if (this.value == 0) { alertText("The volume is now set to 0 (Pause?)") } else { alertText("The volume is now set to " + this.value.toString()) diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 65cd09a..a506e82 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -143,11 +143,12 @@ def settingsControl(): recieveData = request.get_json(force=True) if recieveData["setting"] == "volume": if ADMIN_PASS == recieveData['password'] or controlPerms["VOL"]: - if(recieveData["level"] <= 100 and recieveData["level"] >= 0): - volumePassed = player.audio_set_volume(int(recieveData["level"])) + volumeLevel = int(recieveData["level"]) + if(volumeLevel <= 100 and volumeLevel >= 0): + volumePassed = player.audio_set_volume(volumeLevel) return {"error":"ok","data":{"volumePassed":volumePassed}},200 else: - return {"error":"Invalid volume level","data":None} + return {"error":"Invalid volume level","data":None},422 else: return ERR_NO_ADMIN elif recieveData["setting"] == "partymode-toggle": @@ -166,7 +167,7 @@ def settingsControl(): # 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: - return {"error":"Not a valid setting","data":None},400 + return {"error":"Not a valid setting","data":None},422 @app.route("/search", methods=['POST']) def searchSongDB(): From 417ecc8cede000e8b614ae940875055afcf38403 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:50:59 -0500 Subject: [PATCH 40/69] messing around, will fix later --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3920035..87c9ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ server/sound/ *.db start.bat -.env \ No newline at end of file +.env +venv/ \ No newline at end of file From 2002dd1afaa4bd7ff70bada3567bfdd253f21395 Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Mon, 26 Jan 2026 12:15:56 -0500 Subject: [PATCH 41/69] removed broken timer, finalised new responses --- Client/scripts.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 9ecb8a8..8b97fbd 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -47,21 +47,24 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { } }); - const data = await response.json(); + let data = await response.json(); // original json if (response.status == ERR_NO_ADMIN) { // im suprised i didn't comment on this already but this is kinda lame desing // its not wrong but you know // it is easy which i like // and it overrides any other non-async alerts which is nice alertText("Error: Admin restricted action") - } else if(response.status !== 200){ + } else if(!response.ok){ alertText("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) { - console.log("error print here:"); - console.log(e); + // console.log("error print here:"); + // console.log(e); if (e == "TypeError: Failed to fetch"){ alertText("Error: Can't Connect to Server (is the ip set?)") } else { @@ -91,7 +94,6 @@ function getCookie(cname) { } //someone more organised than me would have set all these html elements to variables so they dont have to get them 50 times // also someone who likes things not being dumb more than me would have separated the client and server buttons -let timer = null; async function controlButton(buttonType) { if (buttonType == "pp") { // Play-Pause button getFromServer({control: "play-pause"}, "controls") @@ -106,9 +108,6 @@ async function controlButton(buttonType) { document.getElementById("playlist-mode").style.display = "block"; document.getElementById("songlist-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "none"; - timer = setInterval(() => { - - }) generateVisualPlaylist(); } else if (buttonType == "se") { //SearchMode button document.getElementById("songlist").innerHTML = "

Search to find songs!

"; From fce09edfc555a54a97cf6a6ca65d4df45c289c39 Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Mon, 26 Jan 2026 12:30:08 -0500 Subject: [PATCH 42/69] Added proper handling of internal errors (should be no 500s anywhere) --- Client/scripts.js | 1 - Server/webbyBits.py | 135 ++++++++++++++++++++++++-------------------- 2 files changed, 75 insertions(+), 61 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 8b97fbd..cb7361e 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -38,7 +38,6 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { // the currently set password is always included in every request bodyInfo["password"] = password; } - // console.log(bodyInfo); const response = await fetch("http://"+ip+"/"+source, { method: "POST", body: JSON.stringify(bodyInfo), diff --git a/Server/webbyBits.py b/Server/webbyBits.py index a506e82..d0d2e36 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -13,6 +13,7 @@ portTheUserPicked=os.getenv("SERVER_PORT") ERR_NO_ADMIN = ({"error":"no-admin","data":None},401) ERR_200 = ({"error":"OK","data":None},200) +ERR_MISSING_ARGS = ({"error":"Request missing required arguments","data":None}),400 if args.admin: ADMIN_PASS = hashlib.sha256(bytes(args.admin,'utf-8')).hexdigest() else: @@ -108,7 +109,7 @@ def playerControls(): global skipNow global partyMode recieveData=request.get_json(force=True) - if recieveData["control"] != None: + try: if recieveData["control"] == "play-pause": if ADMIN_PASS == recieveData['password'] or controlPerms["PP"]: player.pause() @@ -131,8 +132,8 @@ def playerControls(): return ERR_NO_ADMIN else: return {"error":"Not a valid control","data":None},400 - else: - return {"error":"No control sent","data":None},400 + except KeyError: + return ERR_MISSING_ARGS @app.route("/settings", methods=['POST']) def settingsControl(): @@ -141,76 +142,90 @@ def settingsControl(): global partyMode global player recieveData = request.get_json(force=True) - if recieveData["setting"] == "volume": - if ADMIN_PASS == recieveData['password'] or controlPerms["VOL"]: - volumeLevel = int(recieveData["level"]) - if(volumeLevel <= 100 and volumeLevel >= 0): - volumePassed = player.audio_set_volume(volumeLevel) - return {"error":"ok","data":{"volumePassed":volumePassed}},200 + try: + if recieveData["setting"] == "volume": + if ADMIN_PASS == recieveData['password'] or controlPerms["VOL"]: + volumeLevel = int(recieveData["level"]) + if(volumeLevel <= 100 and volumeLevel >= 0): + volumePassed = player.audio_set_volume(volumeLevel) + return {"error":"ok","data":{"volumePassed":volumePassed}},200 + else: + return {"error":"Invalid volume level","data":None},422 else: - return {"error":"Invalid volume level","data":None},422 + return ERR_NO_ADMIN + elif recieveData["setting"] == "partymode-toggle": + if ADMIN_PASS == recieveData['password'] or controlPerms["PM"]: + partyMode = not(partyMode) + return ERR_200 + else: + return ERR_NO_ADMIN + elif recieveData["setting"] == "perms": + if ADMIN_PASS == recieveData["password"]: + controlPerms = recieveData["admin"] + return ERR_200 + else: + 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: - return ERR_NO_ADMIN - elif recieveData["setting"] == "partymode-toggle": - if ADMIN_PASS == recieveData['password'] or controlPerms["PM"]: - partyMode = not(partyMode) - return ERR_200 - else: - return ERR_NO_ADMIN - elif recieveData["setting"] == "perms": - if ADMIN_PASS == recieveData["password"]: - controlPerms = recieveData["admin"] - return ERR_200 - else: - 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: - return {"error":"Not a valid setting","data":None},422 + return {"error":"Not a valid setting","data":None},400 + except: + return ERR_MISSING_ARGS @app.route("/search", methods=['POST']) def searchSongDB(): recieveData=request.get_json(force=True) fileofDB = sql.connect("songDatabase.db") songDatabase = fileofDB.cursor() - results = [] - if (recieveData['search'] == ""): - songDatabase.execute("SELECT * FROM virtualSongs") - results = songDatabase.fetchall() - else: - songDatabase.execute("SELECT * FROM virtualSongs WHERE virtualSongs MATCH ?",[recieveData['search']]) - results = songDatabase.fetchall() - tempdata = {} - # this is a temporary solution so i dont have to change the client - for i in results: - tempdata[i[0]] = { - "title": i[1], - "artist": i[2], - "art": i[3], - "length": i[4], - "lossless":i[5] - } - fileofDB.close() + try: + results = [] + if (recieveData['search'] == ""): + songDatabase.execute("SELECT * FROM virtualSongs") + results = songDatabase.fetchall() + else: + songDatabase.execute("SELECT * FROM virtualSongs WHERE virtualSongs MATCH ?",[recieveData['search']]) + results = songDatabase.fetchall() + tempdata = {} + # this is a temporary solution so i dont have to change the client + for i in results: + tempdata[i[0]] = { + "title": i[1], + "artist": i[2], + "art": i[3], + "length": i[4], + "lossless":i[5] + } + fileofDB.close() + + return {"error":"ok","data":tempdata},200 + except KeyError: + fileofDB.close() + return ERR_MISSING_ARGS + except sql.OperationalError: + fileofDB.close() + return ({"error":"Invalid search, sorry!","data":None},422) - return {"error":"ok","data":tempdata},200 @app.route("/songadd", methods=["POST"]) def songadd(): recieveData=request.get_json(force=True) - if (ADMIN_PASS == recieveData['password']) or controlPerms["AS"]: - # Password exists and is correct, or it's not restricted - # if (recieveData['song'] in playlist): - # return {"error":"song-in-queue"} - # else: - # Right now the above is disabled since i want to make it optional first - # probably with a checkbox like the other admin controls - if True: - queueSong(recieveData['song']) - return ERR_200 - else: - # the password is incorrect (technically a password not existing falls into the above case because controlPerms is never changed) - return ERR_NO_ADMIN + try: + if (ADMIN_PASS == recieveData['password']) or controlPerms["AS"]: + # Password exists and is correct, or it's not restricted + # if (recieveData['song'] in playlist): + # return {"error":"song-in-queue"} + # else: + # Right now the above is disabled since i want to make it optional first + # probably with a checkbox like the other admin controls + if True: + queueSong(recieveData['song']) + return ERR_200 + else: + # the password is incorrect (technically a password not existing falls into the above case because controlPerms is never changed) + return ERR_NO_ADMIN + except KeyError: + return ERR_MISSING_ARGS @app.route("/playlist", methods=["POST"]) def getPlaylist(): From 7d45d9498ed83072922ab5ae341dfd00ce4083ea Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Mon, 26 Jan 2026 15:32:00 -0500 Subject: [PATCH 43/69] fixed search errors --- Client/styles.css | 5 +++-- Server/webbyBits.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Client/styles.css b/Client/styles.css index 21f7b7c..a592e08 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -1,4 +1,4 @@ -/* testing */ +/* dark mode stuff */ .dark-mode { --bg-main: #333333; @@ -37,8 +37,9 @@ input, button { body { background-color: var(--bg-main); } + * { - font-family: 'arial'; + font-family: 'Work Sans','Arial',sans-serif; } .italic { diff --git a/Server/webbyBits.py b/Server/webbyBits.py index d0d2e36..5586f2c 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -180,11 +180,12 @@ def searchSongDB(): songDatabase = fileofDB.cursor() try: results = [] + # print(recieveData["search"]) if (recieveData['search'] == ""): songDatabase.execute("SELECT * FROM virtualSongs") results = songDatabase.fetchall() else: - songDatabase.execute("SELECT * FROM virtualSongs WHERE virtualSongs MATCH ?",[recieveData['search']]) + songDatabase.execute("SELECT * FROM virtualSongs WHERE virtualSongs MATCH ?",['"' + recieveData['search']+'"']) results = songDatabase.fetchall() tempdata = {} # this is a temporary solution so i dont have to change the client @@ -202,7 +203,8 @@ def searchSongDB(): except KeyError: fileofDB.close() return ERR_MISSING_ARGS - except sql.OperationalError: + except sql.OperationalError as e: + print(e) fileofDB.close() return ({"error":"Invalid search, sorry!","data":None},422) From cda152852c86de846154f1e9ac0a688a59658df2 Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Mon, 26 Jan 2026 15:32:28 -0500 Subject: [PATCH 44/69] Minor changes --- Client/styles.css | 2 +- Server/webbyBits.py | 2 +- readme.md | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Client/styles.css b/Client/styles.css index a592e08..2c2f4c9 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -39,7 +39,7 @@ body { } * { - font-family: 'Work Sans','Arial',sans-serif; + font-family: 'Arial',sans-serif; } .italic { diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 5586f2c..c20959c 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -206,7 +206,7 @@ def searchSongDB(): except sql.OperationalError as e: print(e) fileofDB.close() - return ({"error":"Invalid search, sorry!","data":None},422) + return ({"error":"Database error (Try another search?)","data":None},500) @app.route("/songadd", methods=["POST"]) diff --git a/readme.md b/readme.md index 0bf9c42..4c82c73 100644 --- a/readme.md +++ b/readme.md @@ -20,6 +20,7 @@ The server side consists of 3 files: sound/ databaseGenerator.py webbyBits.py +.env ``` 1. Place mp3 files in the `sound/` folder From 62caee7fd810b572d1668b6f547001827ace3a1d Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:51:01 -0500 Subject: [PATCH 45/69] adaptive qr code, dark mode and more - Dark mode is set based on user browser info - QR Code changes colours based on dark or light mode - "DUP" controlPerm for preventing duplicates in the future - Fully implemented in the server but not yet the client --- Client/scripts.js | 28 +++++++++++++++++++--------- Server/webbyBits.py | 12 +++++------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index cb7361e..7b612d5 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -8,9 +8,12 @@ const VALID_FILE_EXT = ["mp3","flac","wav"]; const params = new URLSearchParams(location.search); let darkmodetemp = getCookie("darkmode"); +if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + darkmodetemp = "true"; +} if(darkmodetemp === "") { darkmodetemp = params.get("darkmode") -} +} if (darkmodetemp === "true") { // i know this is gonna cause weird blinking // maybe the dark mode function should be loaded before any content, would that work? @@ -64,7 +67,7 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { } catch(e) { // console.log("error print here:"); // console.log(e); - if (e == "TypeError: Failed to fetch"){ + if (e.toString().contains("TypeError: Failed to fetch")){ alertText("Error: Can't Connect to Server (is the ip set?)") } else { alertText("Error: " + e); @@ -194,7 +197,11 @@ function alertTimeSet(time) { function ipSetEnter(e){ if (e.key == "Enter") { - e.preventDefault(); + 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) } } @@ -222,13 +229,16 @@ function ipSetter(){ function qrCodeGenerate() { let tempURL = "http://" + document.location.href.split("/")[2] + "/?ip=" + ip; - document.getElementById("qrcode").innerHTML = "" + document.getElementById("qrcode").innerHTML = ""; + // get the current foreground and background + let dark = window.getComputedStyle(document.body).getPropertyValue("--text-color"); + let light = window.getComputedStyle(document.body).getPropertyValue("--bg-main"); new QRCode(document.getElementById("qrcode"), { text: tempURL, width: 256, height: 256, - colorDark : "#000000", - colorLight : "#eeeeee", + colorDark : dark, + colorLight : light, correctLevel : QRCode.CorrectLevel.H }); } @@ -349,8 +359,8 @@ async function submitSong(songid) { let returncode = await getFromServer({song: songid}, "songadd"); if(returncode == ERR_NO_ADMIN) { // right now the error is alerted in getFromServer, maybe will change that - } else if(returncode["error"]=="song-in-queue") { - alertText("That song's about to play! Hang on!") + } else if(returncode["status"]!==200) { + alertText("That song's already in the queue! Hang on!") } else { alertText("Added to Queue"); } @@ -382,7 +392,7 @@ function toggleDark(e) { document.getElementById("darkmode-button").innerHTML = "Off"; x.remove("dark-mode"); } - + qrCodeGenerate(); } async function sha256(message) { diff --git a/Server/webbyBits.py b/Server/webbyBits.py index c20959c..cbab57f 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -28,7 +28,8 @@ controlPerms = { "SK":True, "AS":True, "PM":True, - "VOL":True + "VOL":True, + "DUP":True # Not implemented, allow duplicate songs in queue } fileofDB = sql.connect("songDatabase.db") @@ -215,12 +216,9 @@ def songadd(): try: if (ADMIN_PASS == recieveData['password']) or controlPerms["AS"]: # Password exists and is correct, or it's not restricted - # if (recieveData['song'] in playlist): - # return {"error":"song-in-queue"} - # else: - # Right now the above is disabled since i want to make it optional first - # probably with a checkbox like the other admin controls - if True: + if not(controlPerms["DUP"]) and (recieveData['song'] in playlist) and not(ADMIN_PASS == recieveData['password']): + return {"error":"This song is already in the queue, hang on!","data":None},409 + else: queueSong(recieveData['song']) return ERR_200 else: From f37b2b7691d8c7da830331e9aa4290a709c8666c Mon Sep 17 00:00:00 2001 From: Kristy Fournier Date: Thu, 29 Jan 2026 17:09:00 -0500 Subject: [PATCH 46/69] Added client side access to dupe prevention Still some bugs but ill work through them --- Client/index.html | 2 ++ Client/scripts.js | 12 ++++++++---- Server/webbyBits.py | 4 +++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Client/index.html b/Client/index.html index 428fb2d..a45c6b1 100644 --- a/Client/index.html +++ b/Client/index.html @@ -101,6 +101,8 @@ changes visibility with JS-->


+
+
diff --git a/Client/scripts.js b/Client/scripts.js index 7b612d5..dbf3382 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -100,9 +100,11 @@ async function controlButton(buttonType) { if (buttonType == "pp") { // Play-Pause button getFromServer({control: "play-pause"}, "controls") } else if (buttonType == "sk") { // Skip button - getFromServer({control: "skip"}, "controls") - if (document.getElementById("playlist-mode").style.display == "block") { - generateVisualPlaylist("skip-button"); + let returnCode = getFromServer({control: "skip"}, "controls"); + if(returnCode["ok"]) { + if (document.getElementById("playlist-mode").style.display == "block") { + generateVisualPlaylist("skip-button"); + } } } else if (buttonType == "pl") { // Playlist button document.getElementById("songlist").innerHTML = ""; @@ -284,6 +286,7 @@ async function checkSettings(skipServer=false) { document.getElementById("playpausesettingcheckbox").checked = currentAdminPerms["PP"]; document.getElementById("partymodesettingcheckbox").checked = currentAdminPerms["PM"]; document.getElementById("volumechangesettingcheckbox").checked = currentAdminPerms["VOL"]; + document.getElementById("duplicateallowesettingcheckbox").checked = currentAdminPerms["DUP"]; } async function generateVisualPlaylist(conditions="") { @@ -430,8 +433,9 @@ async function submitPerms(e) { tempData["AS"] = document.getElementById("addsongsettingcheckbox").checked; tempData["PM"] = document.getElementById("partymodesettingcheckbox").checked; tempData["VOL"] = document.getElementById("volumechangesettingcheckbox").checked; + tempData["DUP"] = document.getElementById("duplicateallowesettingcheckbox").checked; let returncode = await getFromServer({"setting":"perms","admin":tempData},"settings"); - if (returncode == ERR_NO_ADMIN || returncode == null) { + if (!(returncode["ok"])) { // 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 let clickedBox = e.srcElement; diff --git a/Server/webbyBits.py b/Server/webbyBits.py index cbab57f..ea876d8 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -163,6 +163,7 @@ def settingsControl(): elif recieveData["setting"] == "perms": if ADMIN_PASS == recieveData["password"]: controlPerms = recieveData["admin"] + print(recieveData["admin"]) return ERR_200 else: return ERR_NO_ADMIN @@ -224,7 +225,8 @@ def songadd(): else: # the password is incorrect (technically a password not existing falls into the above case because controlPerms is never changed) return ERR_NO_ADMIN - except KeyError: + except KeyError as e: + print(e) return ERR_MISSING_ARGS @app.route("/playlist", methods=["POST"]) From f17ab0c42696794b58923a8fb23e87728fcba5b2 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:53:25 -0500 Subject: [PATCH 47/69] broken timer lololol --- Client/scripts.js | 62 +++++++++++++++++++++++++++++++++++------------ wishlist.md | 3 ++- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index dbf3382..7e26d7b 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -5,6 +5,10 @@ let adminPass = ""; const ERR_NO_ADMIN = 401; const VALID_FILE_EXT = ["mp3","flac","wav"]; +let playlistTimeTimer=null; +let playlistElapsedSeconds=0; +let playlistSongLength=-1; + const params = new URLSearchParams(location.search); let darkmodetemp = getCookie("darkmode"); @@ -97,10 +101,12 @@ function getCookie(cname) { //someone more organised than me would have set all these html elements to variables so they dont have to get them 50 times // also someone who likes things not being dumb more than me would have separated the client and server buttons async function controlButton(buttonType) { + clearInterval(playlistTimeTimer); if (buttonType == "pp") { // Play-Pause button getFromServer({control: "play-pause"}, "controls") } else if (buttonType == "sk") { // Skip button - let returnCode = getFromServer({control: "skip"}, "controls"); + let returnCode = await getFromServer({control: "skip"}, "controls"); + console.log(returnCode["ok"]) if(returnCode["ok"]) { if (document.getElementById("playlist-mode").style.display == "block") { generateVisualPlaylist("skip-button"); @@ -245,6 +251,21 @@ function qrCodeGenerate() { }); } +async function displayElapsedPlaylistTime(elapsed=0,length=0) { + if(Math.floor(elapsed) === Math.floor(length)){ + console.log("somethingShouldBeHappening") + playlistElapsedSeconds = 0; + generateVisualPlaylist(); + } + let mins = Math.floor(elapsed/60); + let secs = Math.floor(elapsed%60); + let durMins = Math.floor(length/60); + let durSecs = Math.floor(length%60); + let timeLeft = document.getElementById("elapsed-time-display"); + timeLeft.innerHTML = mins.toString() +":"+ secs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}) + "/"+ durMins.toString()+":"+durSecs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}); + playlistElapsedSeconds++; +} + async function checkSettings(skipServer=false) { //check client stuff first so if the server doesn't exist it can still be changed and seen if (ip.slice(-5)=="19054") { @@ -299,6 +320,7 @@ async function generateVisualPlaylist(conditions="") { return { filename, ...songData }; // Merge filename with song data }); if (playlist.length==0){ + clearInterval(playlistTimeTimer); document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." } else { if (conditions=="skip-button") { @@ -330,20 +352,11 @@ async function generateVisualPlaylist(conditions="") { let head5 = document.createElement("h5"); let timeLeft =document.createElement("h5"); timeLeft.style.fontWeight = 100; - try { - if (i == 0) { // Only the first song in the loop gets a time - head5.innerHTML="Playing"; - if ((conditions != "skip-button")) { - let mins = Math.floor(playlist[i]["time"]/60); - let secs = Math.floor(playlist[i]["time"]%60); - let durMins = Math.floor(playlist[i]["length"]/60); - let durSecs = Math.floor(playlist[i]["length"]%60); - timeLeft.innerHTML = mins.toString() +":"+ secs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}) + "/"+ durMins.toString()+":"+durSecs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}); - } - } - }catch(err){ - // i dont know why there's a try catch here but i'm leaving it i dont want to break something - console.error(err) + if(i== 0) { + // they can all have the text, doesn't really matter, but only the first one + // should get the ids since its the one we want to mess with + head5.id = "playing-indicator-text"; + timeLeft.id = "elapsed-time-display"; } let textdiv = document.createElement("div") textdiv.className="text" @@ -354,8 +367,25 @@ async function generateVisualPlaylist(conditions="") { textdiv.appendChild(head5); newItem.appendChild(textdiv); document.getElementById("playlist").appendChild(newItem); + try { + if (i == 0) { // Only the first song in the loop gets a time + head5.innerHTML="Playing"; + if ((conditions != "skip-button")) { + playlistElapsedSeconds = playlist[0]["time"]; + playlistSongLength = playlist[0]["length"]; + displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); + clearInterval(playlistTimeTimer); + playlistTimeTimer = setInterval(() => { + displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); + },1000) + } + } + }catch(err){ + // i dont know why there's a try catch here but i'm leaving it i dont want to break something + console.error(err) + } } - } + } } async function submitSong(songid) { diff --git a/wishlist.md b/wishlist.md index 9fb170c..e1324e1 100644 --- a/wishlist.md +++ b/wishlist.md @@ -26,4 +26,5 @@ - [ ] Websockets / some method of updating the time remaining to any client on the playlist screen * currently the screen just grabs the "elapsed time" once when it is loaded * websockets can re-update clients - * not actually sure if i can CORS-socket but we're sure gonna try \ No newline at end of file + * not actually sure if i can CORS-socket but we're sure gonna try + - [ ] Set a timeout to change the time \ No newline at end of file From 384b369eeeea1bd878c3575295d1cdf3ac63638c Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:06:43 -0500 Subject: [PATCH 48/69] Update scripts.js --- Client/scripts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Client/scripts.js b/Client/scripts.js index 7e26d7b..7435742 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -71,7 +71,7 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { } catch(e) { // console.log("error print here:"); // console.log(e); - if (e.toString().contains("TypeError: Failed to fetch")){ + if (e.toString().includes("TypeError: Failed to fetch")){ alertText("Error: Can't Connect to Server (is the ip set?)") } else { alertText("Error: " + e); From f064183b9a38d4241ffa342a9522e7881402d9d9 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:08:53 -0500 Subject: [PATCH 49/69] Sockets are implemented need a lot of fixes but are in a mostly working state as of now --- Client/index.html | 1 + Client/scripts.js | 78 +++++++++++++++++++++++++++++++++------------ Server/webbyBits.py | 50 +++++++++++++++++++++++++---- wishlist.md | 14 ++++---- 4 files changed, 111 insertions(+), 32 deletions(-) diff --git a/Client/index.html b/Client/index.html index a45c6b1..d2e4234 100644 --- a/Client/index.html +++ b/Client/index.html @@ -6,6 +6,7 @@ + diff --git a/Client/scripts.js b/Client/scripts.js index 7435742..e8b7604 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -8,6 +8,7 @@ const VALID_FILE_EXT = ["mp3","flac","wav"]; let playlistTimeTimer=null; let playlistElapsedSeconds=0; let playlistSongLength=-1; +let currentlyPlaying = false; const params = new URLSearchParams(location.search); @@ -101,10 +102,12 @@ function getCookie(cname) { //someone more organised than me would have set all these html elements to variables so they dont have to get them 50 times // also someone who likes things not being dumb more than me would have separated the client and server buttons async function controlButton(buttonType) { - clearInterval(playlistTimeTimer); if (buttonType == "pp") { // Play-Pause button - getFromServer({control: "play-pause"}, "controls") + let result = await getFromServer({control: "play-pause"}, "controls"); + console.log(result); + currentlyPlaying = result["data"]["playingState"]; } else if (buttonType == "sk") { // Skip button + clearInterval(playlistTimeTimer); let returnCode = await getFromServer({control: "skip"}, "controls"); console.log(returnCode["ok"]) if(returnCode["ok"]) { @@ -113,6 +116,7 @@ async function controlButton(buttonType) { } } } else if (buttonType == "pl") { // Playlist button + clearInterval(playlistTimeTimer); document.getElementById("songlist").innerHTML = ""; document.getElementById("playlist").innerHTML = "

"; document.getElementById("playlist-mode").style.display = "block"; @@ -120,12 +124,14 @@ async function controlButton(buttonType) { document.getElementById("settings-mode").style.display = "none"; generateVisualPlaylist(); } else if (buttonType == "se") { //SearchMode button + clearInterval(playlistTimeTimer); document.getElementById("songlist").innerHTML = "

Search to find songs!

"; document.getElementById("playlist").innerHTML = ""; document.getElementById("playlist-mode").style.display = "none"; document.getElementById("songlist-mode").style.display = "block"; document.getElementById("settings-mode").style.display = "none"; } else if (buttonType == "st") { //Settings button + clearInterval(playlistTimeTimer); document.getElementById("songlist").innerHTML = ""; document.getElementById("playlist").innerHTML = ""; document.getElementById("playlist-mode").style.display = "none"; @@ -251,19 +257,28 @@ function qrCodeGenerate() { }); } -async function displayElapsedPlaylistTime(elapsed=0,length=0) { - if(Math.floor(elapsed) === Math.floor(length)){ - console.log("somethingShouldBeHappening") - playlistElapsedSeconds = 0; - generateVisualPlaylist(); +async function displayElapsedPlaylistTime(elapsed=0,length=-1) { + if(currentlyPlaying) { + if(Math.floor(elapsed) > Math.floor(length) && typeof length === "number" && typeof elapsed === "number"){ + // console.log("somethingShouldBeHappening") + playlistElapsedSeconds = 0; + generateVisualPlaylist(); + } + let mins = Math.floor(elapsed/60); + let secs = Math.floor(elapsed%60); + let durMins = Math.floor(length/60); + let durSecs = Math.floor(length%60); + let timeLeft = document.getElementById("elapsed-time-display"); + if(mins > durMins) { + mins = durMins; + if(secs > durSecs) { + secs = durSecs; + } + } + + timeLeft.innerHTML = mins.toString() +":"+ secs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}) + "/"+ durMins.toString()+":"+durSecs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}); + // playlistElapsedSeconds++; } - let mins = Math.floor(elapsed/60); - let secs = Math.floor(elapsed%60); - let durMins = Math.floor(length/60); - let durSecs = Math.floor(length%60); - let timeLeft = document.getElementById("elapsed-time-display"); - timeLeft.innerHTML = mins.toString() +":"+ secs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}) + "/"+ durMins.toString()+":"+durSecs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}); - playlistElapsedSeconds++; } async function checkSettings(skipServer=false) { @@ -313,7 +328,8 @@ async function checkSettings(skipServer=false) { async function generateVisualPlaylist(conditions="") { document.getElementById("playlist").innerHTML = "

"; data = await getFromServer(null, "playlist"); - playlist = data["data"]; + playlist = data["data"]["playlist"]; + currentlyPlaying = data["data"]["playingState"] playlist = Object.values(playlist).map(obj => { const filename = Object.keys(obj)[0]; // Get the filename const songData = obj[filename]; // Get the song metadata @@ -323,7 +339,7 @@ async function generateVisualPlaylist(conditions="") { clearInterval(playlistTimeTimer); document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." } else { - if (conditions=="skip-button") { + if (conditions==="skip-button") { playlist.shift() if (playlist.length==0){ document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." @@ -375,9 +391,6 @@ async function generateVisualPlaylist(conditions="") { playlistSongLength = playlist[0]["length"]; displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); clearInterval(playlistTimeTimer); - playlistTimeTimer = setInterval(() => { - displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); - },1000) } } }catch(err){ @@ -386,6 +399,9 @@ async function generateVisualPlaylist(conditions="") { } } } + playlistTimeTimer = setInterval(() => { + displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); + },1000) } async function submitSong(songid) { @@ -565,4 +581,26 @@ if (alertTime == "") { document.cookie = "alertTime="+alertTime+"; path=/;" } // this is the code that makes the qr code at the very start -qrCodeGenerate() \ No newline at end of file +qrCodeGenerate() + +// socket testing stuff + +socket = io("http://"+ip,{ + reconnectionAttemps: 5, + timeout: 10000, +}); + +socket.on("songAdd", function(data) { + console.log("recieved data from songAdd"); + console.log(data); + generateVisualPlaylist(); +}) + +socket.on("timeUpdate", function(data) { + console.log("recieved data from timeUpdate"); + console.log(data); + playlistElapsedSeconds = data["elapsedTime"]; + playingState = data["playingState"] +}); + +socket.on("skipSong",generateVisualPlaylist) \ No newline at end of file diff --git a/Server/webbyBits.py b/Server/webbyBits.py index ea876d8..02636da 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -1,6 +1,7 @@ from flask import Flask from flask import request from flask_cors import CORS +from flask_socketio import SocketIO import sqlite3 as sql import vlc,threading,time,random,argparse,dotenv,os,hashlib,string # Argparse Stuff @@ -64,20 +65,46 @@ player.audio_set_volume(100) app = Flask(__name__) # because you are POSTing from another domain to this one, you need CORS CORS(app) +# Replace the star with the frontend domain if you dislike being hacked +socketio = SocketIO(app, cors_allowed_origins="*") def queueSong(song): with playlistLock: playlist.append(song) + socketio.emit("songAdd",getSongInfo(song)) + +def getSongInfo(song): + fileofDB = sql.connect("songDatabase.db") + songDatabase = fileofDB.cursor() + songDatabase.execute("SELECT * FROM songs WHERE filename = ?",[song]) + result = songDatabase.fetchall()[0] + # again, this is still using the old JSON format to avoid client changes + k = { + "title": result[1], + "artist": result[2], + "art": result[3], + "length": result[4] + } + fileofDB.close() + return {song:k} # this is a loop that plays the songs and checks for playlist changes, skips, ect. +counter = 0 def playQueuedSongs(): global skipNow global songNext global partyMode + global counter while True: with playlistLock: + counter+=1 + if(counter > 10): + playingState = str(player.get_state()) == "State.Playing" + socketio.emit('timeUpdate',{"elapsedTime":player.get_time()/1000,"playingState":playingState}) playerState = str(player.get_state()) endStates = ["State.Ended","State.Stopped","State.NothingSpecial"] + if playerState == "State.Ended": + socketio.emit("skipSong",None) if playlist and (playerState in endStates or skipNow == True): # New song is in the queue and (the previous song is over or skip has been pressed) player.stop() @@ -87,6 +114,7 @@ def playQueuedSongs(): player.set_media(media) player.play() elif (skipNow==True or (playerState in endStates)): + # print(playerState) # skip was pressed and there are no new songs skipNow=False songNext = None @@ -99,11 +127,16 @@ def playQueuedSongs(): # adds the random songs for party mode # the above 2 means this only applies if (a song is playing or paused) and (the queue is empty) playlist.append(result[0][0]) + socketio.emit('songAdd',getSongInfo(result[0][0])) # check for new songs every second # 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 time.sleep(1) +@socketio.on("connect") +def handleConnect(): + pass + @app.route("/controls", methods=['POST']) def playerControls(): # recieve control inputs (play/pause and skip) from the webUI @@ -113,10 +146,12 @@ def playerControls(): try: if recieveData["control"] == "play-pause": if ADMIN_PASS == recieveData['password'] or controlPerms["PP"]: + playingState = str(player.get_state())=="State.Playing" player.pause() - return ERR_200 + return {"error":"ok","data":{"playingState":not(playingState)}},200 else: - return ERR_NO_ADMIN + playingState = str(player.get_state())=="State.Playing" + return {"error":"Admin Restricted Action","data":{"playingState":playingState}},401 elif recieveData["control"] == "skip": if ADMIN_PASS == recieveData['password'] or controlPerms["SK"]: skipNow = True @@ -163,7 +198,7 @@ def settingsControl(): elif recieveData["setting"] == "perms": if ADMIN_PASS == recieveData["password"]: controlPerms = recieveData["admin"] - print(recieveData["admin"]) + # print(recieveData["admin"]) return ERR_200 else: return ERR_NO_ADMIN @@ -261,8 +296,11 @@ def getPlaylist(): } tempPlaylist.append({i:k}) fileofDB.close() - - return {"error":"ok","data":tempPlaylist} + playingState = False + if(str(player.get_state())=="State.Playing"): + playingState = True + # print(playingState) + return {"error":"ok","data":{"playlist":tempPlaylist,"playingState":playingState}},200 if __name__ == "__main__": # There's not really a whole lot of point to a main function for something like this, you'd never use any of these methods @@ -271,5 +309,5 @@ if __name__ == "__main__": queueThread = threading.Thread(target=playQueuedSongs) queueThread.daemon = True queueThread.start() - app.run(host='0.0.0.0', port=portTheUserPicked) + socketio.run(app=app,host='0.0.0.0', port=portTheUserPicked) \ No newline at end of file diff --git a/wishlist.md b/wishlist.md index e1324e1..48f75e6 100644 --- a/wishlist.md +++ b/wishlist.md @@ -1,7 +1,5 @@ ## Wishlist *Features I would like to add, will be completed in any order* -- [x] Admin password - * Allows restricting certain features and changing permissions on the fly on the client - [ ] Refactoring existing code - [x] Remove old comments - [ ] Update the SQL -> Server -> Client pipeline when searching and building playlist @@ -24,7 +22,11 @@ - Potentially a "redemption code" system, which can be tracked client side - All of this is very hackable without a server-side login. - [ ] Websockets / some method of updating the time remaining to any client on the playlist screen - * currently the screen just grabs the "elapsed time" once when it is loaded - * websockets can re-update clients - * not actually sure if i can CORS-socket but we're sure gonna try - - [ ] Set a timeout to change the time \ No newline at end of file + * This is implemented in a very broken way right now + - [x] Set a timeout to change the time (to start) + - [x] Send updates to the playlist in real time when songs are added + * This is only kind of done, still needs work + - [ ] Update the playlist's html without destroying it (create 1 new element) + - [x] Tell clients looking at the playlist when the song has been paused (so they can pause the local timers) + * Again, still needs work + * This is currently solved by just sending the time and "playing status" once a second-ish \ No newline at end of file From 87687506b1b9c7a4461470fdeb5bb2d67da121bc Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sat, 31 Jan 2026 11:24:02 -0500 Subject: [PATCH 50/69] Playlist update instead of refresh, time/skip sync - Playlist destroys and creates members on the fly - Updates time live, and ensures skips aren't detected twice Im sure there are still bugs, but ill find them as i go I am also still going to refactor this, so it''s not going to be merged into main for a while --- Client/scripts.js | 105 ++++++++++++++++++++++++++++++++++++++------ Server/webbyBits.py | 12 +++-- wishlist.md | 1 + 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index e8b7604..159b716 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -2,6 +2,7 @@ let ip; let alertTime = 2; let adminPass = ""; +let justSkipped = false const ERR_NO_ADMIN = 401; const VALID_FILE_EXT = ["mp3","flac","wav"]; @@ -104,15 +105,17 @@ function getCookie(cname) { async function controlButton(buttonType) { if (buttonType == "pp") { // Play-Pause button let result = await getFromServer({control: "play-pause"}, "controls"); - console.log(result); + // console.log(result); currentlyPlaying = result["data"]["playingState"]; } else if (buttonType == "sk") { // Skip button - clearInterval(playlistTimeTimer); + // clearInterval(playlistTimeTimer); let returnCode = await getFromServer({control: "skip"}, "controls"); - console.log(returnCode["ok"]) + // console.log(returnCode["ok"]) if(returnCode["ok"]) { if (document.getElementById("playlist-mode").style.display == "block") { - generateVisualPlaylist("skip-button"); + skipInPlaylist(); + playlistElapsedSeconds = 0; + justSkipped = true; } } } else if (buttonType == "pl") { // Playlist button @@ -277,7 +280,7 @@ async function displayElapsedPlaylistTime(elapsed=0,length=-1) { } timeLeft.innerHTML = mins.toString() +":"+ secs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}) + "/"+ durMins.toString()+":"+durSecs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}); - // playlistElapsedSeconds++; + playlistElapsedSeconds++; } } @@ -325,6 +328,76 @@ async function checkSettings(skipServer=false) { document.getElementById("duplicateallowesettingcheckbox").checked = currentAdminPerms["DUP"]; } +async function addToPlaylist(songObject) { + i = document.getElementById("playlist").children.length-1 + let newItem = document.createElement("div"); + newItem.className = "item"; + newItem.id = Object.keys(songObject)[0]; + newItem.tabIndex = 0; + let image = document.createElement("img"); + try { + if (songObject[newItem.id]["art"] == null) { + throw "no image lolz" + } + image.src = songObject[newItem.id]["art"]; + } catch(err){ + image.src = "./images/placeholder.png"; + } + image.id = String(songObject[newItem.id])+" image"; + let head3 = document.createElement("h3"); + head3.innerText = songObject[newItem.id]["title"]; + let head4 = document.createElement("h4"); + head4.innerText= songObject[newItem.id]["artist"]; + let head5 = document.createElement("h5"); + let timeLeft =document.createElement("h5"); + timeLeft.style.fontWeight = 100; + if(i==0) { + // they can all have the text, doesn't really matter, but only the first one + // should get the ids since its the one we want to mess with + head5.id = "playing-indicator-text"; + timeLeft.id = "elapsed-time-display"; + } + let textdiv = document.createElement("div") + textdiv.className="text" + newItem.appendChild(image); + textdiv.appendChild(head3); + textdiv.appendChild(head4); + textdiv.appendChild(timeLeft); + textdiv.appendChild(head5); + newItem.appendChild(textdiv); + document.getElementById("playlist").appendChild(newItem); + try { + if (i == 0) { // Only the first song in the loop gets a time + head5.innerHTML="Playing"; + playlistElapsedSeconds = playlist[0]["time"]; + playlistSongLength = playlist[0]["length"]; + displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); + clearInterval(playlistTimeTimer); + } + } catch(e) { + console.log("I dunno something bad happened:"+e); + } +} + +async function skipInPlaylist() { + playlistElapsedSeconds = 0; + let playlistChildren = document.getElementById("playlist").children; + if(playlistChildren[1].nodeName === "DIV") { + playlistChildren[1].remove(); + } + playlistChildren = document.getElementById("playlist").children; + if(playlistChildren.length === 1) { + playlistChildren[0].innerText = "Nothing's Queued..." + } else { + let firstElementTextChildren = playlistChildren[1].children[1].children + // console.log(firstElementTextChildren); + firstElementTextChildren[2].id = "elapsed-time-display"; + firstElementTextChildren[3].id = "playing-indicator-text"; + firstElementTextChildren[3].textContent = "Playing"; + } + displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); +} + async function generateVisualPlaylist(conditions="") { document.getElementById("playlist").innerHTML = "

"; data = await getFromServer(null, "playlist"); @@ -398,10 +471,10 @@ async function generateVisualPlaylist(conditions="") { console.error(err) } } + playlistTimeTimer = setInterval(() => { + displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); + },1000) } - playlistTimeTimer = setInterval(() => { - displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); - },1000) } async function submitSong(songid) { @@ -591,16 +664,22 @@ socket = io("http://"+ip,{ }); socket.on("songAdd", function(data) { - console.log("recieved data from songAdd"); + // console.log("recieved data from songAdd"); console.log(data); - generateVisualPlaylist(); + addToPlaylist(data); }) socket.on("timeUpdate", function(data) { - console.log("recieved data from timeUpdate"); + // console.log("recieved data from timeUpdate"); console.log(data); playlistElapsedSeconds = data["elapsedTime"]; - playingState = data["playingState"] + currentlyPlaying = data["playingState"] }); -socket.on("skipSong",generateVisualPlaylist) \ No newline at end of file +socket.on("skipSong",() => { + if(justSkipped === false) { + skipInPlaylist(); + } else { + justSkipped = false; + } +}) \ No newline at end of file diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 02636da..f78961c 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -90,21 +90,22 @@ def getSongInfo(song): # this is a loop that plays the songs and checks for playlist changes, skips, ect. counter = 0 +isPlaying = False def playQueuedSongs(): global skipNow global songNext global partyMode global counter + global isPlaying while True: with playlistLock: counter+=1 - if(counter > 10): + if(counter > 2): playingState = str(player.get_state()) == "State.Playing" socketio.emit('timeUpdate',{"elapsedTime":player.get_time()/1000,"playingState":playingState}) + counter = 0 playerState = str(player.get_state()) endStates = ["State.Ended","State.Stopped","State.NothingSpecial"] - if playerState == "State.Ended": - socketio.emit("skipSong",None) if playlist and (playerState in endStates or skipNow == True): # New song is in the queue and (the previous song is over or skip has been pressed) player.stop() @@ -113,7 +114,12 @@ def playQueuedSongs(): media = vlcInstance.media_new(soundLocation+songNext) player.set_media(media) player.play() + isPlaying = True + socketio.emit("skipSong",None) elif (skipNow==True or (playerState in endStates)): + if(isPlaying): + socketio.emit("skipSong",None) + isPlaying = False # print(playerState) # skip was pressed and there are no new songs skipNow=False diff --git a/wishlist.md b/wishlist.md index 48f75e6..8be1c9d 100644 --- a/wishlist.md +++ b/wishlist.md @@ -1,5 +1,6 @@ ## Wishlist *Features I would like to add, will be completed in any order* +- [ ] Loading indicator while awaiting server stuff - [ ] Refactoring existing code - [x] Remove old comments - [ ] Update the SQL -> Server -> Client pipeline when searching and building playlist From 4c24f13c09761e26e2f7cccace73b2d53fea5bdb Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:19:13 -0500 Subject: [PATCH 51/69] Update readme, slight gramatical change --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 4c82c73..b730a50 100644 --- a/readme.md +++ b/readme.md @@ -14,7 +14,7 @@ The client is a web application that can be hosted on any server, it need not be ### 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 \ \ -The server side consists of 3 files: +The server side consists of 3 files and a directory: ``` sound/ From 17632d4dea1dc30bdd9013fb4e5abb1400cafc22 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:20:05 -0500 Subject: [PATCH 52/69] New settings socket, Different controls css --- Client/index.html | 2 ++ Client/scripts.js | 61 +++++++++++++++++++++++++++++++-------------- Client/styles.css | 1 + Server/webbyBits.py | 3 +++ 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/Client/index.html b/Client/index.html index d2e4234..591a402 100644 --- a/Client/index.html +++ b/Client/index.html @@ -6,6 +6,8 @@ + + diff --git a/Client/scripts.js b/Client/scripts.js index 159b716..7d68143 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -2,7 +2,8 @@ let ip; let alertTime = 2; let adminPass = ""; -let justSkipped = false +let justSkipped = false; +let justChangedSetting = false; const ERR_NO_ADMIN = 401; const VALID_FILE_EXT = ["mp3","flac","wav"]; @@ -41,12 +42,16 @@ async function alertText(text="Song Added!") { } // a lot of this is kinda waffly because i was trying to get // it to return the right stuff and javascript is asyrcronouse (boo) -async function getFromServer(bodyInfo, source="",password=adminPass) { +async function getFromServer(bodyInfo, source="", secure=false, password=adminPass) { try{ if (bodyInfo != null) { // the currently set password is always included in every request bodyInfo["password"] = password; } + let href = ""; + if(secure) { + href = "https://"+ip+"/" + } const response = await fetch("http://"+ip+"/"+source, { method: "POST", body: JSON.stringify(bodyInfo), @@ -142,8 +147,13 @@ async function controlButton(buttonType) { document.getElementById("settings-mode").style.display = "block"; checkSettings() } else if (buttonType = "pm") { //Partymode toggle (in settings) - await getFromServer({setting: "partymode-toggle"}, "settings") - checkSettings(true) + let response = await getFromServer({setting: "partymode-toggle"}, "settings") + if(response.ok) { + justChangedSetting = true; + checkSettings(); + } else { + // dont think anything is needed here + } } @@ -517,19 +527,19 @@ function toggleDark(e) { qrCodeGenerate(); } -async function sha256(message) { - // Encode the message as UTF-8 - const msgBuffer = new TextEncoder().encode(message); +// async function sha256(message) { +// // Encode the message as UTF-8 +// const msgBuffer = new TextEncoder().encode(message); - // Hash the message - const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); +// // Hash the message +// const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); - // Convert ArrayBuffer to hex string - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +// // Convert ArrayBuffer to hex string +// const hashArray = Array.from(new Uint8Array(hashBuffer)); +// const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - return hashHex; -} +// return hashHex; +// } async function adminPassEnter(e) { if (e.key == "Enter") { @@ -559,6 +569,8 @@ async function submitPerms(e) { // its not perfect if you spam click, but it gets the point across to the user let clickedBox = e.srcElement; clickedBox.checked = !clickedBox.checked; + } else { + justChangedSetting = true; } } @@ -627,8 +639,9 @@ document.getElementById("songlist").addEventListener('keydown', function(e){chec document.getElementById("songlist").addEventListener('click', function(e){checkWhatSongWasClicked(e)}); //makes the controls look mostly normal on all screens, best solution i could find, idk man -let tempWidth = document.getElementById('controls').clientWidth; -document.getElementById("controls").style.marginLeft = "-"+String(parseInt(tempWidth/2))+"px"; +// replaced this with "transform" css stuff +// let tempWidth = document.getElementById('controls').clientWidth; +// 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 @@ -665,13 +678,13 @@ socket = io("http://"+ip,{ socket.on("songAdd", function(data) { // console.log("recieved data from songAdd"); - console.log(data); + // console.log(data); addToPlaylist(data); }) socket.on("timeUpdate", function(data) { // console.log("recieved data from timeUpdate"); - console.log(data); + // console.log(data); playlistElapsedSeconds = data["elapsedTime"]; currentlyPlaying = data["playingState"] }); @@ -682,4 +695,14 @@ socket.on("skipSong",() => { } else { justSkipped = false; } -}) \ No newline at end of file +}) + +socket.on("settingsChange",(data) => { + console.log(data); + if(justChangedSetting) { + console.log("working"); + justChangedSetting = false; + } else { + checkSettings(); + } +}); \ No newline at end of file diff --git a/Client/styles.css b/Client/styles.css index 2c2f4c9..70bd991 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -62,6 +62,7 @@ h4 { left: 50%; bottom: 0; margin: 0 auto; + transform: translateX(-50%); background-color:var(--bg-main); } diff --git a/Server/webbyBits.py b/Server/webbyBits.py index f78961c..22a238c 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -190,6 +190,7 @@ def settingsControl(): volumeLevel = int(recieveData["level"]) if(volumeLevel <= 100 and volumeLevel >= 0): volumePassed = player.audio_set_volume(volumeLevel) + socketio.emit("settingsChange") return {"error":"ok","data":{"volumePassed":volumePassed}},200 else: return {"error":"Invalid volume level","data":None},422 @@ -198,11 +199,13 @@ def settingsControl(): elif recieveData["setting"] == "partymode-toggle": if ADMIN_PASS == recieveData['password'] or controlPerms["PM"]: partyMode = not(partyMode) + socketio.emit("settingsChange") return ERR_200 else: return ERR_NO_ADMIN elif recieveData["setting"] == "perms": if ADMIN_PASS == recieveData["password"]: + socketio.emit("settingsChange") controlPerms = recieveData["admin"] # print(recieveData["admin"]) return ERR_200 From 8cb8b6139793252c28ee7807687ac31c87f84689 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:21:02 -0500 Subject: [PATCH 53/69] Update wishlist.md --- wishlist.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/wishlist.md b/wishlist.md index 8be1c9d..50eee2d 100644 --- a/wishlist.md +++ b/wishlist.md @@ -27,7 +27,5 @@ - [x] Set a timeout to change the time (to start) - [x] Send updates to the playlist in real time when songs are added * This is only kind of done, still needs work - - [ ] Update the playlist's html without destroying it (create 1 new element) - - [x] Tell clients looking at the playlist when the song has been paused (so they can pause the local timers) - * Again, still needs work - * This is currently solved by just sending the time and "playing status" once a second-ish \ No newline at end of file + - [x] Update the playlist's html without destroying it (create 1 new element) + - [x] Tell clients looking at the playlist when the song has been paused (so they can pause the local timers) \ No newline at end of file From 95efd937f6a5857996b8a0e45d2d8ff4d5c6dda9 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:22:07 -0500 Subject: [PATCH 54/69] Update wishlist.md --- wishlist.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wishlist.md b/wishlist.md index 50eee2d..9bf1ffb 100644 --- a/wishlist.md +++ b/wishlist.md @@ -23,9 +23,9 @@ - Potentially a "redemption code" system, which can be tracked client side - All of this is very hackable without a server-side login. - [ ] Websockets / some method of updating the time remaining to any client on the playlist screen - * This is implemented in a very broken way right now - [x] Set a timeout to change the time (to start) - [x] Send updates to the playlist in real time when songs are added - * This is only kind of done, still needs work - [x] Update the playlist's html without destroying it (create 1 new element) - - [x] Tell clients looking at the playlist when the song has been paused (so they can pause the local timers) \ No newline at end of file + - [x] Tell clients looking at the playlist when the song has been paused (so they can pause the local timers) + - [x] Settings updates + - [ ] Without re-posting the server (contain update data in websocket ping) \ No newline at end of file From 86a37a89c6fa11924d5d8a4f2bea4948a0e81d2c Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 1 Feb 2026 23:35:47 -0500 Subject: [PATCH 55/69] secure getFromServer is possible --- Client/scripts.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 7d68143..46da7c2 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -50,9 +50,11 @@ async function getFromServer(bodyInfo, source="", secure=false, password=adminPa } let href = ""; if(secure) { - href = "https://"+ip+"/" + href = "https://"+ip+"/" + source; + } else { + href = "http://"+ip+"/" + source; } - const response = await fetch("http://"+ip+"/"+source, { + const response = await fetch(href, { method: "POST", body: JSON.stringify(bodyInfo), headers: { From 6effff1dc5f8a96765b06f627311d93d32d14820 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:58:31 -0500 Subject: [PATCH 56/69] Adding a placeholder for future socket stuff --- Client/scripts.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Client/scripts.js b/Client/scripts.js index 46da7c2..cda16b4 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -296,6 +296,17 @@ async function displayElapsedPlaylistTime(elapsed=0,length=-1) { } } +async function updateSingleSetting(data) { + let toBeChanged = data["settingToChange"]; + if (toBeChanged === "partymode") { + + } else if (toBeChanged === "perms") { + + } else if (toBeChanged === "volume") { + + } +} + async function checkSettings(skipServer=false) { //check client stuff first so if the server doesn't exist it can still be changed and seen if (ip.slice(-5)=="19054") { From 4b3e64bd1a6557c21ee7ba42de215d8b6f40218f Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 8 Feb 2026 20:34:50 -0500 Subject: [PATCH 57/69] Updated readme --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 4c82c73..60d24d4 100644 --- a/readme.md +++ b/readme.md @@ -70,7 +70,7 @@ These are specific details on each section of the app, and how to use them - Add track to queue - Partymode toggle - Change volume - - When this argument is left out (or empty string) the admin features aren't used, and everyone can do everything + - When this argument is left out a random string is generated and printed in console to be the admin password (so keep the console window hidden if you don't want to use it) ### Client: ![image](./Screenshot_MAIN.png) \ From d8b261dcb7da897c76ad48e4d2c61dd803dc29d2 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:12:38 -0500 Subject: [PATCH 58/69] Adjusted details relating to new features --- readme.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index b730a50..5f67c5b 100644 --- a/readme.md +++ b/readme.md @@ -60,7 +60,6 @@ These are specific details on each section of the app, and how to use them - Accepts Play-Pause and Skip commands - Uses port 19054 by default - Can be changed in the `.env` file - - The default port can be changed in the file - Running with `--admin (admin password)` sets an admin password for moderation on the client - ***Note: Do not reuse a password, the password is hashed before being sent over the network, but I still wouldn't bet my house on it, no security is guaranteed*** - Anyone who knows the admin password can enter it on the client and change the abilities of any non-admin users (for example to limit skipping) @@ -70,6 +69,9 @@ These are specific details on each section of the app, and how to use them - Add track to queue - Partymode toggle - Change volume + - Add duplicate track to queue + - This is a seperate toggle, but is based on the setting "Add track to queue" + - Basically if you can't add at all, you can't add a duplicate either (obviously) - When this argument is left out (or empty string) the admin features aren't used, and everyone can do everything ### Client: @@ -89,6 +91,7 @@ From left to right: - Volume controls the VLC volume of the connected server - *Because the volume can be controlled in the client, for best usage set your device volume as high as possible and turn it down using this slider* - QR code to allow others to connect to and use the Remote + - Admin password can be set to restrict actions for general users, or avoid the set restrictions ### A quick note on the password feature From 37bdd33aff2126f4bad9d81917816b5de1280b84 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:04:45 -0500 Subject: [PATCH 59/69] Removed a false comment --- Client/scripts.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index cda16b4..9b13cad 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -67,9 +67,9 @@ async function getFromServer(bodyInfo, source="", secure=false, password=adminPa // im suprised i didn't comment on this already but this is kinda lame desing // its not wrong but you know // it is easy which i like - // and it overrides any other non-async alerts which is nice alertText("Error: Admin restricted action") } else if(!response.ok){ + throw new Error(data.error); alertText("Error: "+data.error); } // we add some information from the response just in case it is needed @@ -502,7 +502,7 @@ async function generateVisualPlaylist(conditions="") { async function submitSong(songid) { let returncode = await getFromServer({song: songid}, "songadd"); - if(returncode == ERR_NO_ADMIN) { + if(returncode["status"] === ERR_NO_ADMIN) { // right now the error is alerted in getFromServer, maybe will change that } else if(returncode["status"]!==200) { alertText("That song's already in the queue! Hang on!") From 566ce9cd73b0328e78323c5a449ec483da919f1e Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:06:02 -0500 Subject: [PATCH 60/69] Sockets are finished to an acceptable level --- Client/index.html | 2 +- Client/scripts.js | 18 +++++++++++++----- Server/webbyBits.py | 13 +++++++++---- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/Client/index.html b/Client/index.html index 591a402..4fa3aac 100644 --- a/Client/index.html +++ b/Client/index.html @@ -104,7 +104,7 @@ changes visibility with JS-->


-
+
diff --git a/Client/scripts.js b/Client/scripts.js index cda16b4..e25604c 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -299,11 +299,17 @@ async function displayElapsedPlaylistTime(elapsed=0,length=-1) { async function updateSingleSetting(data) { let toBeChanged = data["settingToChange"]; if (toBeChanged === "partymode") { - + document.getElementById("partymode-button").textContent = data["newData"]; } else if (toBeChanged === "perms") { - + let currentAdminPerms = data["newData"]; + document.getElementById("addsongsettingcheckbox").checked = currentAdminPerms["AS"]; + document.getElementById("skipsongsettingcheckbox").checked = currentAdminPerms["SK"]; + document.getElementById("playpausesettingcheckbox").checked = currentAdminPerms["PP"]; + document.getElementById("partymodesettingcheckbox").checked = currentAdminPerms["PM"]; + document.getElementById("volumechangesettingcheckbox").checked = currentAdminPerms["VOL"]; + document.getElementById("duplicateallowesettingcheckbox").checked = currentAdminPerms["DUP"]; } else if (toBeChanged === "volume") { - + document.getElementById("volumerange").value = data["newData"]; } } @@ -608,9 +614,10 @@ document.addEventListener('keydown', function(e){ }}) document.getElementById("playlist-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "none"; -document.getElementById("volumerange").onchange = async function() { +document.getElementById("volumerange").onchange = async function(e) { // there is no reason for this not to be a defined function // FIX THIS + console.log(e); let returnValue = await getFromServer({setting:"volume",level:this.value}, "settings") if (returnValue["status"] == ERR_NO_ADMIN) { // alertText("Error: Admin restricted action"); @@ -716,6 +723,7 @@ socket.on("settingsChange",(data) => { console.log("working"); justChangedSetting = false; } else { - checkSettings(); + // checkSettings(); + updateSingleSetting(data); } }); \ No newline at end of file diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 22a238c..8342e75 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -190,8 +190,12 @@ def settingsControl(): volumeLevel = int(recieveData["level"]) if(volumeLevel <= 100 and volumeLevel >= 0): volumePassed = player.audio_set_volume(volumeLevel) - socketio.emit("settingsChange") - return {"error":"ok","data":{"volumePassed":volumePassed}},200 + if(volumePassed == 0): + # only emit a signal i the volume really changed + socketio.emit("settingsChange",{"settingToChange":"volume","newData":volumeLevel}) + return {"error":"ok","data":{"volumePassed":volumePassed}},200 + else: + return {"error":"VLC cannot take volume change requests at this time","data":None},500 else: return {"error":"Invalid volume level","data":None},422 else: @@ -199,15 +203,16 @@ def settingsControl(): elif recieveData["setting"] == "partymode-toggle": if ADMIN_PASS == recieveData['password'] or controlPerms["PM"]: partyMode = not(partyMode) - socketio.emit("settingsChange") + partyModeStr = "On" if partyMode else "Off" + socketio.emit("settingsChange",{"settingToChange":"partymode","newData":partyModeStr}) return ERR_200 else: return ERR_NO_ADMIN elif recieveData["setting"] == "perms": if ADMIN_PASS == recieveData["password"]: - socketio.emit("settingsChange") controlPerms = recieveData["admin"] # print(recieveData["admin"]) + socketio.emit("settingsChange",{"settingToChange":"perms","newData":controlPerms}) return ERR_200 else: return ERR_NO_ADMIN From f2204ee7ed3425cf8e59d17870fc4f40994911d1 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:12:00 -0500 Subject: [PATCH 61/69] Add versions and socketio --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ca37329..7b0c3f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,9 +3,10 @@ certifi==2024.2.2 charset-normalizer==3.3.2 click==8.1.7 colorama==0.4.6 -dotenv +dotenv==0.9.9 Flask==3.0.3 Flask-Cors==4.0.1 +Flask-SocketIO==5.6.0 idna==3.7 itsdangerous==2.2.0 Jinja2==3.1.4 From f556f17cce34c74222a79bec115e1aea8c34ac50 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:36:19 -0500 Subject: [PATCH 62/69] Few fixes that i'm going to un-re-fix later --- Client/scripts.js | 11 ++++++----- Server/webbyBits.py | 4 +--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 5cb324c..a330653 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -148,7 +148,7 @@ async function controlButton(buttonType) { document.getElementById("songlist-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "block"; 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") if(response.ok) { justChangedSetting = true; @@ -156,6 +156,8 @@ async function controlButton(buttonType) { } else { // dont think anything is needed here } + } else { + alertText("Error: You pushed a button that does not exist"); } @@ -617,8 +619,7 @@ document.getElementById("settings-mode").style.display = "none"; document.getElementById("volumerange").onchange = async function(e) { // there is no reason for this not to be a defined function // FIX THIS - console.log(e); - let returnValue = await getFromServer({setting:"volume",level:this.value}, "settings") + let returnValue = await getFromServer({setting:"volume",level:e.target.value}, "settings") if (returnValue["status"] == ERR_NO_ADMIN) { // alertText("Error: Admin restricted action"); // there's an admin restrict alert built into getFromServer @@ -718,9 +719,9 @@ socket.on("skipSong",() => { }) socket.on("settingsChange",(data) => { - console.log(data); + // console.log(data); if(justChangedSetting) { - console.log("working"); + // console.log("working"); justChangedSetting = false; } else { // checkSettings(); diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 8342e75..25e245b 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -193,9 +193,7 @@ def settingsControl(): if(volumePassed == 0): # only emit a signal i the volume really changed socketio.emit("settingsChange",{"settingToChange":"volume","newData":volumeLevel}) - return {"error":"ok","data":{"volumePassed":volumePassed}},200 - else: - return {"error":"VLC cannot take volume change requests at this time","data":None},500 + return {"error":"ok","data":{"volumePassed":volumePassed}},200 else: return {"error":"Invalid volume level","data":None},422 else: From 725cc3eb89c839b88e0842925d1d28100d8e031c Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:36:32 -0500 Subject: [PATCH 63/69] Re-freeze pip with minimum requirements --- requirements.txt | Bin 303 -> 930 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7b0c3f99893315b19b02dce1572dc86a1f9ec42d..31537619c81b0adb7515c103e17411abccb798c0 100644 GIT binary patch literal 930 zcmZva%}>Hm5XARv;-6AN%ZGCCYNCnp;K6e#j6)xfAxwO6a z%b9dfyn0V}ik`q7c;#Gk=UpvAgOpazAWtD5p-v2uy?~NVxqGl@M~_@do70j!<4#BH z`Gf?Oe~A`32s%RYE4V6ZDbB!OEeFg*H(-asa2ENu=?-0#rV*cP2B?(z;L zHv{*)re0Ikq|)o!^U!X(DO>vlvBYV~2LGljv=jyHmsl|_Tw~66&3?rOLfjk3544b!$>8Kt*=oI|X^#ta{{A tCnVpHY{72@a?o+=orI~ca437d@P-UdJ8owln6L$-w;GZWyWT-~i@!tOhCKiP literal 303 zcmXv}yKciU4BYiECiHM#1E&H7S|I3Fgod!eQ)*oe7gJ5p@J`d#(kzVkK3?h)MDLrbNNV7eDt@^7kXa|?&y&u0Ci N#3%9eq%!_@{{gJmSpWb4 From 192a84deeda6cc382b6cc922997d2916dd50115b Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:38:45 -0500 Subject: [PATCH 64/69] Update Wishlist, sockets are done --- wishlist.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wishlist.md b/wishlist.md index 9bf1ffb..e561f40 100644 --- a/wishlist.md +++ b/wishlist.md @@ -22,10 +22,10 @@ - Without a login system there's no easy way to give credits to specific clients (and a login is beyond scope of what I want to do) - Potentially a "redemption code" system, which can be tracked client side - All of this is very hackable without a server-side login. -- [ ] Websockets / some method of updating the time remaining to any client on the playlist screen +- [x] Websockets / some method of updating the time remaining to any client on the playlist screen - [x] Set a timeout to change the time (to start) - [x] Send updates to the playlist in real time when songs are added - [x] Update the playlist's html without destroying it (create 1 new element) - [x] Tell clients looking at the playlist when the song has been paused (so they can pause the local timers) - [x] Settings updates - - [ ] Without re-posting the server (contain update data in websocket ping) \ No newline at end of file + - [x] Without re-posting the server (contain update data in websocket ping) \ No newline at end of file From 3690a66ef21f6ba9dc1a4db064d82d1f2cdce1ab Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:50:31 -0500 Subject: [PATCH 65/69] Small modifications, CRLF to LF --- requirements.txt | Bin 930 -> 880 bytes wishlist.md | 6 +++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 31537619c81b0adb7515c103e17411abccb798c0..140b87e1ec4373c5f4bba1c23e2d3c6eb0fdd4fb 100644 GIT binary patch delta 165 zcmZ3){()_R+(ZSri4H0g(@ZALk(#(eVd5U* delta 209 zcmeyswupU#93$^US$QB~tqLUKO=Wo*xEPWdau_m!WHy5>5Lz(kF&G1}Ap`HkwTeJh zXJvuJOKC>l$;^yEU6a)rb&WyVGJr}9!CDN!%FTeJ0Ru=TlOcs6k0B8(2Qdn0*yKh= zO`z_Tj6mZi9|fEAk`ZYBWKJe+AZg5G2qcri Date: Mon, 2 Mar 2026 17:24:54 -0500 Subject: [PATCH 66/69] Change back to CRLF, since basically everything else is For some reason github thinks this is binary, promise it isn't --- requirements.txt | Bin 880 -> 439 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 140b87e1ec4373c5f4bba1c23e2d3c6eb0fdd4fb..ac8054e583590052f38a872862c6864a6a659661 100644 GIT binary patch literal 439 zcmX|-&2qya4218zi!}!Sv^j9?Ogq!*A%~tZRvc;!F5qOEr>_*Jy`hz~`j)|s&K9j} zPz^{ico$|&T1z-UEi9(uPEKo8s~#k@!k$gaSgH`yV!XTYYy&NKytA{`&%i!5`k2gO z*tmsWjIm(2aCLx#c=0CFC=G%VCoU=*Q`Wi%;@I2OG1?i+>xb4IkS@+r^1kG^8v|L> zz7($H(S!+8+{i$IqV}5$UqE z;UQv}TtFAKZb5Riho0|vtar@xuV1{~&nT(tMkM^+Fqb=8qo5PnEvv`sio@=}5Rw?S fgjj?fe0C#Ly^+q8%1#5J!e7P=; literal 880 zcmZXS%}>Hm5XAS)|58HBhjQ?0qKWa~!E-5<4@*JXLiCSUf3uGk(2&=Jx3jY|v+w77 zW-FU>GOO*5-@y8o*pP2*J)2oUt+aQ`ZSVD%%9Jypn)z)F;*F#3g!{xM-rcv6W0scE zTY;@Df@{ma4R>Px>UcGAD%Ikg(j(hlA$H&Q#L zc28%Dj=&li<+X?Uwh}Q_`YP|1kC4Y@cVsVMq)v_=?AftnR}gYpl3m=*k3A3NrKC$V zNOhR6j90K!R8ndS&tq%NNkIauj=CQ~mbM0$TrdRRPu%WN)YuMnDUR|EBL_qJH&knC zno)XHyByigiGOFG)R%ZG+0)-7g_RO1zD9~|;o5S(TW8ij;%;l#`066P7O(U|PXGB2 zY$v;NlYrGrldC;$K5&+km~srSP>!W`45C8S2F(SS$c}tx+?_Y54!P4&&JCOezZJM) j$EUZTd9U;m_0oO;)V_rH From f35b074543b8df476e9e0ea14f7a300fedf168fe Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:28:12 -0500 Subject: [PATCH 67/69] Added lossless tag to search --- Client/scripts.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index a330653..cd7cb03 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -198,12 +198,12 @@ async function searchSongs(searchTerm){ newItem.appendChild(head3); newItem.appendChild(head4); // I like this concept but i'm leaving it out for now - // if(currentSongInJSON.lossless === 1) { - // let losslesstag = document.createElement("p"); - // losslesstag.textContent = "Ⓛ"; - // losslesstag.classList.add("lossless-tag"); - // newItem.appendChild(losslesstag); - // } + if(currentSongInJSON.lossless === 1) { + let losslesstag = document.createElement("p"); + losslesstag.textContent = "Ⓛ"; + losslesstag.classList.add("lossless-tag"); + newItem.appendChild(losslesstag); + } document.getElementById("songlist").appendChild(newItem); } From 8d78960f0ba48de4c193c958ee847fd0057fa622 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:28:27 -0500 Subject: [PATCH 68/69] Added text hint to QR code in settings --- Client/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/Client/index.html b/Client/index.html index 4fa3aac..70b36c0 100644 --- a/Client/index.html +++ b/Client/index.html @@ -86,6 +86,7 @@ changes visibility with JS-->

Share the remote:

+

This shares the IP and URL you are currently connected to

Admin Settings

From a2433e0e5eb1398b7378df23b802c2dc853f348c Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:51:28 -0500 Subject: [PATCH 69/69] rearranged, started using different wgsi server --- Client/index.html | 2 +- Client/scripts.js | 2 +- Server/webbyBits.py | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Client/index.html b/Client/index.html index 70b36c0..3a86b52 100644 --- a/Client/index.html +++ b/Client/index.html @@ -111,7 +111,7 @@ changes visibility with JS-->

Clear the playlist

-

Wipe the playlist, except the currently playing song*

+

Wipe the playlist, except the currently playing song. With PartyMode enabled, a second song will be added back randomly

PartyJukebox is under an AGPLV3 liscense. You can access the source code here.

diff --git a/Client/scripts.js b/Client/scripts.js index cd7cb03..58e923b 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -83,7 +83,7 @@ async function getFromServer(bodyInfo, source="", secure=false, password=adminPa if (e.toString().includes("TypeError: Failed to fetch")){ alertText("Error: Can't Connect to Server (is the ip set?)") } else { - alertText("Error: " + e); + alertText(e); } const response=null; return response; diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 25e245b..0e0560f 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -1,9 +1,12 @@ +import eventlet +eventlet.monkey_patch() from flask import Flask from flask import request from flask_cors import CORS from flask_socketio import SocketIO import sqlite3 as sql import vlc,threading,time,random,argparse,dotenv,os,hashlib,string + # Argparse Stuff 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')