summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorClaudius "keldu" Holeksa <mail@keldu.de>2026-11-04 23:47:04 +0100
committerClaudius "keldu" Holeksa <mail@keldu.de>2026-11-04 23:47:04 +0100
commit53e760b603f23ca02ef39283a172a942e1891bc3 (patch)
tree5610bac6de53691934e77145b09d8f8551297592 /scripts
parent29b7e03b93229090928d08dbcdb2622be0b27b53 (diff)
downloadapps-dev_tools-main.tar.gz
Music entry helperHEADmain
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/kel_yt_dlp_tag462
1 files changed, 462 insertions, 0 deletions
diff --git a/scripts/kel_yt_dlp_tag b/scripts/kel_yt_dlp_tag
new file mode 100755
index 0000000..8f93c69
--- /dev/null
+++ b/scripts/kel_yt_dlp_tag
@@ -0,0 +1,462 @@
+#!/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" <<EOF
+# ~/.config/kel/music/config
+MUSIC_ROOT="$MUSIC_ROOT_DEFAULT"
+DB_PATH="$DB_PATH_DEFAULT"
+EOF
+fi
+
+# shellcheck disable=SC1090
+source "$CONFIG_FILE"
+
+[[ -n $_env_MUSIC_ROOT ]] && MUSIC_ROOT="$_env_MUSIC_ROOT"
+[[ -n $_env_DB_PATH ]] && DB_PATH="$_env_DB_PATH"
+: "${MUSIC_ROOT:=$MUSIC_ROOT_DEFAULT}"
+: "${DB_PATH:=$DB_PATH_DEFAULT}"
+
+mkdir -p "$MUSIC_ROOT" "$(dirname "$DB_PATH")"
+export MUSIC_ROOT DB_PATH
+
+# =========================================================================
+# URL-EINGABE + NORMALISIERUNG
+# =========================================================================
+read -r -p "YouTube URL : " youtube_url
+[[ -n $youtube_url ]] || { echo "URL erforderlich" >&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
+)" \ No newline at end of file