Skip to content

Instantly share code, notes, and snippets.

@thetzel
Created August 14, 2025 21:04
Show Gist options
  • Select an option

  • Save thetzel/601bd93a57a178a4a65c726d09905242 to your computer and use it in GitHub Desktop.

Select an option

Save thetzel/601bd93a57a178a4a65c726d09905242 to your computer and use it in GitHub Desktop.
Gets the beginning of a mp3 file and looks for chapters until it finds them
#!/usr/bin/env bash
# fetch the smallest possible head of an MP3 to list chapters (HH:MM:SS)
# deps: curl, ffprobe (ffmpeg), jq, column, awk
set -euo pipefail
usage() {
cat <<EOF
Usage: $0 <mp3_url> [--json] [--max-mb N] [--quiet]
--json Output chapters as JSON (seconds), not a table
--max-mb N Stop after N megabytes (default: 32)
--quiet Suppress progress messages
EOF
}
if [ $# -lt 1 ]; then usage; exit 1; fi
URL="$1"; shift || true
JSON_OUT="no"
MAX_MB=32
QUIET="no"
while [ $# -gt 0 ]; do
case "$1" in
--json) JSON_OUT="yes" ;;
--max-mb) shift; MAX_MB="${1:-32}" ;;
--quiet) QUIET="yes" ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
shift || true
done
TMP="$(mktemp -t mp3head_chaps.XXXXXX).mp3"
HDR="$(mktemp -t mp3head_hdr.XXXXXX)"
trap 'rm -f "$TMP" "$HDR"' EXIT
# 1 MB chunk size
MB=$((1024*1024))
chunk_mb=1
start_byte=0
end_byte=$((chunk_mb*MB - 1))
say() { [ "$QUIET" = "yes" ] || echo "$@"; }
fetch_range() {
local s="$1" e="$2"
: > "$HDR"
# Append bytes s..e to TMP
curl -fsSL --range "${s}-${e}" -D "$HDR" "$URL" -o - >> "$TMP" || return $?
# Detect if server honored range (206) or sent full file (200)
local code
code="$(awk 'BEGIN{c=""} /^HTTP\/[0-9.]+ /{c=$2} END{print c}' "$HDR")"
if [ "$code" = "200" ]; then
say "(server ignored range request; likely sent full file)"
# No point continuing — we already have the entire response
return 200
fi
if grep -qi '^HTTP/.* 416' "$HDR"; then
return 416 # past EOF
fi
return 0
}
have_chapters() {
ffprobe -v error -print_format json -show_chapters "$TMP" \
| jq -e '.chapters | length > 0' >/dev/null
}
print_chapters_table() {
ffprobe -v error -print_format json -show_chapters "$TMP" \
| jq -r '
def two(n): ("0\((n|floor))" | (.[-2:] // .));
def hms(t):
(t|tonumber|floor) as $s
| ($s/3600|floor) as $h
| (($s%3600)/60|floor) as $m
| ($s%60|floor) as $sec
| (two($h) + ":" + two($m) + ":" + two($sec));
.chapters[]
| [hms(.start_time), hms(.end_time), (.tags.title // .tags.TIT2 // "")]
| @tsv
' \
| awk 'BEGIN{print "start_time\tend_time\ttitle"}1' \
| column -t -s $'\t'
}
print_chapters_json() {
ffprobe -v error -print_format json -show_chapters "$TMP" \
| jq '.chapters
| map({start: (.start_time|tonumber),
end: (.end_time|tonumber),
title: (.tags.title // .tags.TIT2 // "")})'
}
# Incrementally fetch until chapters appear or we hit the cap
while : ; do
say "Fetching bytes ${start_byte}..${end_byte} (chunk: ${chunk_mb} MB; total: $((end_byte+1)) bytes)…"
if ! fetch_range "$start_byte" "$end_byte"; then
rc=$?
if [ $rc -eq 200 ]; then
# Full content delivered despite range; proceed to parse once.
:
elif [ $rc -eq 416 ]; then
say "Reached end of file without finding chapters."
break
else
echo "Download failed (curl exit $rc)." >&2
exit $rc
fi
fi
if have_chapters; then
say "—— Chapters ——"
if [ "$JSON_OUT" = "yes" ]; then
print_chapters_json
else
print_chapters_table
fi
exit 0
fi
# If server ignored range (got full file), no point continuing
if grep -q '^HTTP/.* 200' "$HDR"; then
say "No chapter markers found."
exit 3
fi
# Next chunk
chunk_mb=$((chunk_mb + 1))
if [ $chunk_mb -gt $MAX_MB ]; then
echo "Gave up after ${MAX_MB} MB without finding chapters." >&2
exit 4
fi
start_byte=$((end_byte + 1))
end_byte=$((end_byte + MB))
done
# If we exit the loop without chapters:
say "No chapter markers found."
exit 3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment