diff --git a/software/frontend/index.html b/software/frontend/index.html index 39681e9..d8a9103 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -271,6 +271,12 @@ + +
+ + + 0% +
@@ -482,7 +488,8 @@ document.getElementById('playlist-exist-button-gettag') .addEventListener('click', selectLastTag); document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => { - showScreen("playlist_edit", {load: lastSelected.innerText}); + if (lastSelected !== null) + showScreen("playlist_edit", {load: lastSelected.innerText}); }); document.getElementById('playlist-exist-list').addEventListener("click", (e) => { const node = e.target.closest("li"); @@ -564,6 +571,7 @@ .forEach(n => { if (n.innerText == tagtext) { n.classList.add("selected"); + lastSelected = n; } else { n.classList.remove("selected"); } @@ -733,6 +741,9 @@ document.getElementById('playlist-filebrowser-addtrack').addEventListener("click", (e) => { returnSelectedTracks(); }); + document.getElementById('playlist-filebrowser-upload').addEventListener("click", (e) => { + uploadFiles(); + }); tree.init(); } @@ -822,6 +833,54 @@ } showScreen("playlist_edit", {addtracks: tracks}); } + + function uploadFiles() { + const tree = document.getElementById("playlist-filebrowser-tree"); + const selectedNodes = [...tree.querySelectorAll(".selected")]; + const files = [...document.getElementById("playlist-filebrowser-upload-files").files]; + if (selectedNodes.length !== 1 || + selectedNodes[0].getAttribute('data-type') !== "directory") { + alert("Please select a single directory for upload"); + return; + } + uploadFileHelper(files.length, 0, selectedNodes[0].getAttribute('data-path'), files); + } + + function uploadFileHelper(totalcount, donecount, destdir, files) { + // Upload files sequentially by recursivly calling this function from the 'load' callback when + // the previous upload is completed. + if (files.length === 0) + return; + const reader = new FileReader(); + const ctrl = document.getElementById("playlist-filebrowser-upload-progress"); + const xhr = new XMLHttpRequest(); + const location = destdir + '/' + files[0].name; + + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) { + const percentage = Math.round((e.loaded * 100) / e.total / totalcount + donecount * 100 / totalcount); + ctrl.value = percentage; + } + }); + //xhr.upload.addEventListener("load", (e) => { + xhr.onreadystatechange = () => { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status !== 204) { + alert(`File upload failed: ${xhr.responseText} (${xhr.status})`); + return; + } + if (donecount + 1 === totalcount) { + // Reload file list from device + onShow(); + } else { + uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1)); + } + } + }; + xhr.open("POST", `/api/v1/audiofiles?type=file&location=${location}`); + xhr.overrideMimeType("audio/mpeg"); + xhr.send(files[0]); + } let tree = (() => { let tree = null; diff --git a/software/src/webserver.py b/software/src/webserver.py index 5af66fa..85f4917 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -7,7 +7,8 @@ import asyncio import json import os -from microdot import Microdot, redirect, send_file +from array import array +from microdot import Microdot, redirect, send_file, Request webapp = Microdot() server = None @@ -16,6 +17,8 @@ app = None nfc = None playlist_db = None +Request.max_content_length = 128 * 1024 * 1024 # 128MB requests allowed + def start_webserver(config_, app_): global server, config, app, nfc, playlist_db @@ -28,7 +31,7 @@ def start_webserver(config_, app_): @webapp.before_request async def before_request_handler(request): - if request.method in ['PUT', 'POST'] and app.is_playing(): + if request.method in ['PUT', 'POST', 'DELETE'] and app.is_playing(): return "Cannot write to device while playback is active", 503 app.reset_idle_timeout() @@ -179,3 +182,39 @@ async def audiofiles_get(request): yield ']' return directory_iterator(), {'Content-Type': 'application/json; charset=UTF-8'} + + +@webapp.route('/api/v1/audiofiles', methods=['POST']) +async def audiofile_upload(request): + if 'type' not in request.args or request.args['type'] not in ['file', 'directory']: + return 'invalid or missing type', 400 + if 'location' not in request.args: + return 'missing location', 400 + path = fsroot + '/' + request.args['location'] + type_ = request.args['type'] + length = request.content_length + print(f'Got upload request of type {type_} to {path} with length {length}') + if type_ == 'directory': + if length != 0: + return 'directory request may not have content', 400 + os.mkdir(path) + return '', 204 + with open(path, 'wb') as newfile: + data = array('b', range(4096)) + bytes_copied = 0 + while True: + bytes_read = await request.stream.readinto(data) + if bytes_read == 0: + # End of body + break + bytes_written = newfile.write(data[:bytes_read]) + if bytes_written != bytes_read: + # short writes shouldn't happen + return 'write failure', 500 + bytes_copied += bytes_written + if bytes_copied == length: + break + if bytes_copied == length: + return '', 204 + else: + return 'size mismatch', 500