Created
August 8, 2025 11:19
-
-
Save Asmilex/0df42786171cb0d117cbe86cc140f97e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env fish | |
| # playlisteador.fish | |
| # Crea/actualiza una playlist en Navidrome (API Subsonic) a partir de capítulos/comentarios/descripción de un vídeo de YouTube. | |
| # Requisitos: fish 3.x, yt-dlp, jq, curl, y md5 (o md5sum) | |
| # | |
| # Uso: | |
| # playlisteador | |
| # --from https://youtube.com/watch?v=... | |
| # --playlist-name "Sonic Adventure — Relaxing ..." | |
| # --user asmilex | |
| # --password "contraseña" | |
| # --subsonic-url https://navidrome.tu.dominio | |
| # [--dry-run] | |
| # [--override] | |
| # | |
| # Notas: | |
| # - Autenticación: token = md5(password + salt). La sal es fija dentro del script. | |
| # - --dry-run: muestra lo que haría sin llamar a la API de creación/edición de playlists. | |
| # - --override: si ya existía, la machaca con exactamente el nuevo contenido. | |
| # - Muestra el mapeo "Parsed | Subsonic" por cada entrada detectada. | |
| # - Extracción por capas: capítulos -> descripción -> comentarios (top N) para minimizar llamadas y evitar 403. | |
| set -g __API_VERSION "1.16.1" | |
| set -g __CLIENT_ID "playlisteador" | |
| # Debe tener al menos 6 chars. | |
| set -g __FIXED_SALT "plstdr-42" | |
| function __usage | |
| echo "Uso:" | |
| echo " playlisteador --from <youtube-url> --playlist-name <nombre> --user <usuario> --password <password> --subsonic-url <url> [--dry-run] [--override]" | |
| echo | |
| echo "Ejemplo:" | |
| echo " playlisteador --from https://youtube.com/watch?v=ABC --playlist-name \"Mi playlist\" --user pepe --password \"secreto\" --subsonic-url https://navidrome.example.org" | |
| end | |
| function __die | |
| set_color red | |
| echo $argv 1>&2 | |
| set_color normal | |
| exit 1 | |
| end | |
| function __log | |
| set_color cyan | |
| echo $argv | |
| set_color normal | |
| end | |
| function __check_deps | |
| set -l missing 0 | |
| for dep in yt-dlp jq curl | |
| if not type -q $dep | |
| set_color red | |
| echo "Falta dependencia: $dep" | |
| set_color normal | |
| set missing 1 | |
| end | |
| end | |
| if not type -q md5 | |
| if not type -q md5sum | |
| set_color red | |
| echo "Falta dependencia: md5 o md5sum" | |
| set_color normal | |
| set missing 1 | |
| end | |
| end | |
| if test $missing -ne 0 | |
| __die "Instala las dependencias indicadas y vuelve a ejecutar." | |
| end | |
| end | |
| function __md5_hex --argument-names s | |
| if type -q md5 | |
| echo -n $s | md5 -q | tr 'A-Z' 'a-z' | |
| else if type -q md5sum | |
| echo -n $s | md5sum | awk '{print tolower($1)}' | |
| else | |
| __die "No encuentro md5 ni md5sum" | |
| end | |
| end | |
| function __compute_token --argument-names password | |
| set -l combined "$password$__FIXED_SALT" | |
| __md5_hex $combined | |
| end | |
| # Limpia y normaliza una colección de strings. Devuelve uno por línea. | |
| function clean_strings | |
| set -l seen | |
| set -l out | |
| for s in $argv | |
| set -l x $s | |
| # Normalizaciones y limpieza | |
| set x (printf "%s" "$x" \ | |
| | sed -E ' | |
| s/\r//g; | |
| s/[[:space:]]+/ /g; | |
| s/^[[:space:]]+//; s/[[:space:]]+$//; | |
| s/[–—]/-/g; | |
| s/[“”"”]//g; | |
| s/\[[^][]*\]//g; # [..] | |
| s/\([^()]*\)//g; # (..) | |
| s/\{[^{}]*\}//g; # {..} | |
| s/^[0-9]+[.)-][[:space:]]*//; # 01. Title | |
| s/[|•]+/ /g; | |
| s/[[:space:]]+-[[:space:]]+/ - /g; | |
| ' \ | |
| ) | |
| # Descartar vacíos | |
| if test -n "$x" | |
| # Evitar duplicados manteniendo orden | |
| if not contains -- "$x" $seen | |
| set -a seen "$x" | |
| set -a out "$x" | |
| end | |
| end | |
| end | |
| for o in $out | |
| echo $o | |
| end | |
| end | |
| # Extrae títulos candidatos de un JSON de yt-dlp (capítulos, y si no hay, descripción/comentarios con timestamp al inicio). | |
| function __extract_titles_from_ytdlp_json --argument-names json | |
| set -l titles | |
| # Primero: capítulos nativos | |
| set -l chap (printf "%s" "$json" | jq -r '.chapters[]?.title' 2>/dev/null) | |
| if test -n "$chap" | |
| for t in $chap | |
| set -a titles "$t" | |
| end | |
| else | |
| # Fallback: líneas con timestamps al inicio en la descripción | |
| set -l desc (printf "%s" "$json" | jq -r '.description // ""' 2>/dev/null) | |
| if test -n "$desc" | |
| set -l from_desc (printf "%s\n" "$desc" \ | |
| | sed -n -E 's/^[[:space:]]*([0-9]{1,2}:)?[0-9]{1,2}:[0-9]{2}[[:space:]]*[-–—:|]*[[:space:]]*(.+)$/\2/p') | |
| for t in $from_desc | |
| set -a titles "$t" | |
| end | |
| end | |
| # Comentarios: se consultan en una pasada separada si hiciera falta (para evitar 403/abuso). | |
| end | |
| # Sanitiza y dedup | |
| if test (count $titles) -gt 0 | |
| clean_strings $titles | |
| end | |
| end | |
| # Extrae títulos candidatos únicamente desde comentarios (JSON de yt-dlp limitado a top comments). | |
| function __extract_titles_from_comments_json --argument-names json | |
| set -l titles | |
| set -l comments (printf "%s" "$json" | jq -r '.comments[]?.text // empty' 2>/dev/null) | |
| if test -n "$comments" | |
| set -l from_comments (printf "%s\n" "$comments" \ | |
| | sed -n -E 's/^[[:space:]]*([0-9]{1,2}:)?[0-9]{1,2}:[0-9]{2}[[:space:]]*[-–—:|]*[[:space:]]*(.+)$/\2/p') | |
| for t in $from_comments | |
| set -a titles "$t" | |
| end | |
| end | |
| if test (count $titles) -gt 0 | |
| clean_strings $titles | |
| end | |
| end | |
| # Wrapper para llamadas a Subsonic en JSON, con URL-encoding automático de params por curl. | |
| # Uso: __subsonic_get endpoint key1=value1 key2=value2 ... | |
| function __subsonic_get --argument-names endpoint | |
| set -e argv[1] | |
| set -l curl_args -sS -G --fail "$__SUBSONIC_URL/rest/$endpoint" | |
| set -a curl_args --data-urlencode "u=$__USER" | |
| set -a curl_args --data-urlencode "t=$__TOKEN" | |
| set -a curl_args --data-urlencode "s=$__FIXED_SALT" | |
| set -a curl_args --data-urlencode "v=$__API_VERSION" | |
| set -a curl_args --data-urlencode "c=$__CLIENT_ID" | |
| set -a curl_args --data-urlencode "f=json" | |
| for kv in $argv | |
| set -a curl_args --data-urlencode "$kv" | |
| end | |
| curl $curl_args | |
| end | |
| # Busca la mejor coincidencia de canción para un string de consulta. | |
| # Devuelve: "<id>\t<Artist - Title>\t<Album>" o nada si no encuentra. | |
| # TODO: modo interactivo para elegir entre varias coincidencias (futuro). | |
| function __subsonic_search_song --argument-names query | |
| set -l resp (__subsonic_get "search3" "query=$query" "songCount=10" 2>/dev/null) | |
| if test -z "$resp" | |
| return 1 | |
| end | |
| # Intentar tomar la primera canción; construir etiqueta legible. | |
| set -l best (printf "%s" "$resp" \ | |
| | jq -r ' | |
| if .["subsonic-response"].status == "ok" then | |
| (.["subsonic-response"].searchResult3.song[0]? // empty) | | |
| if . then "\(.id)\t\((.artist // "") + " - " + (.title // .name // ""))\t\(.album // "")" else empty end | |
| else empty end | |
| ' 2>/dev/null) | |
| if test -n "$best" | |
| echo $best | |
| return 0 | |
| end | |
| # Fallback: usar search2 por si el servidor puntúa distinto | |
| set -l resp2 (__subsonic_get "search2" "query=$query" "songCount=10" 2>/dev/null) | |
| set -l best2 (printf "%s" "$resp2" \ | |
| | jq -r ' | |
| if .["subsonic-response"].status == "ok" then | |
| (.["subsonic-response"].searchResult2.song[0]? // empty) | | |
| if . then "\(.id)\t\((.artist // "") + " - " + (.title // .name // ""))\t\(.album // "")" else empty end | |
| else empty end | |
| ' 2>/dev/null) | |
| if test -n "$best2" | |
| echo $best2 | |
| return 0 | |
| end | |
| return 1 | |
| end | |
| # Obtiene ID de playlist existente por nombre exacto (case-sensitive según servidor). | |
| function __get_playlist_id_by_name --argument-names name | |
| set -l resp (__subsonic_get "getPlaylists" 2>/dev/null) | |
| if test -z "$resp" | |
| return 1 | |
| end | |
| set -l id (printf "%s" "$resp" \ | |
| | jq -r --arg NAME "$name" ' | |
| .["subsonic-response"].playlists.playlist[]? | |
| | select(.name == $NAME) | |
| | .id | |
| ' 2>/dev/null) | |
| if test -n "$id" | |
| echo $id | |
| return 0 | |
| end | |
| return 1 | |
| end | |
| # Crea playlist con nombre y lista de songIds (machacando si override=1 y existe). | |
| function __ensure_playlist --argument-names name override_flag | |
| set -l ids | |
| for id in $argv[3..-1] | |
| set -a ids $id | |
| end | |
| if test (count $ids) -eq 0 | |
| __die "No hay canciones que crear en la playlist." | |
| end | |
| set -l existing_id (__get_playlist_id_by_name "$name") | |
| if test -n "$existing_id" | |
| if test "$override_flag" = "1" | |
| if test "$__DRY_RUN" = "1" | |
| __log "[dry-run] Borraría playlist existente id=$existing_id y la recrearía como '$name' con "(count $ids)" pistas, la marcaría como pública y le pondría comentario 'autogenerated from $__YOUTUBE_URL'." | |
| return 0 | |
| end | |
| __log "Borrando playlist existente '$name' (id=$existing_id)..." | |
| set -l del (__subsonic_get "deletePlaylist" "id=$existing_id") | |
| set -l ok (printf "%s" "$del" | jq -r '.["subsonic-response"].status // "failed"' 2>/dev/null) | |
| if test "$ok" != "ok" | |
| printf "%s\n" "$del" 1>&2 | |
| __die "Fallo al borrar la playlist existente." | |
| end | |
| else | |
| __die "La playlist '$name' ya existe. Usa --override para machacarla." | |
| end | |
| end | |
| if test "$__DRY_RUN" = "1" | |
| __log "[dry-run] Crearía playlist '$name' con "(count $ids)" pistas, la marcaría como pública y le pondría comentario 'autogenerated from $__YOUTUBE_URL'." | |
| return 0 | |
| end | |
| # Crear con todos los songId | |
| set -l args "name=$name" | |
| for sid in $ids | |
| set -a args "songId=$sid" | |
| end | |
| __log "Creando playlist '$name' con "(count $ids)" pistas..." | |
| set -l create_resp (__subsonic_get "createPlaylist" $args) | |
| set -l ok (printf "%s" "$create_resp" | jq -r '.["subsonic-response"].status // "failed"' 2>/dev/null) | |
| if test "$ok" != "ok" | |
| printf "%s\n" "$create_resp" 1>&2 | |
| __die "Fallo al crear la playlist." | |
| end | |
| set -l new_pl_id (printf "%s" "$create_resp" | jq -r '.["subsonic-response"].playlist.id // empty' 2>/dev/null) | |
| if test -n "$new_pl_id" | |
| set -l upd (__subsonic_get "updatePlaylist" "playlistId=$new_pl_id" "public=true" "comment=autogenerated from $__YOUTUBE_URL") | |
| set -l ok2 (printf "%s" "$upd" | jq -r '.["subsonic-response"].status // "failed"' 2>/dev/null) | |
| if test "$ok2" = "ok" | |
| __log "Playlist creada y marcada como pública." | |
| else | |
| __log "Playlist creada, pero no se pudo marcar como pública." | |
| end | |
| else | |
| __log "Playlist creada; no se pudo obtener ID para marcarla como pública." | |
| end | |
| end | |
| # ------- Main -------- | |
| set -l __DRY_RUN 0 | |
| set -l __OVERRIDE 0 | |
| # Parseo de flags | |
| set -l args $argv | |
| for i in (seq 1 (count $args)) | |
| set -l a $args[$i] | |
| switch $a | |
| case -h --help | |
| __usage | |
| exit 0 | |
| case --dry-run | |
| set __DRY_RUN 1 | |
| case --override | |
| set __OVERRIDE 1 | |
| case --from | |
| set -l next_i (math $i + 1) | |
| set -g __YOUTUBE_URL $args[$next_i] | |
| case --playlist-name | |
| set -l next_i (math $i + 1) | |
| set -g __PLAYLIST_NAME $args[$next_i] | |
| case --user | |
| set -l next_i (math $i + 1) | |
| set -g __USER $args[$next_i] | |
| case --password | |
| set -l next_i (math $i + 1) | |
| set -g __PASSWORD $args[$next_i] | |
| case --subsonic-url | |
| set -l next_i (math $i + 1) | |
| set -g __SUBSONIC_URL $args[$next_i] | |
| end | |
| end | |
| # Validaciones | |
| if test -z "$__YOUTUBE_URL" | |
| __usage | |
| __die "Falta parámetro: --from <youtube-url>" | |
| end | |
| if test -z "$__PLAYLIST_NAME" | |
| __usage | |
| __die "Falta parámetro: --playlist-name <nombre>" | |
| end | |
| if test -z "$__USER" | |
| __usage | |
| __die "Falta parámetro: --user <usuario>" | |
| end | |
| if test -z "$__PASSWORD" | |
| __usage | |
| __die "Falta parámetro: --password <password>" | |
| end | |
| if test -z "$__SUBSONIC_URL" | |
| __usage | |
| __die "Falta parámetro: --subsonic-url <url>" | |
| end | |
| # Normaliza URL base (sin slash final) | |
| if string match -q "*/" $__SUBSONIC_URL | |
| set __SUBSONIC_URL (string replace -r '/+$' '' -- $__SUBSONIC_URL) | |
| end | |
| __check_deps | |
| # Token de autenticación Subsonic | |
| set -g __TOKEN (__compute_token "$__PASSWORD") | |
| # Obtener JSON de yt-dlp | |
| __log "Extrayendo metadatos del vídeo (yt-dlp, capítulos/descrición primero)..." | |
| set -l ytdlp_json (yt-dlp --dump-json --no-warnings -q --no-playlist "$__YOUTUBE_URL") | |
| if test $status -ne 0 -o -z "$ytdlp_json" | |
| __die "No se pudo extraer información del vídeo con yt-dlp." | |
| end | |
| # 1) Intentar capítulos; 2) si no, descripción | |
| set -l raw_titles (__extract_titles_from_ytdlp_json "$ytdlp_json") | |
| # 3) Si sigue vacío, intentar con comentarios destacados (limitados) | |
| if test (count $raw_titles) -eq 0 | |
| __log "No hay capítulos ni timestamps en descripción. Consultando comentarios destacados (limitado)..." | |
| set -l comments_json (yt-dlp --dump-json --no-warnings -q --no-playlist --extractor-args "youtube:comment_sort=top,max_comments=40" "$__YOUTUBE_URL") | |
| if test $status -ne 0 -o -z "$comments_json" | |
| __die "No se pudo extraer comentarios con yt-dlp." | |
| end | |
| set raw_titles (__extract_titles_from_comments_json "$comments_json") | |
| end | |
| if test (count $raw_titles) -eq 0 | |
| __die "No se detectaron títulos con timestamps en capítulos, descripción ni comentarios." | |
| end | |
| # Limpieza/formateo final | |
| set -l cleaned_titles (clean_strings $raw_titles) | |
| if test (count $cleaned_titles) -eq 0 | |
| __die "Tras limpiar los títulos, no quedan entradas válidas." | |
| end | |
| # Buscar cada entrada en Subsonic/Navidrome | |
| __log "Buscando coincidencias en Navidrome..." | |
| set -l mappings # Lista de tríos "Parsed\tSubsonicLabel\tID" o "Parsed\tmissing" | |
| set -l song_ids | |
| for title in $cleaned_titles | |
| set -l match_type "exact" | |
| # Estrategia de búsqueda: consulta directa; si no hay resultados, prueba variaciones simples | |
| set -l best (__subsonic_search_song "$title") | |
| if test -z "$best" | |
| # Variante: reemplazar ' - ' por espacio, por si el título contiene artista - titulo | |
| set -l simplified (string replace -a -r '[[:space:]]*-[[:space:]]*' ' ' -- "$title") | |
| if test "$simplified" != "$title" | |
| set best (__subsonic_search_song "$simplified") | |
| if test -n "$best" | |
| set match_type "relaxed" | |
| end | |
| end | |
| if test -z "$best" | |
| # Variante: eliminar sufijos tipo " - XC2", " - XC1 DE", " - XCX" | |
| set -l stripped (string replace -r '\s*-\s*[A-Za-z0-9 ]{1,10}$' '' -- "$title") | |
| if test "$stripped" != "$title" | |
| set best (__subsonic_search_song "$stripped") | |
| if test -n "$best" | |
| set match_type "relaxed" | |
| end | |
| end | |
| end | |
| end | |
| if test -n "$best" | |
| set -l sid (printf "%s" "$best" | cut -f1) | |
| set -l label (printf "%s" "$best" | cut -f2) | |
| set -l album (printf "%s" "$best" | cut -f3) | |
| set -a song_ids $sid | |
| set -a mappings (printf "%s\t%s\t%s\t%s\t%s" "$title" "$label" "$sid" "$album" "$match_type") | |
| else | |
| set -a mappings (printf "%s\tmissing" "$title") | |
| end | |
| end | |
| # Mostrar resultados de mapeo como listas legibles | |
| echo "Found (exact):" | |
| printf "%s\n" $mappings | awk -F '\t' ' | |
| NF>=5 && $2 != "missing" && $5 == "exact" { | |
| alb = (length($4) ? $4 : "-"); | |
| printf "- %s -> %s (album: %s, id: %s)\n", $1, $2, alb, $3; | |
| exact=1 | |
| } | |
| END { | |
| if (!exact) print "- none" | |
| } | |
| ' | |
| echo | |
| echo "Found (relaxed):" | |
| printf "%s\n" $mappings | awk -F '\t' ' | |
| NF>=5 && $2 != "missing" && $5 == "relaxed" { | |
| alb = (length($4) ? $4 : "-"); | |
| printf "- %s -> %s (album: %s, id: %s)\n", $1, $2, alb, $3; | |
| relaxed=1 | |
| } | |
| END { | |
| if (!relaxed) print "- none" | |
| } | |
| ' | |
| echo | |
| echo "Missing:" | |
| printf "%s\n" $mappings | awk -F '\t' ' | |
| $2 == "missing" { | |
| printf "- %s\n", $1; | |
| missing=1 | |
| } | |
| END { | |
| if (!missing) print "- none" | |
| } | |
| ' | |
| # Resumen | |
| set -l total (count $cleaned_titles) | |
| set -l found (count $song_ids) | |
| set -l missing (math $total - $found) | |
| __log "Resumen: total parseados=$total, encontrados=$found, missing=$missing" | |
| # Si no hay ninguna coincidencia, termina aquí | |
| if test $found -eq 0 | |
| __die "No se encontró ninguna canción en Navidrome que coincida con los títulos parseados." | |
| end | |
| # Crear/override playlist (o simular en dry-run) | |
| __ensure_playlist "$__PLAYLIST_NAME" "$__OVERRIDE" $song_ids |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment