072a8e72c1
gates / consistency-and-conventions (push) Successful in 2m14s
New tool that classifies URLs by domain and delegates to pos media mp3 (audio) or pos media mp4 (video). Listener gains URL detection step between prefix map and AI bridge — bare URLs auto-download. Domain rules: music.youtube.com/soundcloud/bandcamp → mp3, youtube/vimeo/twitch → mp4 --best, unknown → configurable default. 28 test cases / 70 assertions / 0 failed. make gen && make check green, make lint 0 FAIL / 0 WARN.
228 lines
7.7 KiB
Bash
Executable File
228 lines
7.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
# POS: media grab — Auto-download URL as audio or video (classify + route)
|
|
# POS_FLAGS: --audio --video --best --worst --output --no-playlist --cookies --dry-run
|
|
# POS_CONFIG: grab | grab.env | GRAB_DEFAULT=:Default mode for unknown domains (video or audio, default video)
|
|
|
|
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
|
|
|
# Load grab.env config (env-seam: GRAB_DEFAULT)
|
|
load_grab_config() {
|
|
local f="$HOME/.config/linux_post_install/grab.env" k v
|
|
[ -f "$f" ] || return 0
|
|
while IFS='=' read -r k v; do
|
|
[ -n "$k" ] || continue
|
|
case "$k" in
|
|
\#*) continue ;;
|
|
esac
|
|
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
|
if [ -z "${!k:-}" ]; then
|
|
export "$k"="$v"
|
|
fi
|
|
done < <(grep -E '^[A-Z_]+=' "$f" || true)
|
|
}
|
|
|
|
load_grab_config
|
|
|
|
# ── URL classification ─────────────────────────────────────────
|
|
classify_url() {
|
|
local url="$1" mode="${GRAB_DEFAULT:-video}"
|
|
case "$url" in
|
|
*music.youtube.com*) echo "audio" ;;
|
|
*soundcloud.com*) echo "audio" ;;
|
|
*bandcamp.com*) echo "audio" ;;
|
|
*youtube.com*|*youtu.be*) echo "video" ;;
|
|
*vimeo.com*) echo "video" ;;
|
|
*twitch.tv*) echo "video" ;;
|
|
*) echo "$mode" ;;
|
|
esac
|
|
}
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: pos media grab [options] <url>
|
|
|
|
Auto-download a URL as audio or video. Classifies the domain and delegates
|
|
to 'pos media mp3' (audio) or 'pos media mp4' (video).
|
|
|
|
Options:
|
|
--audio Force audio (mp3) download
|
|
--video Force video (mp4) download
|
|
--best Best quality for video (default for non-interactive)
|
|
--worst Lowest quality for video
|
|
-o, --output <dir> Output directory (passed to mp3/mp4)
|
|
--no-playlist Download only the single video
|
|
--cookies <file> Netscape cookies.txt for age-gated content
|
|
--dry-run Print the command that would run, don't execute
|
|
-h, --help This help
|
|
|
|
Examples:
|
|
pos media grab https://music.youtube.com/watch?v=abc
|
|
pos media grab https://youtube.com/watch?v=xyz
|
|
pos media grab --audio https://vimeo.com/123
|
|
pos media grab --worst https://youtu.be/abc
|
|
pos media grab --dry-run https://soundcloud.com/artist/track
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
# ── Arg parsing ────────────────────────────────────────────────
|
|
URL=""
|
|
FORCE_AUDIO=0
|
|
FORCE_VIDEO=0
|
|
BEST=0
|
|
WORST=0
|
|
DRY_RUN=0
|
|
EXTRA_ARGS=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-h|--help) usage ;;
|
|
--audio) FORCE_AUDIO=1; shift ;;
|
|
--video) FORCE_VIDEO=1; shift ;;
|
|
--best) BEST=1; shift ;;
|
|
--worst) WORST=1; shift ;;
|
|
-o|--output)
|
|
[ $# -ge 2 ] || err "pos media grab: --output needs a value"
|
|
EXTRA_ARGS+=(--output "$2"); shift 2 ;;
|
|
--no-playlist) EXTRA_ARGS+=(--no-playlist); shift ;;
|
|
--cookies)
|
|
[ $# -ge 2 ] || err "pos media grab: --cookies needs a value"
|
|
EXTRA_ARGS+=(--cookies "$2"); shift 2 ;;
|
|
--dry-run) DRY_RUN=1; shift ;;
|
|
-*) err "pos media grab: Unknown option: $1 (see --help)" ;;
|
|
*)
|
|
[ -z "$URL" ] && URL="$1" && shift || err "pos media grab: Unexpected argument: $1" ;;
|
|
esac
|
|
done
|
|
|
|
[ -n "$URL" ] || usage
|
|
|
|
# Validate URL scheme
|
|
case "$URL" in
|
|
http://*|https://*) ;;
|
|
*) err "pos media grab: not a valid URL: $URL (must start with http:// or https://)" ;;
|
|
esac
|
|
|
|
# Validate mutually exclusive overrides
|
|
[ "$FORCE_AUDIO" -eq 1 ] && [ "$FORCE_VIDEO" -eq 1 ] && \
|
|
err "pos media grab: --audio and --video are mutually exclusive"
|
|
[ "$BEST" -eq 1 ] && [ "$WORST" -eq 1 ] && \
|
|
err "pos media grab: --best and --worst are mutually exclusive"
|
|
|
|
# ── Classification ─────────────────────────────────────────────
|
|
mode=""
|
|
if [ "$FORCE_AUDIO" -eq 1 ]; then
|
|
mode="audio"
|
|
elif [ "$FORCE_VIDEO" -eq 1 ]; then
|
|
mode="video"
|
|
else
|
|
mode="$(classify_url "$URL")"
|
|
fi
|
|
|
|
# ── Build delegated command ────────────────────────────────────
|
|
DELEGATE_ARGS=()
|
|
|
|
if [ "$mode" = "audio" ]; then
|
|
DELEGATE_ARGS=(pos media mp3 "${EXTRA_ARGS[@]}")
|
|
else
|
|
# mp4 route: --best by default (non-interactive), --worst if user passes it
|
|
if [ "$WORST" -eq 1 ]; then
|
|
DELEGATE_ARGS=(pos media mp4 --worst "${EXTRA_ARGS[@]}")
|
|
else
|
|
DELEGATE_ARGS=(pos media mp4 --best "${EXTRA_ARGS[@]}")
|
|
fi
|
|
fi
|
|
|
|
# ── Dry run ────────────────────────────────────────────────────
|
|
if [ "$DRY_RUN" -eq 1 ]; then
|
|
echo "${DELEGATE_ARGS[*]} $URL"
|
|
exit 0
|
|
fi
|
|
|
|
# ── Execute ────────────────────────────────────────────────────
|
|
output=""
|
|
rc=0
|
|
if output=$("${DELEGATE_ARGS[@]}" "$URL" 2>&1); then
|
|
rc=0
|
|
else
|
|
rc=$?
|
|
fi
|
|
|
|
if [ "$rc" -ne 0 ]; then
|
|
# Summarize stderr for the user
|
|
summary="$(printf '%s' "$output" | grep -i 'error\|fail' | head -1 || true)"
|
|
[ -z "$summary" ] && summary="exit code $rc"
|
|
err "pos media grab: ❌ Download failed: $summary"
|
|
fi
|
|
|
|
# ── Metadata + summary ────────────────────────────────────────
|
|
title=""
|
|
duration=""
|
|
file_path=""
|
|
|
|
# Determine expected output directory
|
|
if [ "$mode" = "audio" ]; then
|
|
out_dir="$HOME/Music"
|
|
for (( i=0; i<${#EXTRA_ARGS[@]}; i++ )); do
|
|
if [ "${EXTRA_ARGS[$i]}" = "--output" ] && [ $(( i + 1 )) -lt ${#EXTRA_ARGS[@]} ]; then
|
|
out_dir="${EXTRA_ARGS[$(( i + 1 ))]}"
|
|
break
|
|
fi
|
|
done
|
|
file_ext="mp3"
|
|
else
|
|
out_dir="$HOME/Videos"
|
|
for (( i=0; i<${#EXTRA_ARGS[@]}; i++ )); do
|
|
if [ "${EXTRA_ARGS[$i]}" = "--output" ] && [ $(( i + 1 )) -lt ${#EXTRA_ARGS[@]} ]; then
|
|
out_dir="${EXTRA_ARGS[$(( i + 1 ))]}"
|
|
break
|
|
fi
|
|
done
|
|
file_ext="mp4"
|
|
fi
|
|
|
|
# Fetch metadata (fast, no download)
|
|
if command -v yt-dlp &>/dev/null; then
|
|
meta="$(yt-dlp --print title --print duration_string --no-warnings "$URL" 2>/dev/null || true)"
|
|
title="$(printf '%s' "$meta" | sed -n '1p')"
|
|
duration="$(printf '%s' "$meta" | sed -n '2p')"
|
|
fi
|
|
|
|
# Find the downloaded file (most recent matching extension in out_dir)
|
|
if [ -d "$out_dir" ]; then
|
|
file_path="$(find "$out_dir" -maxdepth 1 -name "*.$file_ext" -printf '%T@ %p\n' 2>/dev/null \
|
|
| sort -rn | head -1 | cut -d' ' -f2- || true)"
|
|
fi
|
|
|
|
# Build summary
|
|
[ -z "$title" ] && title="$(basename "$URL" | sed 's/[?#].*//')"
|
|
[ -z "$duration" ] && duration="?"
|
|
|
|
if [ "$mode" = "audio" ]; then
|
|
emoji="🎵"
|
|
else
|
|
emoji="🎬"
|
|
fi
|
|
|
|
echo "$emoji Downloaded: $title ($duration)"
|
|
|
|
if [ -n "$file_path" ] && [ -f "$file_path" ]; then
|
|
file_size="$(stat --printf='%s' "$file_path" 2>/dev/null || echo "0")"
|
|
# Format size in human-readable form
|
|
if [ "$file_size" -ge 1073741824 ]; then
|
|
size_human="$(awk "BEGIN { printf \"%.1f GB\", $file_size / 1073741824 }")"
|
|
elif [ "$file_size" -ge 1048576 ]; then
|
|
size_human="$(awk "BEGIN { printf \"%.1f MB\", $file_size / 1048576 }")"
|
|
elif [ "$file_size" -ge 1024 ]; then
|
|
size_human="$(awk "BEGIN { printf \"%.1f KB\", $file_size / 1024 }")"
|
|
else
|
|
size_human="${file_size} B"
|
|
fi
|
|
# Show path relative to HOME
|
|
rel_path="${file_path/#$HOME/\~}"
|
|
echo "📁 $rel_path ($size_human)"
|
|
else
|
|
echo "📁 $out_dir/ ($file_ext)"
|
|
fi
|