-
diff --git a/scripts/config.sh b/scripts/config.sh
index 06d9a8e..26e3332 100644
--- a/scripts/config.sh
+++ b/scripts/config.sh
@@ -41,11 +41,16 @@ eval "$(yq -r '
"MNT_DIR=\"" + .installation.mount_dir + "\"",
"IMG_CAM_NAME=\"" + .disk_images.cam_name + "\"",
"IMG_LIGHTSHOW_NAME=\"" + .disk_images.lightshow_name + "\"",
+ "IMG_MUSIC_NAME=\"" + (.disk_images.music_name // "usb_music.img") + "\"",
"LABEL1=\"" + .disk_images.cam_label + "\"",
"LABEL2=\"" + .disk_images.lightshow_label + "\"",
+ "LABEL3=\"" + (.disk_images.music_label // "Music") + "\"",
+ "MUSIC_ENABLED=\"" + (.disk_images.music_enabled // true | tostring) + "\"",
+ "MUSIC_FS=\"" + (.disk_images.music_fs // "fat32") + "\"",
"BOOT_FSCK_ENABLED=\"" + (.disk_images.boot_fsck_enabled | tostring) + "\"",
"PART1_SIZE=\"" + .setup.part1_size + "\"",
"PART2_SIZE=\"" + .setup.part2_size + "\"",
+ "PART3_SIZE=\"" + (.setup.part3_size // "") + "\"",
"RESERVE_SIZE=\"" + .setup.reserve_size + "\"",
"SAMBA_PASS=\"" + .network.samba_password + "\"",
"WEB_PORT=\"" + (.network.web_port | tostring) + "\"",
@@ -74,4 +79,5 @@ eval "$(yq -r '
# ============================================================================
IMG_CAM="$GADGET_DIR/$IMG_CAM_NAME"
IMG_LIGHTSHOW="$GADGET_DIR/$IMG_LIGHTSHOW_NAME"
+IMG_MUSIC="$GADGET_DIR/$IMG_MUSIC_NAME"
STATE_FILE="$GADGET_DIR/state.txt"
diff --git a/scripts/edit_usb.sh b/scripts/edit_usb.sh
index 7531b91..b477f53 100644
--- a/scripts/edit_usb.sh
+++ b/scripts/edit_usb.sh
@@ -47,6 +47,10 @@ log_timing "Script start"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/config.sh"
+MUSIC_ENABLED_LC="$(printf '%s' "${MUSIC_ENABLED:-false}" | tr '[:upper:]' '[:lower:]')"
+MUSIC_ENABLED_BOOL=0
+[ "$MUSIC_ENABLED_LC" = "true" ] && MUSIC_ENABLED_BOOL=1
+
# Check for active file operations before proceeding
LOCK_FILE="$GADGET_DIR/.quick_edit_part2.lock"
LOCK_TIMEOUT=30
@@ -171,7 +175,11 @@ if [ -d "$CONFIGFS_GADGET" ]; then
# NOW unmount read-only mounts after gadget is fully disconnected
echo "Unmounting read-only mounts from present mode..."
RO_MNT_DIR="/mnt/gadget"
- for mp in "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro"; do
+ RO_UNMOUNT_TARGETS=("$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro")
+ if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ RO_UNMOUNT_TARGETS+=("$RO_MNT_DIR/part3-ro")
+ fi
+ for mp in "${RO_UNMOUNT_TARGETS[@]}"; do
if mountpoint -q "$mp" 2>/dev/null; then
echo " Unmounting $mp..."
if ! safe_unmount_dir "$mp"; then
@@ -191,7 +199,11 @@ elif lsmod | grep -q '^g_mass_storage'; then
# Unmount any read-only mounts from present mode first
echo "Unmounting read-only mounts from present mode..."
RO_MNT_DIR="/mnt/gadget"
- for mp in "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro"; do
+ LEGACY_RO_TARGETS=("$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro")
+ if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ LEGACY_RO_TARGETS+=("$RO_MNT_DIR/part3-ro")
+ fi
+ for mp in "${LEGACY_RO_TARGETS[@]}"; do
if mountpoint -q "$mp" 2>/dev/null; then
echo " Unmounting $mp..."
if ! safe_unmount_dir "$mp"; then
@@ -229,7 +241,11 @@ fi
# Verify all mounts are released (quick check - already unmounted above)
RO_MNT_DIR="/mnt/gadget"
-for mp in "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro"; do
+VERIFY_RO_TARGETS=("$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro")
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ VERIFY_RO_TARGETS+=("$RO_MNT_DIR/part3-ro")
+fi
+for mp in "${VERIFY_RO_TARGETS[@]}"; do
if sudo nsenter --mount=/proc/1/ns/mnt mountpoint -q "$mp" 2>/dev/null; then
echo " Clearing remaining mount: $mp"
safe_unmount_dir "$mp" || true
@@ -241,7 +257,11 @@ log_timing "Mounts released"
# After clearing LUN files and unmounting, loop devices may still exist
# We must detach them before creating fresh ones to avoid accumulation
echo "Cleaning up existing loop devices..."
-for img in "$IMG_CAM" "$IMG_LIGHTSHOW"; do
+LOOP_IMAGES=("$IMG_CAM" "$IMG_LIGHTSHOW")
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ LOOP_IMAGES+=("$IMG_MUSIC")
+fi
+for img in "${LOOP_IMAGES[@]}"; do
for loop in $(losetup -j "$img" 2>/dev/null | cut -d: -f1); do
if [ -n "$loop" ]; then
echo " Detaching $loop..."
@@ -260,7 +280,12 @@ sudo chown "$TARGET_USER:$TARGET_USER" "$MNT_DIR/part1" "$MNT_DIR/part2"
# Ensure previous mounts are cleared before setting up new loop devices
# This prevents remounting while drives are still in use
-for PART_NUM in 1 2; do
+PART_RANGE=(1 2)
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ PART_RANGE+=(3)
+fi
+
+for PART_NUM in "${PART_RANGE[@]}"; do
MP="$MNT_DIR/part${PART_NUM}"
if mountpoint -q "$MP" 2>/dev/null; then
echo "Unmounting existing mount at $MP"
@@ -314,6 +339,28 @@ if [ -z "$VERIFY" ]; then
fi
echo "Verified: $LOOP_LIGHTSHOW is attached to $IMG_LIGHTSHOW"
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ echo "Setting up loop device for Music..."
+ LOOP_MUSIC=$(create_loop "$IMG_MUSIC")
+ if [ -z "$LOOP_MUSIC" ]; then
+ echo "ERROR: Failed to get/create loop device for $IMG_MUSIC"
+ sudo losetup -d "$LOOP_CAM" 2>/dev/null || true
+ sudo losetup -d "$LOOP_LIGHTSHOW" 2>/dev/null || true
+ exit 1
+ fi
+ echo "Using loop device for Music: $LOOP_MUSIC"
+
+ VERIFY=$(sudo losetup -l | grep "$LOOP_MUSIC" | grep "$IMG_MUSIC" || true)
+ if [ -z "$VERIFY" ]; then
+ echo "ERROR: Loop device $LOOP_MUSIC is not attached to $IMG_MUSIC"
+ sudo losetup -d "$LOOP_CAM" 2>/dev/null || true
+ sudo losetup -d "$LOOP_LIGHTSHOW" 2>/dev/null || true
+ sudo losetup -d "$LOOP_MUSIC" 2>/dev/null || true
+ exit 1
+ fi
+ echo "Verified: $LOOP_MUSIC is attached to $IMG_MUSIC"
+fi
+
sleep 0.5
# Trap to log on failure but NOT detach loop devices (they may be reused/shared)
@@ -333,6 +380,17 @@ trap log_failure_on_exit EXIT
# Mount drives
echo "Mounting drives..."
+# Ensure mount points exist (present mode may remove them)
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ sudo mkdir -p "$MNT_DIR/part1" "$MNT_DIR/part2" "$MNT_DIR/part3"
+else
+ sudo mkdir -p "$MNT_DIR/part1" "$MNT_DIR/part2"
+fi
+sudo chown "$TARGET_USER:$TARGET_USER" "$MNT_DIR/part1" "$MNT_DIR/part2" 2>/dev/null || true
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ sudo chown "$TARGET_USER:$TARGET_USER" "$MNT_DIR/part3" 2>/dev/null || true
+fi
+
# Mount TeslaCam drive (part1) in system mount namespace
MP="$MNT_DIR/part1"
FS_TYPE=$(sudo blkid -o value -s TYPE "$LOOP_CAM" 2>/dev/null || echo "unknown")
@@ -373,11 +431,36 @@ if ! sudo nsenter --mount=/proc/1/ns/mnt mountpoint -q "$MP"; then
fi
echo " Mounted $LOOP_LIGHTSHOW at $MP (filesystem: $FS_TYPE)"
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ echo "Mounting Music drive (part3) in system mount namespace"
+ MP="$MNT_DIR/part3"
+ FS_TYPE=$(sudo blkid -o value -s TYPE "$LOOP_MUSIC" 2>/dev/null || echo "unknown")
+ echo " Mounting $LOOP_MUSIC at $MP..."
+
+ if [ "$FS_TYPE" = "exfat" ]; then
+ sudo nsenter --mount=/proc/1/ns/mnt mount -t exfat -o rw,uid=$UID_VAL,gid=$GID_VAL,umask=000 "$LOOP_MUSIC" "$MP"
+ elif [ "$FS_TYPE" = "vfat" ]; then
+ sudo nsenter --mount=/proc/1/ns/mnt mount -t vfat -o rw,uid=$UID_VAL,gid=$GID_VAL,umask=000 "$LOOP_MUSIC" "$MP"
+ else
+ echo " Warning: Unknown filesystem type '$FS_TYPE', attempting generic mount"
+ sudo nsenter --mount=/proc/1/ns/mnt mount -o rw "$LOOP_MUSIC" "$MP"
+ fi
+
+ if ! sudo nsenter --mount=/proc/1/ns/mnt mountpoint -q "$MP"; then
+ echo "Error: Failed to mount $LOOP_MUSIC at $MP" >&2
+ exit 1
+ fi
+ echo " Mounted $LOOP_MUSIC at $MP (filesystem: $FS_TYPE)"
+fi
+
# Refresh Samba so shares expose the freshly mounted drives
echo "Refreshing Samba shares..."
# Close any cached shares and reload config (faster than full restart)
sudo smbcontrol all close-share gadget_part1 2>/dev/null || true
sudo smbcontrol all close-share gadget_part2 2>/dev/null || true
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ sudo smbcontrol all close-share gadget_part3 2>/dev/null || true
+fi
# If Samba is running, reload config is sufficient; otherwise start it
if systemctl is-active --quiet smbd; then
sudo smbcontrol all reload-config 2>/dev/null || true
@@ -393,6 +476,9 @@ fi
if [ -d "$MNT_DIR/part2" ]; then
echo " Part2 files: $(ls -A "$MNT_DIR/part2" 2>/dev/null | wc -l) items"
fi
+if [ $MUSIC_ENABLED_BOOL -eq 1 ] && [ -d "$MNT_DIR/part3" ]; then
+ echo " Part3 files: $(ls -A "$MNT_DIR/part3" 2>/dev/null | wc -l) items"
+fi
echo "Updating mode state..."
echo "edit" > "$STATE_FILE"
@@ -406,6 +492,10 @@ echo "Drives are now mounted locally and accessible via Samba shares:"
echo " - Part 1: $MNT_DIR/part1"
echo " - Part 2: $MNT_DIR/part2"
echo " - Samba shares: gadget_part1, gadget_part2"
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ echo " - Part 3: $MNT_DIR/part3"
+ echo " - Samba shares: gadget_part3 (music)"
+fi
log_timing "Script completed successfully"
echo "[PERFORMANCE] Total execution time: $(($(date +%s%3N) - SCRIPT_START))ms"
diff --git a/scripts/present_usb.sh b/scripts/present_usb.sh
index 924a9cc..09b04d4 100644
--- a/scripts/present_usb.sh
+++ b/scripts/present_usb.sh
@@ -47,6 +47,10 @@ log_timing "Script start"
source "$SCRIPT_DIR/config.sh"
log_timing "Config loaded"
+MUSIC_ENABLED_LC="$(printf '%s' "${MUSIC_ENABLED:-false}" | tr '[:upper:]' '[:lower:]')"
+MUSIC_ENABLED_BOOL=0
+[ "$MUSIC_ENABLED_LC" = "true" ] && MUSIC_ENABLED_BOOL=1
+
# Check for active file operations before proceeding
LOCK_FILE="$GADGET_DIR/.quick_edit_part2.lock"
LOCK_TIMEOUT=30
@@ -138,7 +142,11 @@ unmount_with_retry() {
# Unmount drives if mounted
log_timing "Starting unmount sequence"
echo "Unmounting drives..."
-for mp in "$MNT_DIR/part1" "$MNT_DIR/part2"; do
+UNMOUNT_TARGETS=("$MNT_DIR/part1" "$MNT_DIR/part2")
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ UNMOUNT_TARGETS+=("$MNT_DIR/part3")
+fi
+for mp in "${UNMOUNT_TARGETS[@]}"; do
# Sync each partition before unmounting
if mountpoint -q "$mp" 2>/dev/null; then
echo " Syncing $mp..."
@@ -155,7 +163,11 @@ log_timing "Drives unmounted"
# Also unmount any existing read-only mounts from previous present mode
echo "Unmounting any existing read-only mounts..."
RO_MNT_DIR="/mnt/gadget"
-for mp in "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro"; do
+RO_UNMOUNT_TARGETS=("$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro")
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ RO_UNMOUNT_TARGETS+=("$RO_MNT_DIR/part3-ro")
+fi
+for mp in "${RO_UNMOUNT_TARGETS[@]}"; do
if mountpoint -q "$mp" 2>/dev/null || sudo nsenter --mount=/proc/1/ns/mnt -- mountpoint -q "$mp" 2>/dev/null; then
echo " Unmounting $mp..."
unmount_with_retry "$mp" || true
@@ -169,7 +181,11 @@ log_timing "Final sync completed"
# Clean up existing loop devices for our images
# After unmounting, detach any lingering loop devices to avoid accumulation
echo "Cleaning up existing loop devices..."
-for img in "$IMG_CAM" "$IMG_LIGHTSHOW"; do
+LOOP_IMAGES=("$IMG_CAM" "$IMG_LIGHTSHOW")
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ LOOP_IMAGES+=("$IMG_MUSIC")
+fi
+for img in "${LOOP_IMAGES[@]}"; do
for loop in $(losetup -j "$img" 2>/dev/null | cut -d: -f1); do
if [ -n "$loop" ]; then
echo " Detaching $loop..."
@@ -187,6 +203,7 @@ log_timing "Loop devices cleaned up"
# These variables will hold loop devices for reuse later in the script
LOOP_CAM=""
LOOP_LIGHTSHOW=""
+LOOP_MUSIC=""
if [ "${BOOT_FSCK_ENABLED:-false}" = "true" ]; then
echo "Running boot-time filesystem check and repair..."
@@ -194,10 +211,16 @@ if [ "${BOOT_FSCK_ENABLED:-false}" = "true" ]; then
# Create loop devices for fsck (will be reused for local mounts too)
LOOP_CAM=$(create_loop "$IMG_CAM")
LOOP_LIGHTSHOW=$(create_loop "$IMG_LIGHTSHOW")
+ if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ LOOP_MUSIC=$(create_loop "$IMG_MUSIC")
+ fi
# Detect filesystem types
FS_TYPE_CAM=$(sudo blkid -o value -s TYPE "$LOOP_CAM" 2>/dev/null || echo "exfat")
FS_TYPE_LIGHTSHOW=$(sudo blkid -o value -s TYPE "$LOOP_LIGHTSHOW" 2>/dev/null || echo "vfat")
+ if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ FS_TYPE_MUSIC=$(sudo blkid -o value -s TYPE "$LOOP_MUSIC" 2>/dev/null || echo "vfat")
+ fi
# Run fsck on TeslaCam (part1)
echo " Checking TeslaCam ($FS_TYPE_CAM)..."
@@ -231,7 +254,24 @@ if [ "${BOOT_FSCK_ENABLED:-false}" = "true" ]; then
fi
fi
- # Note: Loop devices (LOOP_CAM, LOOP_LIGHTSHOW) preserved for later reuse in local mounts
+ if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ echo " Checking Music ($FS_TYPE_MUSIC)..."
+ if [ "$FS_TYPE_MUSIC" = "exfat" ]; then
+ if sudo fsck.exfat -p "$LOOP_MUSIC" 2>&1; then
+ echo " ✓ Music: clean"
+ else
+ echo " ⚠ Music: repaired or has issues"
+ fi
+ else
+ if sudo fsck.vfat -p "$LOOP_MUSIC" 2>&1; then
+ echo " ✓ Music: clean"
+ else
+ echo " ⚠ Music: repaired or has issues"
+ fi
+ fi
+ fi
+
+ # Note: Loop devices (LOOP_CAM, LOOP_LIGHTSHOW, LOOP_MUSIC) preserved for later reuse in local mounts
echo " Loop devices preserved for local mount reuse"
log_timing "Boot fsck completed"
@@ -241,7 +281,11 @@ fi
# Remove mount directories to avoid accidental access when unmounted
echo "Removing mount directories..."
-for mp in "$MNT_DIR/part1" "$MNT_DIR/part2"; do
+REMOVE_TARGETS=("$MNT_DIR/part1" "$MNT_DIR/part2")
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ REMOVE_TARGETS+=("$MNT_DIR/part3")
+fi
+for mp in "${REMOVE_TARGETS[@]}"; do
# Check if mounted in host namespace
if sudo nsenter --mount=/proc/1/ns/mnt -- mountpoint -q "$mp" 2>/dev/null || mountpoint -q "$mp" 2>/dev/null; then
echo " Skipping removal of $mp (still mounted)" >&2
@@ -358,6 +402,15 @@ echo 1 | sudo tee functions/mass_storage.usb0/lun.1/ro > /dev/null # Read-only
echo 0 | sudo tee functions/mass_storage.usb0/lun.1/cdrom > /dev/null
echo "$IMG_LIGHTSHOW" | sudo tee functions/mass_storage.usb0/lun.1/file > /dev/null
+# Configure LUN 2: Music (READ-ONLY to Tesla)
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ sudo mkdir -p functions/mass_storage.usb0/lun.2
+ echo 1 | sudo tee functions/mass_storage.usb0/lun.2/removable > /dev/null
+ echo 1 | sudo tee functions/mass_storage.usb0/lun.2/ro > /dev/null
+ echo 0 | sudo tee functions/mass_storage.usb0/lun.2/cdrom > /dev/null
+ echo "$IMG_MUSIC" | sudo tee functions/mass_storage.usb0/lun.2/file > /dev/null
+fi
+
# Link function to configuration
sudo ln -s functions/mass_storage.usb0 configs/c.1/
@@ -382,7 +435,11 @@ chown "$TARGET_USER:$TARGET_USER" "$STATE_FILE" 2>/dev/null || true
# - Best used when Tesla is not actively recording (e.g., after driving)
echo "Mounting partitions locally in read-only mode..."
RO_MNT_DIR="/mnt/gadget"
-sudo mkdir -p "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro"
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ sudo mkdir -p "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro" "$RO_MNT_DIR/part3-ro"
+else
+ sudo mkdir -p "$RO_MNT_DIR/part1-ro" "$RO_MNT_DIR/part2-ro"
+fi
# Get user IDs for mounting
UID_VAL=$(id -u "$TARGET_USER")
@@ -435,13 +492,49 @@ if [ -n "$LOOP_LIGHTSHOW" ] && [ -e "$LOOP_LIGHTSHOW" ]; then
else
echo " Warning: Unable to attach loop device for Lightshow read-only mounting"
fi
+
+# Mount Music image (part3) when enabled - reuse fsck loop device if available, otherwise create
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ if [ -z "$LOOP_MUSIC" ] || [ ! -e "$LOOP_MUSIC" ]; then
+ LOOP_MUSIC=$(create_loop "$IMG_MUSIC")
+ fi
+
+ if [ -n "$LOOP_MUSIC" ] && [ -e "$LOOP_MUSIC" ]; then
+ FS_TYPE=$(sudo blkid -o value -s TYPE "$LOOP_MUSIC" 2>/dev/null || echo "vfat")
+
+ echo " Mounting ${LOOP_MUSIC} (Music) at $RO_MNT_DIR/part3-ro (read-only)..."
+
+ if [ "$FS_TYPE" = "vfat" ]; then
+ sudo nsenter --mount=/proc/1/ns/mnt mount -t vfat -o ro,uid=$UID_VAL,gid=$GID_VAL,umask=022 "$LOOP_MUSIC" "$RO_MNT_DIR/part3-ro"
+ elif [ "$FS_TYPE" = "exfat" ]; then
+ sudo nsenter --mount=/proc/1/ns/mnt mount -t exfat -o ro,uid=$UID_VAL,gid=$GID_VAL,umask=022 "$LOOP_MUSIC" "$RO_MNT_DIR/part3-ro"
+ else
+ sudo nsenter --mount=/proc/1/ns/mnt mount -o ro "$LOOP_MUSIC" "$RO_MNT_DIR/part3-ro"
+ fi
+
+ echo " Mounted successfully at $RO_MNT_DIR/part3-ro"
+ else
+ echo " Warning: Unable to attach loop device for Music read-only mounting"
+ fi
+fi
log_timing "USB gadget fully configured and mounted"
echo "USB gadget presented successfully!"
-echo "The Pi should now appear as TWO USB storage devices when connected:"
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ echo "The Pi should now appear as THREE USB storage devices when connected:"
+else
+ echo "The Pi should now appear as TWO USB storage devices when connected:"
+fi
echo " - LUN 0: TeslaCam (Read-Write) - Tesla can record dashcam footage"
echo " - LUN 1: Lightshow (Read-Only) - Optimized read performance for Tesla"
-echo "Read-only mounts available at: $RO_MNT_DIR/part1-ro and $RO_MNT_DIR/part2-ro"
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ echo " - LUN 2: Music (Read-Only) - Media files for Tesla audio"
+fi
+if [ $MUSIC_ENABLED_BOOL -eq 1 ]; then
+ echo "Read-only mounts available at: $RO_MNT_DIR/part1-ro, $RO_MNT_DIR/part2-ro, $RO_MNT_DIR/part3-ro"
+else
+ echo "Read-only mounts available at: $RO_MNT_DIR/part1-ro and $RO_MNT_DIR/part2-ro"
+fi
log_timing "Script completed successfully"
echo "[PERFORMANCE] Total execution time: $(($(date +%s%3N) - SCRIPT_START))ms"
diff --git a/scripts/web/blueprints/__init__.py b/scripts/web/blueprints/__init__.py
index 32cb9a4..4b2a630 100644
--- a/scripts/web/blueprints/__init__.py
+++ b/scripts/web/blueprints/__init__.py
@@ -9,6 +9,7 @@
from .cleanup import cleanup_bp
from .api import api_bp
from .fsck import fsck_bp
+from .music import music_bp
from .captive_portal import captive_portal_bp, catch_all_redirect
__all__ = [
@@ -21,6 +22,7 @@
'cleanup_bp',
'api_bp',
'fsck_bp',
+ 'music_bp',
'captive_portal_bp',
'catch_all_redirect'
]
diff --git a/scripts/web/blueprints/music.py b/scripts/web/blueprints/music.py
new file mode 100644
index 0000000..76f5945
--- /dev/null
+++ b/scripts/web/blueprints/music.py
@@ -0,0 +1,231 @@
+"""Blueprint for music library management."""
+
+import logging
+from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
+
+from utils import get_base_context, format_file_size
+from services.music_service import (
+ list_music_files,
+ save_file,
+ handle_chunk,
+ delete_music_file,
+ delete_directory,
+ create_directory,
+ move_music_file,
+ UploadError,
+ generate_upload_id,
+ require_edit_mode,
+)
+
+music_bp = Blueprint("music", __name__, url_prefix="/music")
+logger = logging.getLogger(__name__)
+
+
+@music_bp.route("/")
+def music_home():
+ ctx = get_base_context()
+ current_path = request.args.get("path", "")
+ dirs, files, error, used_bytes, free_bytes, current_path, total_bytes = list_music_files(current_path)
+ return render_template(
+ "music.html",
+ page="music",
+ **ctx,
+ dirs=dirs,
+ files=files,
+ error=error,
+ used_bytes=used_bytes,
+ free_bytes=free_bytes,
+ total_bytes=total_bytes,
+ current_path=current_path,
+ format_file_size=format_file_size,
+ max_upload_size_mb=current_app.config.get("MAX_CONTENT_LENGTH", 0) // (1024 * 1024),
+ max_upload_chunk_mb=current_app.config.get("MAX_FORM_MEMORY_SIZE", 0) // (1024 * 1024),
+ )
+
+
+@music_bp.route("/upload", methods=["POST"])
+def upload_music():
+ is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
+ current_path = request.args.get("path") or request.form.get("path") or ""
+ try:
+ require_edit_mode()
+ except UploadError as exc:
+ if is_ajax:
+ return jsonify({"success": False, "error": str(exc)}), 400
+ flash(str(exc), "error")
+ return redirect(url_for("music.music_home"))
+
+ files = request.files.getlist("music_files")
+ if not files:
+ if is_ajax:
+ return jsonify({"success": False, "error": "No files selected"}), 400
+ flash("No files selected", "error")
+ return redirect(url_for("music.music_home"))
+
+ successes = 0
+ messages = []
+ for file in files:
+ if not file or not file.filename:
+ continue
+ ok, msg = save_file(file, current_path)
+ if ok:
+ successes += 1
+ messages.append(msg)
+
+ if is_ajax:
+ status = 200 if successes else 400
+ return jsonify({
+ "success": successes > 0,
+ "messages": messages,
+ "uploaded": successes,
+ }), status
+
+ if successes:
+ flash(f"Uploaded {successes} file(s)", "success")
+ else:
+ flash("Failed to upload files", "error")
+ return redirect(url_for("music.music_home", path=current_path, _=request.args.get('_', 0)))
+
+
+@music_bp.route("/upload_chunk", methods=["POST"])
+def upload_chunk():
+ is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
+ current_path = request.args.get("path") or request.form.get("path") or ""
+ try:
+ require_edit_mode()
+ except UploadError as exc:
+ if is_ajax:
+ return jsonify({"success": False, "error": str(exc)}), 400
+ flash(str(exc), "error")
+ return redirect(url_for("music.music_home"))
+
+ try:
+ upload_id = request.args.get("upload_id") or request.form.get("upload_id") or generate_upload_id()
+ filename = request.args.get("filename") or request.form.get("filename")
+ chunk_index = int(request.args.get("chunk_index") or request.form.get("chunk_index") or 0)
+ total_chunks = int(request.args.get("total_chunks") or request.form.get("total_chunks") or 1)
+ total_size = int(request.args.get("total_size") or request.form.get("total_size") or request.headers.get("X-File-Size") or 0)
+ except (TypeError, ValueError):
+ return jsonify({"success": False, "error": "Invalid chunk metadata"}), 400
+
+ if not filename:
+ return jsonify({"success": False, "error": "Missing filename"}), 400
+ if total_size <= 0:
+ return jsonify({"success": False, "error": "Missing file size"}), 400
+
+ try:
+ success, message, finalized = handle_chunk(
+ upload_id=upload_id,
+ filename=filename,
+ chunk_index=chunk_index,
+ total_chunks=total_chunks,
+ total_size=total_size,
+ stream=request.stream,
+ rel_path=current_path,
+ )
+ except UploadError as exc:
+ logger.warning("Chunk upload rejected: %s", exc)
+ return jsonify({"success": False, "error": str(exc)}), 400
+ except Exception as exc: # pylint: disable=broad-except
+ logger.error("Chunk upload failed: %s", exc, exc_info=True)
+ return jsonify({"success": False, "error": "Server error"}), 500
+
+ return jsonify({"success": success, "message": message, "finalized": finalized})
+
+
+@music_bp.route("/delete//Music folder. This page always reads and
+ uploads inside that folder; paths shown below are relative to /Music.| Folders | +Actions | +
|---|---|
| ⬆️ Up one level | +|
| 📁 {{ d.name }} | ++ + + | +
| Name | +Size | +Actions | +
|---|---|---|
| {{ file.name }} | +{{ format_file_size(file.size) }} | ++ + + | +
No music files found on the drive.
+ {% endif %} +Uploads require Edit mode. Files larger than {{ max_upload_chunk_mb }} MiB are sent in chunks + to keep memory low. You can drop whole folders; we'll preserve subfolder structure. Target folder: + /{{ current_path }} +
+