Skip to content

Commit b1013f3

Browse files
committed
wip: implemented file drag example (frontend) and added backend route.
tests now run through, but it still seems that our custom exceptions do not propagate through the xhr (invalid targetdir does not raise in frontend nor console log)
1 parent 0df66f5 commit b1013f3

File tree

7 files changed

+305
-0
lines changed

7 files changed

+305
-0
lines changed

backend/beets_flask/config/flask_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ class ServerConfig:
3838
# FIXME: 2025-06-04 likely we only need this in production.
3939
FRONTEND_DIST_DIR = "../frontend/dist/"
4040

41+
# For file uploads
42+
MAX_CONTENT_LENGTH = 2 * 1024 * 1024 * 1024 # 2 GB
43+
4144
# Not sure if this is even used!
4245
SECRET_KEY = "secret"
4346

backend/beets_flask/server/routes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .config import config_bp
55
from .db_models import register_state_models
66
from .exception import error_bp
7+
from .file_upload import file_upload_bp
78
from .frontend import frontend_bp
89
from .inbox import inbox_bp
910
from .library import library_bp
@@ -15,6 +16,7 @@
1516
backend_bp.register_blueprint(art_blueprint)
1617
backend_bp.register_blueprint(config_bp)
1718
backend_bp.register_blueprint(error_bp)
19+
backend_bp.register_blueprint(file_upload_bp)
1820
backend_bp.register_blueprint(frontend_bp)
1921
backend_bp.register_blueprint(inbox_bp)
2022
backend_bp.register_blueprint(library_bp)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Handle file uploads."""
2+
3+
import shutil
4+
import tempfile
5+
from asyncio import timeout
6+
from pathlib import Path
7+
8+
import aiofiles
9+
from quart import Blueprint, request
10+
11+
from beets_flask.logger import log
12+
from beets_flask.server.exceptions import InvalidUsageException
13+
from beets_flask.watchdog.inbox import get_inbox_folders
14+
15+
file_upload_bp = Blueprint("file_upload", __name__, url_prefix="/file_upload")
16+
17+
18+
@file_upload_bp.route("/", methods=["POST"])
19+
async def upload():
20+
# validate
21+
filename = request.headers.get("X-Filename")
22+
filedir = request.headers.get("X-File-Target-Dir")
23+
log.info(f"Uploading file {filename} to {filedir} ...")
24+
25+
if not filename or not filedir:
26+
raise InvalidUsageException(
27+
"Missing header: X-Filename and X-File-Target-Dir are required"
28+
)
29+
30+
log.info("A")
31+
32+
filedir = Path(filedir)
33+
is_valid_filepath = False
34+
for inbox in get_inbox_folders():
35+
if filedir.is_relative_to(inbox):
36+
is_valid_filepath = True
37+
break
38+
39+
if not is_valid_filepath:
40+
log.error(f"Invalid target path {filedir}, must be within an inbox.")
41+
# FIXME: hmm, seems that our custom Exceptions do not propagate
42+
# through the xhr - although the tests run through.
43+
raise InvalidUsageException("Invalid target path, must be within an inbox.")
44+
45+
log.info("B")
46+
47+
temp_path = Path(tempfile.gettempdir()) / "upload" / filename
48+
temp_path.parent.mkdir(parents=True, exist_ok=True)
49+
50+
log.info("C")
51+
52+
# upload to temp location with 1 hour timeout
53+
async with timeout(60 * 60):
54+
async with aiofiles.open(temp_path, "wb") as f:
55+
async for chunk in request.body:
56+
await f.write(chunk)
57+
58+
log.info("D")
59+
60+
# move to final location
61+
final_path = filedir / filename
62+
final_path.parent.mkdir(parents=True, exist_ok=True)
63+
shutil.move(temp_path, final_path)
64+
65+
log.info(f"Uploading file {filename} to {filedir} done!")
66+
67+
return {"status": "ok"}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import pytest
2+
from quart import Response
3+
4+
5+
class TestFileUploadErrors:
6+
async def test_successful_file_upload(self, client, tmp_path, monkeypatch):
7+
# Setup: create a valid inbox directory
8+
inbox_dir = tmp_path / "inbox"
9+
inbox_dir.mkdir(parents=True, exist_ok=True)
10+
target_dir = inbox_dir / "nested" / "subdir"
11+
12+
monkeypatch.setattr(
13+
"beets_flask.server.routes.file_upload.get_inbox_folders",
14+
lambda: [str(inbox_dir)],
15+
)
16+
filename = "uploaded.txt"
17+
file_content = b"hello test file upload"
18+
headers = {
19+
"X-Filename": filename,
20+
"X-File-Target-Dir": str(target_dir),
21+
}
22+
response = await client.post(
23+
"/api_v1/file_upload/",
24+
headers=headers,
25+
data=file_content,
26+
)
27+
28+
# Check status codes
29+
print(f"{target_dir=}")
30+
data = await response.get_json()
31+
assert response.status_code == 200
32+
assert data["status"] == "ok"
33+
34+
# Check file exists in target dir and content matches
35+
final_path = target_dir / filename
36+
assert final_path.exists()
37+
with open(final_path, "rb") as f:
38+
assert f.read() == file_content
39+
40+
@pytest.mark.parametrize(
41+
"headers",
42+
[
43+
{"X-File-Target-Dir": "/some/path"},
44+
{"X-Filename": "file.txt"},
45+
],
46+
)
47+
async def test_missing_required_headers(self, client, headers):
48+
response = await client.post(
49+
"/api_v1/file_upload/",
50+
headers=headers,
51+
data=b"testdata",
52+
)
53+
data = await response.get_json()
54+
assert str(response.status_code).startswith("4")
55+
assert data["type"] == "InvalidUsageException"
56+
assert "Missing header" in data["message"]
57+
58+
async def test_invalid_target_path(self, client, monkeypatch):
59+
# Patch get_inbox_folders to return a known inbox path
60+
monkeypatch.setattr(
61+
"beets_flask.server.routes.file_upload.get_inbox_folders",
62+
lambda: ["/valid/inbox"],
63+
)
64+
response = await client.post(
65+
"/api_v1/file_upload/",
66+
headers={
67+
"X-Filename": "file.txt",
68+
"X-File-Target-Dir": "/invalid/path",
69+
},
70+
data=b"testdata",
71+
)
72+
data = await response.get_json()
73+
assert str(response.status_code).startswith("4")
74+
assert data["type"] == "InvalidUsageException"
75+
assert "Invalid target path" in data["message"]

frontend/src/api/fileUpload.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { UseMutationOptions } from "@tanstack/react-query";
2+
3+
import { SerializedException } from "@/pythonTypes";
4+
5+
import { APIError } from "./common";
6+
7+
export const fileUploadMutationOptions: UseMutationOptions<
8+
{ status: string },
9+
APIError,
10+
{
11+
file: File;
12+
targetDir: string;
13+
onProgress?: (percent: number) => void;
14+
}
15+
> = {
16+
mutationFn: async ({ file, targetDir, onProgress }) => {
17+
console.debug("Uploading file", file.name, "to", targetDir, file.size);
18+
19+
return new Promise<{ status: string }>((resolve, reject) => {
20+
const req = new XMLHttpRequest();
21+
req.open("POST", "/api_v1/file_upload");
22+
req.setRequestHeader("X-Filename", encodeURIComponent(file.name));
23+
req.setRequestHeader("X-File-Target-Dir", targetDir);
24+
// req.setRequestHeader("Content-Length", String(file.size));
25+
26+
req.upload.onprogress = (event) => {
27+
if (event.lengthComputable && onProgress) {
28+
const percent = (event.loaded / event.total) * 100;
29+
onProgress(percent);
30+
}
31+
};
32+
33+
req.onload = () => {
34+
if (req.status >= 200 && req.status < 300) {
35+
resolve({ status: "ok" });
36+
} else {
37+
const json_error = req.response as SerializedException;
38+
reject(new APIError(json_error, req.status));
39+
}
40+
};
41+
42+
req.onerror = () => {
43+
const json_error = req.response as SerializedException;
44+
reject(new APIError(json_error, req.status));
45+
};
46+
47+
req.send(file);
48+
});
49+
},
50+
};
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { createFileRoute } from "@tanstack/react-router";
2+
import { Box } from "@mui/material";
3+
import { useCallback, useEffect, useRef, useState } from "react";
4+
5+
import React from "react";
6+
import { useMutation } from "@tanstack/react-query";
7+
import { fileUploadMutationOptions } from "@/api/fileUpload";
8+
9+
export const Route = createFileRoute("/debug/file_drag")({
10+
component: RouteComponent,
11+
});
12+
13+
function RouteComponent() {
14+
return (
15+
<Box sx={{ position: "relative", width: "100%", height: "100%" }}>
16+
<DropZone />
17+
</Box>
18+
);
19+
}
20+
21+
function DropZone({ children }: { children?: React.ReactNode }) {
22+
const ref = useRef<HTMLDivElement>(null);
23+
const [isOverZone, setIsOverZone] = useState(false);
24+
const [isOverWindow, setIsOverWindow] = useState(false);
25+
26+
const resetDragState = useCallback(() => {
27+
setIsOverZone(false);
28+
setIsOverWindow(false);
29+
}, []);
30+
31+
const { mutate, status, isPending } = useMutation(fileUploadMutationOptions);
32+
33+
useEffect(() => {
34+
if (!ref.current) return;
35+
36+
const dropzoneEl = ref.current;
37+
const abortController = new AbortController();
38+
39+
const handleDragOver = (event: DragEvent) => {
40+
setIsOverZone(true);
41+
event.preventDefault();
42+
console.log("File(s) in drop zone");
43+
// Optionally, you can add visual feedback here
44+
};
45+
46+
const handleDrop = (event: DragEvent) => {
47+
event.preventDefault();
48+
resetDragState();
49+
const files = event.dataTransfer?.files;
50+
if (files && files.length > 0) {
51+
console.log("Dropped files:", files);
52+
mutate({ file: files[0], targetDir: "/music/upload/" }); // Upload the first file only
53+
}
54+
};
55+
56+
// Dropzone related drag events
57+
dropzoneEl.addEventListener("dragover", handleDragOver, {
58+
signal: abortController.signal,
59+
});
60+
dropzoneEl.addEventListener("drop", handleDrop, {
61+
signal: abortController.signal,
62+
});
63+
dropzoneEl.addEventListener("dragleave", () => setIsOverZone(false), {
64+
signal: abortController.signal,
65+
});
66+
67+
// Windows level drag events
68+
window.addEventListener("dragover", () => setIsOverWindow(true), {
69+
signal: abortController.signal,
70+
});
71+
window.addEventListener("dragend", () => resetDragState(), {
72+
signal: abortController.signal,
73+
});
74+
window.addEventListener("dragleave", () => setIsOverWindow(false), {
75+
signal: abortController.signal,
76+
});
77+
window.addEventListener("drop", (e) => e.preventDefault(), {
78+
signal: abortController.signal,
79+
});
80+
81+
return () => {
82+
// unregister event listeners on cleanup
83+
abortController.abort();
84+
};
85+
}, [ref, setIsOverZone, setIsOverWindow, resetDragState, mutate]);
86+
87+
return (
88+
<Box
89+
ref={ref}
90+
sx={{
91+
position: "absolute",
92+
top: 0,
93+
right: 0,
94+
border: "2px dashed",
95+
borderColor: isOverZone ? "red" : "black",
96+
backgroundColor: isOverWindow ? "lightblue" : "white",
97+
zIndex: 1,
98+
padding: "8px",
99+
width: "300px",
100+
height: "300px",
101+
}}
102+
>
103+
{children}
104+
</Box>
105+
);
106+
}

frontend/vite.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export default defineConfig(({ mode }) => {
5757
"^/api_v1/.*": {
5858
target: "http://localhost:5001",
5959
changeOrigin: true,
60+
// proxyTimeout: 60000, // 60 seconds, possibly needed for file uploads
61+
// timeout: 60000,
6062
},
6163
"^/socket.io/.*": {
6264
target: "http://localhost:5001",

0 commit comments

Comments
 (0)