CCCPaste

ytseries - watch playlists or channel uploads...

#!/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 "$@"