mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-04 04:23:02 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Contains all the file upload routes for manual music upload functionality.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from flask import jsonify, request
|
||||
from flask_openapi3 import APIBlueprint, Tag
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from swingmusic.api.auth import admin_required
|
||||
from swingmusic.config import UserConfig
|
||||
|
||||
tag = Tag(name="Upload", description="Manual music file upload functionality")
|
||||
api = APIBlueprint("upload", __name__, url_prefix="/upload", abp_tags=[tag])
|
||||
|
||||
# Allowed audio file extensions
|
||||
ALLOWED_EXTENSIONS = {
|
||||
"mp3",
|
||||
"flac",
|
||||
"wav",
|
||||
"aac",
|
||||
"m4a",
|
||||
"ogg",
|
||||
"wma",
|
||||
"opus",
|
||||
"aiff",
|
||||
"au",
|
||||
"ra",
|
||||
"3gp",
|
||||
"amr",
|
||||
"awb",
|
||||
"dct",
|
||||
"dvf",
|
||||
"m4p",
|
||||
"mmf",
|
||||
"mpc",
|
||||
"msv",
|
||||
"nmf",
|
||||
"nsf",
|
||||
"qcp",
|
||||
"rm",
|
||||
"sln",
|
||||
"vox",
|
||||
"wv",
|
||||
}
|
||||
|
||||
# Maximum file size (100MB)
|
||||
MAX_FILE_SIZE = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def is_allowed_file(filename: str) -> bool:
|
||||
"""Check if file has an allowed audio extension."""
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def is_path_within_root_dirs(filepath: str) -> bool:
|
||||
"""
|
||||
Check if a filepath is within one of the configured root directories.
|
||||
Prevents directory traversal attacks.
|
||||
"""
|
||||
config = UserConfig()
|
||||
resolved_path = Path(filepath).resolve()
|
||||
|
||||
for root_dir in config.rootDirs:
|
||||
if root_dir == "$home":
|
||||
root_path = Path.home().resolve()
|
||||
else:
|
||||
root_path = Path(root_dir).resolve()
|
||||
|
||||
# Check if resolved_path is the root or a child of root
|
||||
if resolved_path == root_path or root_path in resolved_path.parents:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _default_upload_dir(config: UserConfig) -> Path:
|
||||
"""Resolve the default upload directory from user configuration."""
|
||||
if hasattr(config, "uploadDir") and config.uploadDir:
|
||||
return Path(config.uploadDir).expanduser()
|
||||
|
||||
if config.rootDirs:
|
||||
first_root = config.rootDirs[0]
|
||||
if first_root == "$home":
|
||||
return Path.home() / "Music"
|
||||
return Path(first_root).expanduser()
|
||||
|
||||
return Path.home() / "Music"
|
||||
|
||||
|
||||
def resolve_upload_directory(target_dir: str | None = None) -> Path:
|
||||
"""
|
||||
Resolve and validate upload directory.
|
||||
|
||||
If target_dir is provided, it must resolve within configured root directories.
|
||||
"""
|
||||
config = UserConfig()
|
||||
|
||||
if target_dir:
|
||||
target_dir = target_dir.strip()
|
||||
|
||||
if target_dir:
|
||||
if target_dir == "$home":
|
||||
upload_dir = _default_upload_dir(config).resolve()
|
||||
else:
|
||||
upload_dir = Path(target_dir).expanduser().resolve()
|
||||
|
||||
if not is_path_within_root_dirs(str(upload_dir)):
|
||||
raise ValueError(
|
||||
"Target upload directory must be inside configured library folders"
|
||||
)
|
||||
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
return upload_dir
|
||||
|
||||
upload_dir = _default_upload_dir(config).resolve()
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
return upload_dir
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
success: bool = Field(description="Whether the upload was successful")
|
||||
message: str = Field(description="Status message")
|
||||
track_id: str | None = Field(None, description="ID of the added track")
|
||||
filename: str | None = Field(None, description="Name of the uploaded file")
|
||||
|
||||
|
||||
class BatchUploadResponse(BaseModel):
|
||||
success: bool = Field(description="Whether the batch upload was successful")
|
||||
message: str = Field(description="Status message")
|
||||
uploaded_files: list[UploadResponse] = Field(description="List of upload results")
|
||||
failed_files: list[str] = Field(description="List of failed files")
|
||||
|
||||
|
||||
@api.post("/single")
|
||||
@admin_required()
|
||||
def upload_single_file():
|
||||
"""
|
||||
Upload a single music file
|
||||
|
||||
Uploads a single music file to the configured music folder and adds it to the library.
|
||||
Supports drag-and-drop and file selection.
|
||||
"""
|
||||
try:
|
||||
if "file" not in request.files:
|
||||
return jsonify({"success": False, "message": "No file provided"}), 400
|
||||
|
||||
file = request.files["file"]
|
||||
if file.filename == "":
|
||||
return jsonify({"success": False, "message": "No file selected"}), 400
|
||||
|
||||
# Check file extension
|
||||
if not is_allowed_file(file.filename):
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"File type not allowed. Supported formats: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
|
||||
}
|
||||
), 400
|
||||
|
||||
# Check file size
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0)
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"File too large. Maximum size is {MAX_FILE_SIZE // (1024 * 1024)}MB",
|
||||
}
|
||||
), 400
|
||||
|
||||
target_dir = request.form.get("target_dir")
|
||||
try:
|
||||
upload_dir = resolve_upload_directory(target_dir)
|
||||
except ValueError as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 400
|
||||
|
||||
# Secure the filename and create full path
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = upload_dir / filename
|
||||
|
||||
# Handle filename conflicts
|
||||
counter = 1
|
||||
original_filename = filename
|
||||
while file_path.exists():
|
||||
name, ext = os.path.splitext(original_filename)
|
||||
filename = f"{name}_{counter}{ext}"
|
||||
file_path = upload_dir / filename
|
||||
counter += 1
|
||||
|
||||
# Save the file
|
||||
file.save(file_path)
|
||||
|
||||
# Extract metadata and add to library
|
||||
try:
|
||||
# This would trigger a library rescan for the specific file
|
||||
# For now, we'll return the file info and let the frontend handle the refresh
|
||||
track_info = {
|
||||
"filepath": str(file_path),
|
||||
"filename": filename,
|
||||
"size": file_size,
|
||||
}
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"File '{filename}' uploaded successfully",
|
||||
"filename": filename,
|
||||
"filepath": str(file_path),
|
||||
"track_info": track_info,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# If metadata extraction fails, still return success for the upload
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"File '{filename}' uploaded successfully (metadata extraction failed)",
|
||||
"filename": filename,
|
||||
"filepath": str(file_path),
|
||||
"warning": f"Metadata extraction failed: {str(e)}",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": f"Upload failed: {str(e)}"}), 500
|
||||
|
||||
|
||||
@api.post("/batch")
|
||||
@admin_required()
|
||||
def upload_multiple_files():
|
||||
"""
|
||||
Upload multiple music files
|
||||
|
||||
Uploads multiple music files to the configured music folder and adds them to the library.
|
||||
Supports drag-and-drop of multiple files.
|
||||
"""
|
||||
try:
|
||||
if "files" not in request.files:
|
||||
return jsonify({"success": False, "message": "No files provided"}), 400
|
||||
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
return jsonify({"success": False, "message": "No files selected"}), 400
|
||||
|
||||
uploaded_files = []
|
||||
failed_files = []
|
||||
|
||||
target_dir = request.form.get("target_dir")
|
||||
try:
|
||||
upload_dir = resolve_upload_directory(target_dir)
|
||||
except ValueError as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 400
|
||||
|
||||
for file in files:
|
||||
if file.filename == "":
|
||||
continue
|
||||
|
||||
try:
|
||||
# Check file extension
|
||||
if not is_allowed_file(file.filename):
|
||||
failed_files.append(f"{file.filename} - File type not allowed")
|
||||
continue
|
||||
|
||||
# Check file size
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0)
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
failed_files.append(f"{file.filename} - File too large")
|
||||
continue
|
||||
|
||||
# Secure filename and handle conflicts
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = upload_dir / filename
|
||||
|
||||
counter = 1
|
||||
original_filename = filename
|
||||
while file_path.exists():
|
||||
name, ext = os.path.splitext(original_filename)
|
||||
filename = f"{name}_{counter}{ext}"
|
||||
file_path = upload_dir / filename
|
||||
counter += 1
|
||||
|
||||
# Save the file
|
||||
file.save(file_path)
|
||||
|
||||
uploaded_files.append(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"File '{filename}' uploaded successfully",
|
||||
"filename": filename,
|
||||
"filepath": str(file_path),
|
||||
"size": file_size,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
failed_files.append(f"{file.filename} - {str(e)}")
|
||||
|
||||
total_files = len(uploaded_files) + len(failed_files)
|
||||
success_count = len(uploaded_files)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": len(uploaded_files) > 0,
|
||||
"message": f"Uploaded {success_count} of {total_files} files",
|
||||
"uploaded_files": uploaded_files,
|
||||
"failed_files": failed_files,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"success": False, "message": f"Batch upload failed: {str(e)}"}
|
||||
), 500
|
||||
|
||||
|
||||
@api.get("/config")
|
||||
def get_upload_config():
|
||||
"""
|
||||
Get upload configuration
|
||||
|
||||
Returns the current upload configuration including allowed file types,
|
||||
maximum file size, and upload directory.
|
||||
"""
|
||||
upload_dir = str(resolve_upload_directory())
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"allowed_extensions": sorted(ALLOWED_EXTENSIONS),
|
||||
"max_file_size": MAX_FILE_SIZE,
|
||||
"max_file_size_mb": MAX_FILE_SIZE // (1024 * 1024),
|
||||
"upload_directory": upload_dir,
|
||||
"supported_formats": [
|
||||
{"ext": ext, "description": get_format_description(ext)}
|
||||
for ext in sorted(ALLOWED_EXTENSIONS)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_format_description(extension: str) -> str:
|
||||
"""Get a user-friendly description for a file format."""
|
||||
descriptions = {
|
||||
"mp3": "MP3 Audio",
|
||||
"flac": "FLAC Lossless Audio",
|
||||
"wav": "WAV Audio",
|
||||
"aac": "AAC Audio",
|
||||
"m4a": "M4A Audio",
|
||||
"ogg": "OGG Vorbis Audio",
|
||||
"wma": "WMA Audio",
|
||||
"opus": "Opus Audio",
|
||||
"aiff": "AIFF Audio",
|
||||
"au": "AU Audio",
|
||||
"ra": "RealAudio",
|
||||
"3gp": "3GP Audio",
|
||||
"amr": "AMR Audio",
|
||||
"awb": "AWB Audio",
|
||||
"dct": "DCT Audio",
|
||||
"dvf": "DVF Audio",
|
||||
"m4p": "M4P Audio",
|
||||
"mmf": "MMF Audio",
|
||||
"mpc": "MPC Audio",
|
||||
"msv": "MSV Audio",
|
||||
"nmf": "NMF Audio",
|
||||
"nsf": "NSF Audio",
|
||||
"qcp": "QCP Audio",
|
||||
"rm": "RealMedia Audio",
|
||||
"sln": "SLN Audio",
|
||||
"vox": "VOX Audio",
|
||||
"wv": "WavPack Audio",
|
||||
}
|
||||
return descriptions.get(extension.lower(), f"{extension.upper()} Audio")
|
||||
|
||||
|
||||
@api.post("/rescan")
|
||||
@admin_required()
|
||||
def trigger_library_rescan():
|
||||
"""
|
||||
Trigger library rescan
|
||||
|
||||
Triggers a library rescan to detect newly uploaded files.
|
||||
"""
|
||||
try:
|
||||
# This would integrate with the existing library scanning system
|
||||
# For now, return a success response
|
||||
return jsonify(
|
||||
{"success": True, "message": "Library rescan triggered successfully"}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"success": False, "message": f"Failed to trigger library rescan: {str(e)}"}
|
||||
), 500
|
||||
Reference in New Issue
Block a user