Files
he 6a6c323a89
gates / consistency-and-conventions (push) Successful in 1m35s
ai need continue
2026-09-04 13:58:38 -04:00

1214 lines
40 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
# POS: media ytsync — Incrementally sync YouTube channels/playlists into ~/Videos
# POS_SUBCMDS: add sync list remove
# POS_FLAGS: --dry-run
# POS_CONFIG: ytsync | ytsync.env | YTSYNC_VIDEOS_DIR=:Videos root for synced channels (default ~/Videos) | YTSYNC_EXTRA_ARGS=:Extra yt-dlp flags appended verbatim to every yt-dlp call (advanced)
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
if ! command -v notify_send >/dev/null 2>&1; then
notify_send() { return 0; } # notify.sh absent → silent no-op (opt-in by design)
fi
# ── Global flag pre-scan ────────────────────────────────────────────
DRY_RUN=0
for _arg in "$@"; do
if [ "$_arg" = "--dry-run" ]; then DRY_RUN=1; fi
done
# ── Deps guards (before -h|--help; yt-dlp+jq active EVEN under --dry-run,
# because the preview IS the probe — deliberate divergence from mp3/mp4) ──
command -v yt-dlp >/dev/null 2>&1 || err "yt-dlp not found — installed by preinstall.sh (GitHub release → /usr/local/bin); run ./preinstall.sh or see DOC/howto/media.md"
command -v jq >/dev/null 2>&1 || err "jq not found (needed to parse yt-dlp probe output) — install it with: sudo apt install jq"
if [ "$DRY_RUN" -eq 0 ]; then
command -v ffmpeg >/dev/null 2>&1 || err "ffmpeg not found (needed for MP4 merge) — install it with: sudo apt install ffmpeg"
fi
# ── Runtime config (~/.config/linux_post_install/ytsync.env) ────────
# Exported environment wins over the file; defaults come last.
_YTSYNC_CFG="$CONFIG_DIR/ytsync.env"
if [ -f "$_YTSYNC_CFG" ]; then
while IFS= read -r _line || [ -n "$_line" ]; do
case "$_line" in ''|\#*) continue ;; esac
case "$_line" in
YTSYNC_VIDEOS_DIR=*)
_v="${_line#*=}"
_v="${_v%\"}"; _v="${_v#\"}"; _v="${_v%\'}"; _v="${_v#\'}"
: "${YTSYNC_VIDEOS_DIR:=$_v}"
;;
YTSYNC_EXTRA_ARGS=*)
_v="${_line#*=}"
_v="${_v%\"}"; _v="${_v#\"}"; _v="${_v%\'}"; _v="${_v#\'}"
: "${YTSYNC_EXTRA_ARGS:=$_v}"
;;
esac
done <"$_YTSYNC_CFG"
fi
# ── Env seams (all written VAR="${VAR:-default}") ───────────────────
YTSYNC_VIDEOS_DIR="${YTSYNC_VIDEOS_DIR:-$HOME/Videos}" # user key AND test seam
YTSYNC_STATE_DIR="${YTSYNC_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/share}/linux_post_install/ytsync}"
REG_FILE="$YTSYNC_STATE_DIR/registry"
ARCHIVE_DIR="$YTSYNC_STATE_DIR/archive"
HIST_FILE="$YTSYNC_STATE_DIR/history.log"
BAD_URL_MSG="Not a YouTube URL — expected a channel (@handle, /c/, /user/), a playlist (?list=…), or a video link"
usage() {
cat <<'EOF'
Usage: pos media ytsync [command] [args]
Incrementally download YouTube channels/playlists into ~/Videos.
First run asks for a channel URL; repeat runs fetch only new videos.
Commands:
add [url] Register a source and download it (asks for URL if omitted)
sync [name] Incremental sync of tracked sources (all, if no name given)
list Show tracked sources and their status
remove <name> Stop tracking a source (keeps downloaded files)
Options:
--dry-run Show what would be downloaded, fetch nothing
-h, --help This help
Layout:
Videos/<channel>/<playlist>/<NNN> - <title>.<ext> (playlist sources)
Videos/<channel>/<title>.<ext> (channel/video sources)
Notes:
Existing files are never overwritten; renamed/retitled videos keep their
local filename. Unattended/scheduled use: 'pos media ytsync sync'.
A watch link (?v=…, incl. youtu.be/<id> short links) downloads only that
single video (--no-playlist), even when it carries &list= — never the whole
playlist. 'remove' keeps the downloaded files AND the archive — re-adding
the same source later resumes incrementally instead of re-downloading.
Examples:
pos media ytsync # interactive
pos media ytsync add https://youtube.com/@SomeChannel
pos media ytsync sync # cron/timer entry point
pos media ytsync sync --dry-run # preview only
pos media ytsync list
Environment:
YTSYNC_VIDEOS_DIR Videos root (default: $HOME/Videos)
YTSYNC_EXTRA_ARGS Extra yt-dlp flags appended to every download call
Both editable via 'pos config ytsync'.
EOF
exit 0
}
# ── Small shared helpers ────────────────────────────────────────────
_tmpfiles=()
cleanup() {
if [ "${#_tmpfiles[@]}" -gt 0 ]; then
rm -f "${_tmpfiles[@]}" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
newtmp() {
local t
t="$(mktemp)"
_tmpfiles+=("$t")
printf '%s' "$t"
}
_pretty() { # collapse a $HOME prefix to ~ for display only
local p="$1"
case "$p" in
"$HOME") printf '~' ;;
"$HOME"/*) printf '~%s' "${p#"$HOME"}" ;;
*) printf '%s' "$p" ;;
esac
}
fmt_dur() { # seconds → "4m12s" / "37s" / "1h05m"
local s="$1"
if [ "$s" -ge 3600 ]; then
printf '%dh%02dm' $((s / 3600)) $(((s % 3600) / 60))
elif [ "$s" -ge 60 ]; then
printf '%dm%02ds' $((s / 60)) $((s % 60))
else
printf '%ss' "$s"
fi
}
secs_since() { # nanosecond start stamp → whole seconds elapsed
local end
end=$(_nano_now)
echo $(((end - $1) / 1000000000))
}
is_youtube_url() {
local u="$1"
case "$u" in
http://*youtube.com/* | https://*youtube.com/* | \
http://*youtu.be/* | https://*youtu.be/* | \
www.youtube.com/* | m.youtube.com/* | music.youtube.com/* | \
youtube.com/* | youtu.be/* | @*)
return 0 ;;
esac
return 1
}
classify_url() { # names-one-video (v= or youtu.be/<id>) → video; list= → playlist; else channel
local u="$1" vid=""
case "$u" in
*youtu.be/*)
vid="${u#*youtu.be/}" # short link: video id is the first path segment
vid="${vid%%\?*}"
vid="${vid%%\#*}"
vid="${vid%%/*}"
;;
esac
if [ -n "$vid" ]; then
printf 'video\n'
elif [[ "$u" == *v=* ]]; then
printf 'video\n'
elif [[ "$u" == *list=* ]]; then
printf 'playlist\n'
else
printf 'channel\n'
fi
}
# ── Channel URL canonicalization ─────────────────────────────────────
# yt-dlp --flat-playlist on a bare channel URL returns the channel's
# TAB list (Videos/Live/Shorts; _type "playlist", url null, id==channel_id),
# not videos. Appending /videos makes the probe return the real videos.
canonical_channel_url() { # add /videos to bare channel URLs; echo canonical
local u="$1"
case "$u" in
@*) u="https://www.youtube.com/$u" ;; # bare 'handle' → full URL
esac
[ "$(classify_url "$u")" = "channel" ] || { printf '%s' "$u"; return 0; }
local path="${u%%\?*}"
path="${path%%\#*}"
path="${path%/}"
case "${path##*/}" in
videos | shorts | streams | live | playlists | featured | releases | podcasts | search)
printf '%s' "$u" ;;
*)
printf '%s/videos' "$u" ;;
esac
}
sanitize_component() { # safe directory component from a resolved name
local s="$1"
s="${s//\//-}"
s="$(printf '%s' "$s" | sed -E 's/^[[:space:].]+//; s/[[:space:].]+$//')"
s="${s:0:80}"
if [ -z "$s" ]; then s="YouTube"; fi
printf '%s' "$s"
}
slugify() { # unique internal key [a-z0-9][a-z0-9_-]*
local s
s="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
s="$(printf '%s' "$s" | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' | cut -c1-60)"
if [ -z "$s" ]; then s="source"; fi
case "$s" in
[a-z0-9]*) ;;
*) s="c$s" ;;
esac
printf '%s' "$s"
}
# ── Registry (machine-owned state; \x1f-delimited records) ──────────
# slug ⇥ type ⇥ url ⇥ subdir ⇥ playlist_title ⇥ added_ts
registry_entries() {
if [ -f "$REG_FILE" ]; then
cat "$REG_FILE"
fi
return 0
}
registry_count() {
local n=""
if [ -f "$REG_FILE" ]; then
n="$(grep -c . "$REG_FILE" 2>/dev/null || true)"
fi
printf '%s' "${n:-0}"
}
registry_has_slug() {
local s="$1" line f1
while IFS= read -r line; do
[ -n "$line" ] || continue
f1="${line%%$'\x1f'*}"
if [ "$f1" = "$s" ]; then return 0; fi
done < <(registry_entries)
return 1
}
unique_slug() { # numeric -2 suffix on collision (checks registry slugs only)
local base="$1" cand n=2
cand="$base"
while registry_has_slug "$cand"; do
cand="${base}-${n}"
n=$((n + 1))
done
printf '%s' "$cand"
}
registry_add_line() { # atomic append (temp+mv)
local line="$1" tmp
mkdir -p "$YTSYNC_STATE_DIR"
tmp="$(mktemp "$YTSYNC_STATE_DIR/.registry.XXXXXX")"
_tmpfiles+=("$tmp")
{
if [ -f "$REG_FILE" ]; then cat "$REG_FILE"; fi
printf '%s\n' "$line"
} >"$tmp"
mv "$tmp" "$REG_FILE"
}
registry_remove_slug() { # atomic rewrite without the slug's line
local slug="$1" tmp line f1
mkdir -p "$YTSYNC_STATE_DIR"
tmp="$(mktemp "$YTSYNC_STATE_DIR/.registry.XXXXXX")"
_tmpfiles+=("$tmp")
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
f1="${line%%$'\x1f'*}"
if [ "$f1" = "$slug" ]; then continue; fi
printf '%s\n' "$line"
done < <(registry_entries) >"$tmp"
mv "$tmp" "$REG_FILE"
}
# ── Archive + history ───────────────────────────────────────────────
is_archived() { # native yt-dlp archive format "<extractor> <id>"; exact-field match
local id="$1" f="$2"
[ -f "$f" ] || return 1
awk -v want="$id" '$2 == want { found=1; exit } END { exit(found ? 0 : 1) }' "$f"
}
archive_count() {
local slug="$1" n=""
if [ -f "$ARCHIVE_DIR/$slug.txt" ]; then
n="$(wc -l <"$ARCHIVE_DIR/$slug.txt")"
n="$(printf '%s' "$n" | tr -d '[:space:]')"
fi
printf '%s' "${n:-0}"
}
append_history() { # <date> · <name> · N new · M skipped · K failed
mkdir -p "$YTSYNC_STATE_DIR"
printf '%s · %s · %s new · %s skipped · %s failed\n' \
"$(date '+%Y-%m-%d %H:%M')" "$1" "$2" "$3" "$4" >>"$HIST_FILE"
}
append_history_failed() {
mkdir -p "$YTSYNC_STATE_DIR"
printf '%s · %s · FAILED (probe)\n' "$(date '+%Y-%m-%d %H:%M')" "$1" >>"$HIST_FILE"
}
last_sync_of() { # newest history line naming <display name>, date field only
local name="$1" last=""
if [ -f "$HIST_FILE" ]; then
# grep may rc=1 on no match (pipefail) — || true keeps empty-string semantics
last="$(grep -F " · $name · " "$HIST_FILE" 2>/dev/null | tail -n 1 || true)"
last="${last%% ·*}"
fi
printf '%s' "${last:--}"
}
last_run_overall() {
local last=""
if [ -f "$HIST_FILE" ]; then
last="$(tail -n 1 "$HIST_FILE" || true)"
last="${last%% ·*}"
fi
printf '%s' "$last"
}
# ── Probe (yt-dlp --flat-playlist -J, parsed with jq) ───────────────
# spawn()-shaped UX but RETURNS failure instead of exiting: the interactive add
# flow re-prompts up to 3× and a failing source during sync must not kill the
# others' passes (spawn() exits the process on failure — unusable here).
# Spinner frames are TTY-only so no \r bytes ever reach the dispatcher tee log.
PROBE_JSON=""
PROBE_ERR=""
run_probe() { # run_probe <url>
local url="$1"
url="$(canonical_channel_url "$url")"
PROBE_JSON="$(newtmp)"
PROBE_ERR="$(newtmp)"
local msg="Resolving source …" start rc pid elapsed i
start=$(_nano_now)
yt-dlp --flat-playlist -J --no-warnings -- "$url" >"$PROBE_JSON" 2>"$PROBE_ERR" &
pid=$!
if [ -t 1 ]; then
local spin=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
i=0
while kill -0 "$pid" 2>/dev/null; do
printf '\r%s %s%s %s' "$CYAN" "${spin[$i]}" "$RESET" "$msg"
i=$(((i + 1) % ${#spin[@]}))
sleep 0.1
done
else
printf '%s\n' "$msg"
fi
rc=0
wait "$pid" || rc=$?
elapsed=$(_elapsed "$start")
if [ "$rc" -eq 0 ]; then
if [ -t 1 ]; then
printf '\r%s OK%s %s (%s)\n' "$GREEN" "$RESET" "$msg" "$elapsed"
else
printf '%s OK %s (%s)\n' "$GREEN" "$msg" "$elapsed"
fi
else
if [ -t 1 ]; then
printf '\r%s FAIL%s %s (%s)\n' "$RED" "$RESET" "$msg" "$elapsed"
else
printf '%s FAIL %s (%s)\n' "$RED" "$msg" "$elapsed"
fi
fi
return "$rc"
}
probe_fail_reason() { # echoes "notfound" | "unreachable" (reads $PROBE_ERR)
if grep -qiE 'not found|private|does not exist|404|unavailable|removed' "$PROBE_ERR" 2>/dev/null; then
printf 'notfound\n'
else
printf 'unreachable\n'
fi
}
parse_probe() { # sets P_NAME P_OWNER P_TITLE P_KEY from $PROBE_JSON
jq -e 'type == "object"' "$PROBE_JSON" >/dev/null 2>&1 || return 1
P_NAME="$(jq -r '.channel // .uploader // .uploader_id // .title // ""' "$PROBE_JSON")"
P_OWNER="$(jq -r '.channel // .uploader // .uploader_id // ""' "$PROBE_JSON")"
P_TITLE="$(jq -r '.title // ""' "$PROBE_JSON")"
P_KEY="$(jq -r '.uploader_id // .channel_id // .id // ""' "$PROBE_JSON")"
return 0
}
ENTRY_IDS=()
ENTRY_TITLES=()
collect_entries() { # flat .entries[] or a single video object (?v= URLs)
ENTRY_IDS=()
ENTRY_TITLES=()
local n p id t
n="$(jq -r '((.entries // []) | length)' "$PROBE_JSON")"
if [ "$n" -gt 0 ]; then
local -a pairs=()
mapfile -t pairs < <(jq -r '.entries[] | select((.url // "") | test("watch\\?v=|youtu\\.be/|/shorts/")) | ((.id // "") + "\u001f" + (.title // ""))' "$PROBE_JSON")
for p in "${pairs[@]}"; do
id="${p%%$'\x1f'*}"
t="${p#*$'\x1f'}"
ENTRY_IDS+=("$id")
ENTRY_TITLES+=("$t")
done
elif [ "$(jq -r '._type // ""' "$PROBE_JSON")" = "video" ]; then
ENTRY_IDS+=("$(jq -r '.id // ""' "$PROBE_JSON")")
ENTRY_TITLES+=("$(jq -r '.title // ""' "$PROBE_JSON")")
fi
}
NEW_IDS=()
NEW_TITLES=()
NEW_POS=()
NEW_TOTAL=0
SIGNIN_COUNT=0
TOTAL_VALID=0
is_signin_skipped() { # empty id OR "[Private"/"[Deleted"/"[Unavailable" titles
if [ -z "$1" ]; then return 0; fi
case "$2" in
"[Private"* | "[Deleted"* | "[Unavailable"*) return 0 ;;
esac
return 1
}
compute_new_list() { # $1 = archive file; diff entry ids against it BEFORE downloads
local archive="$1"
NEW_IDS=()
NEW_TITLES=()
NEW_POS=()
NEW_TOTAL=0
SIGNIN_COUNT=0
TOTAL_VALID=0
local i n id t pos=0
n="${#ENTRY_IDS[@]}"
for ((i = 0; i < n; i++)); do
id="${ENTRY_IDS[$i]}"
t="${ENTRY_TITLES[$i]}"
pos=$((pos + 1))
if is_signin_skipped "$id" "$t"; then
SIGNIN_COUNT=$((SIGNIN_COUNT + 1))
continue
fi
TOTAL_VALID=$((TOTAL_VALID + 1))
if ! is_archived "$id" "$archive"; then
NEW_IDS+=("$id")
NEW_TITLES+=("$t")
NEW_POS+=("$pos")
NEW_TOTAL=$((NEW_TOTAL + 1))
fi
done
return 0
}
# ── Per-video download (NEVER wrapped in spawn(): hours-long batches need a
# live heartbeat; spawn captures output until completion) ────────────────
DL_ERRCAP=""
DL_RC=0
download_video() { # <archive> <template> <video_id> <type>; stderr→$DL_ERRCAP
local archive="$1" tpl="$2" vid="$3" vtype="$4"
local -a dl=(
yt-dlp
-f "bestvideo*+bestaudio/best"
--merge-output-format mp4
--embed-metadata --embed-chapters
--embed-thumbnail
--no-overwrites
--download-archive "$archive"
--windows-filenames
--trim-filenames 120
--retries 3 --fragment-retries 3
)
if [ "$vtype" = "video" ]; then dl+=(--no-playlist); fi
dl+=(--quiet --no-warnings)
if [ -t 1 ]; then dl+=(--progress); fi
if [ -n "${YTSYNC_EXTRA_ARGS:-}" ]; then
local -a extra=()
read -r -a extra <<<"$YTSYNC_EXTRA_ARGS" || true
if [ "${#extra[@]}" -gt 0 ]; then dl+=("${extra[@]}"); fi
fi
dl+=(-o "$tpl" "https://www.youtube.com/watch?v=$vid")
DL_ERRCAP="$(newtmp)"
DL_RC=0
"${dl[@]}" 2>"$DL_ERRCAP" || DL_RC=$?
}
# ── Failure-alarm trap (media-sync precedent), armed per pass ───────
CURRENT_SOURCE=""
arm_fail_alarm() {
CURRENT_SOURCE="$1"
trap 'notify_send "⚠️ ytSync FAILED for ${CURRENT_SOURCE}"' ERR
}
disarm_fail_alarm() {
trap - ERR
CURRENT_SOURCE=""
}
# ── Run-result accumulators ─────────────────────────────────────────
G_NEW=0
G_PRESENT=0
G_FAILED=0
G_WHOLEFAIL=0
G_SOURCES=0
NOTIFY_TITLES=()
NOTIFY_MORE=0
NOTIFY_NAMES_NEW=()
NOTIFY_NAMES_FAIL=()
NOTIFY_SRC_NEW=0
NOTIFY_DEST=""
PASS_ROWS=()
reset_globals() {
G_NEW=0
G_PRESENT=0
G_FAILED=0
G_WHOLEFAIL=0
G_SOURCES=0
NOTIFY_TITLES=()
NOTIFY_MORE=0
NOTIFY_NAMES_NEW=()
NOTIFY_NAMES_FAIL=()
NOTIFY_SRC_NEW=0
NOTIFY_DEST=""
PASS_ROWS=()
}
join_comma() { # join_comma <array-name>
local -n arr="$1"
local out="" el
for el in "${arr[@]}"; do
if [ -z "$out" ]; then out="$el"; else out="${out}, ${el}"; fi
done
printf '%s' "$out"
}
send_digest() { # noteworthy-only: new>0 or failed>0 (silent noop runs)
if [ "$DRY_RUN" -eq 1 ]; then return 0; fi
if [ "$G_NEW" -gt 0 ]; then
local msg t dest
msg="📺 ytSync — ${G_NEW} new videos: $(join_comma NOTIFY_NAMES_NEW)"
for t in "${NOTIFY_TITLES[@]}"; do
msg+=$'\n• '"${t}"
done
if [ "$NOTIFY_MORE" -gt 0 ]; then
msg+=$'\n…and '"${NOTIFY_MORE}"' more'
fi
if [ "$NOTIFY_SRC_NEW" -eq 1 ]; then
dest="$NOTIFY_DEST"
else
dest="$YTSYNC_VIDEOS_DIR"
fi
msg+=$'\n→ '"$(_pretty "$dest")"
notify_send "$msg"
fi
if [ "$G_FAILED" -gt 0 ]; then
notify_send "⚠️ ytSync — ${G_FAILED} videos failed: $(join_comma NOTIFY_NAMES_FAIL) (run 'pos media ytsync sync' to retry)"
elif [ "$G_WHOLEFAIL" -gt 0 ]; then
notify_send "⚠️ ytSync — ${G_WHOLEFAIL} sources failed: $(join_comma NOTIFY_NAMES_FAIL) (check connectivity; run 'pos media ytsync sync' to retry)"
fi
return 0
}
# ── Per-source pass engine ──────────────────────────────────────────
S_SLUG="" S_TYPE="" S_URL="" S_SUBDIR="" S_TITLE=""
R_NEW=0 R_PRESENT=0 R_FAILED=0 R_EXISTS=0 R_SIGNIN=0 R_STOPPED=""
pass_prepare() { # fresh probe + diff; rc 0 ok / 1 wholesale failure
R_NEW=0
R_PRESENT=0
R_FAILED=0
R_EXISTS=0
R_SIGNIN=0
R_STOPPED=""
CURRENT_SOURCE="${S_SUBDIR##*/}"
if ! run_probe "$S_URL"; then
if [ "$(probe_fail_reason)" = "notfound" ]; then
warn "source not found or private: $S_URL — skipping"
else
warn "could not reach YouTube — check your connection and try again (${CURRENT_SOURCE})"
fi
return 1
fi
if ! parse_probe; then
warn "unexpected yt-dlp probe output for ${CURRENT_SOURCE} — skipping"
return 1
fi
collect_entries
compute_new_list "$ARCHIVE_DIR/$S_SLUG.txt"
R_SIGNIN="$SIGNIN_COUNT"
R_PRESENT=$((TOTAL_VALID - NEW_TOTAL))
return 0
}
print_dry_plan_for_current() {
if [ "$R_SIGNIN" -gt 0 ]; then
warn "${R_SIGNIN} videos require sign-in — skipped"
fi
printf 'Source : %s\n' "$S_URL"
printf 'Resolved : %s (%s · %s videos)\n' "${P_NAME:-${S_SUBDIR##*/}}" "$S_TYPE" "$TOTAL_VALID"
printf 'Library : %s/\n' "$(_pretty "$YTSYNC_VIDEOS_DIR/$S_SUBDIR")"
printf 'New : %s would be downloaded (%s already present)\n' "$NEW_TOTAL" "$R_PRESENT"
local i shown=0 ex
for ((i = 0; i < NEW_TOTAL; i++)); do
if [ "$shown" -ge 5 ]; then break; fi
if [ "$S_TYPE" = "playlist" ]; then
ex="$(printf '%03d - %s.mp4' "${NEW_POS[$i]}" "${NEW_TITLES[$i]}")"
else
ex="${NEW_TITLES[$i]}.mp4"
fi
printf ' %s\n' "$ex"
shown=$((shown + 1))
done
if [ "$NEW_TOTAL" -gt "$shown" ]; then
printf ' … %s more\n' "$((NEW_TOTAL - shown))"
fi
printf 'DRY RUN complete — %s new would be fetched → %s/%s\n' \
"$NEW_TOTAL" "$(_pretty "$YTSYNC_VIDEOS_DIR")" "$S_SUBDIR"
return 0
}
pass_execute() {
local name="${S_SUBDIR##*/}"
local archive="$ARCHIVE_DIR/$S_SLUG.txt"
local destdir="$YTSYNC_VIDEOS_DIR/$S_SUBDIR"
if [ "$DRY_RUN" -eq 1 ]; then
print_dry_plan_for_current
G_SOURCES=$((G_SOURCES + 1))
G_NEW=$((G_NEW + NEW_TOTAL))
G_PRESENT=$((G_PRESENT + R_PRESENT))
PASS_ROWS+=("${name}${NEW_TOTAL} new · ${R_PRESENT} present")
return 0
fi
if [ "$R_SIGNIN" -gt 0 ]; then
warn "${R_SIGNIN} videos require sign-in — skipped"
fi
log "${name}${NEW_TOTAL} new of ${TOTAL_VALID}"
if [ "${#NEW_IDS[@]}" -eq 0 ]; then
ok "Sync complete: 0 new, ${R_PRESENT} already present, 0 failed → ${destdir}"
append_history "$name" 0 "$R_PRESENT" 0
G_SOURCES=$((G_SOURCES + 1))
G_PRESENT=$((G_PRESENT + R_PRESENT))
PASS_ROWS+=("${name} — 0 new · ${R_PRESENT} present · 0 failed")
return 0
fi
mkdir -p "$destdir" "$ARCHIVE_DIR"
arm_fail_alarm "$name"
local i total idx vid vt tpl pos tstart rc
total="${#NEW_IDS[@]}"
idx=0
for ((i = 0; i < total; i++)); do
vid="${NEW_IDS[$i]}"
vt="${NEW_TITLES[$i]}"
pos="${NEW_POS[$i]}"
idx=$((idx + 1))
echo " [$idx/$total] $vt"
if [ "$S_TYPE" = "playlist" ]; then
tpl="${destdir}/$(printf '%03d' "$pos") - %(title)s.%(ext)s"
else
tpl="${destdir}/%(title)s.%(ext)s"
fi
tstart=$(_nano_now)
download_video "$archive" "$tpl" "$vid" "$S_TYPE"
rc="$DL_RC"
if [ "$rc" -eq 0 ]; then
ok "[$idx/$total] $vt ($(fmt_dur "$(secs_since "$tstart")"))"
R_NEW=$((R_NEW + 1))
G_NEW=$((G_NEW + 1))
if [ "${#NOTIFY_TITLES[@]}" -lt 5 ]; then
NOTIFY_TITLES+=("$vt")
else
NOTIFY_MORE=$((NOTIFY_MORE + 1))
fi
elif grep -qE 'No space left on device|Errno 28' "$DL_ERRCAP" 2>/dev/null; then
warn "disk full — stopping '${name}' mid-run"
sed -n '1,3p' "$DL_ERRCAP" | sed 's/^/ /'
R_STOPPED="yes"
break
elif grep -qiE 'has already been downloaded|already been recorded' "$DL_ERRCAP" 2>/dev/null; then
warn "exists, kept: $vt"
ok "[$idx/$total] $vt ($(fmt_dur "$(secs_since "$tstart")"))"
R_EXISTS=$((R_EXISTS + 1))
else
warn "unavailable: $vt"
sed -n '1,3p' "$DL_ERRCAP" | sed 's/^/ /'
R_FAILED=$((R_FAILED + 1))
G_FAILED=$((G_FAILED + 1))
fi
done
disarm_fail_alarm
local present_total=$((R_PRESENT + R_EXISTS))
ok "Sync complete: ${R_NEW} new, ${present_total} already present, ${R_FAILED} failed → ${destdir}"
append_history "$name" "$R_NEW" "$present_total" "$R_FAILED"
G_SOURCES=$((G_SOURCES + 1))
G_PRESENT=$((G_PRESENT + present_total))
local row="${name}${R_NEW} new · ${present_total} present · ${R_FAILED} failed"
if [ -n "$R_STOPPED" ]; then row+=" · STOPPED (disk full)"; fi
PASS_ROWS+=("$row")
if [ "$R_NEW" -gt 0 ]; then
NOTIFY_NAMES_NEW+=("$name")
NOTIFY_SRC_NEW=$((NOTIFY_SRC_NEW + 1))
NOTIFY_DEST="$destdir"
fi
if [ "$R_FAILED" -gt 0 ]; then
NOTIFY_NAMES_FAIL+=("$name")
fi
return 0
}
# ── Add flow ────────────────────────────────────────────────────────
DERIVED_SLUG=""
DERIVED_SUBDIR=""
derive_source_fields() { # $1=vtype $2=url; needs parsed probe globals
local vtype="$1" url="$2"
local name="$P_NAME" key="$P_KEY" base subdir title
if [ -z "$name" ]; then name="$url"; fi
if [ -n "$key" ]; then
base="$(slugify "$key")"
else
base="$(slugify "$name")"
fi
DERIVED_SLUG="$(unique_slug "$base")"
case "$vtype" in
playlist)
title="$(sanitize_component "${P_TITLE:-Playlist}")"
if [ -n "$P_OWNER" ]; then
subdir="$(sanitize_component "$P_OWNER")/${title}"
else
subdir="$title"
fi
;;
*)
subdir="$(sanitize_component "$name")"
;;
esac
DERIVED_SUBDIR="$subdir"
}
show_resolution_screen() { # $1=vtype $2=subdir
printf 'Resolved : %s (%s · %s videos)\n' "${P_NAME:-unknown}" "$1" "$TOTAL_VALID"
printf 'Library : %s/\n' "$(_pretty "$YTSYNC_VIDEOS_DIR/$2")"
case "$1" in
playlist) printf ' └── numbered: <NNN> - <title>.<ext>\n' ;;
*) printf ' └── flat videos: <title>.<ext>\n' ;;
esac
}
finish_add() { # $1=vtype $2=url $3=confirm|explicit — probe already done
local vtype="$1" url="$2" mode="$3"
collect_entries
derive_source_fields "$vtype" "$url"
local slug="$DERIVED_SLUG" subdir="$DERIVED_SUBDIR"
local name="${subdir##*/}"
if registry_has_slug "$slug"; then
log "'$name' is already tracked — nothing to add (run 'pos media ytsync sync')"
return 0
fi
compute_new_list "$ARCHIVE_DIR/$slug.txt"
# mirror pass_prepare's counters so pass_execute's summary/digest are
# correct on the add path too (archive may hold ids from a previous
# tracking period — remove/re-add resume)
R_SIGNIN="$SIGNIN_COUNT"
R_PRESENT=$((TOTAL_VALID - NEW_TOTAL))
show_resolution_screen "$vtype" "$subdir"
if [ "$DRY_RUN" -eq 0 ]; then
if [ "$mode" = "confirm" ]; then
local ans=""
if ! read -rp "Start download? [Y/n] " ans </dev/tty; then
log "Cancelled — nothing changed"
return 0
fi
case "$ans" in
n | N | no | NO)
log "Cancelled — nothing changed"
return 0
;;
esac
fi
mkdir -p "$YTSYNC_STATE_DIR" "$ARCHIVE_DIR"
registry_add_line "$(printf '%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s' \
"$slug" "$vtype" "$url" "$subdir" "${P_TITLE:-}" "$(date '+%Y-%m-%d %H:%M')")"
fi
S_SLUG="$slug"
S_TYPE="$vtype"
S_URL="$url"
S_SUBDIR="$subdir"
S_TITLE="${P_TITLE:-}"
if [ "$DRY_RUN" -eq 1 ]; then
log "DRY RUN — nothing will be downloaded"
print_dry_plan_for_current
return 0
fi
pass_execute
send_digest
return 0
}
require_tty() {
if [ ! -t 0 ]; then
printf "%s\n" "[!] ytSync needs a terminal for its prompt — use 'pos media ytsync sync' for unattended runs." >&2
return 1
fi
return 0
}
ASKED_URL=""
ATTEMPT=1
attempt_exhausted() { # bumps ATTEMPT; rc 0 → exhausted (cancel), 1 → keep asking
ATTEMPT=$((ATTEMPT + 1))
[ "$ATTEMPT" -gt 3 ]
}
ask_url_interactive() { # sets ASKED_URL; rc 1 = cancelled/EOF
ATTEMPT=1
ASKED_URL=""
local u prompt vtype
while :; do
if [ "$ATTEMPT" -eq 1 ]; then
prompt="Channel or playlist URL: "
else
prompt="Try again (${ATTEMPT} of 3), or press Enter to cancel: "
fi
u=""
if ! read -rp "$prompt" u </dev/tty; then
log "Cancelled — nothing changed"
return 1
fi
if [ -z "$u" ]; then
if attempt_exhausted; then
log "Cancelled — nothing changed"
return 1
fi
continue
fi
if ! is_youtube_url "$u"; then
warn "$BAD_URL_MSG"
if attempt_exhausted; then
log "Cancelled — nothing changed"
return 1
fi
continue
fi
vtype="$(classify_url "$u")"
if ! run_probe "$u"; then
if [ "$(probe_fail_reason)" = "notfound" ]; then
warn "source not found or private: $u"
else
warn "could not reach YouTube — check your connection and try again"
fi
if attempt_exhausted; then
log "Cancelled — nothing changed"
return 1
fi
continue
fi
if ! parse_probe; then
warn "unexpected yt-dlp probe output"
if attempt_exhausted; then
log "Cancelled — nothing changed"
return 1
fi
continue
fi
ASKED_URL="$u"
return 0
done
}
cmd_add() {
local url="${1:-}"
reset_globals
if [ -z "$url" ]; then
if ! require_tty; then exit 0; fi
if ! ask_url_interactive; then return 0; fi
url="$ASKED_URL"
fi
if ! is_youtube_url "$url"; then
warn "$BAD_URL_MSG"
exit 1
fi
if ! run_probe "$url"; then
if [ "$(probe_fail_reason)" = "notfound" ]; then
err "source not found or private: $url"
fi
warn "could not reach YouTube — check your connection and try again"
exit 1
fi
if ! parse_probe; then
err "could not parse yt-dlp probe output for: $url"
fi
local mode="explicit"
if [ -z "${1:-}" ]; then mode="confirm"; fi
finish_add "$(classify_url "$url")" "$url" "$mode"
}
# ── Sync flow ───────────────────────────────────────────────────────
SYNC_LINES=()
MATCHING_SLUGS=()
resolve_sources() { # $1=token-or-empty → SYNC_LINES[]; rc 0 ok / 1 none / 2 ambiguous
SYNC_LINES=()
MATCHING_SLUGS=()
local token="$1" line slug sub
if [ -z "$token" ]; then
mapfile -t SYNC_LINES < <(registry_entries)
if [ "${#SYNC_LINES[@]}" -eq 0 ]; then return 1; fi
return 0
fi
while IFS= read -r line; do
[ -n "$line" ] || continue
IFS=$'\x1f' read -r slug _ _ sub _ _ <<<"$line"
if [ "$token" = "$slug" ] || [ "$token" = "${sub##*/}" ]; then
SYNC_LINES+=("$line")
MATCHING_SLUGS+=("$slug")
fi
done < <(registry_entries)
if [ "${#SYNC_LINES[@]}" -eq 0 ]; then return 1; fi
if [ "${#SYNC_LINES[@]}" -gt 1 ]; then return 2; fi
return 0
}
run_sync_set() { # iterates SYNC_LINES[]; roll-up + digest; rc 1 iff wholesale failure
if [ "$DRY_RUN" -eq 1 ]; then
log "DRY RUN — nothing will be downloaded"
fi
local line
for line in "${SYNC_LINES[@]}"; do
IFS=$'\x1f' read -r S_SLUG S_TYPE S_URL S_SUBDIR S_TITLE rest <<<"$line"
if pass_prepare; then
pass_execute
else
G_WHOLEFAIL=$((G_WHOLEFAIL + 1))
NOTIFY_NAMES_FAIL+=("${S_SUBDIR##*/}")
if [ "$DRY_RUN" -eq 0 ]; then
append_history_failed "${S_SUBDIR##*/}"
fi
fi
done
if [ "${#PASS_ROWS[@]}" -gt 1 ]; then
printf 'Sync summary\n'
local row
for row in "${PASS_ROWS[@]}"; do
printf ' %s\n' "$row"
done
fi
send_digest
if [ "$G_WHOLEFAIL" -gt 0 ]; then
return 1
fi
return 0
}
cmd_sync() {
local token="${1:-}"
reset_globals
local rs=0
resolve_sources "$token" || rs=$?
if [ "$rs" -eq 1 ]; then
if [ -z "$token" ]; then
log "nothing tracked yet — add a source with 'pos media ytsync add'"
return 0
fi
err "unknown source '${token}' — see 'pos media ytsync list'"
fi
if [ "$rs" -eq 2 ]; then
err "ambiguous '${token}' — candidates: $(join_comma MATCHING_SLUGS)"
fi
local src_rc=0
run_sync_set || src_rc=$?
return "$src_rc"
}
# ── List flow ───────────────────────────────────────────────────────
cmd_list() {
local count
count="$(registry_count)"
if [ "$count" -eq 0 ]; then
log "nothing tracked yet — add a source with 'pos media ytsync add'"
return 0
fi
printf 'Tracked sources (%s)\n\n' "$count"
local wname=4 line slug stype surl ssub stitle sts rest
local dn dest vids last
local -a names=() types=() vids_last=() dests=()
while IFS= read -r line; do
[ -n "$line" ] || continue
IFS=$'\x1f' read -r slug stype surl ssub stitle sts rest <<<"$line"
dn="${ssub##*/}"
dest="$(_pretty "$YTSYNC_VIDEOS_DIR/$ssub")"
vids="$(archive_count "$slug")"
last="$(last_sync_of "$dn")"
last="${last:0:10}"
names+=("$dn")
types+=("$stype")
vids_last+=("${vids}|${last}")
dests+=("$dest")
if [ "${#dn}" -gt "$wname" ]; then wname="${#dn}"; fi
done < <(registry_entries)
printf ' %-*s %-8s %6s %-10s %s\n' "$wname" "NAME" "TYPE" "VIDEOS" "LAST SYNC" "DESTINATION"
local i n vl
for ((i = 0; i < ${#names[@]}; i++)); do
n="${names[$i]}"
vl="${vids_last[$i]}"
printf ' %-*s %-8s %6s %-10s %s\n' \
"$wname" "$n" "${types[$i]}" "${vl%%|*}" "${vl#*|}" "${dests[$i]}"
done
printf '\nState: %s (registry · archive/ · history.log)\n' "$(_pretty "$YTSYNC_STATE_DIR")"
return 0
}
# ── Remove flow ─────────────────────────────────────────────────────
remove_by_line() { # $1=registry line — drops tracking, keeps files AND archive
local line="$1" slug stype surl ssub stitle sts rest
IFS=$'\x1f' read -r slug stype surl ssub stitle sts rest <<<"$line"
local name="${ssub##*/}"
if [ "$DRY_RUN" -eq 1 ]; then
log "would stop tracking '${name}' — files and archive stay"
return 0
fi
registry_remove_slug "$slug"
ok "Stopped tracking '${name}' — files kept in $(_pretty "$YTSYNC_VIDEOS_DIR/$ssub")"
log "Archive kept — re-adding this source later resumes incrementally"
return 0
}
cmd_remove() {
local token="${1:-}"
if [ -z "$token" ]; then
err "usage: pos media ytsync remove <name>"
fi
reset_globals
local rs=0
resolve_sources "$token" || rs=$?
if [ "$rs" -eq 1 ]; then
err "unknown source '${token}' — see 'pos media ytsync list'"
fi
if [ "$rs" -eq 2 ]; then
err "ambiguous '${token}' — candidates: $(join_comma MATCHING_SLUGS)"
fi
remove_by_line "${SYNC_LINES[0]}"
}
# ── Interactive front door ──────────────────────────────────────────
menu_render() {
{
printf '════════════════════════════════════════════\n'
printf ' ytSync — YouTube channel sync\n'
printf '════════════════════════════════════════════\n'
local lr suffix
lr="$(last_run_overall)"
suffix=""
if [ -n "$lr" ]; then suffix=" · last run ${lr}"; fi
printf ' 1) Sync all channels now (%s tracked%s)\n' "$(registry_count)" "$suffix"
printf ' 2) Add a channel or playlist\n'
printf ' 3) List channels\n'
printf ' 4) Remove a channel\n'
printf ' 0) Exit\n'
printf '%s\n' '--------------------------------------------'
} >&2
}
menu_remove_flow() {
local -a lines=() dnames=()
mapfile -t lines < <(registry_entries)
if [ "${#lines[@]}" -eq 0 ]; then
log "nothing tracked yet"
return 0
fi
local line slug stype surl ssub stitle sts rest i pick ans target rs=0
for line in "${lines[@]}"; do
IFS=$'\x1f' read -r slug stype surl ssub stitle sts rest <<<"$line"
dnames+=("${ssub##*/}")
done
for i in "${!dnames[@]}"; do
printf ' %s) %s\n' "$((i + 1))" "${dnames[$i]}" >&2
done
pick=""
if ! read -rp "Remove which channel? [1-${#dnames[@]}], 0=cancel " pick </dev/tty; then
log "Cancelled — nothing changed"
return 0
fi
case "$pick" in
0 | "" | q | Q)
log "Cancelled — nothing changed"
return 0
;;
esac
if [[ ! "$pick" =~ ^[0-9]+$ ]] || [ "$pick" -lt 1 ] || [ "$pick" -gt "${#dnames[@]}" ]; then
warn "invalid choice: $pick"
return 0
fi
target="${dnames[$((pick - 1))]}"
ans=""
if ! read -rp "Stop tracking '${target}'? Files stay in ~/Videos. [y/N]: " ans </dev/tty; then
log "Cancelled — nothing changed"
return 0
fi
case "$ans" in
y | Y | yes | YES)
resolve_sources "$target" || rs=$?
if [ "$rs" -ne 0 ]; then
warn "could not uniquely resolve '${target}'"
return 0
fi
remove_by_line "${SYNC_LINES[0]}"
;;
*)
log "Cancelled — nothing changed"
;;
esac
return 0
}
interactive_empty_state() { # zero sources → straight to the URL prompt
while :; do
if ! ask_url_interactive; then
return 0
fi
finish_add "$(classify_url "$ASKED_URL")" "$ASKED_URL" confirm
done
}
interactive_menu() {
if ! require_tty; then exit 0; fi
if [ "$(registry_count)" -eq 0 ]; then
interactive_empty_state
return 0
fi
local choice rs=0
while :; do
menu_render
choice=""
if ! read -rp "Choose: " choice </dev/tty; then
return 0
fi
case "$choice" in
1)
reset_globals
resolve_sources "" || rs=$?
if [ "$rs" -eq 0 ]; then
run_sync_set || true
fi
rs=0
;;
2)
reset_globals
if ask_url_interactive; then
finish_add "$(classify_url "$ASKED_URL")" "$ASKED_URL" confirm
fi
;;
3) cmd_list ;;
4) menu_remove_flow ;;
0 | q | Q) return 0 ;;
*) : ;; # unknown choice → redraw
esac
done
}
# ── Argument dispatch ───────────────────────────────────────────────
VERB=""
TARGET=""
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
-h | --help) usage ;;
--*) err "unknown option: $1 (see 'pos media ytsync --help')" ;;
*)
if [ -z "$VERB" ]; then
VERB="$1"
elif [ -z "$TARGET" ]; then
TARGET="$1"
else
err "too many arguments: $* (see 'pos media ytsync --help')"
fi
;;
esac
shift
done
if [ -z "$VERB" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
printf "[!] use 'add --dry-run' or 'sync --dry-run'\n" >&2
exit 1
fi
interactive_menu
exit 0
fi
case "$VERB" in
add) cmd_add "$TARGET" ;;
sync) cmd_sync "$TARGET" ;;
list) cmd_list ;;
remove) cmd_remove "$TARGET" ;;
*) err "unknown command: $VERB (see 'pos media ytsync --help')" ;;
esac