From 9a9e54f4b9f51567532b9a8e6ebfaea512e592c8 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Thu, 17 Oct 2024 15:52:05 -0400 Subject: [PATCH 001/110] Update readme.md added --port option to the server section --- readme.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/readme.md b/readme.md index 980747e..f2361d1 100644 --- a/readme.md +++ b/readme.md @@ -62,6 +62,8 @@ These are specific details on each section of the app, and how to use them - Searches return matching songs - Accepts Play-Pause and Skip commands - Uses port 19054 by default + - `--port (port)` changes the default port for that run + - The default port can be changed in the file ### Client: ![image](./Screenshot_MAIN.png) \ From c6d457efbf55f2a3c4bb8629ee1edc822256e975 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Thu, 17 Oct 2024 16:26:17 -0400 Subject: [PATCH 002/110] added qrcode generator in settings --- Client/index.html | 6 ++++++ Client/scripts.js | 24 +++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Client/index.html b/Client/index.html index ffda3ea..f7be55c 100644 --- a/Client/index.html +++ b/Client/index.html @@ -81,6 +81,11 @@ changes visibility with JS-->

Volume of the music

+
+

Share the remote:

+

Hit settings icon to refresh the code

+
+
@@ -92,6 +97,7 @@ changes visibility with JS--> search + \ No newline at end of file diff --git a/Client/scripts.js b/Client/scripts.js index 2058852..94b2d5f 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -177,6 +177,16 @@ async function checkSettings(skipServer=false) { } else { document.getElementById("iptextbox").value = ip; } + let tempURL = "http://" + document.location.href.split("/")[2] + "/?ip=" + ip; + document.getElementById("qrcode").innerHTML = "" + new QRCode(document.getElementById("qrcode"), { + text: tempURL, + width: 256, + height: 256, + colorDark : "#000000", + colorLight : "#eeeeee", + correctLevel : QRCode.CorrectLevel.H + }); document.getElementById("alerttimetextbox").value = alertTime partyButtonState = document.getElementById("partymode-button").innerHTML; x = await getFromServer({setting: "getsettings"}, "settings"); @@ -316,16 +326,24 @@ ip = params.get("ip") if (ip == null || ip=="") { ip=getCookie("ip") } -console.log(ip) if (ip==null || ip==""){ ip = "" } document.cookie = "ip="+ip+"; path=/;" - alertTime = getCookie("alertTime") document.getElementById("alerttimetextbox").value = alertTime if (alertTime == "") { alertTime = 2; document.cookie = "alertTime="+alertTime+"; path=/;" -} \ No newline at end of file +} + +let tempURL = "http://" + document.location.href.split("/")[2] + "/?ip=" + ip; +new QRCode(document.getElementById("qrcode"), { +text: tempURL, +width: 256, +height: 256, +colorDark : "#000000", +colorLight : "#eeeeee", +correctLevel : QRCode.CorrectLevel.H +}); \ No newline at end of file From 5f4e2365a213b43adf2dad97787cc100f17bbb06 Mon Sep 17 00:00:00 2001 From: Kristy <124598538+kristy-fournier@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:18:08 -0400 Subject: [PATCH 003/110] added comment --- Client/scripts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Client/scripts.js b/Client/scripts.js index 94b2d5f..b020315 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -337,7 +337,7 @@ if (alertTime == "") { alertTime = 2; document.cookie = "alertTime="+alertTime+"; path=/;" } - +// this is the code that makes the qr code at the very start let tempURL = "http://" + document.location.href.split("/")[2] + "/?ip=" + ip; new QRCode(document.getElementById("qrcode"), { text: tempURL, From dc0cfd80a0c3c9f8a05e84ec6c20842ae5d555e1 Mon Sep 17 00:00:00 2001 From: Kristy <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 8 Nov 2024 23:15:37 -0500 Subject: [PATCH 004/110] I made the directory where the mp3 files are a variable, so they can be stored on an external drive for example --- Server/databaseGenerator.py | 12 ++++++++---- readme.md | 6 +++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 9428460..78d65c0 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -4,15 +4,19 @@ from mutagen.mp3 import MP3 import requests, ast, time, math, argparse, json loading = ["-","\\","|","/"] -songFiles = os.listdir(r'./sound') + 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('-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 (USING FORWARD SLASHES)", default="./sound") args = parser.parse_args() apikeylastfm = args.apikey -# if you want to set the api key permenantly for your setup just uncomment the next line +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) if args.mode == "update": try: with open('songDatabase.json', 'r') as handle: @@ -42,7 +46,7 @@ if args.art.lower() == "true": for i in songFiles: try: - song = EasyID3("sound/"+i) + song = EasyID3(soundLocation+"/"+i) title = song['title'][0] artist = song['artist'][0] except: @@ -66,7 +70,7 @@ for i in songFiles: else: image=None try: - length = math.ceil(MP3("sound/"+i).info.length) + length = math.ceil(MP3(soundLocation+"/"+i).info.length) except: length = 0 if len(songFiles) != 1: diff --git a/readme.md b/readme.md index f2361d1..b7f53d0 100644 --- a/readme.md +++ b/readme.md @@ -36,7 +36,7 @@ Read on for specific information on each piece of the app. ## Details These are specific details on each section of the app, and how to use them ### Server: -- `sound/` contains all mp3 files +- `./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` *and otherwise defaults to the file name as the title, and no artist* @@ -46,6 +46,10 @@ These are specific details on each section of the app, and how to use them - *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_ + - __Make certain you only use forward slashes in your directory, even on Windows__ - `songDatabase.json` stores all the information about each song in this format: ``` [ From fc12e5e1d130349da8365c97618acd71ac0d1be8 Mon Sep 17 00:00:00 2001 From: Kristy <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 8 Nov 2024 23:18:40 -0500 Subject: [PATCH 005/110] Update readme.md changed my mind on some wording --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index b7f53d0..a2e25af 100644 --- a/readme.md +++ b/readme.md @@ -36,7 +36,7 @@ Read on for specific information on each piece of the app. ## Details These are specific details on each section of the app, and how to use them ### Server: -- `./sound` contains all mp3 files by default +- `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` *and otherwise defaults to the file name as the title, and no artist* From 39ed9fb5cb3ee0a56a5acc36e9c683689e3c0f3b Mon Sep 17 00:00:00 2001 From: Kristy <124598538+kristy-fournier@users.noreply.github.com> Date: Sat, 9 Nov 2024 00:08:26 -0500 Subject: [PATCH 006/110] Update readme.md --- readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index a2e25af..903e3c9 100644 --- a/readme.md +++ b/readme.md @@ -82,4 +82,5 @@ From left to right: - 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 - 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* \ No newline at end of file + - *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 the Remote URL \ No newline at end of file From da423f49aa01328f44bdaf7418e5d8c82bc4bf09 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:59:30 -0500 Subject: [PATCH 007/110] just updating some comments --- Client/scripts.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index b020315..bc36002 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -50,7 +50,7 @@ function getCookie(cname) { } return ""; } -//someone more organised than me would have set all these elements to variables so they dont have to get them 50 times +//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") { getFromServer({control: "play-pause"}, "controls") @@ -124,10 +124,10 @@ async function searchSongs(searchTerm){ newItem.appendChild(head3); newItem.appendChild(head4); document.getElementById("songlist").appendChild(newItem); - //display error if no results } if (optionslist.length == 0) { + //display error if no results document.getElementById("songlist").innerHTML = "

We might not have that one...

"; } } @@ -273,8 +273,9 @@ function checkWhatSongWasClicked(e) { if ((itemId.length-itemId.lastIndexOf("image") == 5) && itemId.lastIndexOf("image")!=-1) { itemId = itemId.slice(0,-6) } - //i feel like later dylan won't apreciate this + //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") { submitSong(itemId); } @@ -321,6 +322,7 @@ document.getElementById("controls").style.marginLeft = "-"+String(parseInt(tempW //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") if (ip == null || ip=="") { From 7c580b7befe957f525948eab585cbb695a12da2e Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 27 Nov 2024 17:21:39 -0500 Subject: [PATCH 008/110] tons of comment updates probaby gonna make some incompatible changes soon to the format of the songDatabase --- Server/databaseGenerator.py | 4 ++++ Server/webbyBits.py | 32 ++++++++++++++++++++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 78d65c0..7885322 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -46,19 +46,23 @@ if args.art.lower() == "true": for i in songFiles: try: + # get the metadata song = EasyID3(soundLocation+"/"+i) title = song['title'][0] artist = song['artist'][0] except: try: + # if metadata is missing, try to use file name following title_artist.mp3 song = i.split("_") title = song[0] artist = song[1].split(".")[0] except: + #if the file is not formatted with an underscore, the title is the file name title = i artist = None if args.art.lower() == "true" and not(args.apikey == ""): try: + # get the smallest possible image from lastFM 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 = 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"] diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 80e6b0f..16d98b4 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -1,10 +1,12 @@ from flask import Flask from flask import request from flask_cors import CORS -import json,vlc,csv,threading,time,random, argparse +import json,vlc,threading,time,random, argparse +# Argparse Stuff parser=argparse.ArgumentParser(description="Options for the Webby Bits") parser.add_argument('-p','--port',help="Pick a port to host on, not the same as the web (client) port",default='19054') porttheuserpicked=parser.parse_args().port +#Initializing all the global stuff random.seed() global partyMode global skipNow @@ -19,7 +21,9 @@ 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 CORS(app) +# open the json file as a dictionary with open('./songDatabase.json', 'r') as handle: songDatabaseList = json.load(handle) @@ -36,6 +40,7 @@ def playQueuedSongs(): z = str(player.get_state()) if playlist and (z == "State.Ended" or z== "State.Stopped" or z == "State.NothingSpecial" 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) @@ -43,21 +48,28 @@ def playQueuedSongs(): player.set_media(media) player.play() elif (len(playlist) == 0) and skipNow==True: + # skip was pressed and there are no new songs skipNow=False songNext = None player.stop() elif (len(playlist) == 0) and (z == "State.Ended" or z == "State.NothingSpecial" or z=="State.Stopped"): + # i feel like this could actually be combined with the above, but imma not do that rn songNext = None elif (len(playlist)<1) and (partyMode == True): - playlist.append(random.choice(songDatabaseList)["file"]) + # 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(random.choice(songDatabaseList)["file"]) + # 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(): + # recieve control inputs (play/pause and skip) from the webUI global skipNow global media global partyMode @@ -75,19 +87,22 @@ def playerControls(): @app.route("/settings", methods=['POST']) def settingsControl(): + # set the volume and partymode global partyMode recieveData = request.get_json(force=True) if recieveData["setting"] == "volume": player.audio_set_volume(int(recieveData["level"])) return "200" - elif recieveData["setting"] == "getsettings": - x = {"partymode":partyMode,"volume":player.audio_get_volume()} - return x elif recieveData["setting"] == "partymode-toggle": partyMode = not(partyMode) return "200" + 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()} + return x else: return "400" + @app.route("/search", methods=['POST']) def searchSongDB(): recieveData=request.get_json(force=True) @@ -114,6 +129,9 @@ def songadd(): def getPlaylist(): global songNext tempPlaylist = [] + # what went through my head to make past-me think this is a good idea??? + # i mean actually looping through once still shouldn't ever take that long + # but like a binary search must exist in python and be faster for k in songDatabaseList: if k["file"] == songNext: temp = k.copy() @@ -121,6 +139,8 @@ def getPlaylist(): temp["time"] = player.get_time()/1000 tempPlaylist.append(temp) for i in playlist: + # oh my goodness i did it again + # i seriously need to rewrite the databaseGenerator and this code for j in songDatabaseList: if j["file"] == i: tempPlaylist.append(j) From 1802ec3b79a571f9fb598066de2521f504f489d5 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 15 Jan 2025 11:19:44 -0500 Subject: [PATCH 009/110] renamed a variable also crashing out in the comments --- Server/webbyBits.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 16d98b4..045fff1 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -5,7 +5,7 @@ import json,vlc,threading,time,random, argparse # Argparse Stuff parser=argparse.ArgumentParser(description="Options for the Webby Bits") parser.add_argument('-p','--port',help="Pick a port to host on, not the same as the web (client) port",default='19054') -porttheuserpicked=parser.parse_args().port +portTheUserPicked=parser.parse_args().port #Initializing all the global stuff random.seed() global partyMode @@ -132,6 +132,8 @@ def getPlaylist(): # what went through my head to make past-me think this is a good idea??? # i mean actually looping through once still shouldn't ever take that long # but like a binary search must exist in python and be faster + # wait no binary search only helps if they're sorted + # i mean i guess i could sort them and make searching faster for k in songDatabaseList: if k["file"] == songNext: temp = k.copy() @@ -141,11 +143,14 @@ def getPlaylist(): for i in playlist: # oh my goodness i did it again # i seriously need to rewrite the databaseGenerator and this code + # wait isn't this literally useless code??? + # oh no the playlist only contains names + # i should really have used an object for this. for j in songDatabaseList: if j["file"] == i: tempPlaylist.append(j) return tempPlaylist if __name__ == "__main__": - app.run(host='0.0.0.0', port=porttheuserpicked) + app.run(host='0.0.0.0', port=portTheUserPicked) \ No newline at end of file From b1f1a3874830b8899ab8f70509efdf0ff3597f6a Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 19 Jan 2025 11:16:54 -0500 Subject: [PATCH 010/110] Finally Changing the settings on mobile actually works as intended also other rearranging of the backend or whatever --- Client/index.html | 10 +++++----- Server/databaseGenerator.py | 22 +++++++++++++--------- Server/webbyBits.py | 19 +++++++++++++------ 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/Client/index.html b/Client/index.html index f7be55c..97ab4aa 100644 --- a/Client/index.html +++ b/Client/index.html @@ -19,7 +19,7 @@ changes visibility with JS-->
- +

Search to find songs!

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

Client Settings (Saved to device)

+

Client Settings (Saved to device)

Server IP:

IP of the device running the song server

- +

Alert Time:

How long alerts stay on screen for (seconds)

- +

Server Settings (Saved to server)

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

Share the remote:

Hit settings icon to refresh the code

-
+
diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 7885322..a504848 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -9,10 +9,15 @@ parser=argparse.ArgumentParser(description="Options for the generation of the so parser.add_argument('-k','--apikey', help='String: LastFM api key', default="") parser.add_argument('-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 (USING FORWARD SLASHES)", default="./sound") +parser.add_argument('-d','--directory',help="Directory of the song files", default="./sound/") args = parser.parse_args() apikeylastfm = args.apikey -soundLocation = args.directory +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" @@ -29,8 +34,7 @@ if args.mode == "update": songFiles.index(i["file"]) != -1 except: print("deleted: " + i["file"] + " from database") - songDatabaseList.pop(songDatabaseList.index(i)) - + songDatabaseList.remove(i) for i in songDatabaseList: songFiles.pop(songFiles.index(i["file"])) print("new songs: " + str(songFiles)) @@ -47,7 +51,7 @@ if args.art.lower() == "true": for i in songFiles: try: # get the metadata - song = EasyID3(soundLocation+"/"+i) + song = EasyID3(soundLocation+i) title = song['title'][0] artist = song['artist'][0] except: @@ -62,10 +66,10 @@ for i in songFiles: artist = None if args.art.lower() == "true" and not(args.apikey == ""): try: - # get the smallest possible image from lastFM - 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"] + # 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"] if image == "": - 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"] + 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) @@ -74,7 +78,7 @@ for i in songFiles: else: image=None try: - length = math.ceil(MP3(soundLocation+"/"+i).info.length) + length = math.ceil(MP3(soundLocation+i).info.length) except: length = 0 if len(songFiles) != 1: diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 045fff1..4351984 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -4,8 +4,18 @@ from flask_cors import CORS import json,vlc,threading,time,random, argparse # Argparse Stuff parser=argparse.ArgumentParser(description="Options for the Webby Bits") -parser.add_argument('-p','--port',help="Pick a port to host on, not the same as the web (client) port",default='19054') +parser.add_argument('-d','--directory',help="Directory of the song files", 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 +soundLocation = parser.parse_args().directory +# To set the directory permenantly just uncomment the next line +# soundLocation = "/example/directory/here/" +if soundLocation[-1] == "/" or soundLocation[-1] == "\\": + pass +elif "/" in soundLocation: + soundLocation += "/" +else: + soundLocation += "\\" #Initializing all the global stuff random.seed() global partyMode @@ -47,15 +57,12 @@ def playQueuedSongs(): media = fakeplayer.media_new("sound/"+songNext) player.set_media(media) player.play() - elif (len(playlist) == 0) and skipNow==True: + elif (skipNow==True or (z == "State.Ended" or z == "State.NothingSpecial" or z=="State.Stopped")): # skip was pressed and there are no new songs skipNow=False songNext = None player.stop() - elif (len(playlist) == 0) and (z == "State.Ended" or z == "State.NothingSpecial" or z=="State.Stopped"): - # i feel like this could actually be combined with the above, but imma not do that rn - songNext = None - elif (len(playlist)<1) and (partyMode == True): + elif len(playlist)<1 and (partyMode == True): # 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(random.choice(songDatabaseList)["file"]) From bdcb955165bc7799bb435a9aa01ba5962b43d22e Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 19 Jan 2025 19:14:18 -0500 Subject: [PATCH 011/110] dark mode testing --- Client/index.html | 4 ++-- Client/scripts.js | 12 ++++++++++++ Client/styles.css | 7 +++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Client/index.html b/Client/index.html index 97ab4aa..c2f2400 100644 --- a/Client/index.html +++ b/Client/index.html @@ -6,7 +6,7 @@ - + @@ -56,7 +56,7 @@ changes visibility with JS--> diff --git a/Client/scripts.js b/Client/scripts.js index bc36002..e17be30 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -280,6 +280,17 @@ function checkWhatSongWasClicked(e) { submitSong(itemId); } } +function toggleDark(e) { + let x = document.getElementById("test-body").classList + if (!(x.contains("dark-mode"))) { + document.getElementById("darkmode-button").innerHTML = "On" + x.add("dark-mode"); + } else { + document.getElementById("darkmode-button").innerHTML = "Off" + x.remove("dark-mode"); + } + +} let optionslist = [] @@ -317,6 +328,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()}) //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/Client/styles.css b/Client/styles.css index eab1d5c..8857c11 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -1,5 +1,12 @@ /* testing */ +.dark-mode { + background-color: #333333; + color:#ffffff; + /* -webkit-filter:invert(100%); + filter:progid:DXImageTransform.Microsoft.BasicImage(invert='1'); */ +} + /* Things that are always visible */ body { From 072d04930a2092c4cdf2ae781f284d9ca66a2331 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Tue, 21 Jan 2025 20:41:47 -0500 Subject: [PATCH 012/110] fixed a bug in the client js --- Client/scripts.js | 2 +- readme.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index e17be30..6338825 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -328,7 +328,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 //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/readme.md b/readme.md index 903e3c9..02b7058 100644 --- a/readme.md +++ b/readme.md @@ -83,4 +83,4 @@ From left to right: - Party Mode adds new songs to the queue when the queue has only 1 song in it - 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 the Remote URL \ No newline at end of file + - QR code to allow others to connect to and use the Remote \ No newline at end of file From 44fcd881fc409eb777fe8ffe13166b57ecb8a2f4 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 3 Mar 2025 10:17:42 -0500 Subject: [PATCH 013/110] Changed error message --- Client/scripts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Client/scripts.js b/Client/scripts.js index 6338825..3f43bbc 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -23,7 +23,7 @@ async function getFromServer(bodyInfo, source="") { return await data; } catch(e) { if (e == "TypeError: Failed to fetch"){ - alertText("error: NoConnect to Server (is the ip set?)") + alertText("error: Can't Connect to Server (is the ip set?)") } else { alertText("error: " + e) } From f23ddc79ca9017263945a6585972fe9a9c0727ac Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 3 Mar 2025 10:37:07 -0500 Subject: [PATCH 014/110] added new minor details --- Server/webbyBits.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 4351984..537542c 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -4,7 +4,7 @@ from flask_cors import CORS import json,vlc,threading,time,random, argparse # Argparse Stuff parser=argparse.ArgumentParser(description="Options for the Webby Bits") -parser.add_argument('-d','--directory',help="Directory of the song files", default="./sound/") +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 soundLocation = parser.parse_args().directory @@ -118,6 +118,7 @@ def searchSongDB(): tempData = {} for i in songDatabaseList: if ((i["title"].lower().find(recieveData['search'].lower())) > -1) or (recieveData['search'] == ""): + # In future i would change this to index based on the filename so that it is definately unique tempData[i["title"]] = [i["artist"],i["art"],i["file"]] try: if (i["artist"].lower().find(recieveData['search'].lower()) > -1): From 968d30897eb5d704d91d5a69a4df6e10439ac453 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:07:55 -0500 Subject: [PATCH 015/110] New JSON layout --- Server/databaseGenerator.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index a504848..109ffbe 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -27,20 +27,19 @@ if args.mode == "update": with open('songDatabase.json', 'r') as handle: songDatabaseList = json.load(handle) except: - songDatabaseList=[] + songDatabaseList={"songDirectory":soundLocation,'songData':{}} - for i in songDatabaseList: + for i in songDatabaseList["songData"]: try: - songFiles.index(i["file"]) != -1 + songFiles.index(i) != -1 except: - print("deleted: " + i["file"] + " from database") + print("deleted: " + i + " from database") songDatabaseList.remove(i) - for i in songDatabaseList: - songFiles.pop(songFiles.index(i["file"])) + for i in songDatabaseList["songData"]: + songFiles.remove(i) print("new songs: " + str(songFiles)) elif args.mode=="new": - songDatabaseList = [] - + songDatabaseList={"songDirectory":soundLocation,'songData':{}} if args.art.lower() == "true": x = len(songFiles)*0.25 if x > 60: @@ -85,7 +84,7 @@ for i in songFiles: 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 containing the below stuff, and each dictionary is put into a list - songDatabaseList.append({"file":i,"title":title,"artist":artist,"art":image,"length":length}) + songDatabaseList["songData"][i] = ({"title":title,"artist":artist,"art":image,"length":length}) with open('songDatabase.json', 'w') as handle: json.dump(songDatabaseList, handle) From 9a3008b29e2bf0efb79eea1027240639f2072db0 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:15:33 -0500 Subject: [PATCH 016/110] Update readme to match new generator use --- readme.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/readme.md b/readme.md index 02b7058..0eb0171 100644 --- a/readme.md +++ b/readme.md @@ -49,18 +49,19 @@ 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__ + - ~~__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.json` stores all the information about each song in this format: ``` -[ - { - "file": "The Search_NF.mp3", - "title": "The Search", - "artist": "NF", - "art": "https://lastfm.freetls.fastly.net/i/u/64s/03125956378d531a44e1b7da89aae795.png", - "length": 292 +{ + "songDirectory": "./sound/", + "songData": { + "Circus_Fox Szn.mp3": + {"title": "Circus", + "artist": "Fox Szn", + "art": null, + "length": 141} } -] +} ``` - `webbyBits.py` imports the database, runs all music playing, and accepts all commands from clients - Searches return matching songs From cf8796a3a12784bf09dd1be7d12ddc13abf61e17 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:18:05 -0500 Subject: [PATCH 017/110] Playlist mode, adding songs work in new format --- Client/scripts.js | 46 ++++++++++++++++++------------------ Server/webbyBits.py | 57 +++++++++++++++++++-------------------------- 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 3f43bbc..d52b8e6 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -98,35 +98,33 @@ async function searchSongs(searchTerm){ document.getElementById("songlist").innerHTML = "" searchResults = await getFromServer({search:searchTerm},"search").then() - for (let index in searchResults) { - optionslist.push([index,searchResults[index][0],searchResults[index][1],searchResults[index][2]]); - } //generate the visual song list - for(let i = 0; i < optionslist.length; i++) { + for(var fileName in searchResults) { + let currentSongInJSON = searchResults[fileName] let newItem = document.createElement("div"); newItem.className = "item"; - newItem.id = optionslist[i][3]; + newItem.id = fileName; let image = document.createElement("img"); try { - if (optionslist[i][2] == null) { + if (currentSongInJSON["art"] == null) { throw "no image lolz" } - image.src = optionslist[i][2]; + image.src = currentSongInJSON["art"]; } catch(err){ image.src = "./images/placeholder.png"; } - image.id = String(optionslist[i][3])+" image"; + image.id = String(fileName)+" image"; let head3 = document.createElement("h3"); - head3.innerText = optionslist[i][0]; + head3.innerText = currentSongInJSON["title"]; let head4 = document.createElement("h4"); - head4.innerText=optionslist[i][1]; + head4.innerText = currentSongInJSON["artist"]; newItem.appendChild(image); newItem.appendChild(head3); newItem.appendChild(head4); document.getElementById("songlist").appendChild(newItem); } - if (optionslist.length == 0) { + if (searchResults.length == 0) { //display error if no results document.getElementById("songlist").innerHTML = "

We might not have that one...

"; } @@ -211,29 +209,30 @@ async function generateVisualPlaylist(conditions="") { document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." } else { if (conditions=="skip-button") { - playlist.shift() + delete playlist[0] if (playlist.length==0){ document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." } } - for (i in playlist) { + for (let i in playlist) { + let fileName = Object.keys(playlist[i])[0] let newItem = document.createElement("div"); newItem.className = "item"; - newItem.id = playlist[i]["file"]; + newItem.id = fileName; let image = document.createElement("img"); try { - if (playlist[i]["art"] == null) { + if (playlist[i][fileName]["art"] == null) { throw "no image lolz" } - image.src = playlist[i]["art"]; + image.src = playlist[i][fileName]["art"]; } catch(err){ image.src = "./images/placeholder.png"; } - image.id = String(playlist[i]["file"])+" image"; + image.id = String(fileName)+" image"; let head3 = document.createElement("h3"); - head3.innerText = playlist[i]["title"]; + head3.innerText = playlist[i][fileName]["title"]; let head4 = document.createElement("h4"); - head4.innerText=playlist[i]["artist"]; + head4.innerText=playlist[i][fileName]["artist"]; let head5 = document.createElement("h5"); let timeLeft =document.createElement("h5"); timeLeft.style.fontWeight = 100; @@ -241,10 +240,10 @@ async function generateVisualPlaylist(conditions="") { if (i == 0) { 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); + let mins = Math.floor(playlist[i][fileName]["time"]/60); + let secs = Math.floor(playlist[i][fileName]["time"]%60); + let durMins = Math.floor(playlist[i][fileName]["length"]/60); + let durSecs = Math.floor(playlist[i][fileName]["length"]%60); timeLeft.innerHTML = mins.toString() +":"+ secs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}) + "/"+ durMins.toString()+":"+durSecs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}); } } @@ -270,6 +269,7 @@ async function submitSong(songid) { } function checkWhatSongWasClicked(e) { itemId = e.srcElement.id; + console.log(itemId) if ((itemId.length-itemId.lastIndexOf("image") == 5) && itemId.lastIndexOf("image")!=-1) { itemId = itemId.slice(0,-6) } diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 537542c..edc637c 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -4,12 +4,16 @@ from flask_cors import CORS import json,vlc,threading,time,random, argparse # Argparse Stuff parser=argparse.ArgumentParser(description="Options for the Webby Bits") -parser.add_argument('-d','--directory',help="Directory of the song files (make sure this matches the directory used for the databaseGenerator)", default="./sound/") +# 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 -soundLocation = parser.parse_args().directory -# To set the directory permenantly just uncomment the next line -# soundLocation = "/example/directory/here/" + +# open the json file as a dictionary +with open('./songDatabase.json', 'r') as handle: + songDatabaseList = json.load(handle) +soundLocation = songDatabaseList["songDirectory"] + if soundLocation[-1] == "/" or soundLocation[-1] == "\\": pass elif "/" in soundLocation: @@ -33,9 +37,6 @@ player.audio_set_volume(100) app = Flask(__name__) # because you are posting from another domain to this one, you need CORS CORS(app) -# open the json file as a dictionary -with open('./songDatabase.json', 'r') as handle: - songDatabaseList = json.load(handle) def queueSong(song): with playlistLock: @@ -116,16 +117,16 @@ def searchSongDB(): # the way i put the data in a list was really dumb looking back, i could and should have used a list of dictioaries like i was before # i might try to change it but this layout is embedded deep in the client tempData = {} - for i in songDatabaseList: - if ((i["title"].lower().find(recieveData['search'].lower())) > -1) or (recieveData['search'] == ""): - # In future i would change this to index based on the filename so that it is definately unique - tempData[i["title"]] = [i["artist"],i["art"],i["file"]] + for i in songDatabaseList["songData"]: + if ((songDatabaseList["songData"][i]["title"].lower().find(recieveData['search'].lower())) > -1) or (recieveData['search'] == ""): + tempData[i] = songDatabaseList["songData"][i] + try: - if (i["artist"].lower().find(recieveData['search'].lower()) > -1): - tempData[i["title"]] = [i["artist"],i["art"],i["file"]] + if (songDatabaseList["songData"][i]["artist"].lower().find(recieveData['search'].lower()) > -1): + tempData[i] = songDatabaseList["songData"][i] except: pass - + print(tempData) return tempData @app.route("/songadd", methods=["POST"]) @@ -137,26 +138,16 @@ def songadd(): def getPlaylist(): global songNext tempPlaylist = [] - # what went through my head to make past-me think this is a good idea??? - # i mean actually looping through once still shouldn't ever take that long - # but like a binary search must exist in python and be faster - # wait no binary search only helps if they're sorted - # i mean i guess i could sort them and make searching faster - for k in songDatabaseList: - if k["file"] == songNext: - temp = k.copy() - temp["playing"] = True - temp["time"] = player.get_time()/1000 - tempPlaylist.append(temp) + if songNext != None: + # Adds the currently playing song + k = songDatabaseList["songData"][songNext] + temp = k.copy() + temp["playing"] = True + temp["time"] = player.get_time()/1000 + tempPlaylist.append({songNext:temp}) for i in playlist: - # oh my goodness i did it again - # i seriously need to rewrite the databaseGenerator and this code - # wait isn't this literally useless code??? - # oh no the playlist only contains names - # i should really have used an object for this. - for j in songDatabaseList: - if j["file"] == i: - tempPlaylist.append(j) + tempPlaylist.append({i:songDatabaseList["songData"][i]}) + print(tempPlaylist) return tempPlaylist if __name__ == "__main__": From 03bb05955894d27f9905570eeee0ec9f772ee268 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:34:28 -0500 Subject: [PATCH 018/110] Fixed the freaking bug in ordering i went from array, to json, to realising json can't have duplicates, to now converting the json back to an array (with the help of chatgpt D: ) Its fine though it works now --- Client/scripts.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index d52b8e6..497d58e 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -205,34 +205,41 @@ async function checkSettings(skipServer=false) { async function generateVisualPlaylist(conditions="") { document.getElementById("playlist").innerHTML = "

"; playlist = await getFromServer(null, "playlist"); + playlist = Object.values(playlist).map(obj => { + const filename = Object.keys(obj)[0]; // Get the filename + const songData = obj[filename]; // Get the song metadata + return { filename, ...songData }; // Merge filename with song data + }); + console.log(playlist) if (playlist.length==0){ document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." } else { if (conditions=="skip-button") { - delete playlist[0] + playlist.shift() if (playlist.length==0){ document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." } } for (let i in playlist) { - let fileName = Object.keys(playlist[i])[0] + console.log(i) + let fileName = playlist[i]["filename"] let newItem = document.createElement("div"); newItem.className = "item"; newItem.id = fileName; let image = document.createElement("img"); try { - if (playlist[i][fileName]["art"] == null) { + if (playlist[i]["art"] == null) { throw "no image lolz" } - image.src = playlist[i][fileName]["art"]; + image.src = playlist[i]["art"]; } catch(err){ image.src = "./images/placeholder.png"; } image.id = String(fileName)+" image"; let head3 = document.createElement("h3"); - head3.innerText = playlist[i][fileName]["title"]; + head3.innerText = playlist[i]["title"]; let head4 = document.createElement("h4"); - head4.innerText=playlist[i][fileName]["artist"]; + head4.innerText=playlist[i]["artist"]; let head5 = document.createElement("h5"); let timeLeft =document.createElement("h5"); timeLeft.style.fontWeight = 100; @@ -240,10 +247,10 @@ async function generateVisualPlaylist(conditions="") { if (i == 0) { head5.innerHTML="Playing"; if ((conditions != "skip-button")) { - let mins = Math.floor(playlist[i][fileName]["time"]/60); - let secs = Math.floor(playlist[i][fileName]["time"]%60); - let durMins = Math.floor(playlist[i][fileName]["length"]/60); - let durSecs = Math.floor(playlist[i][fileName]["length"]%60); + 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}); } } From b0c1fa979fc937709e6cf0dc88d41e2938cf0100 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:37:32 -0500 Subject: [PATCH 019/110] removed some unnecesary console logs --- Client/scripts.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 497d58e..fe09161 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -210,7 +210,6 @@ async function generateVisualPlaylist(conditions="") { const songData = obj[filename]; // Get the song metadata return { filename, ...songData }; // Merge filename with song data }); - console.log(playlist) if (playlist.length==0){ document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." } else { @@ -221,7 +220,6 @@ async function generateVisualPlaylist(conditions="") { } } for (let i in playlist) { - console.log(i) let fileName = playlist[i]["filename"] let newItem = document.createElement("div"); newItem.className = "item"; @@ -255,6 +253,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) } let textdiv = document.createElement("div") @@ -276,7 +275,6 @@ async function submitSong(songid) { } function checkWhatSongWasClicked(e) { itemId = e.srcElement.id; - console.log(itemId) if ((itemId.length-itemId.lastIndexOf("image") == 5) && itemId.lastIndexOf("image")!=-1) { itemId = itemId.slice(0,-6) } From 018fc7579dbf841d0866d64ac96e55a04ff8afa4 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:50:14 -0500 Subject: [PATCH 020/110] changed some prints --- Server/webbyBits.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/webbyBits.py b/Server/webbyBits.py index edc637c..d645bb2 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -126,7 +126,7 @@ def searchSongDB(): tempData[i] = songDatabaseList["songData"][i] except: pass - print(tempData) + # print(tempData) return tempData @app.route("/songadd", methods=["POST"]) @@ -147,7 +147,7 @@ def getPlaylist(): tempPlaylist.append({songNext:temp}) for i in playlist: tempPlaylist.append({i:songDatabaseList["songData"][i]}) - print(tempPlaylist) + # print(tempPlaylist) return tempPlaylist if __name__ == "__main__": From 42e78ca9777bf77fe5696af01ca90e9bf80bfb4c Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Thu, 6 Mar 2025 10:14:17 -0500 Subject: [PATCH 021/110] Empty search error displayed --- Client/scripts.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index fe09161..2aa73c7 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -95,7 +95,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 @@ -124,7 +123,7 @@ async function searchSongs(searchTerm){ document.getElementById("songlist").appendChild(newItem); } - if (searchResults.length == 0) { + if (JSON.stringify(searchResults)==JSON.stringify({})) { //display error if no results document.getElementById("songlist").innerHTML = "

We might not have that one...

"; } From 48d4577c5305037ac1caaeda407c06f1756a66f0 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Thu, 6 Mar 2025 10:22:12 -0500 Subject: [PATCH 022/110] made a few more items per line visible on desktop --- Client/styles.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Client/styles.css b/Client/styles.css index 8857c11..59ac410 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -74,7 +74,7 @@ h4 { /* Songlist stuff */ .songlist { - width: 60%; + width: 70%; min-width: 300px; margin:auto auto 150px; display: flex; @@ -84,7 +84,7 @@ h4 { .songlist > .item{ border: 1px solid #333333; width:30%; - max-width: 200px; + max-width: 150px; margin: 5px auto; min-width: 100px; background-color: inherit; From 7505bc28d3df6598921661c50d68ca49b6c62f98 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Thu, 6 Mar 2025 10:45:51 -0500 Subject: [PATCH 023/110] Volume cannot be changed when player isn't playing --- Client/scripts.js | 9 +++++++-- Client/styles.css | 4 ++-- Server/webbyBits.py | 24 ++++++++++++++---------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/Client/scripts.js b/Client/scripts.js index 2aa73c7..bf0f463 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -170,6 +170,7 @@ function ipSetter(){ 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") { + // don't show the port if it is the default document.getElementById("iptextbox").value = ip.slice(0,-6) } else { document.getElementById("iptextbox").value = ip; @@ -309,8 +310,12 @@ document.getElementById("playlist-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "none"; //.ontouch for mobile?? document.getElementById("volumerange").onchange = function() { - getFromServer({setting:"volume",level:this.value}, "settings") - if (this.value == 0) { + let returnValue = getFromServer({setting:"volume",level:this.value}, "settings") + if (returnValue !=0) { + alertText("Nothing is playing") + document.getElementById("volumerange").value = -1 + } + 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/Client/styles.css b/Client/styles.css index 59ac410..bfcd94d 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -74,7 +74,7 @@ h4 { /* Songlist stuff */ .songlist { - width: 70%; + width: 80%; min-width: 300px; margin:auto auto 150px; display: flex; @@ -86,7 +86,7 @@ h4 { width:30%; max-width: 150px; margin: 5px auto; - min-width: 100px; + min-width: 75px; background-color: inherit; } diff --git a/Server/webbyBits.py b/Server/webbyBits.py index d645bb2..3251649 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -97,10 +97,11 @@ def playerControls(): def settingsControl(): # set the volume and partymode global partyMode + global player recieveData = request.get_json(force=True) if recieveData["setting"] == "volume": - player.audio_set_volume(int(recieveData["level"])) - return "200" + volumePassed = player.audio_set_volume(int(recieveData["level"])) + return {"volumePassed":volumePassed} elif recieveData["setting"] == "partymode-toggle": partyMode = not(partyMode) return "200" @@ -117,15 +118,18 @@ def searchSongDB(): # the way i put the data in a list was really dumb looking back, i could and should have used a list of dictioaries like i was before # i might try to change it but this layout is embedded deep in the client tempData = {} - for i in songDatabaseList["songData"]: - if ((songDatabaseList["songData"][i]["title"].lower().find(recieveData['search'].lower())) > -1) or (recieveData['search'] == ""): - tempData[i] = songDatabaseList["songData"][i] - - try: - if (songDatabaseList["songData"][i]["artist"].lower().find(recieveData['search'].lower()) > -1): + if (recieveData['search'] == ""): + tempData = songDatabaseList["songData"].copy() + else: + for i in songDatabaseList["songData"]: + if ((songDatabaseList["songData"][i]["title"].lower().find(recieveData['search'].lower())) > -1): tempData[i] = songDatabaseList["songData"][i] - except: - pass + + try: + if (songDatabaseList["songData"][i]["artist"].lower().find(recieveData['search'].lower()) > -1): + tempData[i] = songDatabaseList["songData"][i] + except: + pass # print(tempData) return tempData From 49d50462ab92a1000d674aaa34e4b49418bc8f24 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Thu, 6 Mar 2025 11:24:06 -0500 Subject: [PATCH 024/110] made the qrcode generator a function its now called when the ip is changed too, so you don't need to refresh --- Client/index.html | 1 + Client/scripts.js | 31 ++++++++++++++----------------- Client/styles.css | 7 +++++++ 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/Client/index.html b/Client/index.html index c2f2400..b56f759 100644 --- a/Client/index.html +++ b/Client/index.html @@ -86,6 +86,7 @@ changes visibility with JS-->

Hit settings icon to refresh the code

+

Version 1.0.0

diff --git a/Client/scripts.js b/Client/scripts.js index bf0f463..e543aa6 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -164,17 +164,11 @@ function ipSetter(){ alertText("Your IP is now set to "+ipBox+" at port 19054 (Default)") } } + qrCodeGenerate() } -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") { - // don't show the port if it is the default - document.getElementById("iptextbox").value = ip.slice(0,-6) - } else { - document.getElementById("iptextbox").value = ip; - } +function qrCodeGenerate() { let tempURL = "http://" + document.location.href.split("/")[2] + "/?ip=" + ip; document.getElementById("qrcode").innerHTML = "" new QRCode(document.getElementById("qrcode"), { @@ -185,6 +179,17 @@ async function checkSettings(skipServer=false) { colorLight : "#eeeeee", correctLevel : QRCode.CorrectLevel.H }); +} + +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") { + // don't show the port if it is the default + document.getElementById("iptextbox").value = ip.slice(0,-6) + } else { + document.getElementById("iptextbox").value = ip; + } + qrCodeGenerate() document.getElementById("alerttimetextbox").value = alertTime partyButtonState = document.getElementById("partymode-button").innerHTML; x = await getFromServer({setting: "getsettings"}, "settings"); @@ -361,12 +366,4 @@ if (alertTime == "") { document.cookie = "alertTime="+alertTime+"; path=/;" } // this is the code that makes the qr code at the very start -let tempURL = "http://" + document.location.href.split("/")[2] + "/?ip=" + ip; -new QRCode(document.getElementById("qrcode"), { -text: tempURL, -width: 256, -height: 256, -colorDark : "#000000", -colorLight : "#eeeeee", -correctLevel : QRCode.CorrectLevel.H -}); \ No newline at end of file +qrCodeGenerate() \ No newline at end of file diff --git a/Client/styles.css b/Client/styles.css index bfcd94d..a00db3b 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -172,6 +172,13 @@ h4 { border-bottom: 0; } +.versionNumber { + font-size: 8px; + font-style: italic; + text-align: left; + width: 80%; +} + #volumerange { background-color: #4477AA; color: #4477ff; From 49c1b0bf25e4a77c9646efa5f6976da16800b356 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Fri, 7 Mar 2025 21:33:05 -0500 Subject: [PATCH 025/110] trying to fix deleteymode, work in progress --- Server/databaseGenerator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 109ffbe..b04682c 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -30,11 +30,15 @@ if args.mode == "update": songDatabaseList={"songDirectory":soundLocation,'songData':{}} for i in songDatabaseList["songData"]: + deleteySongs = [] try: songFiles.index(i) != -1 except: - print("deleted: " + i + " from database") - songDatabaseList.remove(i) + deleteySongs.append(i) + if deleteySongs: + print("deleted: " + ", ".join(deleteySongs)+ " from database") + for i in deleteySongs: + songDatabaseList["songData"].pop(i) for i in songDatabaseList["songData"]: songFiles.remove(i) print("new songs: " + str(songFiles)) From 247c684b58dbcdb81fb80f331a5560c3ade121fe Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:01:40 -0400 Subject: [PATCH 026/110] bleh --- Server/databaseGenerator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index b04682c..465b96d 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -44,6 +44,7 @@ if args.mode == "update": print("new songs: " + str(songFiles)) elif args.mode=="new": songDatabaseList={"songDirectory":soundLocation,'songData':{}} + if args.art.lower() == "true": x = len(songFiles)*0.25 if x > 60: From 44bd5dc481f3a7d79ef76d0fcde3822867929b8c Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:05:07 -0400 Subject: [PATCH 027/110] Remove "update" database mode temporarily --- Server/databaseGenerator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 109ffbe..05c3898 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -7,7 +7,8 @@ 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('-m', '--mode', help='new/update: Remake database or update current', default= "update") +# parser.add_argument('-m', '--mode', help='new/update: Remake database or update current', default= "update") +parser.add_argument('-m', '--mode', help='new mode required temporarily', default= "new") 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() From 83fbdc7658ea5bd2a4bea6e43844f81ec8251750 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:32:27 -0400 Subject: [PATCH 028/110] Update mode back to working, non mp3's are not added to the database --- Server/databaseGenerator.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 465b96d..5494c3f 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -22,17 +22,17 @@ else: # apikeylastfm = "KeyHere" # soundLocation = "directoryHere" songFiles = os.listdir(soundLocation) -if args.mode == "update": +if args.mode.lower() == "update": try: with open('songDatabase.json', 'r') as handle: songDatabaseList = json.load(handle) except: songDatabaseList={"songDirectory":soundLocation,'songData':{}} - + deleteySongs = [] for i in songDatabaseList["songData"]: - deleteySongs = [] try: - songFiles.index(i) != -1 + if songFiles.index(i) == -1: + deleteySongs.append(i) except: deleteySongs.append(i) if deleteySongs: @@ -41,11 +41,12 @@ if args.mode == "update": songDatabaseList["songData"].pop(i) for i in songDatabaseList["songData"]: songFiles.remove(i) - print("new songs: " + str(songFiles)) -elif args.mode=="new": + print("new songs: " + ", ".join(songFiles)) +elif args.mode.lower()=="new": songDatabaseList={"songDirectory":soundLocation,'songData':{}} - -if args.art.lower() == "true": +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") @@ -53,6 +54,9 @@ if args.art.lower() == "true": print("ETA "+ str(x) + " seconds") for i in songFiles: + if i[-4].lower() != ".mp3": + # skip any non-mp3's (like directories or cover art) + continue try: # get the metadata song = EasyID3(soundLocation+i) From 058d1a9a3c1a1a40080c81f867c4a2003a650194 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 10 Mar 2025 12:40:38 -0400 Subject: [PATCH 029/110] Not defaulting to new mode --- Server/databaseGenerator.py | 5 ++--- readme.md | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index b9e4c2d..6e04639 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -7,8 +7,7 @@ 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('-m', '--mode', help='new/update: Remake database or update current', default= "update") -parser.add_argument('-m', '--mode', help='new mode required temporarily', default= "new") +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() @@ -93,7 +92,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 containing the below stuff, and each dictionary is put into a list + # each "song" is stored as a dictionary/JSON entry following the format seen in the readME songDatabaseList["songData"][i] = ({"title":title,"artist":artist,"art":image,"length":length}) with open('songDatabase.json', 'w') as handle: diff --git a/readme.md b/readme.md index 0eb0171..d0de91f 100644 --- a/readme.md +++ b/readme.md @@ -41,13 +41,13 @@ These are specific details on each section of the app, and how to use 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` *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 only adds new songs, or recreates the entire database (update is default) + - 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"` + - 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.json` stores all the information about each song in this format: From 3e770fe66dc7d59d0219d646a48e25261a57fbc5 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 10 Mar 2025 12:55:38 -0400 Subject: [PATCH 030/110] added a colon --- Server/databaseGenerator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 6e04639..d2569d9 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -54,7 +54,7 @@ if args.art.lower() == "true" and not(args.apikey == ""): print("ETA "+ str(x) + " seconds") for i in songFiles: - if i[-4].lower() != ".mp3": + if i[-4:].lower() != ".mp3": # skip any non-mp3's (like directories or cover art) continue try: From 0f814923320a7200d233909fdf1c461767785cb7 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Mon, 10 Mar 2025 13:42:38 -0400 Subject: [PATCH 031/110] Removed a comment for an issue i fixed --- Server/webbyBits.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 3251649..65427f7 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -115,8 +115,6 @@ def settingsControl(): @app.route("/search", methods=['POST']) def searchSongDB(): recieveData=request.get_json(force=True) - # the way i put the data in a list was really dumb looking back, i could and should have used a list of dictioaries like i was before - # i might try to change it but this layout is embedded deep in the client tempData = {} if (recieveData['search'] == ""): tempData = songDatabaseList["songData"].copy() From a7c9f8ec4b0881f00d32c6323aa452f3684931b3 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Tue, 11 Mar 2025 10:10:07 -0400 Subject: [PATCH 032/110] updated manifest for PWA solved some more issues, added some more issues, you know how it goes --- Client/images/Icon-144.png | Bin 0 -> 5651 bytes Client/images/Screenshot-Main-Desktop.png | Bin 0 -> 102122 bytes Client/images/Screenshot-Main-Mobile.png | Bin 0 -> 42932 bytes Client/index.html | 9 +++++---- Client/manifest.json | 20 ++++++++++++++++++++ Client/styles.css | 5 +++++ Server/databaseGenerator.py | 2 ++ 7 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 Client/images/Icon-144.png create mode 100644 Client/images/Screenshot-Main-Desktop.png create mode 100644 Client/images/Screenshot-Main-Mobile.png diff --git a/Client/images/Icon-144.png b/Client/images/Icon-144.png new file mode 100644 index 0000000000000000000000000000000000000000..7d62c8b8b20042d105132bb7a911861af63253af GIT binary patch literal 5651 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 literal 0 HcmV?d00001 diff --git a/Client/images/Screenshot-Main-Desktop.png b/Client/images/Screenshot-Main-Desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..19ec8e234f504006de7366b3c76c9f789dcc8cb0 GIT binary patch literal 102122 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 z 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

q;4}~ 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

Alert Time:

How long alerts stay on screen for (seconds)

- +

Server Settings (Saved to server)

Party Mode:

@@ -81,12 +81,13 @@ changes visibility with JS-->

Volume of the music

-
+

Share the remote:

-

Hit settings icon to refresh the code

+
-

Version 1.0.0

+

Version 1.0.1

diff --git a/Client/manifest.json b/Client/manifest.json index 7ab15d3..975ff13 100644 --- a/Client/manifest.json +++ b/Client/manifest.json @@ -1,6 +1,7 @@ { "name": "Jukebox Remote", "short_name": "Jukebox Remote", + "description": "Controller for the PartyJukebox server app.", "start_url": "index.html", "display": "standalone", "background_color": "#eeeeee", @@ -10,6 +11,25 @@ { "src": "/favicon.ico", "type": "image/ico", "sizes": "100x100" + }, + { + "src":"images/Icon-144.png", + "type": "image/png", + "sizes": "144x144" + } + ], + "screenshots": [ + { + "src": "images/Screenshot-Main-Desktop.png", + "sizes" : "1919x1199", + "type": "image/png", + "form_factor": "wide" + }, + { + "src": "images/Screenshot-Main-Mobile.png", + "type": "image/png", + "sizes": "485x859", + "form_factor": "narrow" } ] } \ No newline at end of file diff --git a/Client/styles.css b/Client/styles.css index a00db3b..803c5df 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -168,6 +168,11 @@ h4 { border-bottom: 1px solid #333333; } +.settings > .item.no-line { + border-bottom: 0px none #00000000; + padding-bottom: 0px; +} + .settings > .lastSet1 { border-bottom: 0; } diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index d2569d9..68cdeb1 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -41,6 +41,8 @@ if args.mode.lower() == "update": songDatabaseList["songData"].pop(i) for i in songDatabaseList["songData"]: songFiles.remove(i) + # This prints everything in the directory, including non mp3s + # theres not agood way to fix this without looping again. print("new songs: " + ", ".join(songFiles)) elif args.mode.lower()=="new": songDatabaseList={"songDirectory":soundLocation,'songData':{}} From 152325c9ed304088ef2cab273507d45851dcf799 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Tue, 11 Mar 2025 10:31:51 -0400 Subject: [PATCH 033/110] Update readme.md --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index d0de91f..f142c69 100644 --- a/readme.md +++ b/readme.md @@ -67,7 +67,7 @@ These are specific details on each section of the app, and how to use them - Searches return matching songs - Accepts Play-Pause and Skip commands - Uses port 19054 by default - - `--port (port)` changes the default port for that run + - `--port (port)` changes the port for that run - The default port can be changed in the file ### Client: From fc49e710eea101b0f6eaf45693f777c731bd4add Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Tue, 25 Mar 2025 15:30:12 -0400 Subject: [PATCH 034/110] Add AGPL3 license --- LICENSE.md | 661 +++++++++++++++++++++++++++++++++++++++++++++++++++++ readme.md | 4 +- 2 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 LICENSE.md diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..29ebfa5 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/readme.md b/readme.md index f142c69..ac9627a 100644 --- a/readme.md +++ b/readme.md @@ -84,4 +84,6 @@ From left to right: - Party Mode adds new songs to the queue when the queue has only 1 song in it - 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 \ No newline at end of file + - QR code to allow others to connect to and use the Remote + +*See `LICENSE.md` for redistribution details. \ No newline at end of file From d5481cbc57709eaee042dde2c8c31b81789adee9 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Thu, 27 Mar 2025 16:15:54 -0400 Subject: [PATCH 035/110] Added AGPLv3 reference to the actual web app to make it compliant i think if anyone actually knows how this liscense works please do a PR --- Client/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Client/index.html b/Client/index.html index 6e110d9..6012ac4 100644 --- a/Client/index.html +++ b/Client/index.html @@ -87,7 +87,8 @@ changes visibility with JS--> You actually no longer need to do that, it does it anytime the ip changes -->
-

Version 1.0.1

+

Version 1.0.2

+

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

From 488f426d02f685b5d558e44f19338a407901fcad Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:08:56 -0400 Subject: [PATCH 036/110] The database generator can make a functional SQLite database --- .gitignore | 1 + Server/databaseGenerator.py | 55 ++++++++++++++++++++----------------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 245e57a..15580f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ server/sound/ server/songDatabase.json +*.db start.bat \ No newline at end of file diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 68cdeb1..71e1607 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -1,6 +1,7 @@ import os from mutagen.easyid3 import EasyID3 from mutagen.mp3 import MP3 +import sqlite3 as sql import requests, ast, time, math, argparse, json loading = ["-","\\","|","/"] @@ -22,32 +23,35 @@ else: # apikeylastfm = "KeyHere" # soundLocation = "directoryHere" songFiles = os.listdir(soundLocation) +fileOfDB = sql.connect("songDatabase.db") +songDatabase = fileOfDB.cursor() +# setting song directory +songDatabase.execute("CREATE TABLE IF NOT EXISTS meta (id TEXT PRIMARY KEY, data TEXT);") +try: + songDatabase.execute("INSERT INTO meta (id, data) VALUES (?,?)",("songDirectory",soundLocation)) +except: + songDatabase.execute("UPDATE meta SET data = ? WHERE id = 'songDirectory'", (soundLocation,)) if args.mode.lower() == "update": - try: - with open('songDatabase.json', 'r') as handle: - songDatabaseList = json.load(handle) - except: - songDatabaseList={"songDirectory":soundLocation,'songData':{}} - deleteySongs = [] - for i in songDatabaseList["songData"]: - try: - if songFiles.index(i) == -1: - deleteySongs.append(i) - except: - deleteySongs.append(i) - if deleteySongs: - print("deleted: " + ", ".join(deleteySongs)+ " from database") - for i in deleteySongs: - songDatabaseList["songData"].pop(i) - for i in songDatabaseList["songData"]: - songFiles.remove(i) - # This prints everything in the directory, including non mp3s - # theres not agood way to fix this without looping again. + #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("SELECT filename FROM songs;") + dBfilelist = songDatabase.fetchall() + dBfilelistSet = set() + for i in dBfilelist: + dBfilelistSet.add(i[0]) + # Delete nonexistant files + deleteySongs = list(dBfilelistSet - set(songFiles)) + songDatabase.executemany("DELETE FROM songs WHERE filename = ?", [(item,) for item in deleteySongs]) # in this line it turns the list of strings into a list of tuples of strings + print("Deleted: " + ", ".join(deleteySongs)+ " from database") + # only include new files in list to be used + songFiles = list(set(songFiles) - dBfilelistSet) print("new songs: " + ", ".join(songFiles)) elif args.mode.lower()=="new": - songDatabaseList={"songDirectory":soundLocation,'songData':{}} + songDatabase.execute("DROP TABLE IF EXISTS songs;") + songDatabase.execute("CREATE TABLE songs (filename TEXT PRIMARY KEY, title TEXT, artist TEXT, art TEXT, length INTEGER);") 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: @@ -95,7 +99,8 @@ for i in songFiles: 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 - songDatabaseList["songData"][i] = ({"title":title,"artist":artist,"art":image,"length":length}) - -with open('songDatabase.json', 'w') as handle: - json.dump(songDatabaseList, handle) + songDatabase.execute(f"INSERT INTO songs (filename, title, artist, art, length) VALUES (?,?,?,?,?)",(i,title,artist,image,length)) + + + +fileOfDB.commit() From ea183794c13c1a18f57df5878ab9ebc37ba218f7 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 6 Jul 2025 12:46:01 -0400 Subject: [PATCH 037/110] SQLite is Completely done, json is not used at all anymore --- .gitignore | 1 - Client/scripts.js | 6 ++-- Server/databaseGenerator.py | 2 +- Server/webbyBits.py | 72 +++++++++++++++++++++++++++---------- 4 files changed, 57 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 15580f4..49c6f73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ server/sound/ -server/songDatabase.json *.db start.bat \ No newline at end of file diff --git a/Client/scripts.js b/Client/scripts.js index e543aa6..a63ee01 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -314,9 +314,9 @@ 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 = function() { - let returnValue = getFromServer({setting:"volume",level:this.value}, "settings") - if (returnValue !=0) { +document.getElementById("volumerange").onchange = async function() { + let returnValue = await getFromServer({setting:"volume",level:this.value}, "settings") + if (returnValue["volumePassed"] !=0) { alertText("Nothing is playing") document.getElementById("volumerange").value = -1 } diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 71e1607..70ffa5f 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -2,7 +2,7 @@ import os from mutagen.easyid3 import EasyID3 from mutagen.mp3 import MP3 import sqlite3 as sql -import requests, ast, time, math, argparse, json +import requests, ast, time, math, argparse loading = ["-","\\","|","/"] diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 65427f7..516ca8c 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -1,7 +1,8 @@ from flask import Flask from flask import request from flask_cors import CORS -import json,vlc,threading,time,random, argparse +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 @@ -9,10 +10,12 @@ 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') portTheUserPicked=parser.parse_args().port -# open the json file as a dictionary -with open('./songDatabase.json', 'r') as handle: - songDatabaseList = json.load(handle) -soundLocation = songDatabaseList["songDirectory"] +fileofDB = sql.connect("songDatabase.db") +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 @@ -20,6 +23,12 @@ elif "/" in soundLocation: soundLocation += "/" else: soundLocation += "\\" +#Create Virtual table for searching +songDatabase.execute("DROP TABLE virtualSongs;") +songDatabase.execute("CREATE VIRTUAL TABLE virtualSongs USING fts5(filename, title, artist, art, length);") +songDatabase.execute("INSERT INTO virtualSongs SELECT * FROM songs;") +fileofDB.commit() +fileofDB.close() #Initializing all the global stuff random.seed() global partyMode @@ -115,21 +124,27 @@ def settingsControl(): @app.route("/search", methods=['POST']) def searchSongDB(): recieveData=request.get_json(force=True) - tempData = {} + fileofDB = sql.connect("songDatabase.db") + songDatabase = fileofDB.cursor() + results = [] if (recieveData['search'] == ""): - tempData = songDatabaseList["songData"].copy() + songDatabase.execute("SELECT * FROM virtualSongs") + results = songDatabase.fetchall() else: - for i in songDatabaseList["songData"]: - if ((songDatabaseList["songData"][i]["title"].lower().find(recieveData['search'].lower())) > -1): - tempData[i] = songDatabaseList["songData"][i] - - try: - if (songDatabaseList["songData"][i]["artist"].lower().find(recieveData['search'].lower()) > -1): - tempData[i] = songDatabaseList["songData"][i] - except: - pass + 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 + for i in results: + tempdata[i[0]] = { + "title": i[1], + "artist": i[2], + "art": i[3], + "length": i[4] + } # print(tempData) - return tempData + fileofDB.close() + return tempdata @app.route("/songadd", methods=["POST"]) def songadd(): @@ -139,17 +154,36 @@ def songadd(): @app.route("/playlist", methods=["POST"]) def getPlaylist(): global songNext + fileofDB = sql.connect("songDatabase.db") + songDatabase = fileofDB.cursor() tempPlaylist = [] if songNext != None: # Adds the currently playing song - k = songDatabaseList["songData"][songNext] + songDatabase.execute("SELECT * FROM songs WHERE filename = ?",[songNext]) + 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] + } temp = k.copy() temp["playing"] = True temp["time"] = player.get_time()/1000 tempPlaylist.append({songNext:temp}) for i in playlist: - tempPlaylist.append({i:songDatabaseList["songData"][i]}) + songDatabase.execute("SELECT * FROM songs WHERE filename = ?",[i]) + result = songDatabase.fetchall() + k = { + "title": result[1], + "artist": result[2], + "art": result[3], + "length": result[4] + } + tempPlaylist.append({i:k}) # print(tempPlaylist) + fileofDB.close() return tempPlaylist if __name__ == "__main__": From 2d26d0d63a497d96cbbb1d92295432eb1eabe5e3 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 6 Jul 2025 12:50:31 -0400 Subject: [PATCH 038/110] change readme for new database --- readme.md | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/readme.md b/readme.md index ac9627a..1315787 100644 --- a/readme.md +++ b/readme.md @@ -24,7 +24,7 @@ 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` - * *The `databaseGenerator.py` will index all mp3 files, and save the information to `songDatabase.json`* + * *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* @@ -50,19 +50,7 @@ These are specific details on each section of the app, and how to use them - 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.json` stores all the information about each song in this format: -``` -{ - "songDirectory": "./sound/", - "songData": { - "Circus_Fox Szn.mp3": - {"title": "Circus", - "artist": "Fox Szn", - "art": null, - "length": 141} - } -} -``` +- `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 From d63847761cde4b5135a405be9df1d83d2b88616d Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 6 Jul 2025 13:16:12 -0400 Subject: [PATCH 039/110] Fixes to partymode and playlist retrival --- Server/webbyBits.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 516ca8c..6839d6e 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -73,9 +73,13 @@ def playQueuedSongs(): songNext = None player.stop() elif len(playlist)<1 and (partyMode == True): + fileofDB = sql.connect("songDatabase.db") + songDatabase = fileofDB.cursor() + 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 - playlist.append(random.choice(songDatabaseList)["file"]) + 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) @@ -174,7 +178,7 @@ def getPlaylist(): tempPlaylist.append({songNext:temp}) for i in playlist: songDatabase.execute("SELECT * FROM songs WHERE filename = ?",[i]) - result = songDatabase.fetchall() + result = songDatabase.fetchall()[0] k = { "title": result[1], "artist": result[2], From 776ebbbd3461f9cd378bda7be13dc662a5fee027 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 6 Jul 2025 13:42:31 -0400 Subject: [PATCH 040/110] soundlocation is used for the file playing --- Server/webbyBits.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Server/webbyBits.py b/Server/webbyBits.py index 6839d6e..614e1b4 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -23,6 +23,7 @@ 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);") @@ -64,7 +65,7 @@ def playQueuedSongs(): player.stop() skipNow = False songNext = playlist.pop(0) - media = fakeplayer.media_new("sound/"+songNext) + media = fakeplayer.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")): From 824bbdea453bcb33ca511376105b8005255e57b6 Mon Sep 17 00:00:00 2001 From: Kristy Fournier <124598538+kristy-fournier@users.noreply.github.com> Date: Sun, 6 Jul 2025 13:55:30 -0400 Subject: [PATCH 041/110] added new file format for artist/song info --- Server/databaseGenerator.py | 11 +++++++++-- readme.md | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py index 70ffa5f..e83cbc3 100644 --- a/Server/databaseGenerator.py +++ b/Server/databaseGenerator.py @@ -69,12 +69,19 @@ for i in songFiles: title = song['title'][0] artist = song['artist'][0] except: - try: + if "_" in i: # if metadata is missing, try to use file name following title_artist.mp3 song = i.split("_") title = song[0] artist = song[1].split(".")[0] - except: + elif "-" in i: + # 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 title = i artist = None diff --git a/readme.md b/readme.md index 1315787..e63bcbd 100644 --- a/readme.md +++ b/readme.md @@ -39,7 +39,7 @@ 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` *and otherwise defaults to the file name as the title, and no artist* + - *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* - 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) 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 042/110] 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 043/110] 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 044/110] 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 045/110] 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 046/110] 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 047/110] 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 048/110] 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 049/110] 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 050/110] 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 051/110] 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 052/110] 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 053/110] 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 054/110] 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 055/110] 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 056/110] 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 057/110] 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 = + ''; + + 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 058/110] 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 059/110] 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 060/110] 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 061/110] 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 062/110] 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 063/110] 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 064/110] 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 065/110] 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 066/110] 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 067/110] 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 068/110] 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 069/110] 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 070/110] 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 071/110] 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 072/110] 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 073/110] 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 074/110] 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 075/110] 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 076/110] 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 077/110] 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 078/110] 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 079/110] 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 080/110] 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 081/110] 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 082/110] 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 083/110] 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 084/110] 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 085/110] 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 086/110] 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 087/110] 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 088/110] 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 089/110] 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 090/110] 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 091/110] 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 092/110] 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 093/110] 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 094/110] 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 095/110] 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 096/110] 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 097/110] 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 098/110] 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 099/110] 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 100/110] 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 101/110] 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 102/110] 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 103/110] 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 104/110] 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 105/110] 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 106/110] 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 107/110] 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 108/110] 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 109/110] 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 110/110] 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')