Archives
Trending
Support
Login
clear text
XML
Django
JavaScript
MATLAB
C
C++
C#
Python
SQL
Shell
Bash
Markdown
YAML
JSON
HTML
CSS
PHP
Java
Ruby
Go
Rust
Swift
Kotlin
Arduino
TypeScript
Perl
Autohotkey
Lua
SQF
R
Scala
Haskell
Groovy
Dart
Clojure
VB.NET
Objective-C
PowerShell
Bash
CoffeeScript
Verilog
#!/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" # name
url CONFIG_FILE="$CONF_DIR/config" KEYS_FILE="$CONF_DIR/keys.tsv" # url
source_key HISTORY_FILE="$CONF_DIR/history.tsv" # timestamp
name
vid
upload_date
title 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 "name
url" 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: index
id
date
title 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 "idx
vid
date
title" 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: name
date
title 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 "$@"
Mark as private (unlisted)
for 30 minutes
for 6 hours
for 1 day
for 1 week
for 1 month
for 1 year
everlasting (like CCCP)