Skip to content

Instantly share code, notes, and snippets.

@Asmilex
Created August 8, 2025 11:19
Show Gist options
  • Select an option

  • Save Asmilex/0df42786171cb0d117cbe86cc140f97e to your computer and use it in GitHub Desktop.

Select an option

Save Asmilex/0df42786171cb0d117cbe86cc140f97e to your computer and use it in GitHub Desktop.
#!/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