diff --git a/README.md b/README.md
index 957ecaa..c51f0a4 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
Run
-
+
## :star: Features
@@ -31,19 +31,20 @@
- Search history and yt query cache
- Customize script using available flags
- Simple and intuitive rofi menu
- - Play video, audio
- - Append to playlist
+ - Play video or audio [`Alt+v`]
+ - Append to playlist [`Alt+Tab`]()
- Loop playlist
- - Edit current playlist
+ - Edit current playlist [`Alt+r`]()
- Save/Load current playlist
- - Help for key bindings
+ - Help for key bindings [`Alt+h`]
+ - Jump to chapters [`Alt+Enter`]()
- ...
## :rocket: Setup
**Dependencies**
-- `socat` `sqlite3` `xargs`
+- `socat` `sqlite3` `xargs` `jq` `iconv`
- [`mpv>0.35.1`](https://github.com/mpv-player/mpv)
- [`rofi>=1.6.1`](https://github.com/davatorium/rofi)
- [`yt-dlp==2023.10.13`](https://github.com/yt-dlp/yt-dlp)
@@ -70,5 +71,5 @@ make install
## :runner: Run
-Is advisable to key bind `ytdl-mpv`, so that rofi menu can be opened wherever you are!\
+Is advisable to key bind (e.g. [`Cmd+m`]()) `ytdl-mpv`, so that rofi menu can be opened wherever you are!\
For **debugging purposes**, instead, could be useful run `ytdl-mpv` inside a shell to view stdout/stderr
diff --git a/bin/mpvctl b/bin/mpvctl
index 60fec1d..16e3ec2 100755
--- a/bin/mpvctl
+++ b/bin/mpvctl
@@ -24,9 +24,13 @@ _die() {
# Ensure dependencies
_checkDep() {
- type mpv > /dev/null 2>&1 || _die "Cannot find mpv in your \$PATH"
- type yt-dlp > /dev/null 2>&1 || _die "Cannot find yt-dlp in your \$PATH"
- type socat > /dev/null 2>&1 || _die "Cannot find socat in your \$PATH"
+ local deps
+ deps=(mpv socat yt-dlp jq iconv)
+ for dep in "${deps[@]}"; do
+ type "$dep" > /dev/null 2>&1 || _die "Cannot find ${dep} in your \$PATH"
+ done
+ (echo "D̲eep̲ ̲P̲u̲r̲ple" | iconv -f utf-8 -t ascii//translit &>/dev/null) \
+ || _die "Unsupported iconv flavour, install gnu-libiconv"
SOCKCMD="socat - $SOCKET"
}
@@ -47,20 +51,24 @@ usage: $(basename "$0") [-h] [--socket SOCKET] action
MPVCTL - mpv cli ipc-json frontend
positional arguments:
- add add items to playlist
+ add add tracks to playlist
check check socket status
clear playlist clear
load load a playlist from given path
loop loop/unloop currently playing playlist
loop-status get loop status currently playing playlist
- next play next item in playlist
+ next play next track (or chapter) in playlist
playlist print sorted list of tracks
- prev play prev item in playlist
- rm remove item number from playlist
+ prev play prev track (or chapter) in playlist
+ rm remove track number from playlist
save save current playlist to given path
stop always stop playback
toggle toggle playback
- track jump to playlist item number
+ track jump to playlist track number
+ ctrack get current track playlist number
+ chapters get chapter list (if available)
+ chapter jump to track chapter number
+ cchapter get current chapter (-1 if no chapters)
optional arguments:
-h, --help print this help
@@ -89,10 +97,10 @@ _getPlaylist() {
_checkSock
# track numbers
local trnum
- trnum=$(_getProperty 'get_property_string' 'playlist-count')
+ trnum=$(_getProperty 'playlist-count')
# current track number
local trcur
- trcur=$(_getProperty 'get_property_string' 'playlist-pos')
+ trcur=$(_getProperty 'playlist-pos')
local count
count=0
@@ -103,15 +111,13 @@ _getPlaylist() {
# track name
local trname
local trcurmark
- trname=$(_getProperty 'get_property_string' "playlist/$count/filename")
+ trname=$(_getProperty "playlist/$count/filename")
if [ "$count" -eq "$trcur" ]; then trcurmark='*'; fi
# check if local or yt media
if [[ $trname =~ ^ytdl://(.*)$ ]]; then
# track id
local trid
trid=${trname:7}
- # escape quotes
- trid=$(printf '%s' "$trid" | sed "s/'/''/g")
# if cache db given, search yt media using it
if [ -n "$1" ] && [ -f "$1" ]; then
local db
@@ -119,22 +125,26 @@ _getPlaylist() {
# search for track title
local trtitle
trtitle="$(sqlite3 "${db}" \
- "select distinct title from main where id='${trid}'" 2> /dev/null)"
+ "select distinct nchapters,title from main where id='${trid}'")"
if [ -z "$trtitle" ]; then
- local trtitle
- trtitle=$(yt-dlp --get-filename "$trid" -o "%(title)s" 2> /dev/null)
+ local csv
+ csv=$(yt-dlp --dump-json "https://youtube.com/watch?v=${trid}" \
+ | sed 's/\xE2\x80\x9D//g;s/\xE2\x80\x9C//g' \
+ | jq -r '. | {query:"NULL",id:.id,title:.title,nchapters:(.chapters|length),chapters:(.chapters|if .==null then [] else [.[].title] end)} | map(tostring) | @csv' \
+ | iconv -f utf-8 -t ascii//translit
+ )
# cache this single search as relative to a NULL query
- sqlite3 "${db}" \
- "insert into main (query,id,title) values ('NULL','${trid}','${trtitle}')" \
- 2> /dev/null
+ sqlite3 "${db}" "insert into main (query,id,title,nchapters,chapters) values (${csv})"
+ trtitle="$(sqlite3 "${db}" "select nchapters,distinct title from main where id='${trid}'")"
fi
else
# searching track title, using ytdl
local trtitle
- trtitle=$(yt-dlp --get-filename "$trid" -o "%(title)s" 2> /dev/null)
+ trtitle=$(yt-dlp --get-title "https://youtube.com/watch?v=${trid}" \
+ | iconv -f utf-8 -t ascii//translit)
fi
if [ -z "$trtitle" ]; then
- printf "[Warning] yt-dlp title search fail\n" >&2
+ printf '[Warning] yt-dlp title search failed for %s\n' "$trid" >&2
local trtitle
trtitle="NULL"
fi
@@ -145,12 +155,117 @@ _getPlaylist() {
fi
local zerocount
- zerocount=$(printf '%s\n' "$((count+1))" | sed 's/\<[0-9]\>/0&/')
- printf '%s)%s %s\n' "$zerocount" "$trcurmark" "$trtitle"
+ zerocount=$(printf '%s\n' "$((count+1))")
+ printf '%s)%s %s\n' "$zerocount" "$trcurmark" "$trtitle" \
+ | sed 's/ 0|/ [##] /;s/ \([0-9]\+\)|/ [\1] /' \
+ | sed 's/^[0-9])/0&/;s/\[\([0-9]\)\]/[0\1]/'
count=$((count + 1))
done
}
+# get chapters from mpv metadata
+_getChaptersMpv() {
+ local nch
+ # number of chapters
+ nch=$(_getProperty "chapters")
+ if [ "$nch" -gt "0" ]; then
+ local chcur
+ # get current chapter
+ chcur=$(_getProperty 'chapter')
+ chcur=$((chcur+1))
+ # get chapters list from mpv player
+ _getProperty 'chapter-list' \
+ | jq -r '.[].title' \
+ | awk '{ print FNR ") " $0 }' \
+ | sed "s/\(^$chcur)\) /\1*/" \
+ | sed 's/^[0-9])/0&/'
+ fi
+}
+#
+# get chapters from metadata inside sqlite3 db
+_getChaptersDb() {
+ # sqlite db
+ local db
+ db=$2
+ # track to inspect id
+ local trid
+ trid=$(_getProperty "playlist/$trnum/filename")
+ trid=${trid:7}
+ # number of chapters
+ local nch
+ nch="$(sqlite3 "${db}" "select nchapters from main where id='${trid}'")"
+ if [ "$nch" -gt "0" ]; then
+ # get chapters list from sqlite3 db
+ sqlite3 "$db" "select chapters from main where id='${trid}'" \
+ | jq -r '.[]' \
+ | awk '{ print FNR ") " $0 }' \
+ | sed 's/^[0-9])/0&/'
+ fi
+}
+
+# Get track chapters if available
+_getChapters() {
+ # track number to inspect
+ local trnum
+ trnum=$1
+ [ -z "$trnum" ] && _die "Missing parameter: track number in playlist"
+ # current track number
+ local trcur
+ trcur=$(_getProperty 'playlist-pos')
+ if [ "$trnum" -eq "$trcur" ]; then
+ _getChaptersMpv
+ else
+ # if cache db given, search track chapters using it
+ if [ -n "$2" ] && [ -f "$2" ]; then
+ _getChaptersDb "$trnum" "$2"
+ fi
+ fi
+}
+
+# next track in playlist unless chapters detected
+# in this case it is preferable to play next chapter
+_nextPlaylist() {
+ local nch
+ # number of chapters
+ nch=$(_getProperty "chapters")
+ if [ "$nch" -gt "0" ]; then
+ local chcur
+ # get current chapter
+ chcur=$(_getProperty 'chapter')
+ # if at the last chapter play next track
+ # else next chapter
+ if [ "$chcur" -eq "$((nch-1))" ]; then
+ _setProperty 'playlist_next'
+ else
+ _setProperty 'set_property' 'chapter' "$((chcur+1))"
+ fi
+ else
+ _setProperty 'playlist_next'
+ fi
+}
+
+# previous track in playlist unless chapters detected
+# in this case it is preferable to play previous chapter
+_prevPlaylist() {
+ local nch
+ # number of chapters
+ nch=$(_getProperty "chapters")
+ if [ "$nch" -gt "0" ]; then
+ local chcur
+ # get current chapter
+ chcur=$(_getProperty 'chapter')
+ # if at the first chapter play previous track
+ # else previous chapter
+ if [ "$chcur" -eq "0" ]; then
+ _setProperty 'playlist_prev'
+ else
+ _setProperty 'set_property' 'chapter' "$((chcur-1))"
+ fi
+ else
+ _setProperty 'playlist_prev'
+ fi
+}
+
# Save current playlist to given file
_savePlaylist() {
_checkSock
@@ -158,13 +273,13 @@ _savePlaylist() {
[[ -d "$(dirname "$1")" ]] || _die 'Invalid path given'
# track numbers
local trnum
- trnum=$(_getProperty 'get_property_string' 'playlist-count')
+ trnum=$(_getProperty 'playlist-count')
local count
count=0
while [ "$count" -lt "$trnum" ]; do
# track name
local trname
- trname=$(_getProperty 'get_property_string' "playlist/$count/filename")
+ trname=$(_getProperty "playlist/$count/filename")
printf '%s\n' "$trname"
count=$((count + 1))
done > "$1"
@@ -183,8 +298,7 @@ _loadPlaylist() {
_getLoop() {
#loop state
local lstate
- lstate=$(_getProperty 'get_property_string' 'loop-playlist' \
- | sed "s/inf/on/" | sed "s/no/off/")
+ lstate=$(_getProperty 'loop-playlist' | sed "s/inf/on/" | sed "s/no/off/")
if [ -z "$lstate" ]; then
exit 1
fi
@@ -208,28 +322,15 @@ _toggleLoop() {
# Get method to read from socket
_getProperty() {
_checkSock
- local tosend
- tosend='{ "command": ['
- for arg in "$@"; do
- tosend="$tosend \"$arg\","
- done
- tosend=${tosend%?}' ] }'
local property
- property=$(printf '%s\n' "$tosend" | $SOCKCMD 2> /dev/null \
- | cut -d'"' -f 4 | rev | cut -d'.' -f 2- | rev)
+ property=$(printf '{"command":["get_property_string","%s"]}\n' "$1" | $SOCKCMD | jq -r .data)
printf '%s\n' "$property"
}
# Set method to write from socket
_setProperty() {
_checkSock
- local tosend
- tosend='{ "command": ['
- for arg in "$@"; do
- tosend="$tosend \"$arg\","
- done
- tosend=${tosend%?}' ] }'
- printf '%s\n' "$tosend" | $SOCKCMD &> /dev/null
+ printf '%s\n' "$@" | jq -R . | jq -c -s '{command:.}' | $SOCKCMD &> /dev/null
}
@@ -249,14 +350,18 @@ case "$1" in
load) _loadPlaylist "$2" ;;
loop) _toggleLoop ;;
loop-status) _getLoop ;;
- next) _setProperty 'playlist_next' ;;
- playlist) _getPlaylist "$2" "$3" ;;
- prev) _setProperty 'playlist_prev' ;;
+ next) _nextPlaylist ;;
+ playlist) _getPlaylist "$2" ;;
+ prev) _prevPlaylist ;;
rm) _setProperty 'playlist_remove' "$2" ;;
save) _savePlaylist "$2" ;;
stop) _setProperty 'quit' ;;
toggle) _setProperty 'cycle' 'pause' ;;
track) _setProperty 'set_property' 'playlist-pos' "$2" ;;
+ ctrack) _getProperty 'playlist-pos' ;;
+ chapters) _getChapters "$2" "$3" ;;
+ chapter) _setProperty 'set_property' 'chapter' "$2" ;;
+ cchapter) _getProperty 'chapter' | sed 's/null/-1/' ;;
-h | --help) shift; _usage; exit 0 ;;
*) shift; _usage; exit 1 ;;
esac
diff --git a/bin/ytdl-mpv b/bin/ytdl-mpv
index 98b5e33..7ebc951 100755
--- a/bin/ytdl-mpv
+++ b/bin/ytdl-mpv
@@ -80,6 +80,7 @@ _helpEdit() {
_rofi -theme-str "$STYLE" -mesg "-- edit menu key bindings --" > /dev/null
[Enter] | Play playlist item
[${remove_track}] | Remove playlist item
+[$(printf '%s' "${key_enter}" | sed 's/Return/⏎/')] | Explore chapters
[$(printf '%s' "${multi_select}" | sed 's/Tab/⇄/')] | Multi selection
[$(printf '%s' "${key_return}" | sed 's/Left/←/')] | Return
EOF
@@ -95,17 +96,25 @@ remove_track="Alt+r"
key_help="Alt+h"
multi_select="Alt+Tab"
key_return="Alt+Left"
+key_enter="Alt+Return"
# Default envs
CACHEDIR=$HOME/.cache/ytdl-mpv
+[ -d "$CACHEDIR" ] || mkdir -p "$CACHEDIR"
+TMPDIR=$XDG_RUNTIME_DIR/ytdl-mpv
+[ -n "$XDG_RUNTIME_DIR" ] || TMPDIR=/tmp/ytdl-mpv
+[ -d "$TMPDIR" ] || mkdir -p "$TMPDIR"
+
DB=$CACHEDIR/ytdl-mpv.sqlite3
-DELAY=0.3
+SOCKET=$TMPDIR/ytdl-mpv.sock
HISTORY=$HOME/.ytdl-mpv.history
+PLAYDIR=$HOME/.local/share/ytdl-mpv
+[ -d "$PLAYDIR" ] || mkdir -p "$PLAYDIR"
+
+DELAY=0.3
LINEN=16
NUMBER=20
-PLAYDIR=$HOME/.local/share/ytdl-mpv
PROMPT='ytdl-mpv > '
-SOCKET=/tmp/ytdl-mpv.sock
WIDTH=70
XCLIP=1
@@ -125,7 +134,7 @@ _info() {
# Ensure dependencies
_checkDep() {
local deps
- deps=(mpv mpvctl socat rofi sqlite3 yt-dlp xargs xclip)
+ deps=(mpv mpvctl socat rofi sqlite3 yt-dlp xargs jq iconv xclip)
for dep in "${deps[@]}"; do
type "$dep" > /dev/null 2>&1 || {
if [ "$dep" == "xclip" ]; then
@@ -135,6 +144,8 @@ _checkDep() {
fi
}
done
+ (echo "D̲eep̲ ̲P̲u̲r̲ple" | iconv -f utf-8 -t ascii//translit &>/dev/null) \
+ || _die "Unsupported iconv flavour, install gnu-libiconv"
}
# Ensure internet connection is on
@@ -204,7 +215,7 @@ _isCachedQuery() {
if [ -f "$DB" ]; then
local count
count="$(sqlite3 "${DB}" \
- "select count(*) from main where query='${query}'" 2> /dev/null)"
+ "select count(*) from main where query='${query}'")"
if [[ "$count" -gt 0 ]]; then printf "cached"; fi
fi
}
@@ -215,8 +226,10 @@ _getCachedQuery() {
local query
query="$1"
sqlite3 "${DB}" \
- "select title from main where query='${query}'" 2> /dev/null \
- | awk '{ print FNR ") " $0 }' | sed 's/\<[0-9]\>/0&/'
+ "select nchapters,title from main where query='${query}'" \
+ | sed 's/^0|/[##] /;s/\(^[0-9]\+\)|/[\1] /' \
+ | awk '{ print FNR ") " $0 }' \
+ | sed 's/^[0-9])/0&/;s/\[\([0-9]\)\]/[0\1]/'
}
# Get id of yt content from cached table
@@ -228,7 +241,7 @@ _getCachedIdQuery() {
# escape quotes
title=$(printf '%s' "$title" | sed "s/'/''/g")
printf '%s' "$(sqlite3 "${DB}" \
- "select id from main where query='${query}' and title='${title}'" 2> /dev/null)"
+ "select id from main where query='${query}' and title='${title}'")"
}
# Cache a query inside main table
@@ -236,16 +249,17 @@ _cacheQuery() {
local query
query="$1"
# create main table and cache items
- sqlite3 "${DB}" "create table if not exists main (query str, id str, title str, unique(title))"
- sed "s/;//g" "$CACHEDIR/$query" | sed "s/\"/\'/g" | sed -E "N;s/(.*)\n(.*)/${query};\2;\1/" \
- | sqlite3 -separator ';' "${DB}" ".import /dev/stdin main"
+ sqlite3 "${DB}" \
+ "create table if not exists main (query str,id str,title str,nchapters int,chapters str,unique(title))"
+ sqlite3 -separator ',' "${DB}" ".import ${TMPDIR}/${query} main"
}
# Delete a cached query inside main table
_deleteQuery() {
local query
query="$1"
- sqlite3 "${DB}" "delete from main where query='${query}'" 2> /dev/null
+ sqlite3 "${DB}" "delete from main where query='${query}'" \
+ || _die "Deleting query ${query} from cache db"
}
# ytdl-mpv main interactive menu
@@ -282,44 +296,42 @@ _editMenu() {
local args
local STYLE="window {width: ${WIDTH}%;} listview {lines: ${LINEN};}"
args=( -kb-custom-1 "${remove_track}"
+ -kb-custom-2 "${key_enter}"
-kb-custom-4 "${key_help}"
-kb-custom-5 "${key_return}"
-kb-accept-alt "${multi_select}"
-theme-str "$STYLE"
-multi-select
-no-custom
+ -format i
-mesg "-- loop [$(_ytdl_mpvctl loop-status)] -- edit menu: edit playlist, help [Alt+h] --" )
# get current playlist
local pl
pl="$(_ytdl_mpvctl playlist "${DB}")"
# selected track
local rofi_exit
- strs="$(printf '%s' "${pl}" | _rofi "${args[@]}")"
+ stns="$(printf '%s' "${pl}" | _rofi "${args[@]}")"
rofi_exit="$?"
# check if help requested
if [[ "${rofi_exit}" -eq 13 ]]; then
_helpEdit; _editMenu;
else
- if [ -z "$strs" ]; then
+ if [ -z "$stns" ]; then
_info "Nothing selected"
exit 0
elif [[ "${rofi_exit}" -eq 14 ]]; then
_info "Back to main menu"
_mainMenu; return;
else
- local i
local IFS
- i=1
IFS=$'\n'
- for str in $strs; do
- local stn
+ for stn in $stns; do
# get track number
- stn="$(printf '%s' "${str::2}" | sed 's/^0*//')"
case "${rofi_exit}" in
- 0) _ytdl_mpvctl track "$((stn-i))"; return ;;
- 10) _ytdl_mpvctl rm "$((stn-i))" ;;
+ 0) _ytdl_mpvctl track "$stn"; return ;;
+ 10) _ytdl_mpvctl rm "$stn" ;;
+ 11) _editMenuChapters "$stn" "$pl"; return ;;
esac
- i=$((i+1))
done
# recursive until explicit exit
sleep $DELAY; _editMenu
@@ -327,10 +339,61 @@ _editMenu() {
fi
}
+# Edit menu for track chapters (if available)
+# display available chapters, start/choose between them
+_editMenuChapters() {
+ local stn
+ stn=$1
+ local stn1
+ stn1=$((stn+1))
+ local trtitle
+ trtitle=$(printf '%s' "$2" | sed "$stn1 q;d")
+ # if no chapters available play as usual
+ # otherwise rofi menu to choose between chapters
+ if printf '%s' "$trtitle" | grep -Fq ' [##] '; then
+ _ytdl_mpvctl track "$stn"
+ else
+ local ch
+ local STYLE="window {width: ${WIDTH}%;} listview {lines: ${LINEN};}"
+ trtitle=${trtitle:9}
+ trtitle=${trtitle:0:30}
+ # selected chapter
+ local rofi_exit
+ ch=$(_ytdl_mpvctl chapters "$stn" "${DB}" \
+ | _rofi -theme-str "$STYLE" -kb-custom-5 "${key_return}" \
+ -no-custom -format i -mesg "-- chapters menu:${trtitle}[...], simply [Enter] --")
+ rofi_exit="$?"
+ if [ -z "$ch" ]; then
+ _info "Nothing selected"
+ exit 0
+ elif [[ "${rofi_exit}" -eq 14 ]]; then
+ _info "Back to main menu"
+ _editMenu; return;
+ else
+ # if selected track is the current track
+ # just change chapter
+ # otherwise change track and then change chapter
+ if [ "$(_ytdl_mpvctl ctrack)" -eq "$stn" ]; then
+ _ytdl_mpvctl chapter "$ch"
+ else
+ local i
+ i=0
+ _ytdl_mpvctl toggle
+ _ytdl_mpvctl track "$stn"
+ until [ "$(_ytdl_mpvctl cchapter)" -eq "$ch" ]; do
+ _ytdl_mpvctl chapter "$ch"
+ [[ $i -gt 1000000 ]] && break
+ i=$((i+1))
+ done
+ _ytdl_mpvctl toggle
+ fi
+ fi
+ fi
+}
+
# Save menu,
# save the current playlist as text file
_saveMenu() {
- mkdir -p "$PLAYDIR"
# saved playlists
local saved
local rofi_exit
@@ -441,20 +504,25 @@ _startPlay() {
# yt-dlp search
local query
query="$(_hashStr "${search}:${NUMBER}")"
- mkdir -p "$CACHEDIR"
# if not cached or marked as to_recache
# search it and cache it
local cache
cache="$(_isCachedQuery "$query")"
if [ -z "$cache" ] || [ "$to_recache" -eq 1 ]; then
if [ "$to_recache" -eq 1 ]; then _deleteQuery "$query"; fi
+ # steps to digest single json payload:
+ # * jq from json to csv
+ # * remove left|right double quotes (“|”)
+ # * translite from utf8 to ascii
yt-dlp --default-search \
- ytsearch"$NUMBER" "$search" --get-id --get-title \
- 2> /dev/null > "$CACHEDIR/$query" &
+ ytsearch"$NUMBER" "$search" --dump-single-json \
+ | query=$query jq -r '.entries[] | {query:env.query,id:.id,title:.title,nchapters:(.chapters|length),chapters:(.chapters|if .==null then [] else [.[].title] end)} | map(tostring) | @csv' \
+ | sed 's/\xE2\x80\x9D//g;s/\xE2\x80\x9C//g' \
+ | iconv -f utf-8 -t ascii//translit \
+ > "$TMPDIR/$query" &
wait "$!"; yt_dlp_exit="$?"
[[ "$yt_dlp_exit" -eq 0 ]] || _die "yt-dlp search fail, exit code ${yt_dlp_exit}"
- _cacheQuery "$query" 2> /dev/null
- rm -f "$CACHEDIR/$query"
+ _cacheQuery "$query" || _die "Adding query ${query} inside cache db"
fi
# check if ytdl-mpv is already running, if yes append track to playlist
local args
@@ -497,7 +565,7 @@ _startPlay() {
local IFS
IFS=$'\n'
for strack in $stracks; do
- strack="${strack:4}"
+ strack="${strack:9}"
local id
id="ytdl://$(_getCachedIdQuery "$query" "$strack")"
# check if ytdl socket is idle, if yes append instead play