diff --git a/config.yaml b/config.yaml index f25b71e..321ae44 100644 --- a/config.yaml +++ b/config.yaml @@ -9,19 +9,23 @@ # Installation & Paths # ============================================================================ installation: - gadget_dir: /home/pi/TeslaUSB # Installation directory where TeslaUSB is installed - target_user: pi # Linux user running the TeslaUSB services - mount_dir: /mnt/gadget # Mount directory for USB drives + gadget_dir: /home/pi/TeslaUSB # Installation directory where TeslaUSB is installed + target_user: pi # Linux user running the TeslaUSB services + mount_dir: /mnt/gadget # Mount directory for USB drives # ============================================================================ # Disk Images # ============================================================================ disk_images: - cam_name: usb_cam.img # TeslaCam disk image filename + cam_name: usb_cam.img # TeslaCam disk image filename lightshow_name: usb_lightshow.img # LightShow disk image filename - cam_label: TeslaCam # Filesystem label for TeslaCam drive - lightshow_label: Lightshow # Filesystem label for LightShow drive - boot_fsck_enabled: true # Auto-repair filesystems on boot (recommended) + cam_label: TeslaCam # Filesystem label for TeslaCam drive + lightshow_label: Lightshow # Filesystem label for LightShow drive + music_name: usb_music.img # Music disk image filename (optional third LUN) + music_label: Music # Filesystem label for Music drive (Tesla expects FAT32) + music_enabled: true # Create and present music partition (LUN2) + music_fs: fat32 # Filesystem for music image (fat32 recommended for Tesla) + boot_fsck_enabled: true # Auto-repair filesystems on boot (recommended) # ============================================================================ # Setup Configuration (used only by setup_usb.sh) @@ -29,57 +33,60 @@ disk_images: # These values are specific to the setup process and can be configured here # or left empty ("") for interactive prompts during setup. setup: - part1_size: "" # TeslaCam drive size (e.g., "50G" or leave empty for interactive) - part2_size: "" # LightShow drive size (e.g., "10G" or leave empty for interactive) - reserve_size: "" # Headroom to leave free on Pi filesystem (default: 5G) + part1_size: "" # TeslaCam drive size (e.g., "50G" or leave empty for interactive) + part2_size: "" # LightShow drive size (e.g., "10G" or leave empty for interactive) + part3_size: "" # Music drive size (e.g., "32G" or leave empty to skip/interactive) + reserve_size: "" # Headroom to leave free on Pi filesystem (default: 5G) # ============================================================================ # Network & Security # ============================================================================ network: - samba_password: tesla # Samba password for authenticated user (CHANGE THIS!) - web_port: 80 # Web interface port (80 required for captive portal - do not change) + samba_password: tesla # Samba password for authenticated user (CHANGE THIS!) + web_port: 80 # Web interface port (80 required for captive portal - do not change) # ============================================================================ # Offline Access Point Configuration # ============================================================================ # Fallback WiFi access point for in-car/mobile access when STA WiFi is unavailable offline_ap: - enabled: true # Set to false to disable fallback AP - interface: wlan0 # WiFi interface used for AP/STA - ssid: TeslaUSB # SSID broadcast when AP is active (CHANGE THIS!) - passphrase: teslausb1234 # WPA2 passphrase 8-63 chars (CHANGE THIS!) - channel: 6 # 2.4GHz channel (1-11) - ipv4_cidr: 192.168.4.1/24 # Static IP for AP interface - dhcp_start: 192.168.4.10 # DHCP range start - dhcp_end: 192.168.4.50 # DHCP range end - check_interval: 20 # Seconds between health checks - disconnect_grace: 30 # Seconds offline before starting AP - min_rssi: -70 # Minimum RSSI (dBm) to tear down AP - stable_seconds: 20 # Seconds of good link before stopping AP - ping_target: 8.8.8.8 # Ping target to confirm WAN reachability - retry_seconds: 300 # While AP is active, retry STA join every N seconds - virtual_interface: uap0 # Virtual AP interface name (concurrent mode always enabled) - force_mode: auto # Persistent force mode: auto, force_on, force_off + enabled: true # Set to false to disable fallback AP + interface: wlan0 # WiFi interface used for AP/STA + ssid: TeslaUSB # SSID broadcast when AP is active (CHANGE THIS!) + passphrase: teslausb1234 # WPA2 passphrase 8-63 chars (CHANGE THIS!) + channel: 6 # 2.4GHz channel (1-11) + ipv4_cidr: 192.168.4.1/24 # Static IP for AP interface + dhcp_start: 192.168.4.10 # DHCP range start + dhcp_end: 192.168.4.50 # DHCP range end + check_interval: 20 # Seconds between health checks + disconnect_grace: 30 # Seconds offline before starting AP + min_rssi: -70 # Minimum RSSI (dBm) to tear down AP + stable_seconds: 20 # Seconds of good link before stopping AP + ping_target: 8.8.8.8 # Ping target to confirm WAN reachability + retry_seconds: 300 # While AP is active, retry STA join every N seconds + virtual_interface: uap0 # Virtual AP interface name (concurrent mode always enabled) + force_mode: auto # Persistent force mode: auto, force_on, force_off # ============================================================================ # System Configuration File Paths # ============================================================================ system: - config_file: /boot/firmware/config.txt # Raspberry Pi boot configuration - samba_conf: /etc/samba/smb.conf # Samba configuration file + config_file: /boot/firmware/config.txt # Raspberry Pi boot configuration + samba_conf: /etc/samba/smb.conf # Samba configuration file # ============================================================================ # Web Application Configuration # ============================================================================ web: - secret_key: CHANGE-THIS-TO-A-RANDOM-SECRET-KEY-ON-FIRST-INSTALL # Flask secret key (auto-generated if default) - max_lock_chime_size: 1048576 # 1 MiB (1024 * 1024 bytes) - max_lock_chime_duration: 10.0 # 10 seconds (configurable per Tesla model) - min_lock_chime_duration: 0.3 # 300ms minimum - speed_range_min: 0.5 # Half speed (audio trimmer) - speed_range_max: 2.0 # Double speed (audio trimmer) - speed_step: 0.05 # Fine-grained control (audio trimmer) - lock_chime_filename: LockChime.wav # Active lock chime filename - chimes_folder: Chimes # Folder on part2 where custom chimes are stored - lightshow_folder: LightShow # Folder on part2 where light shows are stored + secret_key: CHANGE-THIS-TO-A-RANDOM-SECRET-KEY-ON-FIRST-INSTALL # Flask secret key (auto-generated if default) + max_lock_chime_size: 1048576 # 1 MiB (1024 * 1024 bytes) + max_lock_chime_duration: 10.0 # 10 seconds (configurable per Tesla model) + min_lock_chime_duration: 0.3 # 300ms minimum + speed_range_min: 0.5 # Half speed (audio trimmer) + speed_range_max: 2.0 # Double speed (audio trimmer) + speed_step: 0.05 # Fine-grained control (audio trimmer) + lock_chime_filename: LockChime.wav # Active lock chime filename + chimes_folder: Chimes # Folder on part2 where custom chimes are stored + lightshow_folder: LightShow # Folder on part2 where light shows are stored + max_upload_size_mb: 2048 # Max accepted upload size for music/lightshow (MiB) + max_upload_chunk_mb: 16 # Chunk size for streaming uploads (MiB) diff --git a/readme.md b/readme.md index b61f68f..4bb0307 100644 --- a/readme.md +++ b/readme.md @@ -245,6 +245,13 @@ When WiFi is unavailable, the Pi automatically creates a fallback access point: - Download all camera views as zip file - Delete entire events (Edit mode only) - deletes all camera views for the session +**Music Tab**: +- Tesla scans music only from a root-level `Music` folder; the app enforces this and automatically creates it if missing +- Browse folders with breadcrumb navigation and clean per-folder views +- Drag-and-drop or select files and whole folders; chunked uploads keep memory low and preserve subfolder structure +- Per-file progress and status indicators with size limit validation +- Create folders, move files, and delete files or entire folders (Edit mode) +- Usage gauge shows used/free space for the music partition **Lock Chimes Tab**: - Upload WAV/MP3 files (auto-converted to Tesla format) @@ -413,7 +420,7 @@ sudo systemctl disable present_usb_on_boot.service The hardware watchdog automatically reboots the Pi if the system becomes unresponsive. The default configuration is intentionally simple and reliable: -``` +```bash watchdog-device = /dev/watchdog watchdog-timeout = 60 max-load-1 = 24 @@ -532,4 +539,3 @@ All screenshots shown in dark mode. Lock Chimes Management Lock Chime Audio Editor with Waveform Light Shows Management - 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/", methods=["POST"]) +def delete_music(filename): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + 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: + ok, msg = delete_music_file(filename) + except UploadError as exc: + ok, msg = False, str(exc) + + if is_ajax: + status = 200 if ok else 400 + return jsonify({"success": ok, "message": msg}), status + + flash(msg, "success" if ok else "error") + return redirect(url_for("music.music_home", path=request.args.get("path", ""), _=request.args.get('_', 0))) + + +@music_bp.route("/delete_dir/", methods=["POST"]) +def delete_music_dir(dirname): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + 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: + ok, msg = delete_directory(dirname) + except UploadError as exc: + ok, msg = False, str(exc) + + if is_ajax: + status = 200 if ok else 400 + return jsonify({"success": ok, "message": msg}), status + + flash(msg, "success" if ok else "error") + parent = dirname.rsplit("/", 1)[0] if "/" in dirname else "" + return redirect(url_for("music.music_home", path=parent, _=request.args.get('_', 0))) + + +@music_bp.route("/move", methods=["POST"]) +def move_music(): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + 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")) + + data = request.get_json(silent=True) or request.form + source = data.get("source", "") if data else "" + dest_path = data.get("dest_path", "") if data else "" + new_name = data.get("new_name", "") if data else "" + + ok, msg = move_music_file(source, dest_path, new_name) + if is_ajax: + status = 200 if ok else 400 + return jsonify({"success": ok, "message": msg}), status + + flash(msg, "success" if ok else "error") + return redirect(url_for("music.music_home", path=dest_path)) + + +@music_bp.route("/mkdir", methods=["POST"]) +def create_music_folder(): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + 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")) + + data = request.get_json(silent=True) or request.form + current_path = data.get("path", "") if data else "" + name = data.get("name", "") if data else "" + + ok, msg = create_directory(current_path, name) + if is_ajax: + status = 200 if ok else 400 + return jsonify({"success": ok, "message": msg}), status + + flash(msg, "success" if ok else "error") + return redirect(url_for("music.music_home", path=current_path)) diff --git a/scripts/web/config.py b/scripts/web/config.py index 9d35e8c..eed3c11 100644 --- a/scripts/web/config.py +++ b/scripts/web/config.py @@ -36,6 +36,10 @@ # Disk Images IMG_CAM_NAME = config['disk_images']['cam_name'] IMG_LIGHTSHOW_NAME = config['disk_images']['lightshow_name'] +IMG_MUSIC_NAME = config['disk_images'].get('music_name', 'usb_music.img') +MUSIC_ENABLED = bool(config['disk_images'].get('music_enabled', False)) +MUSIC_FS = config['disk_images'].get('music_fs', 'fat32') +MUSIC_LABEL = config['disk_images'].get('music_label', 'Music') # Network & Security WEB_PORT = config['network']['web_port'] @@ -63,6 +67,8 @@ # Light Show Configuration LIGHT_SHOW_FOLDER = config['web']['lightshow_folder'] +MAX_UPLOAD_SIZE_MB = int(config['web'].get('max_upload_size_mb', 2048)) +MAX_UPLOAD_CHUNK_MB = int(config['web'].get('max_upload_chunk_mb', 16)) # ============================================================================ # ADVANCED SETTINGS - Computed values (don't modify these) @@ -75,8 +81,16 @@ STATE_FILE = os.path.join(GADGET_DIR, "state.txt") # USB drive configuration -USB_PARTITIONS = ("part1", "part2") -PART_LABEL_MAP = {"part1": "gadget_part1", "part2": "gadget_part2"} +USB_PARTITIONS = tuple( + part for part in ("part1", "part2", "part3") + if part != "part3" or MUSIC_ENABLED +) + +PART_LABEL_MAP = { + "part1": "gadget_part1", + "part2": "gadget_part2", + **({"part3": "gadget_part3"} if MUSIC_ENABLED else {}), +} # Thumbnail configuration THUMBNAIL_CACHE_DIR = os.path.join(GADGET_DIR, "thumbnails") diff --git a/scripts/web/requirements.txt b/scripts/web/requirements.txt new file mode 100644 index 0000000..6eb9eef --- /dev/null +++ b/scripts/web/requirements.txt @@ -0,0 +1,6 @@ +flask +waitress +pyyaml +werkzeug +av +pillow diff --git a/scripts/web/services/mode_service.py b/scripts/web/services/mode_service.py index 02da039..ef2c2f4 100644 --- a/scripts/web/services/mode_service.py +++ b/scripts/web/services/mode_service.py @@ -21,6 +21,7 @@ MNT_DIR, USB_PARTITIONS, MODE_DISPLAY, + PART_LABEL_MAP, ) logger = logging.getLogger(__name__) @@ -97,10 +98,9 @@ def mode_display(): if token == "edit": hostname = socket.gethostname() - share_paths = [ - f"\\\\{hostname}\\gadget_part1", - f"\\\\{hostname}\\gadget_part2", - ] + for part in USB_PARTITIONS: + share_name = PART_LABEL_MAP.get(part, f"gadget_{part}") + share_paths.append(f"\\\\{hostname}\\{share_name}") return token, label, css_class, share_paths diff --git a/scripts/web/services/music_service.py b/scripts/web/services/music_service.py new file mode 100644 index 0000000..0a5ea4c --- /dev/null +++ b/scripts/web/services/music_service.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +"""Music upload and management helpers.""" + +import os +import uuid +import shutil +import logging +from typing import Tuple, List + +from werkzeug.utils import secure_filename + +from config import MAX_UPLOAD_CHUNK_MB, MAX_UPLOAD_SIZE_MB +from services.partition_service import get_mount_path +from services.samba_service import close_samba_share +from services.mode_service import current_mode + +logger = logging.getLogger(__name__) + +# Allow common Tesla-friendly audio formats +ALLOWED_EXTS = {".mp3", ".flac", ".wav", ".aac", ".m4a"} +CHUNK_SIZE = MAX_UPLOAD_CHUNK_MB * 1024 * 1024 +MAX_UPLOAD_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024 + + +class UploadError(Exception): + """Raised for user-facing upload errors.""" + + +def _fs_free_bytes(path: str) -> int: + """Return available bytes for the filesystem containing path.""" + stat = os.statvfs(path) + return stat.f_bavail * stat.f_frsize + + +def _fsync_path(path: str) -> None: + """fsync a file path safely.""" + with open(path, "rb") as fh: + os.fsync(fh.fileno()) + + +def _fsync_dir(path: str) -> None: + """fsync a directory to persist renames.""" + fd = os.open(path, os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _stream_to_file(stream, dest_path: str) -> int: + """Stream request data to dest_path without loading into memory.""" + total = 0 + with open(dest_path, "ab", buffering=0) as fh: + while True: + chunk = stream.read(CHUNK_SIZE) + if not chunk: + break + total += len(chunk) + fh.write(chunk) + return total + + +def _normalize_rel_path(rel_path: str) -> str: + rel = (rel_path or "").strip("/") + if not rel: + return "" + cleaned: List[str] = [] + for segment in rel.split("/"): + segment = segment.strip() + if segment in {"", ".", ".."}: + continue + safe_seg = secure_filename(segment) + if not safe_seg: + raise UploadError("Invalid folder name") + cleaned.append(safe_seg) + return "/".join(cleaned) + + +def _resolve_subpath(mount_path: str, rel_path: str) -> str: + """Return an absolute path within the music mount for the given relative path.""" + rel = _normalize_rel_path(rel_path) + if not rel: + return mount_path + target = os.path.join(mount_path, rel) + common = os.path.commonpath([mount_path, target]) + if common != os.path.abspath(mount_path): + raise UploadError("Invalid path") + return target + + +def _ensure_music_mount() -> Tuple[str, str]: + """Return the music mount path or an error string.""" + mount_path = get_mount_path("part3") + if not mount_path: + return "", "Music drive not mounted. Switch to Edit mode and try again." + if not os.path.isdir(mount_path): + return "", "Music drive is unavailable." + return mount_path, "" + + +def _get_music_root(mount_path: str) -> Tuple[str, str]: + """Ensure the Tesla-required Music folder exists and return its path.""" + music_root = os.path.join(mount_path, "Music") + try: + os.makedirs(music_root, exist_ok=True) + except OSError as exc: + logger.error("Unable to create Music folder: %s", exc) + return "", "Unable to access Music folder" + return music_root, "" + + +def _validate_filename(name: str) -> str: + safe = secure_filename(name) + if not safe: + raise UploadError("Invalid filename") + ext = os.path.splitext(safe)[1].lower() + if ext not in ALLOWED_EXTS: + raise UploadError("Unsupported file type. Allowed: mp3, flac, wav, aac, m4a") + return safe + + +def list_music_files(rel_path: str = ""): + mount_path, err = _ensure_music_mount() + if err: + return [], [], err, 0, 0, "", 0 + + music_root, err = _get_music_root(mount_path) + if err: + return [], [], err, 0, 0, "", 0 + + try: + current_rel = _normalize_rel_path(rel_path) + except UploadError as exc: + return [], [], str(exc), 0, 0, "", 0 + + try: + stat = os.statvfs(mount_path) + total_bytes = stat.f_blocks * stat.f_frsize + free_bytes = stat.f_bavail * stat.f_frsize + used_bytes = max(0, total_bytes - (stat.f_bfree * stat.f_frsize)) + except OSError as exc: + logger.warning("Could not stat music mount: %s", exc) + return [], [], "Music drive unavailable", 0, 0, current_rel, 0 + + target_dir = _resolve_subpath(music_root, current_rel) + if not os.path.isdir(target_dir): + return [], [], "Folder not found", used_bytes, free_bytes, current_rel, total_bytes + + dirs = [] + music_files = [] + total_size = 0 + try: + for entry in os.scandir(target_dir): + if entry.name.startswith('.'): + # Skip temp/upload internals and hidden items + continue + if entry.is_dir(): + dirs.append({ + "name": entry.name, + "path": f"{current_rel + '/' if current_rel else ''}{entry.name}", + }) + continue + if not entry.is_file(): + continue + ext = os.path.splitext(entry.name)[1].lower() + if ext not in ALLOWED_EXTS: + continue + stat = entry.stat() + rel_file = f"{current_rel + '/' if current_rel else ''}{entry.name}" + music_files.append({ + "name": entry.name, + "path": rel_file, + "size": stat.st_size, + "mtime": int(stat.st_mtime), + }) + total_size += stat.st_size + except OSError as e: + logger.warning("Could not read music directory: %s", e) + return [], [], "Unable to read music directory", used_bytes, free_bytes, current_rel, total_bytes + + dirs.sort(key=lambda x: x["name"].lower()) + music_files.sort(key=lambda x: x["name"].lower()) + return dirs, music_files, "", used_bytes, free_bytes, current_rel, total_bytes + + +def _prepare_paths(filename: str, music_dir: str): + tmp_dir = os.path.join(music_dir, ".uploads") + os.makedirs(tmp_dir, exist_ok=True) + tmp_path = os.path.join(tmp_dir, f"{filename}.upload") + final_path = os.path.join(music_dir, filename) + return tmp_dir, tmp_path, final_path + + +def save_file(file_storage, rel_path: str = "") -> Tuple[bool, str]: + """Stream a Werkzeug FileStorage to the music partition with fsync + atomic rename.""" + mount_path, err = _ensure_music_mount() + if err: + return False, err + + music_root, err = _get_music_root(mount_path) + if err: + return False, err + + target_dir = _resolve_subpath(music_root, rel_path) + if not os.path.isdir(target_dir): + try: + os.makedirs(target_dir, exist_ok=True) + except OSError: + return False, "Target folder unavailable" + + filename = _validate_filename(file_storage.filename) + tmp_dir, tmp_path, final_path = _prepare_paths(filename, target_dir) + + # Free space check + file_storage.stream.seek(0, os.SEEK_END) + incoming_size = file_storage.stream.tell() + file_storage.stream.seek(0) + if incoming_size > MAX_UPLOAD_BYTES: + return False, f"File too large (>{MAX_UPLOAD_SIZE_MB} MiB limit)" + + free_bytes = _fs_free_bytes(mount_path) + if free_bytes <= incoming_size + (4 * 1024 * 1024): + return False, "Not enough free space on Music drive" + + # Stream to temp then atomically move + if os.path.exists(tmp_path): + os.remove(tmp_path) + + _stream_to_file(file_storage.stream, tmp_path) + _fsync_path(tmp_path) + _fsync_dir(tmp_dir) + + os.replace(tmp_path, final_path) + _fsync_dir(target_dir) + try: + close_samba_share("part3") + except Exception: + pass + return True, f"Uploaded {filename}" + + +def handle_chunk(upload_id: str, filename: str, chunk_index: int, total_chunks: int, total_size: int, stream, rel_path: str = "") -> Tuple[bool, str, bool]: + """ + Append a chunk to the staged upload file. + + Returns (success, message, is_finalized) + """ + if total_size > MAX_UPLOAD_BYTES: + raise UploadError(f"File too large (>{MAX_UPLOAD_SIZE_MB} MiB limit)") + + mount_path, err = _ensure_music_mount() + if err: + return False, err, False + + music_root, err = _get_music_root(mount_path) + if err: + return False, err, False + + target_dir = _resolve_subpath(music_root, rel_path) + os.makedirs(target_dir, exist_ok=True) + + filename = _validate_filename(filename) + tmp_dir = os.path.join(target_dir, ".uploads") + os.makedirs(tmp_dir, exist_ok=True) + staged_path = os.path.join(tmp_dir, f"{upload_id}.part") + final_path = os.path.join(target_dir, filename) + + # On first chunk, ensure space and clear any stale parts + if chunk_index == 0: + if os.path.exists(staged_path): + os.remove(staged_path) + free_bytes = _fs_free_bytes(mount_path) + if free_bytes <= total_size + (4 * 1024 * 1024): + raise UploadError("Not enough free space on Music drive") + + written = _stream_to_file(stream, staged_path) + logger.debug("Chunk %s/%s wrote %s bytes", chunk_index + 1, total_chunks, written) + + if chunk_index < total_chunks - 1: + return True, "Chunk stored", False + + # Final chunk: validate size then atomically move + actual_size = os.path.getsize(staged_path) + if actual_size != total_size: + raise UploadError(f"Size mismatch. Expected {total_size} bytes, got {actual_size}") + + _fsync_path(staged_path) + _fsync_dir(tmp_dir) + os.replace(staged_path, final_path) + _fsync_dir(target_dir) + + try: + close_samba_share("part3") + except Exception: + pass + + return True, f"Uploaded {filename}", True + + +def delete_music_file(rel_path: str) -> Tuple[bool, str]: + mount_path, err = _ensure_music_mount() + if err: + return False, err + + music_root, err = _get_music_root(mount_path) + if err: + return False, err + + target_path = _resolve_subpath(music_root, rel_path) + filename = os.path.basename(target_path) + if not os.path.isfile(target_path): + return False, "File not found" + + try: + os.remove(target_path) + _fsync_dir(os.path.dirname(target_path)) + close_samba_share("part3") + except Exception as exc: + logger.error("Failed to delete %s: %s", filename, exc) + return False, "Unable to delete file" + return True, f"Deleted {filename}" + + +def create_directory(rel_path: str, name: str) -> Tuple[bool, str]: + mount_path, err = _ensure_music_mount() + if err: + return False, err + + music_root, err = _get_music_root(mount_path) + if err: + return False, err + + base_dir = _resolve_subpath(music_root, rel_path) + safe_name = secure_filename(name or "") + if not safe_name: + return False, "Invalid folder name" + + target_dir = os.path.join(base_dir, safe_name) + + try: + os.makedirs(target_dir, exist_ok=False) + _fsync_dir(base_dir) + except FileExistsError: + return False, "Folder already exists" + except Exception: + return False, "Could not create folder" + return True, f"Created folder {safe_name}" + + +def delete_directory(rel_path: str) -> Tuple[bool, str]: + mount_path, err = _ensure_music_mount() + if err: + return False, err + + music_root, err = _get_music_root(mount_path) + if err: + return False, err + + if not rel_path: + return False, "Invalid folder path" + + target_dir = _resolve_subpath(music_root, rel_path) + if os.path.abspath(target_dir) == os.path.abspath(music_root): + return False, "Cannot delete root folder" + if not os.path.isdir(target_dir): + return False, "Folder not found" + + try: + shutil.rmtree(target_dir) + _fsync_dir(os.path.dirname(target_dir)) + close_samba_share("part3") + except Exception as exc: # pylint: disable=broad-except + logger.error("Failed to delete folder %s: %s", rel_path, exc) + return False, "Unable to delete folder" + return True, "Deleted folder" + + +def move_music_file(source_rel: str, dest_rel: str, new_name: str = "") -> Tuple[bool, str]: + mount_path, err = _ensure_music_mount() + if err: + return False, err + + music_root, err = _get_music_root(mount_path) + if err: + return False, err + + src_path = _resolve_subpath(music_root, source_rel) + if not os.path.isfile(src_path): + return False, "Source file not found" + + dest_dir = _resolve_subpath(music_root, dest_rel) + try: + os.makedirs(dest_dir, exist_ok=True) + except OSError: + return False, "Destination unavailable" + + dest_name = _validate_filename(new_name) if new_name else os.path.basename(src_path) + dest_path = os.path.join(dest_dir, dest_name) + + try: + os.replace(src_path, dest_path) + _fsync_dir(os.path.dirname(src_path)) + _fsync_dir(dest_dir) + close_samba_share("part3") + except Exception as exc: + logger.error("Failed to move %s -> %s: %s", src_path, dest_path, exc) + return False, "Unable to move file" + return True, f"Moved to {dest_name}" + + +def require_edit_mode(): + if current_mode() != "edit": + raise UploadError("Switch to Edit mode to upload music.") + + +def generate_upload_id() -> str: + return uuid.uuid4().hex diff --git a/scripts/web/static/js/music.js b/scripts/web/static/js/music.js new file mode 100644 index 0000000..0d1be9c --- /dev/null +++ b/scripts/web/static/js/music.js @@ -0,0 +1,439 @@ +(function () { + const page = document.getElementById('music-page'); + if (!page) return; + + const chunkMb = parseInt(page.dataset.chunkMb || '8', 10) || 8; + const maxMb = parseInt(page.dataset.maxMb || '0', 10) || 0; + const uploadUrl = page.dataset.uploadUrl; + const deleteTemplate = page.dataset.deleteUrl; + const deleteDirTemplate = page.dataset.deleteDirUrl; + const moveUrl = page.dataset.moveUrl; + const browseUrl = page.dataset.browseUrl; + const mkdirUrl = page.dataset.mkdirUrl; + const currentPath = page.dataset.currentPath || ''; + const chunkSize = Math.max(1, chunkMb) * 1024 * 1024; + const maxBytes = maxMb > 0 ? maxMb * 1024 * 1024 : null; + + const dropZone = document.getElementById('dropZone'); + const fileInput = document.getElementById('fileInput'); + const fileList = document.getElementById('fileList'); + const startBtn = document.getElementById('startUpload'); + const clearBtn = document.getElementById('clearSelection'); + const statusEl = document.getElementById('uploadStatus'); + const fileTable = document.getElementById('fileTable'); + const folderTable = document.getElementById('folderTable'); + const folderNameInput = document.getElementById('folderName'); + const createFolderBtn = document.getElementById('createFolder'); + const currentPathLabel = document.getElementById('currentPathLabel'); + + let queue = []; + let uploading = false; + + function setStatus(msg, isError) { + statusEl.textContent = msg || ''; + statusEl.className = isError ? 'error' : 'muted'; + } + + function renderQueue() { + fileList.innerHTML = ''; + queue.forEach((item, idx) => { + const row = document.createElement('div'); + row.className = 'file-row'; + row.innerHTML = ` +
+
${item.file.name}
+
${formatBytes(item.file.size)} → /${item.targetPath || currentPath || ''}
+
+ + `; + const progress = document.createElement('div'); + progress.className = 'progress-bar'; + const bar = document.createElement('span'); + progress.appendChild(bar); + row.appendChild(progress); + const status = document.createElement('div'); + status.className = 'meta'; + status.textContent = 'Waiting'; + row.appendChild(status); + item.progressEl = bar; + item.statusEl = status; + item.rowEl = row; + fileList.appendChild(row); + }); + const disabled = queue.length === 0 || uploading; + startBtn.disabled = disabled; + clearBtn.disabled = queue.length === 0 || uploading; + } + + function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let val = bytes; + let i = 0; + while (val >= 1024 && i < units.length) { + val /= 1024; + i += 1; + } + return `${val.toFixed(val >= 10 ? 0 : 1)} ${units[i - 1] || 'KB'}`; + } + + function deriveTargetPath(file) { + const rel = (file.webkitRelativePath || file.relativePath || '').replace(/^\/+/, ''); + const relDir = rel.includes('/') ? rel.substring(0, rel.lastIndexOf('/')) : ''; + if (currentPath && relDir) return `${currentPath}/${relDir}`; + if (currentPath) return currentPath; + return relDir; + } + + function addFiles(files) { + const allowed = ['.mp3', '.flac', '.wav', '.aac', '.m4a']; + let skipped = 0; + Array.from(files).forEach((file) => { + if (file.name.startsWith('.')) { skipped += 1; return; } + const lastDot = file.name.lastIndexOf('.'); + if (lastDot < 0) { skipped += 1; return; } + const ext = file.name.substring(lastDot).toLowerCase(); + if (!allowed.includes(ext)) { skipped += 1; return; } + if (maxBytes && file.size > maxBytes) { + setStatus(`${file.name} exceeds ${maxMb} MiB limit`, true); + return; + } + queue.push({ file, progressEl: null, targetPath: deriveTargetPath(file) }); + }); + if (queue.length > 0) { + setStatus(`${queue.length} file(s) queued`, false); + } else if (skipped > 0) { + setStatus(`${skipped} item(s) skipped (unsupported type)`, true); + } + renderQueue(); + } + + function readEntriesAsync(dirReader) { + return new Promise((resolve, reject) => { + dirReader.readEntries((entries) => resolve(entries), reject); + }); + } + + async function collectFromEntry(entry, basePath, out) { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); + if (file.name.startsWith('.')) return; + file.relativePath = basePath ? `${basePath}/${file.name}` : file.name; + out.push(file); + return; + } + if (entry.isDirectory) { + const reader = entry.createReader(); + let entries; + do { + entries = await readEntriesAsync(reader); + for (const child of entries) { + await collectFromEntry(child, basePath ? `${basePath}/${entry.name}` : entry.name, out); + } + } while (entries.length > 0); + } + } + + async function uploadFile(item) { + const file = item.file; + const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize)); + const uploadId = crypto.randomUUID ? crypto.randomUUID() : `upload-${Date.now()}-${Math.random()}`; + const pathForFile = item.targetPath || currentPath || ''; + + if (item.statusEl) item.statusEl.textContent = 'Starting...'; + if (item.rowEl) item.rowEl.classList.remove('uploaded'); + if (item.progressEl) item.progressEl.style.width = '0%'; + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) { + const start = chunkIndex * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); + + const params = new URLSearchParams({ + upload_id: uploadId, + filename: file.name, + chunk_index: String(chunkIndex), + total_chunks: String(totalChunks), + total_size: String(file.size), + }); + if (pathForFile) params.set('path', pathForFile); + + const res = await fetch(`${uploadUrl}?${params.toString()}`, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Content-Type': 'application/octet-stream', + 'X-File-Size': String(file.size), + }, + body: chunk, + }); + + let data; + try { + data = await res.json(); + } catch (e) { + if (item.statusEl) item.statusEl.textContent = 'Failed'; + setStatus('Upload failed: invalid server response', true); + return false; + } + + if (!res.ok || !data.success) { + const message = data && data.error ? data.error : 'Upload failed'; + if (item.statusEl) item.statusEl.textContent = 'Failed'; + setStatus(`${file.name}: ${message}`, true); + return false; + } + + const pct = Math.round(((chunkIndex + 1) / totalChunks) * 100); + if (item.progressEl) item.progressEl.style.width = `${pct}%`; + if (item.statusEl) item.statusEl.textContent = `${pct}%`; + } + + if (item.progressEl) item.progressEl.style.width = '100%'; + if (item.statusEl) item.statusEl.textContent = 'Uploaded'; + if (item.rowEl) item.rowEl.classList.add('uploaded'); + setStatus(`${file.name} uploaded`, false); + return true; + } + + async function uploadQueue() { + if (queue.length === 0 || uploading) return; + uploading = true; + startBtn.disabled = true; + clearBtn.disabled = true; + + for (const item of queue) { + if (item.progressEl) item.progressEl.style.width = '0%'; + const ok = await uploadFile(item); + if (!ok) break; + } + + uploading = false; + startBtn.disabled = queue.length === 0; + clearBtn.disabled = queue.length === 0; + // Refresh page to show new files + if (!statusEl.classList.contains('error')) { + const target = currentPath ? `${browseUrl}?path=${encodeURIComponent(currentPath)}` : browseUrl; + setTimeout(() => { window.location.href = target; }, 600); + } + } + + function clearQueue() { + if (uploading) return; + queue = []; + fileInput.value = ''; + renderQueue(); + setStatus('', false); + } + + dropZone.addEventListener('click', () => fileInput.click()); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('dragging'); + }); + + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragging')); + + dropZone.addEventListener('drop', async (e) => { + e.preventDefault(); + dropZone.classList.remove('dragging'); + + const items = e.dataTransfer.items; + if (items && items.length && items[0].webkitGetAsEntry) { + const collected = []; + for (const item of items) { + const entry = item.webkitGetAsEntry(); + if (entry) { + await collectFromEntry(entry, '', collected); + } + } + if (collected.length) { + addFiles(collected); + return; + } + } + + if (e.dataTransfer.files) { + addFiles(e.dataTransfer.files); + } + }); + + fileInput.addEventListener('change', (e) => { + addFiles(e.target.files); + }); + + fileList.addEventListener('click', (e) => { + const removeIdx = e.target.getAttribute('data-remove'); + if (removeIdx !== null) { + queue.splice(Number(removeIdx), 1); + renderQueue(); + return; + } + }); + + startBtn.addEventListener('click', () => uploadQueue()); + clearBtn.addEventListener('click', () => clearQueue()); + + const navigateTo = (path) => { + const target = path ? `${browseUrl}?path=${encodeURIComponent(path)}` : browseUrl; + window.location.href = target; + }; + + // File actions: delete or move + if (fileTable) { + fileTable.addEventListener('click', async (e) => { + const target = e.target; + const filename = target.getAttribute('data-delete'); + const moveTarget = target.getAttribute('data-move'); + if (!filename && !moveTarget) return; + e.preventDefault(); + + if (filename) { + const url = deleteTemplate.replace('__NAME__', encodeURIComponent(filename)); + target.disabled = true; + const res = await fetch(url, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + let data; + try { + data = await res.json(); + } catch (err) { + target.disabled = false; + setStatus('Delete failed: bad response', true); + return; + } + if (!res.ok || !data.success) { + target.disabled = false; + setStatus(data && data.error ? data.error : 'Delete failed', true); + return; + } + const row = target.closest('tr'); + if (row) row.remove(); + setStatus(data.message || 'Deleted', false); + return; + } + + if (moveTarget) { + const dest = window.prompt('Move to folder (relative path):', currentPath); + if (dest === null) return; + const newName = window.prompt('Optional new filename (leave blank to keep name):', ''); + target.disabled = true; + const res = await fetch(moveUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ source: moveTarget, dest_path: dest || '', new_name: newName || '' }), + }); + let data; + try { + data = await res.json(); + } catch (err) { + target.disabled = false; + setStatus('Move failed: bad response', true); + return; + } + target.disabled = false; + if (!res.ok || !data.success) { + setStatus(data && data.error ? data.error : 'Move failed', true); + return; + } + const targetPath = dest ? `${browseUrl}?path=${encodeURIComponent(dest)}` : browseUrl; + window.location.href = targetPath; + } + }); + } + + if (folderTable) { + folderTable.addEventListener('click', async (e) => { + const target = e.target; + const deleteDir = target.getAttribute('data-delete-dir'); + const openDir = target.getAttribute('data-open'); + + if (deleteDir) { + e.preventDefault(); + e.stopPropagation(); + if (!deleteDirTemplate) return; + target.disabled = true; + const url = deleteDirTemplate.replace('__DIR__', encodeURIComponent(deleteDir)); + const res = await fetch(url, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + let data; + try { + data = await res.json(); + } catch (err) { + target.disabled = false; + setStatus('Delete folder failed: bad response', true); + return; + } + target.disabled = false; + if (!res.ok || !data.success) { + setStatus(data && data.error ? data.error : 'Delete folder failed', true); + return; + } + const row = target.closest('tr'); + if (row) row.remove(); + setStatus(data.message || 'Folder deleted', false); + return; + } + + if (openDir) { + e.preventDefault(); + navigateTo(openDir); + return; + } + + const row = target.closest('tr'); + if (!row) return; + const dir = row.getAttribute('data-dir'); + if (dir !== null) { + navigateTo(dir); + } + }); + } + + if (createFolderBtn) { + createFolderBtn.addEventListener('click', async () => { + const name = (folderNameInput.value || '').trim(); + if (!name) { + setStatus('Folder name required', true); + return; + } + createFolderBtn.disabled = true; + const res = await fetch(mkdirUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ path: currentPath, name }), + }); + let data; + try { + data = await res.json(); + } catch (err) { + createFolderBtn.disabled = false; + setStatus('Create folder failed: bad response', true); + return; + } + createFolderBtn.disabled = false; + if (!res.ok || !data.success) { + setStatus(data && data.error ? data.error : 'Create folder failed', true); + return; + } + const target = currentPath ? `${browseUrl}?path=${encodeURIComponent(currentPath)}` : browseUrl; + window.location.href = target; + }); + } + + if (currentPathLabel) { + const labelPath = currentPath ? `/${currentPath}` : '/'; + currentPathLabel.textContent = labelPath; + } +})(); diff --git a/scripts/web/templates/base.html b/scripts/web/templates/base.html index 70894fc..25c5c2c 100644 --- a/scripts/web/templates/base.html +++ b/scripts/web/templates/base.html @@ -1,5 +1,6 @@ + @@ -9,7 +10,7 @@ Tesla USB Gadget Control {% endif %} + diff --git a/scripts/web/templates/music.html b/scripts/web/templates/music.html new file mode 100644 index 0000000..68d2193 --- /dev/null +++ b/scripts/web/templates/music.html @@ -0,0 +1,289 @@ +{% extends "base.html" %} +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Music Library

+ {% if error %} +
{{ error }}
+ {% endif %} +
Tesla only scans music inside the /Music folder. This page always reads and + uploads inside that folder; paths shown below are relative to /Music.
+
+
+
Used
+
{{ format_file_size(used_bytes) }}
+
+
+
Free
+
{{ format_file_size(free_bytes) }}
+
+
+
Files
+
{{ files|length }}
+
+
+ {% set total_space = total_bytes if total_bytes else used_bytes + free_bytes %} + {% set used_pct = (100 * used_bytes / total_space) if total_space else 0 %} +
+ +
+ +

Files

+ {% if dirs %} + + + + + + + + + {% if current_path %} + + + + {% endif %} + {% for d in dirs %} + + + + + {% endfor %} + +
FoldersActions
⬆️ Up one level
📁 {{ d.name }} + + +
+ {% endif %} + {% if files %} + + + + + + + + + + {% for file in files %} + + + + + + {% endfor %} + +
NameSizeActions
{{ file.name }}{{ format_file_size(file.size) }} + + +
+ {% else %} +

No music files found on the drive.

+ {% endif %} +
+ +
+

Upload

+

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 }} +

+
+
Drop files here or click to choose
+
Allowed: mp3, flac, wav, aac, m4a. Max {{ max_upload_size_mb }} + MiB per file.
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +{% endblock %} diff --git a/scripts/web/web_control.py b/scripts/web/web_control.py index 530c042..1fc97eb 100644 --- a/scripts/web/web_control.py +++ b/scripts/web/web_control.py @@ -10,12 +10,16 @@ import os # Import configuration -from config import SECRET_KEY, WEB_PORT, GADGET_DIR +from config import SECRET_KEY, WEB_PORT, GADGET_DIR, MAX_UPLOAD_SIZE_MB, MAX_UPLOAD_CHUNK_MB # Flask app initialization app = Flask(__name__) app.secret_key = SECRET_KEY +# Upload limits (protect RAM-constrained devices) +app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE_MB * 1024 * 1024 +app.config['MAX_FORM_MEMORY_SIZE'] = MAX_UPLOAD_CHUNK_MB * 1024 * 1024 + # Production optimizations app.config['USE_X_SENDFILE'] = False # Disabled - requires nginx/apache app.config['TEMPLATES_AUTO_RELOAD'] = False # Disable template watching - saves memory @@ -26,6 +30,7 @@ videos_bp, lock_chimes_bp, light_shows_bp, + music_bp, wraps_bp, analytics_bp, cleanup_bp, @@ -39,6 +44,7 @@ app.register_blueprint(videos_bp) app.register_blueprint(lock_chimes_bp) app.register_blueprint(light_shows_bp) +app.register_blueprint(music_bp) app.register_blueprint(wraps_bp) app.register_blueprint(analytics_bp) app.register_blueprint(cleanup_bp) diff --git a/setup_usb.sh b/setup_usb.sh index 3427213..e435380 100644 --- a/setup_usb.sh +++ b/setup_usb.sh @@ -59,13 +59,18 @@ fi IMG_CAM_PATH="$GADGET_DIR/$IMG_CAM_NAME" IMG_LIGHTSHOW_PATH="$GADGET_DIR/$IMG_LIGHTSHOW_NAME" +IMG_MUSIC_PATH="$GADGET_DIR/$IMG_MUSIC_NAME" # ===== Check if image files already exist ===== -# Skip sizing and creation if both images already exist -if [ -f "$IMG_CAM_PATH" ] && [ -f "$IMG_LIGHTSHOW_PATH" ]; then - echo "Both image files already exist:" +# Skip sizing and creation if all required images already exist +MUSIC_ENABLED_LC="$(printf '%s' "${MUSIC_ENABLED:-false}" | tr '[:upper:]' '[:lower:]')" +MUSIC_REQUIRED=$([ "$MUSIC_ENABLED_LC" = "true" ] && echo 1 || echo 0) + +if [ -f "$IMG_CAM_PATH" ] && [ -f "$IMG_LIGHTSHOW_PATH" ] && { [ $MUSIC_REQUIRED -eq 0 ] || [ -f "$IMG_MUSIC_PATH" ]; }; then + echo "All required image files already exist:" echo " - TeslaCam: $IMG_CAM_PATH" echo " - Lightshow: $IMG_LIGHTSHOW_PATH" + [ $MUSIC_REQUIRED -eq 1 ] && echo " - Music: $IMG_MUSIC_PATH" echo "Skipping size configuration and image creation." echo "" SKIP_IMAGE_CREATION=1 @@ -75,6 +80,7 @@ else # Determine which images need to be created NEED_CAM_IMAGE=0 NEED_LIGHTSHOW_IMAGE=0 + NEED_MUSIC_IMAGE=0 if [ ! -f "$IMG_CAM_PATH" ]; then NEED_CAM_IMAGE=1 @@ -89,6 +95,15 @@ else else echo "Lightshow image already exists at $IMG_LIGHTSHOW_PATH" fi + + if [ $MUSIC_REQUIRED -eq 1 ]; then + if [ ! -f "$IMG_MUSIC_PATH" ]; then + NEED_MUSIC_IMAGE=1 + echo "Music image not found at $IMG_MUSIC_PATH - will create" + else + echo "Music image already exists at $IMG_MUSIC_PATH" + fi + fi echo "" fi @@ -135,7 +150,7 @@ size_to_bytes() { NEED_SIZE_VALIDATION=0 USABLE_MIB=0 -if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2_SIZE}" ]; }; then +if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2_SIZE}" ] || { [ $MUSIC_REQUIRED -eq 1 ] && [ -z "${PART3_SIZE}" ]; }; }; then # Ensure parent directory exists for df check mkdir -p "$GADGET_DIR" 2>/dev/null || true FS_AVAIL_BYTES="$(fs_avail_bytes_for_path "$GADGET_DIR")" @@ -161,19 +176,36 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2 USABLE_BYTES=$(( FS_AVAIL_BYTES - RESERVE_BYTES )) USABLE_MIB=$(( USABLE_BYTES / 1024 / 1024 )) - # Default Lightshow to 10G + # Default sizes: Lightshow 10G, Music 32G (if enabled), remaining to TeslaCam DEFAULT_P2_MIB=10240 DEFAULT_P2_STR="10G" - if [ "$USABLE_MIB" -le "$DEFAULT_P2_MIB" ]; then - echo "ERROR: Not enough usable space for Lightshow default (${DEFAULT_P2_STR}) after safety reserve." - echo "Usable: ${USABLE_MIB} MiB, Lightshow: ${DEFAULT_P2_MIB} MiB" - echo "Free up space or reduce Lightshow size." + DEFAULT_P3_MIB=32768 + DEFAULT_P3_STR="32G" + + # Compute total baseline needed + BASELINE_MIB=$DEFAULT_P2_MIB + if [ $MUSIC_REQUIRED -eq 1 ]; then + BASELINE_MIB=$(( BASELINE_MIB + DEFAULT_P3_MIB )) + fi + + if [ "$USABLE_MIB" -le "$BASELINE_MIB" ]; then + echo "ERROR: Not enough usable space for defaults after safety reserve." + echo "Usable: ${USABLE_MIB} MiB, Baseline required: ${BASELINE_MIB} MiB" + echo "Free up space or reduce Lightshow/Music size." exit 1 fi SUG_P2_STR="$DEFAULT_P2_STR" - SUG_P1_MIB="$(round_down_gib_mib $(( USABLE_MIB - DEFAULT_P2_MIB )))" + + if [ $MUSIC_REQUIRED -eq 1 ]; then + SUG_P3_STR="$DEFAULT_P3_STR" + SUG_P1_MIB="$(round_down_gib_mib $(( USABLE_MIB - DEFAULT_P2_MIB - DEFAULT_P3_MIB )))" + else + SUG_P3_STR="" + SUG_P1_MIB="$(round_down_gib_mib $(( USABLE_MIB - DEFAULT_P2_MIB )))" + fi + SUG_P1_STR="$(mib_to_gib_str "$SUG_P1_MIB")" echo "" @@ -187,6 +219,7 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2 echo "" echo "Recommended sizes (safe, leaves headroom for Raspberry Pi OS):" echo " Lightshow (PART2_SIZE): $SUG_P2_STR (default)" + [ $MUSIC_REQUIRED -eq 1 ] && echo " Music (PART3_SIZE): ${SUG_P3_STR:-custom}" || true echo " TeslaCam (PART1_SIZE): $SUG_P1_STR (uses remaining usable space)" echo "" @@ -205,6 +238,18 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2 PART2_SIZE="${PART2_SIZE:-1G}" fi + if [ $MUSIC_REQUIRED -eq 1 ] && [ "$NEED_MUSIC_IMAGE" = "1" ] && [ -z "${PART3_SIZE}" ]; then + read -r -p "Enter Music size (default ${SUG_P3_STR}): " PART3_SIZE_INPUT + PART3_SIZE="${PART3_SIZE_INPUT:-$SUG_P3_STR}" + if ! size_to_bytes "$PART3_SIZE" >/dev/null 2>&1; then + echo "ERROR: Invalid size format for Music: $PART3_SIZE" + echo "Use format like 512M or 5G (whole numbers only)" + exit 2 + fi + elif [ $MUSIC_REQUIRED -eq 1 ] && [ "$NEED_MUSIC_IMAGE" = "0" ]; then + PART3_SIZE="${PART3_SIZE:-1G}" + fi + if [ "$NEED_CAM_IMAGE" = "1" ] && [ -z "${PART1_SIZE}" ]; then read -r -p "Enter TeslaCam size (default ${SUG_P1_STR}): " PART1_SIZE_INPUT PART1_SIZE="${PART1_SIZE_INPUT:-$SUG_P1_STR}" @@ -223,6 +268,7 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ] && { [ -z "${PART1_SIZE}" ] || [ -z "${PART2 echo "Selected sizes:" echo " PART1_SIZE=$PART1_SIZE" echo " PART2_SIZE=$PART2_SIZE" + [ $MUSIC_REQUIRED -eq 1 ] && echo " PART3_SIZE=$PART3_SIZE" echo "" NEED_SIZE_VALIDATION=1 @@ -232,6 +278,7 @@ fi if [ "$SKIP_IMAGE_CREATION" = "1" ]; then PART1_SIZE="${PART1_SIZE:-1G}" # Dummy value - image already exists PART2_SIZE="${PART2_SIZE:-1G}" # Dummy value - image already exists + [ $MUSIC_REQUIRED -eq 1 ] && PART3_SIZE="${PART3_SIZE:-1G}" fi # Validate user exists @@ -257,17 +304,22 @@ to_mib() { } P1_MB=$(to_mib "$PART1_SIZE") P2_MB=$(to_mib "$PART2_SIZE") +if [ $MUSIC_REQUIRED -eq 1 ]; then + P3_MB=$(to_mib "$PART3_SIZE") +else + P3_MB=0 +fi # Note: We no longer need TOTAL_MB since we're creating separate images # Validate selected sizes against usable space (if computed and images need creation) if [ "${NEED_SIZE_VALIDATION:-0}" = "1" ] && [ "$SKIP_IMAGE_CREATION" = "0" ]; then - TOTAL_MIB=$(( P1_MB + P2_MB )) + TOTAL_MIB=$(( P1_MB + P2_MB + P3_MB )) if [ "$TOTAL_MIB" -gt "$USABLE_MIB" ]; then echo "ERROR: Selected sizes exceed safe usable space under $GADGET_DIR." echo "Usable: ${USABLE_MIB} MiB (after safety reserve)" - echo "Chosen: ${TOTAL_MIB} MiB (PART1=${P1_MB} MiB, PART2=${P2_MB} MiB)" - echo "Reduce TeslaCam and/or Lightshow sizes." + echo "Chosen: ${TOTAL_MIB} MiB (PART1=${P1_MB} MiB, PART2=${P2_MB} MiB, PART3=${P3_MB} MiB)" + echo "Reduce TeslaCam, Lightshow, and/or Music sizes." exit 1 fi fi @@ -277,20 +329,17 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ]; then echo "============================================" echo "Preview" echo "============================================" - if [ "$NEED_CAM_IMAGE" = "1" ] && [ "$NEED_LIGHTSHOW_IMAGE" = "1" ]; then + if [ "$NEED_CAM_IMAGE" = "1" ] || [ "$NEED_LIGHTSHOW_IMAGE" = "1" ] || [ "$NEED_MUSIC_IMAGE" = "1" ]; then echo "This will create the following image files:" - echo " 1) TeslaCam : $IMG_CAM_PATH size=$PART1_SIZE label=$LABEL1 (read-write)" - echo " 2) Lightshow : $IMG_LIGHTSHOW_PATH size=$PART2_SIZE label=$LABEL2 (read-only)" - elif [ "$NEED_CAM_IMAGE" = "1" ]; then - echo "This will create the TeslaCam image file:" - echo " - TeslaCam : $IMG_CAM_PATH size=$PART1_SIZE label=$LABEL1 (read-write)" - echo "" - echo "Lightshow image already exists at: $IMG_LIGHTSHOW_PATH" - elif [ "$NEED_LIGHTSHOW_IMAGE" = "1" ]; then - echo "This will create the Lightshow image file:" - echo " - Lightshow : $IMG_LIGHTSHOW_PATH size=$PART2_SIZE label=$LABEL2 (read-only)" - echo "" - echo "TeslaCam image already exists at: $IMG_CAM_PATH" + [ "$NEED_CAM_IMAGE" = "1" ] && echo " - TeslaCam : $IMG_CAM_PATH size=$PART1_SIZE label=$LABEL1 (read-write)" || echo " - TeslaCam : already exists" + [ "$NEED_LIGHTSHOW_IMAGE" = "1" ] && echo " - Lightshow : $IMG_LIGHTSHOW_PATH size=$PART2_SIZE label=$LABEL2 (read-only)" || echo " - Lightshow : already exists" + if [ $MUSIC_REQUIRED -eq 1 ]; then + if [ "$NEED_MUSIC_IMAGE" = "1" ]; then + echo " - Music : $IMG_MUSIC_PATH size=$PART3_SIZE label=$LABEL3 (read-only by Tesla)" + else + echo " - Music : already exists" + fi + fi fi echo "" echo "Images are stored under: $GADGET_DIR" @@ -318,6 +367,7 @@ REQUIRED_PACKAGES=( python3-av python3-pil python3-yaml + python3-pip yq samba samba-common-bin @@ -566,6 +616,19 @@ else echo "All required packages already installed; skipping apt install." fi +# Ensure Python web dependencies from requirements.txt are present (pip will no-op if already satisfied) +REQ_FILE="$SCRIPT_DIR/scripts/web/requirements.txt" +if [ -f "$REQ_FILE" ]; then + if command -v pip3 >/dev/null 2>&1; then + echo "Installing Python web requirements from $REQ_FILE..." + if ! pip3 install --no-deps --requirement "$REQ_FILE" >/dev/null; then + echo "Warning: pip install of web requirements failed; continuing with apt-provided packages" + fi + else + echo "pip3 not found; skip pip install (apt packages cover required modules)" + fi +fi + # Ensure hostapd/dnsmasq don't auto-start outside our controller systemctl disable hostapd 2>/dev/null || true systemctl stop hostapd 2>/dev/null || true @@ -699,6 +762,11 @@ cleanup_loop_devices() { losetup -d "$LOOP_LIGHTSHOW" 2>/dev/null || true LOOP_LIGHTSHOW="" fi + if [ -n "${LOOP_MUSIC:-}" ]; then + echo "Cleaning up loop device: $LOOP_MUSIC" + losetup -d "$LOOP_MUSIC" 2>/dev/null || true + LOOP_MUSIC="" + fi } # Create TeslaCam image (if missing) @@ -795,14 +863,57 @@ if [ "$SKIP_IMAGE_CREATION" = "0" ] && [ "$NEED_LIGHTSHOW_IMAGE" = "1" ]; then echo "Lightshow image created and formatted." fi +# Create Music image (if enabled and missing) +if [ "$SKIP_IMAGE_CREATION" = "0" ] && [ $MUSIC_REQUIRED -eq 1 ] && [ "${NEED_MUSIC_IMAGE:-0}" = "1" ]; then + trap cleanup_loop_devices EXIT INT TERM + + echo "Creating Music image $IMG_MUSIC_PATH (${P3_MB}M)..." + truncate -s "${P3_MB}M" "$IMG_MUSIC_PATH" || { + echo "Error: Failed to create Music image file" + exit 1 + } + + LOOP_MUSIC=$(losetup --find --show "$IMG_MUSIC_PATH") || { + echo "Error: Failed to create loop device for Music" + exit 1 + } + + if [ -z "$LOOP_MUSIC" ] || [ ! -e "$LOOP_MUSIC" ]; then + echo "Error: Loop device creation failed or device not accessible" + exit 1 + fi + + echo "Using loop device: $LOOP_MUSIC" + + # Format Music drive (Tesla prefers FAT32 for media) + echo "Formatting Music drive (${LABEL3})..." + FS_LOWER="$(printf '%s' "$MUSIC_FS" | tr '[:upper:]' '[:lower:]')" + if [ "$FS_LOWER" = "exfat" ]; then + mkfs.exfat -n "$LABEL3" "$LOOP_MUSIC" || { + echo "Error: Failed to format Music drive with exFAT" + exit 1 + } + else + mkfs.vfat -F 32 -n "$LABEL3" "$LOOP_MUSIC" || { + echo "Error: Failed to format Music drive with FAT32" + exit 1 + } + fi + + losetup -d "$LOOP_MUSIC" 2>/dev/null || true + LOOP_MUSIC="" + + echo "Music image created and formatted." +fi + # Clean up any remaining loop devices cleanup_loop_devices trap - EXIT INT TERM # Remove trap since we're done with image creation # Create mount points -mkdir -p "$MNT_DIR/part1" "$MNT_DIR/part2" -chown "$TARGET_USER:$TARGET_USER" "$MNT_DIR/part1" "$MNT_DIR/part2" -chmod 775 "$MNT_DIR/part1" "$MNT_DIR/part2" +mkdir -p "$MNT_DIR/part1" "$MNT_DIR/part2" "$MNT_DIR/part3" +chown "$TARGET_USER:$TARGET_USER" "$MNT_DIR/part1" "$MNT_DIR/part2" "$MNT_DIR/part3" +chmod 775 "$MNT_DIR/part1" "$MNT_DIR/part2" "$MNT_DIR/part3" # Create thumbnail cache directory in persistent location THUMBNAIL_CACHE_DIR="$GADGET_DIR/thumbnails" @@ -824,7 +935,8 @@ awk ' BEGIN{skip=0} /^\[gadget_part1\]/{skip=1} /^\[gadget_part2\]/{skip=1} - /^\[.*\]$/ { if(skip==1 && $0 !~ /^\[gadget_part1\]/ && $0 !~ /^\[gadget_part2\]/) { skip=0 } } + /^\[gadget_part3\]/{skip=1} + /^\[.*\]$/ { if(skip==1 && $0 !~ /^\[gadget_part1\]/ && $0 !~ /^\[gadget_part2\]/ && $0 !~ /^\[gadget_part3\]/) { skip=0 } } { if(skip==0) print } ' "$SMB_CONF" > "${SMB_CONF}.tmp" || cp "$SMB_CONF" "${SMB_CONF}.tmp" mv "${SMB_CONF}.tmp" "$SMB_CONF" @@ -879,6 +991,20 @@ cat >> "$SMB_CONF" <> "$SMB_CONF" </dev/null || systemctl restart smbd || true