diff options
| author | Claudius "keldu" Holeksa <mail@keldu.de> | 2026-11-04 23:47:04 +0100 |
|---|---|---|
| committer | Claudius "keldu" Holeksa <mail@keldu.de> | 2026-11-04 23:47:04 +0100 |
| commit | 53e760b603f23ca02ef39283a172a942e1891bc3 (patch) | |
| tree | 5610bac6de53691934e77145b09d8f8551297592 | |
| parent | 29b7e03b93229090928d08dbcdb2622be0b27b53 (diff) | |
| download | apps-dev_tools-53e760b603f23ca02ef39283a172a942e1891bc3.tar.gz | |
| -rwxr-xr-x | scripts/kel_yt_dlp_tag | 462 |
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 |
