diff --git a/README.md b/README.md index 957ecaa..c51f0a4 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Run
- Demo + Demo ## :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