#!/usr/bin/env bash # # kel-music — YouTube-Audio → SQLite-Musikbibliothek set -euo pipefail # ========================================================================= # KONFIGURATION (XDG-konform) # ========================================================================= CONFIG_DIR="$HOME/.config/kel/music" CONFIG_FILE="$CONFIG_DIR/config" MUSIC_ROOT_DEFAULT="$HOME/.local/kel/music/audio" DB_PATH_DEFAULT="$HOME/.local/kel/music/library.db" _env_MUSIC_ROOT="${MUSIC_ROOT-}" _env_DB_PATH="${DB_PATH-}" if [[ ! -f $CONFIG_FILE ]]; then mkdir -p "$CONFIG_DIR" cat > "$CONFIG_FILE" <&2; exit 1; } [[ $youtube_url =~ ^https?:// ]] || { echo "URL muss http(s) sein" >&2; exit 1; } normalize_url() { local u="$1" if [[ $u =~ (v=|youtu\.be/|shorts/|embed/|/v/)([A-Za-z0-9_-]{11}) ]]; then printf 'https://youtu.be/%s' "${BASH_REMATCH[2]}" else printf '%s' "$u" fi } norm_url=$(normalize_url "$youtube_url") export youtube_url norm_url # ========================================================================= # NIX-SHELL # ========================================================================= nix-shell -p sqlite coreutils findutils yt-dlp eyed3 ffmpeg flac \ --run "$(cat <<'NIX_SCRIPT' set -euo pipefail IFS=$'\n\t' SEP=$'\x1f' # ========================================================================= # META + SCHEMA-HASH # ========================================================================= init_db() { sqlite3 -batch "$DB_PATH" <<'SQL' PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS artists ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE); CREATE TABLE IF NOT EXISTS albums ( id INTEGER PRIMARY KEY, artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE, name TEXT NOT NULL COLLATE NOCASE, UNIQUE(artist_id, name)); CREATE TABLE IF NOT EXISTS songs ( id INTEGER PRIMARY KEY, album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE, title TEXT NOT NULL, youtube_url TEXT NOT NULL UNIQUE, file_path TEXT NOT NULL, format TEXT NOT NULL, downloaded_at TEXT NOT NULL DEFAULT (datetime('now'))); SQL local current_hash stored_hash stored_version current_hash=$( sqlite3 -batch "$DB_PATH" \ "SELECT sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'meta' ORDER BY name;" | sha256sum | cut -d' ' -f1 ) stored_hash=$(sqlite3 -batch "$DB_PATH" \ "SELECT value FROM meta WHERE key='schema_hash';" 2>/dev/null || true) stored_version=$(sqlite3 -batch "$DB_PATH" \ "SELECT value FROM meta WHERE key='db_version';" 2>/dev/null || true) if [[ -z $stored_hash ]]; then sqlite3 -batch "$DB_PATH" " BEGIN; INSERT INTO meta(key,value) VALUES('db_version','1'); INSERT INTO meta(key,value) VALUES('schema_hash','$current_hash'); INSERT INTO meta(key,value) VALUES('created_at',datetime('now')); COMMIT;" return fi [[ $stored_version == "1" ]] || { echo "FEHLER: DB-Version '$stored_version' != 1" >&2; exit 3; } [[ $stored_hash == "$current_hash" ]] || { echo "FEHLER: Schema-Hash-Mismatch" >&2 echo " gespeichert: $stored_hash" >&2 echo " berechnet : $current_hash" >&2 exit 4 } } init_db # ========================================================================= # HELFER # ========================================================================= sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; } trim() { local s="$1" s="${s%$'\r'}" s="${s#"${s%%[![:space:]]*}"}" s="${s%"${s##*[![:space:]]}"}" printf '%s' "$s" } ask() { local prompt="$1" default="${2-}" ans if [[ -n $default ]]; then IFS= read -r -p "$prompt [$default]: " ans || exit 1 trim "${ans:-$default}" else IFS= read -r -p "$prompt: " ans || exit 1 trim "$ans" fi } # ========================================================================= # GET-OR-CREATE — eine Verbindung, atomar; setzt SEL_*-Globale # ========================================================================= get_or_create_artist() { local name="$1" esc result esc=$(sql_escape "$name") result=$(sqlite3 -batch -separator "$SEP" "$DB_PATH" " INSERT OR IGNORE INTO artists(name) VALUES('$esc'); SELECT id, name FROM artists WHERE name='$esc';") [[ -n $result ]] || { echo "FEHLER: Artist '$name' nicht lesbar" >&2; exit 6; } IFS=$SEP read -r SEL_ARTIST_ID SEL_ARTIST_NAME <<<"$result" } get_or_create_album() { local artist_id="$1" name="$2" esc result [[ -n $artist_id ]] || { echo "FEHLER: get_or_create_album ohne artist_id" >&2; exit 6; } esc=$(sql_escape "$name") result=$(sqlite3 -batch -separator "$SEP" "$DB_PATH" " INSERT OR IGNORE INTO albums(artist_id, name) VALUES($artist_id, '$esc'); SELECT id, name FROM albums WHERE artist_id=$artist_id AND name='$esc';") [[ -n $result ]] || { echo "FEHLER: Album '$name' nicht lesbar" >&2; exit 6; } IFS=$SEP read -r SEL_ALBUM_ID SEL_ALBUM_NAME <<<"$result" } # ========================================================================= # ARTIST-AUSWAHL — setzt SEL_*; UI auf stderr # ========================================================================= select_artist() { SEL_ARTIST_ID=""; SEL_ARTIST_NAME="" local query="" name choice row i local -a rows while :; do rows=() if [[ -n $query ]]; then mapfile -t rows < <(sqlite3 -batch -separator "$SEP" "$DB_PATH" \ "SELECT id, name FROM artists WHERE name LIKE '%$(sql_escape "$query")%' ORDER BY name COLLATE NOCASE;") else mapfile -t rows < <(sqlite3 -batch -separator "$SEP" "$DB_PATH" \ "SELECT id, name FROM artists ORDER BY name COLLATE NOCASE;") fi echo >&2 echo "=== Artists${query:+ passend zu \"$query\"} ===" >&2 if (( ${#rows[@]} == 0 )); then if [[ -z $query ]]; then echo " (noch keine Artists in der Datenbank)" >&2 echo " → neuen Artist anlegen:" >&2 name=$(ask "neuer Artist") [[ -n $name ]] || { echo " leerer Name." >&2; continue; } get_or_create_artist "$name" return else echo " (keine Treffer für \"$query\")" >&2 echo " n) neu anlegen als \"$query\" (Name editierbar)" >&2 echo " s) neue Suche (leer = alle)" >&2 fi else i=1 for row in "${rows[@]}"; do echo " $i) ${row#*"$SEP"}" >&2 i=$((i+1)) done echo " s) Suche · n) neuer Artist · oder Namen eintippen" >&2 fi IFS= read -r -p "> " choice || exit 1 choice=$(trim "$choice") case "$choice" in "") continue ;; s|S) query=$(ask "Suchbegriff (leer = alle)"); continue ;; n|N) name=$(ask "neuer Artist" "$query") [[ -n $name ]] || { echo " leerer Name." >&2; continue; } get_or_create_artist "$name" return ;; esac if [[ $choice =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#rows[@]} )); then IFS=$SEP read -r SEL_ARTIST_ID SEL_ARTIST_NAME <<<"${rows[$((choice-1))]}" return fi query="$choice" done } # ========================================================================= # ALBUM-AUSWAHL — analog # ========================================================================= select_album() { local artist_id="$1" [[ -n $artist_id ]] || { echo "FEHLER: select_album ohne artist_id" >&2; exit 6; } SEL_ALBUM_ID=""; SEL_ALBUM_NAME="" local query="" name choice row i local -a rows while :; do rows=() if [[ -n $query ]]; then mapfile -t rows < <(sqlite3 -batch -separator "$SEP" "$DB_PATH" \ "SELECT id, name FROM albums WHERE artist_id=$artist_id AND name LIKE '%$(sql_escape "$query")%' ORDER BY name COLLATE NOCASE;") else mapfile -t rows < <(sqlite3 -batch -separator "$SEP" "$DB_PATH" \ "SELECT id, name FROM albums WHERE artist_id=$artist_id ORDER BY name COLLATE NOCASE;") fi echo >&2 echo "=== Alben für Artist #$artist_id${query:+ passend zu \"$query\"} ===" >&2 if (( ${#rows[@]} == 0 )); then if [[ -z $query ]]; then echo " (dieser Artist hat noch keine Alben)" >&2 echo " → neues Album anlegen:" >&2 name=$(ask "neues Album") [[ -n $name ]] || { echo " leerer Name." >&2; continue; } get_or_create_album "$artist_id" "$name" return else echo " (keine Treffer für \"$query\")" >&2 echo " n) neu anlegen als \"$query\" (Name editierbar)" >&2 echo " s) neue Suche (leer = alle)" >&2 fi else i=1 for row in "${rows[@]}"; do echo " $i) ${row#*"$SEP"}" >&2 i=$((i+1)) done echo " s) Suche · n) neues Album · oder Namen eintippen" >&2 fi IFS= read -r -p "> " choice || exit 1 choice=$(trim "$choice") case "$choice" in "") continue ;; s|S) query=$(ask "Suchbegriff (leer = alle)"); continue ;; n|N) name=$(ask "neues Album" "$query") [[ -n $name ]] || { echo " leerer Name." >&2; continue; } get_or_create_album "$artist_id" "$name" return ;; esac if [[ $choice =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#rows[@]} )); then IFS=$SEP read -r SEL_ALBUM_ID SEL_ALBUM_NAME <<<"${rows[$((choice-1))]}" return fi query="$choice" done } # ========================================================================= # URL-VORSCHAU # ========================================================================= echo >&2 echo "Prüfe URL …" >&2 yt_title=$(yt-dlp --skip-download --no-playlist \ --print '%(title)s' -- "$youtube_url" 2>/dev/null || true) echo " Titel : ${yt_title:-(nicht abrufbar)}" >&2 echo " URL : $youtube_url" >&2 echo " Norm : $norm_url" >&2 # ========================================================================= # DB-CHECK # ========================================================================= existing=$(sqlite3 -batch -separator "$SEP" "$DB_PATH" " SELECT s.id, s.title, ar.name, al.name, s.file_path, s.downloaded_at FROM songs s JOIN albums al ON al.id = s.album_id JOIN artists ar ON ar.id = al.artist_id WHERE s.youtube_url = '$(sql_escape "$norm_url")' OR s.youtube_url = '$(sql_escape "$youtube_url")';") if [[ -n $existing ]]; then IFS=$SEP read -r song_id t art alb path ts <<<"$existing" if [[ $path == /* ]]; then display_path="$path" else display_path="$MUSIC_ROOT/$path" fi echo >&2 echo "Bereits vorhanden:" >&2 echo " $art — $alb — $t" >&2 echo " Datei : $display_path" >&2 echo " Wann : $ts" >&2 IFS= read -r -p "Neu laden (r) oder abbrechen (a)? [r/a] " choice || exit 1 case "$choice" in r|R) sqlite3 -batch "$DB_PATH" "PRAGMA foreign_keys=ON; DELETE FROM songs WHERE id=$song_id;" echo " → alter DB-Eintrag entfernt (Datei: $display_path)" >&2 ;; *) echo "Abgebrochen." >&2; exit 0 ;; esac else if [[ -z $yt_title ]]; then IFS= read -r -p "Titel nicht abrufbar. Trotzdem fortfahren? [y/N] " ok [[ $ok =~ ^[Yy] ]] || exit 1 fi fi # ========================================================================= # HAUPTFLUSS # ========================================================================= song_name=$(ask "Songtitel") [[ -n $song_name ]] || { echo "Titel erforderlich" >&2; exit 1; } select_artist artist_id="$SEL_ARTIST_ID" artist_name="$SEL_ARTIST_NAME" [[ -n $artist_id ]] || { echo "FEHLER: kein Artist gewählt" >&2; exit 6; } select_album "$artist_id" album_id="$SEL_ALBUM_ID" album_name="$SEL_ALBUM_NAME" [[ -n $album_id ]] || { echo "FEHLER: kein Album gewählt" >&2; exit 6; } dup=$(sqlite3 -batch "$DB_PATH" "SELECT id FROM songs WHERE album_id=$album_id AND title='$(sql_escape "$song_name")';") [[ -z $dup ]] || echo "WARNUNG: Song existiert bereits (id=$dup)" >&2 # ========================================================================= # DATEINAMEN SANITIZEN — nur für die Platte, DB-Namen bleiben raw # ========================================================================= safe_song=${song_name//\//-} safe_artist=${artist_name//\//-} safe_album=${album_name//\//-} for _comp in "$safe_artist" "$safe_album" "$safe_song"; do if [[ -z $_comp || $_comp == "." || $_comp == ".." ]]; then echo "FEHLER: unzulässiger Name '$_comp' (nach Sanitizing)" >&2 exit 7 fi done unset _comp # ========================================================================= # DOWNLOAD # ========================================================================= work_dir=$(mktemp -d) trap 'rm -rf "$work_dir"' EXIT yt-dlp \ -f 'ba[acodec^=flac]/ba[acodec^=opus]/ba' \ --extract-audio --audio-format best --audio-quality 0 \ --no-embed-metadata --no-write-info-json \ --no-write-thumbnail --no-write-description \ --no-playlist \ -o "$work_dir/%(id)s.%(ext)s" \ -- "$youtube_url" src=$(find "$work_dir" -maxdepth 1 -type f \ \( -name '*.flac' -o -name '*.opus' -o -name '*.mp3' \ -o -name '*.ogg' -o -name '*.m4a' \) \ -print -quit) [[ -n $src && -f $src ]] || { echo "keine Audiodatei erzeugt" >&2; exit 2; } ext=${src##*.} # ========================================================================= # TAGGING # ========================================================================= case "$ext" in flac) metaflac --set-tag="ARTIST=$artist_name" \ --set-tag="ALBUM=$album_name" \ --set-tag="TITLE=$song_name" "$src" ;; mp3) eyed3 --artist "$artist_name" --album "$album_name" \ --title "$song_name" -- "$src" ;; opus|ogg|m4a) ffmpeg -y -loglevel error -i "$src" -c copy \ -metadata artist="$artist_name" \ -metadata album="$album_name" \ -metadata title="$song_name" \ "${src%.*}.tagged.$ext" mv -- "${src%.*}.tagged.$ext" "$src" ;; esac # ========================================================================= # VERSCHIEBEN — relativer Pfad in DB, absoluter Pfad auf Platte # ========================================================================= rel_path="${safe_artist}/${safe_album}/${safe_song}.${ext}" dest="$MUSIC_ROOT/$rel_path" mkdir -p "$(dirname "$dest")" mv -v -- "$src" "$dest" >&2 # ========================================================================= # IN DB SCHREIBEN # ========================================================================= sqlite3 -batch "$DB_PATH" "PRAGMA foreign_keys=ON; INSERT INTO songs(album_id, title, youtube_url, file_path, format) VALUES($album_id, '$(sql_escape "$song_name")', '$(sql_escape "$norm_url")', '$(sql_escape "$rel_path")', '$ext');" echo >&2 echo "fertig: $dest" >&2 echo " DB-Pfad: $rel_path (relativ zu \$MUSIC_ROOT)" >&2 echo " Artist #$artist_id $artist_name" >&2 echo " Album #$album_id $album_name" >&2 echo " Titel $song_name" >&2 NIX_SCRIPT )"