#!/usr/bin/env bash
#version=0.79.0
#
# ytseries - watch YouTube playlists or channel uploads like a TV series
#
# Features:
# - Friendly names: add , then use the name everywhere
# - Works with playlists and channels (/@handle, /channel/UC..., /c/name, with or without /videos)
# - Chronological playback: oldest -> newest using cached upload dates
# - Quality caps for mpv and vlc: 1440, 1080, 720, 480, or best
# - Shows date and title on list, next, and pick
# - Auto mark watched after N percent with mpv (default 90).
# VLC marks on exit.
# - Uses Android client for yt-dlp metadata calls to avoid nsig/SABR issues
#
# Requirements:
# yt-dlp, jq, fzf, mpv or vlc, xdg-open, sha1sum (or shasum)
#
# Quick start:
# ytseries add benamy "https://www.youtube.com/@sailwithbenandamy/videos"
# ytseries config player mpv
# ytseries config quality 1080
# ytseries config mark_threshold 90
# ytseries next benamy
#
# Files:
# Config dir: $XDG_CONFIG_HOME/ytseries (aliases.tsv, config)
# State dir: $XDG_STATE_HOME/ytseries/ (videos.tsv, watched.txt, dates.tsv)
# Key format: pl_ or ch_
set -euo pipefail
export LC_ALL=C
die(){ printf 'Error: %s\n' "$*" >&2; exit 1; }
need(){ command -v "$1" >/dev/null 2>&1 || die "Missing dependency: $1"; }
need yt-dlp; need jq; need fzf
command -v xdg-open >/dev/null 2>&1 || true
if ! command -v sha1sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then
die "Need sha1sum or shasum for key generation"
fi
CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/ytseries"
STATE_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/ytseries"
ALIASES_FILE="$CONF_DIR/aliases.tsv" # nameurl
CONFIG_FILE="$CONF_DIR/config"
KEYS_FILE="$CONF_DIR/keys.tsv" # urlsource_key
HISTORY_FILE="$CONF_DIR/history.tsv" # timestampnamevidupload_datetitle
mkdir -p "$CONF_DIR" "$STATE_ROOT"
touch "$ALIASES_FILE" "$CONFIG_FILE" "$KEYS_FILE" "$HISTORY_FILE"
# -------- fzf appearance settings --------
FZF_COMMON_OPTS=(
--ansi --tac --no-sort --cycle
--border=rounded --margin=1 --padding=1
--color='border:#00ff87,label:#ff87d7,prompt:#00d7ff'
--with-nth=1,2,4,5
--delimiter=$'\t'
--preview='echo {}' --preview-window=hidden
--bind='change:top'
)
# -------- config helpers --------
cfg_get(){ awk -F= -v k="$1" '$1==k{print $2}' "$CONFIG_FILE" | tail -n1; }
cfg_set(){ local k="$1" v="$2"; grep -v "^$k=" "$CONFIG_FILE" > "$CONFIG_FILE.tmp" || true; mv "$CONFIG_FILE.tmp" "$CONFIG_FILE"; printf '%s=%s\n' "$k" "$v" >> "$CONFIG_FILE"; }
get_default_player(){
local p="${PLAYER:-$(cfg_get player || true)}"
[ -n "${p:-}" ] || p="mpv"
printf '%s\n' "$p"
}
get_default_quality(){
local q="${QUALITY:-$(cfg_get quality || true)}"
[ -n "${q:-}" ] || q="best"
printf '%s\n' "$q"
}
get_default_mark_threshold(){
local m="${MARK_THRESHOLD:-$(cfg_get mark_threshold || true)}"
[ -n "${m:-}" ] || m="90"
printf '%s\n' "$m"
}
usage(){
cat <<'EOF'
ytseries commands:
add Save a friendly name and sync
rm Remove a saved name (keeps watch history)
rename Rename a saved series
series [--all] List saved series with progress (--all includes fully watched)
info Show details for one series
recent [N] Show last N watched videos (default: 10)
open Open playlist or channel in browser
sync [|--all] Refresh cached list (shows new episodes)
list [--all] Show unwatched or all entries (date + title)
next [] Play next unwatched video (fzf selector if no name)
[--player mpv|vlc]
[--quality 1440|1080|720|480|best]
[--mark-threshold N]
[--no-auto-mark]
pick [] Pick a specific video to play with fzf (fzf selector if no name)
[--player mpv|vlc]
[--quality 1440|1080|720|480|best]
[--mark-threshold N]
[--no-auto-mark]
mark Mark watched (fzf multiselect if no args)
[|--index N|--range N-M]
unmark Mark unwatched (fzf multiselect if no args)
[|--range N-M]
config player mpv|vlc Set default player
config quality Set default quality (1440|1080|720|480|best)
config mark_threshold Set auto-mark threshold (50..100 percent for mpv)
EOF
}
# -------- alias helpers --------
is_url(){ [[ "$1" == http*://* ]] || [[ "$1" == www.* ]]; }
alias_has(){ grep -q "^$1"$'\t' "$ALIASES_FILE"; }
alias_get_url(){ awk -F'\t' -v n="$1" '$1==n{print $2}' "$ALIASES_FILE" | head -n1; }
alias_add(){ local n="$1" u="$2"; alias_has "$n" && die "Name exists: $n"; printf '%s\t%s\n' "$n" "$u" >> "$ALIASES_FILE"; }
alias_rm(){ local n="$1"; grep -v "^$n"$'\t' "$ALIASES_FILE" > "$ALIASES_FILE.tmp" || true; mv "$ALIASES_FILE.tmp" "$ALIASES_FILE"; }
alias_mv(){ local o="$1" n="$2" u; u="$(alias_get_url "$o")"; [ -n "$u" ] || die "Unknown: $o"; alias_has "$n" && die "Target name exists: $n"; alias_rm "$o"; printf '%s\t%s\n' "$n" "$u" >> "$ALIASES_FILE"; }
resolve_target(){ # input name or url -> prints "nameurl"
local t="$1"
if is_url "$t"; then
local name
name="$(awk -F'\t' -v u="$t" '$2==u{print $1}' "$ALIASES_FILE" | head -n1)"
[ -n "$name" ] || name="src_$(get_source_key "$t")"
printf '%s\t%s\n' "$name" "$t"
else
alias_has "$t" || die "No saved series named: $t"
printf '%s\t%s\n' "$t" "$(alias_get_url "$t")"
fi
}
# -------- key cache helpers --------
key_cache_get(){ awk -F'\t' -v u="$1" '$1==u{print $2}' "$KEYS_FILE" | head -n1; }
key_cache_set(){
local u="$1" k="$2"
# Remove any existing entry for this URL, then add new one
grep -v "^$(printf '%s' "$u" | sed 's/[]\/$*.^[]/\\&/g')"$'\t' "$KEYS_FILE" > "$KEYS_FILE.tmp" 2>/dev/null || true
mv "$KEYS_FILE.tmp" "$KEYS_FILE"
printf '%s\t%s\n' "$u" "$k" >> "$KEYS_FILE"
}
# -------- source keys and state paths --------
# Key: playlists -> pl_, channels -> ch_
get_source_key(){
local url="$1" pid cached_key
# Check cache first
cached_key="$(key_cache_get "$url")"
if [ -n "$cached_key" ]; then
printf '%s\n' "$cached_key"
return
fi
# For playlists, extract from URL (no network call needed)
pid="$(printf '%s\n' "$url" | sed -n 's/.*[?&]list=\([^&]*\).*/\1/p')"
if [ -n "${pid:-}" ]; then
local key="pl_$pid"
key_cache_set "$url" "$key"
printf '%s\n' "$key"
return
fi
# For channels, need to fetch metadata (network call)
local tmp key
tmp="$(mktemp)"
yt-dlp -q --no-warnings --extractor-args "youtube:player_client=android" \
-J --flat-playlist "$url" > "$tmp"
key="$(jq -r '.id // .channel_id // .uploader_id // empty' "$tmp")"
if [ -z "$key" ]; then
if command -v sha1sum >/dev/null 2>&1; then key="$(printf '%s' "$url" | sha1sum | awk '{print $1}')"
else key="$(printf '%s' "$url" | shasum | awk '{print $1}')"
fi
fi
rm -f "$tmp"
local full_key="ch_$key"
key_cache_set "$url" "$full_key"
printf '%s\n' "$full_key"
}
state_dir_for(){ local sk="$1"; local d="$STATE_ROOT/$sk"; mkdir -p "$d"; printf '%s\n' "$d"; }
cache_paths(){ local d="$1"; printf '%s\t%s\t%s\n' "$d/videos.tsv" "$d/watched.txt" "$d/dates.tsv"; }
# -------- date helpers --------
fmt_date(){
# input YYYYMMDD -> YYYY-MM-DD, or "unknown" if blank
local d="$1"
if [[ "$d" =~ ^[0-9]{8}$ ]]; then
printf '%s-%s-%s\n' "${d:0:4}" "${d:4:2}" "${d:6:2}"
else
printf 'unknown\n'
fi
}
# Fetch upload dates for the given video IDs that are missing from dates.tsv
# Batches requests to yt-dlp for speed.
ensure_dates_for_ids(){
local _url="$1" ids_file="$2" dates_file="$3"
touch "$dates_file"
# Build list of IDs that do not exist in dates.tsv in ONE awk pass
local unknown
if [ -s "$dates_file" ]; then
# dates_file has content, compare normally
unknown="$(awk -F'\t' 'NR==FNR{seen[$1]=1; next}
{if(!($1 in seen)) print $1}' \
"$dates_file" "$ids_file")"
else
# dates_file is empty, all IDs are unknown
unknown="$(cat "$ids_file")"
fi
[ -n "${unknown:-}" ] || return 0
local batch=50
local buf=()
local count=0
while read -r vid; do
buf+=("https://www.youtube.com/watch?v=${vid}")
count=$((count+1))
if [ $count -ge $batch ]; then
ytdlp_write_dates "$dates_file" "${buf[@]}"
buf=(); count=0
fi
done <<< "$unknown"
if [ ${#buf[@]} -gt 0 ]; then
ytdlp_write_dates "$dates_file" "${buf[@]}"
fi
}
# Call yt-dlp to print id, date, title for many URLs and append to dates.tsv
ytdlp_write_dates(){
local dates_file="$1"; shift
yt-dlp -q --no-warnings \
--extractor-args "youtube:player_client=android" \
--ignore-no-formats-error \
--skip-download --no-playlist \
--print $'%(id)s\t%(upload_date)s\t%(title)s' \
"$@" \
| awk -F'\t' '
{
id=$1; d=$2;
# Get title as everything from field 3 onward
t=""; for(i=3; i<=NF; i++) t = t (i==3?"":"\t") $i;
if (d == "" || d ~ /[^0-9]/ || d == "NA" || length(d) != 8) d="99999999";
if (!(id in seen)) { print id "\t" d "\t" t; seen[id]=1 }
}
' >> "$dates_file.tmp"
if [ -f "$dates_file" ]; then
awk -F'\t' 'NR==FNR{d[$1]=$2; for(i=3;i<=NF;i++) t[$1]= t[$1] (i==3?"":"\t") $i; next}
{ if ($1 in d){ print $1"\t"d[$1]"\t"t[$1]; delete d[$1]}
else print $0 }
END{ for (k in d) print k"\t"d[k]"\t"t[k] }' \
"$dates_file.tmp" "$dates_file" > "$dates_file.merged" || true
mv "$dates_file.merged" "$dates_file"
else
mv "$dates_file.tmp" "$dates_file"
fi
rm -f "$dates_file.tmp"
}
# -------- discovery, caching, and ordering --------
ensure_cached(){
local url="$1" sk dir videos watched dates
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
IFS=$'\t' read -r videos watched dates < <(cache_paths "$dir")
if [ ! -s "$videos" ]; then sync_playlist "$url"; fi
}
# videos.tsv format: indexiddatetitle
sync_playlist(){
local url="$1" sk dir videos watched dates
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
IFS=$'\t' read -r videos watched dates < <(cache_paths "$dir")
printf 'Syncing %s\n' "$sk" >&2
# Save old video IDs for comparison
local old_ids="$dir/old_ids.txt"
if [ -f "$videos" ]; then
awk -F'\t' '{print $2}' "$videos" > "$old_ids"
else
: > "$old_ids"
fi
local tmp; tmp="$(mktemp)"
yt-dlp -q --no-warnings \
--extractor-args "youtube:player_client=android" \
-J --flat-playlist "$url" > "$tmp"
jq -r '.entries | .[] | "\(.id)\t\(.title // "Untitled")"' "$tmp" > "$dir/ids_titles.tsv"
rm -f "$tmp"
awk -F'\t' '{print $1}' "$dir/ids_titles.tsv" > "$dir/ids.txt"
touch "$dates"
ensure_dates_for_ids "$url" "$dir/ids.txt" "$dates"
awk -F'\t' 'NR==FNR{d[$1]=$2; t[$1]=$3; next}
{ id=$1; title=$2; date=(id in d? d[id] : "99999999"); print id "\t" date "\t" title }' \
"$dates" "$dir/ids_titles.tsv" \
| sort -t $'\t' -k2,2n \
| awk -F'\t' 'BEGIN{OFS="\t"}{print NR,$1,$2,$3}' > "$videos"
[ -f "$watched" ] || : > "$watched"
# Find new videos (IDs in new list but not in old list)
local new_videos="$dir/new_videos.tsv"
awk -F'\t' 'NR==FNR{old[$1]=1; next} !($2 in old){print $2"\t"$3"\t"$4}' \
"$old_ids" "$videos" > "$new_videos"
rm -f "$dir/ids_titles.tsv" "$dir/ids.txt" "$old_ids"
local total new_count
total=$(wc -l < "$videos")
new_count=$(wc -l < "$new_videos")
printf 'Cached %s items' "$total" >&2
if [ "$new_count" -gt 0 ]; then
printf ' (%s new)\n' "$new_count" >&2
else
printf '\n' >&2
fi
# Return path to new videos file for caller to display
if [ "$new_count" -gt 0 ]; then
printf '%s\n' "$new_videos"
fi
}
show_new_videos(){
local name="$1" new_videos_file="$2"
[ -f "$new_videos_file" ] || return 0
[ -s "$new_videos_file" ] || return 0
printf '\nšŗ New episodes for %s:\n' "$name"
while IFS=$'\t' read -r vid date title; do
printf ' %s %s\n' "$(fmt_date "$date")" "$title"
done < "$new_videos_file"
rm -f "$new_videos_file"
}
# -------- listing and selection --------
list_entries(){ # prints [ ] idx id date title
local url="$1" all="${2:-0}" sk dir videos watched dates
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
IFS=$'\t' read -r videos watched dates < <(cache_paths "$dir")
ensure_cached "$url"
awk -F'\t' '{print $1,$2,$3,$4}' OFS=$'\t' "$videos" \
| while IFS=$'\t' read -r idx vid d title; do
if grep -F -x -q -e "$vid" -- "$watched" 2>/dev/null; then mark='[x]'; else mark='[ ]'; fi
if [ "$all" = "1" ] || [ "$mark" = "[ ]" ]; then
printf '%s\t%04d\t%s\t%s\t%s\n' "$mark" "$idx" "$vid" "$(fmt_date "$d")" "$title"
fi
done
}
first_unseen_row(){ # echo "idxviddatetitle" for first unseen
local url="$1" sk dir videos watched
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
IFS=$'\t' read -r videos watched _ < <(cache_paths "$dir")
awk -F'\t' '{print $1"\t"$2"\t"$3"\t"$4}' "$videos" \
| while IFS=$'\t' read -r idx vid d title; do
if ! grep -F -x -q -e "$vid" -- "$watched" 2>/dev/null; then
printf '%s\t%s\t%s\t%s\n' "$idx" "$vid" "$d" "$title"
break
fi
done
}
# -------- watch state --------
log_watch_history(){ # Log to global history file
local name="$1" vid="$2" upload_date="$3" title="$4"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
printf '%s\t%s\t%s\t%s\t%s\n' "$timestamp" "$name" "$vid" "$upload_date" "$title" >> "$HISTORY_FILE"
}
mark_watched(){ # by id with optional name/metadata for history
local url="$1" vid="$2" name="${3:-}" upload_date="${4:-}" title="${5:-}" sk dir watched videos
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
watched="$dir/watched.txt"
touch "$watched"
# Only mark if not already marked
if ! grep -F -x -q -e "$vid" -- "$watched" 2>/dev/null; then
printf '%s\n' "$vid" >> "$watched"
# Log to history if we have metadata
if [ -n "$name" ]; then
# If we don't have upload_date/title, try to fetch from videos.tsv
if [ -z "$upload_date" ] || [ -z "$title" ]; then
videos="$dir/videos.tsv"
IFS=$'\t' read -r _ _ upload_date title < <(awk -F'\t' -v v="$vid" '$2==v{print $1"\t"$2"\t"$3"\t"$4}' "$videos" | head -n1)
fi
log_watch_history "$name" "$vid" "${upload_date:-unknown}" "${title:-Unknown}"
fi
fi
}
mark_by_index(){ # index -> id -> mark with optional name for history
local url="$1" idx="$2" name="${3:-}" sk dir videos vid upload_date title
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
videos="$dir/videos.tsv"
ensure_cached "$url"
IFS=$'\t' read -r _ vid upload_date title < <(awk -F'\t' -v i="$idx" '$1==i{print $1"\t"$2"\t"$3"\t"$4}' "$videos")
[ -n "$vid" ] || die "Index not found: $idx"
mark_watched "$url" "$vid" "$name" "$upload_date" "$title"
}
unmark_watched(){
local url="$1" vid="$2" sk dir watched
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
watched="$dir/watched.txt"
[ -f "$watched" ] || return 0
grep -F -v -x -e "$vid" -- "$watched" > "$watched.tmp" || true
mv "$watched.tmp" "$watched"
}
mark_range(){ # Mark a range of indices
local url="$1" range="$2" name="$3" sk dir videos
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
videos="$dir/videos.tsv"
ensure_cached "$url"
# Parse range (e.g., "1-50")
if [[ ! "$range" =~ ^[0-9]+-[0-9]+$ ]]; then
die "Invalid range format. Use: N-M (e.g., 1-50)"
fi
local start="${range%-*}"
local end="${range#*-}"
if [ "$start" -gt "$end" ]; then
die "Invalid range: start ($start) must be <= end ($end)"
fi
printf 'Marking episodes %s to %s...\n' "$start" "$end"
local count=0
for ((i=start; i<=end; i++)); do
local idx vid upload_date title
IFS=$'\t' read -r idx vid upload_date title < <(awk -F'\t' -v i="$i" '$1==i{print $1"\t"$2"\t"$3"\t"$4}' "$videos")
if [ -n "$vid" ]; then
mark_watched "$url" "$vid" "$name" "$upload_date" "$title"
count=$((count + 1))
fi
done
printf 'Marked %s episodes\n' "$count"
}
unmark_range(){ # Unmark a range of indices
local url="$1" range="$2" sk dir videos
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
videos="$dir/videos.tsv"
ensure_cached "$url"
# Parse range (e.g., "1-50")
if [[ ! "$range" =~ ^[0-9]+-[0-9]+$ ]]; then
die "Invalid range format. Use: N-M (e.g., 1-50)"
fi
local start="${range%-*}"
local end="${range#*-}"
if [ "$start" -gt "$end" ]; then
die "Invalid range: start ($start) must be <= end ($end)"
fi
printf 'Unmarking episodes %s to %s...\n' "$start" "$end"
local count=0
for ((i=start; i<=end; i++)); do
local vid
vid="$(awk -F'\t' -v i="$i" '$1==i{print $2}' "$videos")"
if [ -n "$vid" ]; then
unmark_watched "$url" "$vid"
count=$((count + 1))
fi
done
printf 'Unmarked %s episodes\n' "$count"
}
# -------- quality handling --------
build_ytdl_format(){
local h="${1:-best}"
if [ "$h" = "best" ]; then
printf '(bestvideo[vcodec!=av01]/bestvideo)+bestaudio/best\n'
return
fi
printf '(bestvideo[height<=%s][vcodec!=av01]/bestvideo[height<=%s])+' "$h" "$h"
printf 'bestaudio/best[height<=%s]\n' "$h"
}
resolve_progressive_url(){
local url="$1" h="${2:-best}" fmt
if [ "$h" = "best" ]; then
fmt='best[protocol^=http]/best'
else
fmt="best[height<=${h}][protocol^=http]/best[protocol^=http]"
fi
yt-dlp -q --no-warnings --extractor-args "youtube:player_client=android" \
-f "$fmt" -g "$url" 2>/dev/null | head -n1
}
# -------- mpv percent-watched auto mark --------
# Make a temporary Lua that tracks max percent played and writes a file on shutdown
make_mpv_watch_lua(){
local outfile="$1"
cat > "$outfile" <<'LUA'
local maxp = 0
local function clamp(x) if x == nil then return 0 end if x < 0 then return 0 end if x > 100 then return 100 end return x end
mp.observe_property("percent-pos", "number", function(_, val)
val = clamp(val)
if val > maxp then maxp = val end
end)
mp.register_event("shutdown", function()
local f = io.open(mp.get_opt("ytseries_out") or "", "w")
if f then f:write(string.format("{\"max_percent\":%.3f}\n", maxp)); f:close() end
end)
LUA
}
mpv_play_with_automark(){
local page="$1" quality="$2" threshold="$3"
local tmpdir; tmpdir="$(mktemp -d)"
local outjson="$tmpdir/watch.json"
local luafile="$tmpdir/ytseries_watch.lua"
make_mpv_watch_lua "$luafile"
local fmt; fmt="$(build_ytdl_format "$quality")"
mpv --ytdl-format="$fmt" --ytdl-raw-options=playlist-start=1 \
--script="$luafile" --script-opts="ytseries_out=$outjson" \
"$page"
local maxp
if [ -f "$outjson" ]; then
maxp="$(awk -F'[[:space:]:,}]+' '/max_percent/{print $(NF-1)}' "$outjson" 2>/dev/null || echo 0)"
else
maxp=0
fi
rm -rf "$tmpdir"
awk -v m="$maxp" -v t="$threshold" 'BEGIN{exit (m+0 >= t+0)?0:1}'
}
# -------- flag parsing for player, quality, mark --------
resolve_play_flags(){
# echo "\t\t\t" auto_mark: 1 or 0
local player quality threshold auto_mark=1
player="$(get_default_player)"
quality="$(get_default_quality)"
threshold="$(get_default_mark_threshold)"
while [ $# -gt 0 ]; do
case "$1" in
--player) [ -n "${2:-}" ] || die "Missing value for --player"; player="$2"; shift 2; continue ;;
--quality) [ -n "${2:-}" ] || die "Missing value for --quality"; quality="$2"; shift 2; continue ;;
--mark-threshold) [ -n "${2:-}" ] || die "Missing value for --mark-threshold"; threshold="$2"; shift 2; continue ;;
--no-auto-mark) auto_mark=0; shift; continue ;;
*) break ;;
esac
done
printf '%s\t%s\t%s\t%s\n' "$player" "$quality" "$threshold" "$auto_mark"
}
play_video_by_id(){
local vid="$1" player="$2" quality="$3" threshold="$4" auto_mark="$5"
local page="https://www.youtube.com/watch?v=${vid}"
case "$player" in
mpv)
if [ "$auto_mark" = "1" ]; then
if mpv_play_with_automark "$page" "$quality" "$threshold"; then
printf 'Autodetected playback reached %s%%. Marking watched.\n' "$threshold" >&2
echo "AUTO_OK"
else
printf 'Playback below %s%%. Not auto-marking.\n' "$threshold" >&2
echo "AUTO_SKIP"
fi
else
local fmt; fmt="$(build_ytdl_format "$quality")"
mpv --ytdl-format="$fmt" --ytdl-raw-options=playlist-start=1 "$page"
echo "AUTO_OFF"
fi
;;
vlc)
if [ "$quality" = "best" ] || [ "$quality" -le 720 ] 2>/dev/null; then
local direct; direct="$(resolve_progressive_url "$page" "$quality" || true)"
if [ -n "$direct" ]; then vlc --play-and-exit "$direct"; else vlc --play-and-exit "$page"; fi
else
printf 'Note: VLC quality control above 720p is limited. Using site URL.\n' >&2
vlc --play-and-exit "$page"
fi
echo "VLC_DONE"
;;
*) die "Unknown player: $player" ;;
esac
}
# -------- commands --------
cmd_add(){
[ $# -eq 2 ] || die "Usage: ytseries add "
local n="$1" u="$2"
alias_add "$n" "$u"
local new_videos_file
new_videos_file="$(sync_playlist "$u")"
printf 'Added "%s"\n' "$n"
[ -n "${new_videos_file:-}" ] && show_new_videos "$n" "$new_videos_file"
}
cmd_rm(){ [ $# -eq 1 ] || die "Usage: ytseries rm "; alias_rm "$1"; printf 'Removed "%s"\n' "$1"; }
cmd_rename(){ [ $# -eq 2 ] || die "Usage: ytseries rename "; alias_mv "$1" "$2"; printf 'Renamed to "%s"\n' "$2"; }
list_series_for_selection(){
# Output format: namedatetitle for fzf selection
if [ ! -s "$ALIASES_FILE" ]; then return 1; fi
while IFS=$'\t' read -r name url; do
local sk dir videos watched dates total seen nidx nvid nd ntitle
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
IFS=$'\t' read -r videos watched dates < <(cache_paths "$dir")
[ -s "$videos" ] || continue
total=$(wc -l < "$videos")
seen=$( [ -f "$watched" ] && wc -l < "$watched" || echo 0 )
read -r nidx nvid nd ntitle < <(first_unseen_row "$url" | head -n1 || true)
if [ -n "${nvid:-}" ]; then
printf '%s\t%s\t%s\n' "$name" "$(fmt_date "$nd")" "$ntitle"
fi
done < "$ALIASES_FILE"
}
cmd_series(){
local show_all=0
[ "${1:-}" = "--all" ] && show_all=1
if [ ! -s "$ALIASES_FILE" ]; then printf "No series saved. Use: ytseries add \n"; return 0; fi
while IFS=$'\t' read -r name url; do
local sk dir videos watched dates total seen nextid next_title nextdate
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
IFS=$'\t' read -r videos watched dates < <(cache_paths "$dir")
[ -s "$videos" ] || sync_playlist "$url" >/dev/null
total=$(wc -l < "$videos")
seen=$( [ -f "$watched" ] && wc -l < "$watched" || echo 0 )
read -r nidx nvid nd ntitle < <(first_unseen_row "$url" | head -n1 || true)
if [ -n "${nvid:-}" ]; then
nextdate="$(fmt_date "$nd")"
next_title="$nextdate $ntitle"
else
next_title="All watched"
fi
# Show series if --all flag is set OR if there are unwatched videos
if [ "$show_all" = "1" ] || [ -n "${nvid:-}" ]; then
printf '%-20s %3s/%-3s next: %s\n' "$name" "$seen" "$total" "$next_title"
fi
done < "$ALIASES_FILE"
}
cmd_info(){
[ $# -eq 1 ] || die "Usage: ytseries info "
IFS=$'\t' read -r name url < <(resolve_target "$1")
local sk dir videos watched dates total seen
sk="$(get_source_key "$url")"
dir="$(state_dir_for "$sk")"
IFS=$'\t' read -r videos watched dates < <(cache_paths "$dir")
ensure_cached "$url"
total=$(wc -l < "$videos")
seen=$( [ -f "$watched" ] && wc -l < "$watched" || echo 0 )
printf 'Name: %s\nURL: %s\nKey: %s\nTotal: %s\nSeen: %s\n' "$name" "$url" "$sk" "$total" "$seen"
}
cmd_recent(){
local limit="${1:-10}"
# Validate limit is a number
if [[ ! "$limit" =~ ^[0-9]+$ ]]; then
die "Invalid limit. Must be a number."
fi
if [ ! -s "$HISTORY_FILE" ]; then
printf "No watch history yet.\n"
return 0
fi
printf 'Recent activity (last %s):\n\n' "$limit"
# Sort by timestamp descending and take last N entries
# Format: watched_time series_name upload_date title
tail -n "$limit" "$HISTORY_FILE" \
| tac \
| awk -F'\t' '{
watched=$1
series=$2
vid=$3
upload_date=$4
# Title is everything from field 5 onward
title=""
for(i=5; i<=NF; i++) title = title (i==5?"":"\t") $i
# Format upload date YYYYMMDD -> YYYY-MM-DD
if (length(upload_date) == 8) {
upload_date = substr(upload_date,1,4) "-" substr(upload_date,5,2) "-" substr(upload_date,7,2)
}
printf " %s %-20s %s %s\n", watched, series, upload_date, title
}'
}
cmd_open(){
[ $# -eq 1 ] || die "Usage: ytseries open "
IFS=$'\t' read -r _ url < <(resolve_target "$1")
if command -v xdg-open >/dev/null 2>&1; then xdg-open "$url" >/dev/null 2>&1 || true
else printf '%s\n' "$url"
fi
}
cmd_sync(){
# Handle --all flag or specific series
if [ $# -eq 0 ]; then
die "Usage: ytseries sync OR ytseries sync --all"
fi
if [ "$1" = "--all" ]; then
# Sync all series
if [ ! -s "$ALIASES_FILE" ]; then
printf "No series saved. Use: ytseries add \n"
return 0
fi
printf 'Syncing all series...\n\n'
local count=0 total
total=$(wc -l < "$ALIASES_FILE")
while IFS=$'\t' read -r name url; do
count=$((count + 1))
printf '[%s/%s] ' "$count" "$total"
local new_videos_file
new_videos_file="$(sync_playlist "$url")"
[ -n "${new_videos_file:-}" ] && show_new_videos "$name" "$new_videos_file"
done < "$ALIASES_FILE"
printf '\nā All series synced\n'
else
# Sync single series
IFS=$'\t' read -r name url < <(resolve_target "$1")
local new_videos_file
new_videos_file="$(sync_playlist "$url")"
[ -n "${new_videos_file:-}" ] && show_new_videos "$name" "$new_videos_file"
fi
}
cmd_list(){
[ $# -ge 1 ] || die "Usage: ytseries list [--all]"
local target="$1"; shift || true
local all=0; [ "${1:-}" = "--all" ] && all=1
IFS=$'\t' read -r _ url < <(resolve_target "$target")
printf '[ ]\tIDX \tVIDEO_ID\tDATE \tTITLE\n'
list_entries "$url" "$all"
}
cmd_next(){
local target
# If no argument provided, show fzf series selector
if [ $# -eq 0 ]; then
local choice name date title
choice="$(list_series_for_selection \
| fzf "${FZF_COMMON_OPTS[@]}" \
--with-nth=1,2,3 \
--prompt="šŗ Select series to watch > " \
--header=$'Enter: play next ⢠Esc: cancel\n')" || return 1
IFS=$'\t' read -r target date title <<<"$choice"
[ -n "$target" ] || die "Could not parse selection"
else
target="$1"; shift || true
fi
IFS=$'\t' read -r player quality threshold auto_mark < <(resolve_play_flags "$@")
IFS=$'\t' read -r name url < <(resolve_target "$target")
ensure_cached "$url"
local idx vid d title
read -r idx vid d title < <(first_unseen_row "$url" | head -n1 || true)
[ -n "${vid:-}" ] || die "No unwatched entries found. Try list --all"
printf 'Now playing [%s] %s %s\n' "$idx" "$(fmt_date "$d")" "$title"
local result; result="$(play_video_by_id "$vid" "$player" "$quality" "$threshold" "$auto_mark")"
case "$player:$result" in
mpv:AUTO_OK) mark_watched "$url" "$vid" "$name" "$d" "$title" ;;
mpv:AUTO_SKIP)
printf 'Not marking. Use "ytseries mark %s --index %s" if you finished it.\n' "$target" "$idx"
;;
mpv:AUTO_OFF|vlc:VLC_DONE)
mark_watched "$url" "$vid" "$name" "$d" "$title"
;;
esac
}
cmd_pick(){
local target
# If no argument provided, show fzf series selector
if [ $# -eq 0 ]; then
local choice name date title
choice="$(list_series_for_selection \
| fzf "${FZF_COMMON_OPTS[@]}" \
--with-nth=1,2,3 \
--prompt="š¬ Select series to pick from > " \
--header=$'Enter: select ⢠Esc: cancel\n')" || return 1
IFS=$'\t' read -r target date title <<<"$choice"
[ -n "$target" ] || die "Could not parse selection"
else
target="$1"; shift || true
fi
IFS=$'\t' read -r player quality threshold auto_mark < <(resolve_play_flags "$@")
IFS=$'\t' read -r name url < <(resolve_target "$target")
ensure_cached "$url"
local choice mark idx vid date title raw_date
choice="$(list_entries "$url" 1 \
| fzf "${FZF_COMMON_OPTS[@]}" \
--prompt="š¬ Pick episode > " \
--header=$'Enter: play ⢠Esc: cancel\n')" || return 1
IFS=$'\t' read -r mark idx vid date title <<<"$choice"
[ -n "$vid" ] || die "Could not parse selection"
# Convert date from YYYY-MM-DD back to YYYYMMDD for history
raw_date="${date//-/}"
printf 'Now playing [%s] %s %s\n' "$idx" "$date" "$title"
local result; result="$(play_video_by_id "$vid" "$player" "$quality" "$threshold" "$auto_mark")"
if [ "$player" = "mpv" ]; then
case "$result" in
AUTO_OK) mark_watched "$url" "$vid" "$name" "$raw_date" "$title"; printf 'Marked.\n' ;;
AUTO_SKIP)
printf 'Mark watched? [y/N]: '; read -r ans
case "$ans" in y|Y) mark_watched "$url" "$vid" "$name" "$raw_date" "$title"; printf 'Marked.\n' ;; *) printf 'Left as unwatched.\n' ;; esac
;;
AUTO_OFF) printf 'Mark watched? [y/N]: '; read -r ans
case "$ans" in y|Y) mark_watched "$url" "$vid" "$name" "$raw_date" "$title"; printf 'Marked.\n' ;; *) printf 'Left as unwatched.\n' ;; esac
;;
esac
else
mark_watched "$url" "$vid" "$name" "$raw_date" "$title"; printf 'Marked.\n'
fi
}
cmd_mark(){
[ $# -ge 1 ] || die "Usage: ytseries mark [|--index N|--range N-M]"
local target="$1"; shift || true
IFS=$'\t' read -r name url < <(resolve_target "$target")
# If no additional args, use fzf multiselect
if [ $# -eq 0 ]; then
ensure_cached "$url"
local selections mark idx vid date title raw_date
selections="$(list_entries "$url" 1 \
| fzf "${FZF_COMMON_OPTS[@]}" \
--multi \
--prompt="ā Mark watched (multi-select) > " \
--header=$'Tab: select/deselect ⢠Enter: confirm ⢠Esc: cancel\n')" || return 1
[ -n "$selections" ] || die "No selections made"
while IFS=$'\t' read -r mark idx vid date title; do
[ -n "$vid" ] || continue
raw_date="${date//-/}"
mark_watched "$url" "$vid" "$name" "$raw_date" "$title"
printf 'Marked [%s] %s %s\n' "$idx" "$date" "$title"
done <<<"$selections"
elif [ "${1:-}" = "--index" ]; then
[ -n "${2:-}" ] || die "Need N"
mark_by_index "$url" "$2" "$name"
elif [ "${1:-}" = "--range" ]; then
[ -n "${2:-}" ] || die "Need range (e.g., 1-50)"
mark_range "$url" "$2" "$name"
else
mark_watched "$url" "$1" "$name"
fi
}
cmd_unmark(){
[ $# -ge 1 ] || die "Usage: ytseries unmark [|--range N-M]"
local target="$1"; shift || true
IFS=$'\t' read -r name url < <(resolve_target "$target")
# If no additional args, use fzf multiselect
if [ $# -eq 0 ]; then
ensure_cached "$url"
local selections mark idx vid date title
selections="$(list_entries "$url" 1 \
| fzf "${FZF_COMMON_OPTS[@]}" \
--multi \
--prompt="ā Unmark (multi-select) > " \
--header=$'Tab: select/deselect ⢠Enter: confirm ⢠Esc: cancel\n')" || return 1
[ -n "$selections" ] || die "No selections made"
while IFS=$'\t' read -r mark idx vid date title; do
[ -n "$vid" ] || continue
unmark_watched "$url" "$vid"
printf 'Unmarked [%s] %s %s\n' "$idx" "$date" "$title"
done <<<"$selections"
elif [ "${1:-}" = "--range" ]; then
[ -n "${2:-}" ] || die "Need range (e.g., 1-50)"
unmark_range "$url" "$2"
else
unmark_watched "$url" "$1"
fi
}
cmd_config(){
[ $# -ge 2 ] || die "Usage: ytseries config player mpv|vlc OR ytseries config quality N OR ytseries config mark_threshold N"
case "$1" in
player) [ "${2:-}" = "mpv" ] || [ "${2:-}" = "vlc" ] || die "Must be mpv or vlc"; cfg_set player "$2"; printf 'Default player set to %s\n' "$2" ;;
quality)
case "${2:-}" in best|1440|1080|720|480) cfg_set quality "$2"; printf 'Default quality set to %s\n' "$2" ;; *) die "Quality must be best, 1440, 1080, 720, or 480" ;;
esac
;;
mark_threshold)
case "${2:-}" in
''|*[!0-9]*) die "mark_threshold must be an integer percent 50..100" ;;
*)
if [ "$2" -lt 50 ] || [ "$2" -gt 100 ]; then die "mark_threshold out of range 50..100"; fi
cfg_set mark_threshold "$2"; printf 'Mark threshold set to %s%%\n' "$2"
;;
esac
;;
*) die "Unknown config key: $1" ;;
esac
}
main(){
local cmd="${1:-help}"; shift || true
case "$cmd" in
help|-h|--help) usage ;;
add) cmd_add "$@" ;;
rm) cmd_rm "$@" ;;
rename) cmd_rename "$@" ;;
series) cmd_series "$@" ;;
info) cmd_info "$@" ;;
recent) cmd_recent "$@" ;;
open) cmd_open "$@" ;;
config) cmd_config "$@" ;;
sync) cmd_sync "$@" ;;
sync-all) cmd_sync --all ;; # Backwards compatibility
list) cmd_list "$@" ;;
next) cmd_next "$@" ;;
pick) cmd_pick "$@" ;;
mark) cmd_mark "$@" ;;
unmark) cmd_unmark "$@" ;;
*) die "Unknown command: $cmd. Try: ytseries help" ;;
esac
}
main "$@"