import_music.sh
Rama main · réplica de lectura del repositorio original.
#!/bin/bash
#
# Aoede — Bulk Music Import
# ───────────────────────────
# Imports audio files from a host folder into the SHARED POOL only. Tracks
# become available to every user of the instance via search / library views.
# This script does NOT create playlists and does NOT assign tracks to any
# user; it only moves audio into /opt/saas-data/pool, registers each track
# in the DB with metadata, and optionally fetches missing covers via the
# remote APIs already configured by the admin in `Settings > Engine`.
#
# Usage:
# ./import_music.sh /path/to/music [options]
#
# Options forwarded to the Python importer:
# --enrich use Spotify/Last.fm/MusicBrainz APIs for covers + metadata
# (uses the keys configured in Settings > Engine)
# --dry-run scan and report what would happen, change nothing
# --workers N concurrent API calls during enrichment (default 4)
# --verbose per-track debug output
#
# Examples:
# ./import_music.sh /mnt/library
# ./import_music.sh /mnt/library --enrich
# ./import_music.sh /tmp/old-music --dry-run
#
set -euo pipefail
# ─── Locate the Compose project ──────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_DIR="$SCRIPT_DIR/Aoede"
if [[ ! -f "$COMPOSE_DIR/docker-compose.yaml" ]]; then
echo "✗ docker-compose.yaml not found at $COMPOSE_DIR" >&2
echo " Run this script from the Aoede repository root." >&2
exit 1
fi
if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then
echo "✗ docker / docker compose not available on PATH" >&2
exit 1
fi
# ─── Parse first positional argument: the source folder ──────────────────────
if [[ $# -lt 1 || "$1" == "-h" || "$1" == "--help" ]]; then
sed -n '2,30p' "$0"
exit 0
fi
SRC="$1"; shift
if [[ ! -d "$SRC" ]]; then
echo "✗ source folder does not exist: $SRC" >&2
exit 2
fi
SRC="$(cd "$SRC" && pwd)"
# ─── Verify the concierge container is up ────────────────────────────────────
cd "$COMPOSE_DIR"
if ! docker compose ps --status running --services 2>/dev/null | grep -qx concierge; then
echo "✗ the 'concierge' container isn't running. Start the stack first:" >&2
echo " cd $COMPOSE_DIR && docker compose up -d" >&2
exit 3
fi
# ─── Resolve the host path that the container sees as /saas-data/import_stage
DATA_ROOT="${HOST_DATA_ROOT:-/opt/saas-data}"
STAGE_HOST="$DATA_ROOT/import_stage"
if [[ ! -d "$STAGE_HOST" ]]; then
echo "→ creating staging directory: $STAGE_HOST"
sudo mkdir -p "$STAGE_HOST"
sudo chmod 777 "$STAGE_HOST"
fi
# ─── Move audio files into the staging volume ────────────────────────────────
# Strategy: enumerate files with `find -iname` (case-insensitive,
# recursive, doesn't depend on shell globstar or rsync filter rules)
# and move each one preserving its relative subpath under $SRC.
#
# Why not rsync? We used rsync's --include filter previously, which
# DOES recurse but is case-sensitive — files like "Song.Mp3" slipped
# through. `find -iname` matches *.MP3, *.mp3, *.Mp3, *.mP3, etc.,
# eliminating an entire class of "no audio files found" reports.
echo "→ staging audio files from $SRC into $STAGE_HOST"
# Audio extensions we accept. Add new ones here — the find call below
# expands them with -iname so case is irrelevant.
EXTS=(
mp3 m4a flac wav ogg opus aac wma
aif aiff alac dsf dsd ape mka mp4
)
# Build `\( -iname '*.mp3' -o -iname '*.flac' -o ... \)` for find.
FIND_FILTER=( '(' )
first=1
for ext in "${EXTS[@]}"; do
if [[ $first -eq 1 ]]; then
FIND_FILTER+=( -iname "*.${ext}" )
first=0
else
FIND_FILTER+=( -o -iname "*.${ext}" )
fi
done
FIND_FILTER+=( ')' )
# Enumerate audio files NUL-separated to survive spaces/newlines in
# filenames. `-print0` + `read -d ''` is the canonical safe loop.
count=0
while IFS= read -r -d '' f; do
rel="${f#$SRC/}"
dest="$STAGE_HOST/$rel"
sudo mkdir -p "$(dirname "$dest")"
sudo mv "$f" "$dest"
# `count=$((count+1))` instead of `((count++))` because the latter
# exits with status 1 when the OLD value was 0, which under
# `set -e` would abort the script on the very first file.
count=$((count+1))
done < <(sudo find "$SRC" -type f "${FIND_FILTER[@]}" -print0)
# Clean up empty source directories left behind by the moves so the
# user's original tree ends up tidy (the importer also drops empty
# dirs in the staging volume on its way out).
sudo find "$SRC" -mindepth 1 -depth -type d -empty -delete 2>/dev/null || true
if [[ "$count" -eq 0 ]]; then
echo "✗ no audio files found under $SRC" >&2
exit 4
fi
echo "→ staged $count file(s)"
# ─── Make the staged files readable by `appuser` inside the container ───────
# We sudo'd the moves on the host, so the new files end up root-owned both
# on disk and (via the bind mount) inside the container. The importer
# below runs as `appuser` (UID 1000) — the same user as the running
# concierge — so it needs read access to the staged content. A single
# chown over the staging tree is the simplest fix.
sudo chown -R 1000:1000 "$STAGE_HOST" 2>/dev/null || true
# ─── Run the in-container importer ───────────────────────────────────────────
# Run as `appuser`, not the Dockerfile default (root). The running
# concierge process holds SQLite open as appuser; another process
# attempting to open the same WAL DB as a different UID (even root)
# can hit "unable to open database file" because fcntl locks are
# UID-scoped on some bind-mounted filesystems. Matching the UID
# eliminates that whole class of failure.
echo "→ running importer inside the concierge container (as appuser)"
echo
docker compose exec -T --user appuser concierge python importer.py \
--source /saas-data/import_stage "$@"
RC=$?
# ─── Tidy up: remove now-empty staging directory contents ───────────────────
sudo find "$STAGE_HOST" -mindepth 1 -depth -empty -delete 2>/dev/null || true
exit $RC