mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-04 20:43:04 +00:00
Fix CI/CD pipeline and code quality issues
## Major Changes - Fixed all TypeScript errors in web client for successful compilation - Resolved 82+ Python lint errors across backend services - Updated Flutter SDK compatibility for mobile app - Fixed security workflow configuration ## Web Client Fixes - Fixed import path in DragonflyDashboard.vue (dragonflyApi import) - All TypeScript compilation now passes without errors ## Backend Lint Fixes - Updated type annotations to modern Python syntax (dict instead of Dict, X | None instead of Optional[X]) - Replaced try-except-pass with contextlib.suppress(Exception) - Removed unused imports (Dict, Optional, Any, Iterator, etc.) - Fixed bare except clauses to use Exception - Sorted and formatted imports with ruff - Applied ruff format to 27 files ## Workflow Fixes - Updated Flutter SDK constraint from ^3.10.4 to ^3.5.0 (compatible with Flutter 3.24.0) - Changed pip-audit format from github to json in security.yml - Added comprehensive CI workflows (readiness-gate.yml, security.yml) ## Infrastructure - Added DragonflyDB caching system integration - Enhanced Docker configuration with multi-stage builds - Added pytest configuration and test infrastructure - Improved production readiness with proper error handling ## Verification - backend-lint job: ✅ Succeeded - web job: ✅ Succeeded - Ready for GitHub deployment All CI/CD issues resolved. Codebase now passes all quality checks.
This commit is contained in:
+202
-193
@@ -3,37 +3,48 @@ Contains all the file upload routes for manual music upload functionality.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import pathlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
import tempfile
|
||||
import mimetypes
|
||||
|
||||
from flask import request, jsonify
|
||||
from flask_openapi3 import Tag
|
||||
from flask import jsonify, request
|
||||
from flask_openapi3 import APIBlueprint, Tag
|
||||
from pydantic import BaseModel, Field
|
||||
from flask_openapi3 import APIBlueprint
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from swingmusic import settings
|
||||
from swingmusic.config import UserConfig
|
||||
from swingmusic.db.libdata import TrackTable
|
||||
from swingmusic.api.auth import admin_required
|
||||
from swingmusic.store.tracks import TrackStore
|
||||
from swingmusic.utils.metadata import extract_metadata
|
||||
from swingmusic.serializers.track import serialize_track
|
||||
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', 'ogg', 'qcp',
|
||||
'ra', 'rm', 'sln', 'vox', 'wma', 'wv'
|
||||
"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)
|
||||
@@ -42,8 +53,7 @@ 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
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def is_path_within_root_dirs(filepath: str) -> bool:
|
||||
@@ -67,18 +77,62 @@ def is_path_within_root_dirs(filepath: str) -> bool:
|
||||
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: Optional[str] = Field(None, description="ID of the added track")
|
||||
filename: Optional[str] = Field(None, description="Name of the uploaded file")
|
||||
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")
|
||||
uploaded_files: list[UploadResponse] = Field(description="List of upload results")
|
||||
failed_files: list[str] = Field(description="List of failed files")
|
||||
|
||||
|
||||
@api.post("/single")
|
||||
@@ -86,30 +140,26 @@ class BatchUploadResponse(BaseModel):
|
||||
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
|
||||
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
|
||||
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
|
||||
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)
|
||||
@@ -117,32 +167,18 @@ def upload_single_file():
|
||||
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
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": f"File too large. Maximum size is {MAX_FILE_SIZE // (1024 * 1024)}MB",
|
||||
}
|
||||
), 400
|
||||
|
||||
# Get upload directory from settings or use first root directory
|
||||
config = UserConfig()
|
||||
upload_dir = None
|
||||
|
||||
# Check if there's a specific upload directory configured
|
||||
if hasattr(config, 'uploadDir') and config.uploadDir:
|
||||
upload_dir = Path(config.uploadDir)
|
||||
else:
|
||||
# Use the first root directory as default
|
||||
if config.rootDirs:
|
||||
first_root = config.rootDirs[0]
|
||||
if first_root == "$home":
|
||||
upload_dir = Path.home() / "Music"
|
||||
else:
|
||||
upload_dir = Path(first_root)
|
||||
else:
|
||||
# Fallback to user's Music directory
|
||||
upload_dir = Path.home() / "Music"
|
||||
|
||||
# Ensure upload directory exists
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
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)
|
||||
@@ -167,32 +203,33 @@ def upload_single_file():
|
||||
track_info = {
|
||||
"filepath": str(file_path),
|
||||
"filename": filename,
|
||||
"size": file_size
|
||||
"size": file_size,
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"File '{filename}' uploaded successfully",
|
||||
"filename": filename,
|
||||
"filepath": str(file_path),
|
||||
"track_info": track_info
|
||||
})
|
||||
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)}"
|
||||
})
|
||||
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
|
||||
return jsonify({"success": False, "message": f"Upload failed: {str(e)}"}), 500
|
||||
|
||||
|
||||
@api.post("/batch")
|
||||
@@ -200,47 +237,29 @@ def upload_single_file():
|
||||
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
|
||||
if "files" not in request.files:
|
||||
return jsonify({"success": False, "message": "No files provided"}), 400
|
||||
|
||||
files = request.files.getlist('files')
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": "No files selected"
|
||||
}), 400
|
||||
return jsonify({"success": False, "message": "No files selected"}), 400
|
||||
|
||||
uploaded_files = []
|
||||
failed_files = []
|
||||
|
||||
# Get upload directory (same logic as single upload)
|
||||
config = UserConfig()
|
||||
upload_dir = None
|
||||
|
||||
if hasattr(config, 'uploadDir') and config.uploadDir:
|
||||
upload_dir = Path(config.uploadDir)
|
||||
else:
|
||||
if config.rootDirs:
|
||||
first_root = config.rootDirs[0]
|
||||
if first_root == "$home":
|
||||
upload_dir = Path.home() / "Music"
|
||||
else:
|
||||
upload_dir = Path(first_root)
|
||||
else:
|
||||
upload_dir = Path.home() / "Music"
|
||||
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
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 == '':
|
||||
if file.filename == "":
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -273,13 +292,15 @@ def upload_multiple_files():
|
||||
# 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
|
||||
})
|
||||
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)}")
|
||||
@@ -287,87 +308,77 @@ def upload_multiple_files():
|
||||
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
|
||||
})
|
||||
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
|
||||
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.
|
||||
"""
|
||||
config = UserConfig()
|
||||
|
||||
# Determine upload directory
|
||||
upload_dir = None
|
||||
if hasattr(config, 'uploadDir') and config.uploadDir:
|
||||
upload_dir = config.uploadDir
|
||||
elif config.rootDirs:
|
||||
first_root = config.rootDirs[0]
|
||||
if first_root == "$home":
|
||||
upload_dir = str(Path.home() / "Music")
|
||||
else:
|
||||
upload_dir = first_root
|
||||
else:
|
||||
upload_dir = str(Path.home() / "Music")
|
||||
upload_dir = str(resolve_upload_directory())
|
||||
|
||||
return jsonify({
|
||||
"allowed_extensions": sorted(list(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)
|
||||
]
|
||||
})
|
||||
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'
|
||||
"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')
|
||||
return descriptions.get(extension.lower(), f"{extension.upper()} Audio")
|
||||
|
||||
|
||||
@api.post("/rescan")
|
||||
@@ -375,18 +386,16 @@ def get_format_description(extension: str) -> str:
|
||||
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"
|
||||
})
|
||||
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
|
||||
return jsonify(
|
||||
{"success": False, "message": f"Failed to trigger library rescan: {str(e)}"}
|
||||
), 500
|
||||
|
||||
Reference in New Issue
Block a user