From c4f71c55e64a36b5199586d9e65634fd1ee409f2 Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 08:03:58 +0000 Subject: [PATCH 01/12] Show type and episode counts in search results Search results now carry a details column next to the title. - Parse type, total, sub and dub episode counts from the search page - fzf hides the id column and aligns the details before the title - rofi, dmenu and custom menus get the same details as plain text - Bump version to 5.2.0 --- ani-cli | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/ani-cli b/ani-cli index c6e6299a2..765b3d5b6 100755 --- a/ani-cli +++ b/ani-cli @@ -1,6 +1,6 @@ #!/bin/sh -version_number="5.1.4" +version_number="5.2.0" # UI @@ -8,14 +8,14 @@ version_number="5.1.4" # $1 = prompt, $2 = multi select flag (for fzf/rofi), $3 = extra flags by user menu() { case "$menu_program" in - fzf) fzf --reverse --cycle --prompt "$1" $2 $3 ;; + fzf) fzf --reverse --cycle --prompt "$1" --delimiter '\t' --with-nth 1,4,3 --tabstop 4 $2 $3 ;; rofi) rofi -sort -dmenu -i -p "$1" $2 $3 ;; dmenu) dmenu -l 20 -p "$1" $3 ;; *) "$menu_program" $3 "$1" ;; esac } -# provide menu. input format is either (ep_list) or (number \t anime_id \t anime_name) +# provide menu. input format is either (ep_list) or (number \t anime_id \t anime_name [\t details]) nth() { _stdin=$(cat -) [ -z "$_stdin" ] && return 1 @@ -23,7 +23,13 @@ nth() { [ "$_line_count" -eq 1 ] && printf "%s" "$_stdin" | cut -f 2,3 && return 0 _prompt="$1" [ $# -ne 1 ] && _multi_flag="$2" - _line=$(printf "%s" "$_stdin" | cut -f 1,3 | tr '\t' ' ' | menu "$_prompt" "$_multi_flag" "$menu_extra_flags" | cut -d " " -f 1) + if [ "$menu_program" = "fzf" ]; then + # fzf gets the raw lines, it hides the id and reorders the columns itself (see menu) + _line=$(printf "%s" "$_stdin" | menu "$_prompt" "$_multi_flag" "$menu_extra_flags" | cut -f 1) + else + # other menus get plain text: number, details (if any), then the name. literal tabs, BSD sed has no \t in brackets + _line=$(printf "%s" "$_stdin" | sed -E 's|^([^ ]*) [^ ]* ([^ ]*) ([^ ]*)$|\1 \3 \2|' | cut -f 1,3 | tr '\t' ' ' | menu "$_prompt" "$_multi_flag" "$menu_extra_flags" | cut -d " " -f 1) + fi _line_start=$(printf "%s" "$_line" | head -n 1) _line_end=$(printf "%s" "$_line" | tail -n 1) [ -n "$_line" ] || return 1 @@ -195,7 +201,12 @@ deobfuscate_blob() ( printf "%b" "$_output" ) -# search the query and give results. format is (id \t name) +# appends the first group of the regex $1 as a new tab separated column, "-" when the line has no match +tab_column() { + sed -E -e "s|^(.*$1.*)\$|\\1\\t\\2|" -e t -e 's|$|\t-|' +} + +# search the query and give results. format is (id \t name \t details) hianime_search() { #shellcheck disable=SC2059 _page="$(hianime_curl "$(printf "$search_api" "$1")")" || return 1 @@ -204,9 +215,17 @@ hianime_search() { die "Blocked by cloudflare." fi # the top 10 sidebar repeats the result markup, cut it off before flattening the page - printf "%s" "$_page" | sed '/id="main-sidebar"/,$d' | tr '\n' ' ' | sed 's|
|\n
|g' | - sed -nE 's|.*

[[:space:]]*([A-Za-z]+)' | tab_column 'tick-eps">[[:space:]]*([0-9]+)' | + tab_column 'tick-sub">[[:space:]]*]*>[[:space:]]*([0-9]+)' | tab_column 'tick-dub">[[:space:]]*]*>[[:space:]]*([0-9]+)' | + sed -nE 's|.*

[[:space:]]*|g' -e "s|&|\&|g" | + while IFS=" " read -r _id _title _type _eps _sub _dub; do + case "$_type" in MOVIE) _type=Movie ;; SPECIAL) _type=Special ;; MUSIC) _type=Music ;; *) ;; esac + [ "$_eps" = "-" ] && _eps="?" + printf "%s\t%s\t%-7s %4s ep sub %-4s dub %-4s \n" "$_id" "$_title" "$_type" "$_eps" "$_sub" "$_dub" + done } # get the episodes list of the selected anime. format is (id \t ep_no) From e828dfa71654f2ed8b7b6e5c659ae818b29fa497 Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 08:06:42 +0000 Subject: [PATCH 02/12] Add a preview pane with anime details to fzf The anime menu shows the facts of the highlighted title next to the list. - New --info option prints title, Japanese name, aired dates, status, score, genres, studios, rating and synopsis of an anime id - fzf runs the script with --info for the highlighted row, in search and in continue mode; ANI_CLI_MENU_FLAGS=--no-preview turns it off - Entity decoding shared between search results and details --- ani-cli | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/ani-cli b/ani-cli index 765b3d5b6..2c45a32e7 100755 --- a/ani-cli +++ b/ani-cli @@ -8,7 +8,7 @@ version_number="5.2.0" # $1 = prompt, $2 = multi select flag (for fzf/rofi), $3 = extra flags by user menu() { case "$menu_program" in - fzf) fzf --reverse --cycle --prompt "$1" --delimiter '\t' --with-nth 1,4,3 --tabstop 4 $2 $3 ;; + fzf) fzf --reverse --cycle --prompt "$1" --delimiter '\t' --with-nth 1,4,3 --tabstop 4 ${menu_preview:+--preview "$menu_preview" --preview-window "$preview_window"} $2 $3 ;; rofi) rofi -sort -dmenu -i -p "$1" $2 $3 ;; dmenu) dmenu -l 20 -p "$1" $3 ;; *) "$menu_program" $3 "$1" ;; @@ -93,6 +93,8 @@ help_info() { Exit the player, and return the player exit code (useful for non interactive scenarios) -N, --nextep-countdown Display a countdown to the next episode + --info + Show the details of an anime id, this is what the preview pane displays -U, --update Update the script Some example usages: @@ -201,6 +203,11 @@ deobfuscate_blob() ( printf "%b" "$_output" ) +# turns the html entities the site uses for titles and descriptions back into characters +decode_entities() { + sed -e "s|'|'|g" -e 's|"|"|g' -e 's|<|<|g' -e 's|>|>|g' -e "s|&|\&|g" +} + # appends the first group of the regex $1 as a new tab separated column, "-" when the line has no match tab_column() { sed -E -e "s|^(.*$1.*)\$|\\1\\t\\2|" -e t -e 's|$|\t-|' @@ -220,7 +227,7 @@ hianime_search() { tab_column '([A-Za-z]+)' | tab_column 'tick-eps">[[:space:]]*([0-9]+)' | tab_column 'tick-sub">[[:space:]]*]*>[[:space:]]*([0-9]+)' | tab_column 'tick-dub">[[:space:]]*]*>[[:space:]]*([0-9]+)' | sed -nE 's|.*

[[:space:]]*|g' -e "s|&|\&|g" | + decode_entities | while IFS=" " read -r _id _title _type _eps _sub _dub; do case "$_type" in MOVIE) _type=Movie ;; SPECIAL) _type=Special ;; MUSIC) _type=Music ;; *) ;; esac [ "$_eps" = "-" ] && _eps="?" @@ -228,6 +235,39 @@ hianime_search() { done } +# prints what follows the first occurrence of $1 on a single line +after_first() { + sed "s|$1|\\ +|" | sed -n '2p' +} + +# print the details of the anime with id $1, this is what the preview pane shows +hianime_info() { + case "$1" in + "" | *[!A-Za-z0-9._-]*) die "Invalid anime id: $1" ;; + *) ;; + esac + _page="$(hianime_curl "$base_api/$1" 2>/dev/null)" || { + printf "No details available\n" + exit 1 + } + _page="$(printf "%s" "$_page" | tr '\n\t' ' ' | sed 's/ */ /g')" + _title="$(printf "%s" "$_page" | after_first '

' | sed 's| *<.*||')" + # the anisc-info block lists the facts, one item each. an item ends at its first closing div, links become comma separated + _items="$(printf "%s" "$_page" | after_first '
' | sed 's|
.*||; s||,|g' | + sed -nE 's|.*item-head">([^<:]*):(.*)|\1\t\2|p' | sed 's|<[^>]*>| |g; s/ */ /g; s/ *, */, /g; s/ *,* *$//; s/\t /\t/' | decode_entities)" + [ -n "$_rating" ] && _items="$(printf "%s\nRating\t%s" "$_items" "$_rating")" + printf "\033[1m%s\033[0m\n\n" "$_title" + printf "%s\n" "$_items" | grep -v "^Overview" | while IFS=" " read -r _key _value; do + [ -n "$_key" ] && printf "\033[1;34m%-10s\033[0m %s\n" "$_key" "$_value" + done + printf "%s\n" "$_items" | sed -n 's|^Overview\t|\ +|p' + exit 0 +} + # get the episodes list of the selected anime. format is (id \t ep_no) hianime_episodes() { #shellcheck disable=SC2059 @@ -493,6 +533,9 @@ exit_after_play="${ANI_CLI_EXIT_AFTER_PLAY:-0}" skip_intro="${ANI_CLI_SKIP_INTRO:-0}" menu_program="${ANI_CLI_MENU:-fzf}" menu_extra_flags="${ANI_CLI_MENU_FLAGS:-""}" +# the preview pane runs this script again with --info for the highlighted anime +script_path="$(command -v "$0" || printf "%s" "$0")" +preview_window="right:40%:wrap" # not opening from a terminal if [ ! -t 0 ]; then @@ -568,6 +611,10 @@ while [ $# -gt 0 ]; do --dmenu) menu_program=dmenu ;; --skip) skip_intro=1 ;; -N | --nextep-countdown) source=nextep ;; + --info) + [ $# -lt 2 ] && die "missing argument!" + hianime_info "$2" + ;; -U | --update) dep_ch "patch" branch="${2:-$branch}" @@ -606,7 +653,9 @@ case "$source" in anime_list=$(while read -r ep_no anime_id anime_title; do process_hist_entry & done <"$histfile") wait [ -z "$anime_list" ] && die "No unwatched series in history!" + menu_preview="sh '$script_path' --info {2}" [ -z "${index##*[!0-9]*}" ] && anime_id=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: " | cut -f 1) + unset menu_preview [ -z "${index##*[!0-9]*}" ] || anime_id=$(printf "%s" "$anime_list" | sed -n "${index}p" | cut -f 1) [ -z "$anime_id" ] && exit 1 anime_title=$(printf "%s" "$anime_list" | grep "^$anime_id " | cut -f 2 | sed 's| - episode.*||') @@ -631,7 +680,9 @@ case "$source" in anime_list=$(hianime_search "$query") || exit 1 [ -z "$anime_list" ] && die "No results found!" [ "$index" -eq "$index" ] 2>/dev/null && result=$(printf "%s" "$anime_list" | sed -n "${index}p") + menu_preview="sh '$script_path' --info {2}" [ -z "$index" ] && result=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: ") + unset menu_preview [ -z "$result" ] && die "Invalid anime selection" anime_title="$(printf "%s" "$result" | cut -f 2)" anime_id="$(printf "%s" "$result" | cut -f 1)" From 5752fa2eb128b6cc6d6a5798bc804e2855fd0b65 Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 08:08:30 +0000 Subject: [PATCH 03/12] Move the preview below the list on narrow terminals Terminals under 100 columns get the pane at the bottom so titles stay readable. --- ani-cli | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ani-cli b/ani-cli index 2c45a32e7..92ecf0aa2 100755 --- a/ani-cli +++ b/ani-cli @@ -533,9 +533,11 @@ exit_after_play="${ANI_CLI_EXIT_AFTER_PLAY:-0}" skip_intro="${ANI_CLI_SKIP_INTRO:-0}" menu_program="${ANI_CLI_MENU:-fzf}" menu_extra_flags="${ANI_CLI_MENU_FLAGS:-""}" -# the preview pane runs this script again with --info for the highlighted anime +# the preview pane runs this script again with --info for the highlighted anime, it goes below the list on narrow terminals script_path="$(command -v "$0" || printf "%s" "$0")" preview_window="right:40%:wrap" +_cols="$(tput cols 2>/dev/null)" +[ "${_cols:-100}" -lt 100 ] 2>/dev/null && preview_window="down:40%:wrap" # not opening from a terminal if [ ! -t 0 ]; then From 079082bd060f41d20b8d8d63ad8560513b3e8300 Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 08:09:24 +0000 Subject: [PATCH 04/12] List the other seasons in the preview The details show every season of the highlighted anime and mark the current one. --- ani-cli | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ani-cli b/ani-cli index 92ecf0aa2..7ebef6eb9 100755 --- a/ani-cli +++ b/ani-cli @@ -237,8 +237,7 @@ hianime_search() { # prints what follows the first occurrence of $1 on a single line after_first() { - sed "s|$1|\\ -|" | sed -n '2p' + sed "s|$1|\\n|" | sed -n '2p' } # print the details of the anime with id $1, this is what the preview pane shows @@ -255,16 +254,17 @@ hianime_info() { _title="$(printf "%s" "$_page" | after_first '

' | sed 's| *<.*||')" # the anisc-info block lists the facts, one item each. an item ends at its first closing div, links become comma separated - _items="$(printf "%s" "$_page" | after_first '
' | sed 's|
.*||; s||,|g' | + _items="$(printf "%s" "$_page" | after_first '
' | sed 's|
.*||; s||,|g' | sed -nE 's|.*item-head">([^<:]*):(.*)|\1\t\2|p' | sed 's|<[^>]*>| |g; s/ */ /g; s/ *, */, /g; s/ *,* *$//; s/\t /\t/' | decode_entities)" [ -n "$_rating" ] && _items="$(printf "%s\nRating\t%s" "$_items" "$_rating")" + _seasons="$(printf "%s" "$_page" | sed 's|class="os-item|\n&|g' | sed '1d; s|.*||' | + sed -nE 's|^class="os-item ?(active)?"[^>]*>.*
([^<]*)
.*|\2 \1|p' | sed 's| active$| (this)|; s| $||' | tr '\n' ',' | sed 's|,$||; s|,|, |g')" + [ -n "$_seasons" ] && _items="$(printf "%s\nSeasons\t%s" "$_items" "$_seasons")" printf "\033[1m%s\033[0m\n\n" "$_title" printf "%s\n" "$_items" | grep -v "^Overview" | while IFS=" " read -r _key _value; do [ -n "$_key" ] && printf "\033[1;34m%-10s\033[0m %s\n" "$_key" "$_value" done - printf "%s\n" "$_items" | sed -n 's|^Overview\t|\ -|p' + printf "%s\n" "$_items" | sed -n 's|^Overview\t|\n|p' exit 0 } From e587051638f5c60cc34f6a2beddf4ec0983900a1 Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 08:11:06 +0000 Subject: [PATCH 05/12] Document the search columns and the preview pane README FAQ and man page explain the new columns, the --info option and how to hide the pane. --- README.md | 2 ++ ani-cli.1 | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 20a7789c2..ee1db3082 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,8 @@ Ani-skip uses the external lua script function of mpv and as such – for now * How can I download? - Use `-d`, it will download into your working directory. * Can i change download folder? - Yes, set the `ANI_CLI_DOWNLOAD_DIR` to your desired location. * How can I bulk download? - `Use -d -e firstepisode-lastepisode`, for example `ani-cli onepiece -d -e 1-1000`. +* What do the columns in the search results mean? - The type (TV, Movie, OVA, ONA, Special), the number of episodes (`?` while a show is unreleased or still airing) and how many episodes have subtitles and a dub (`-` when there are none). +* What is the pane next to the search results? - The details of the highlighted anime (Japanese name, aired dates, status, score, genres, studios, rating, seasons, synopsis). It exists with fzf only, moves below the list on narrow terminals and can be turned off with `export ANI_CLI_MENU_FLAGS="--no-preview"`. **Note:** All features are documented in `ani-cli --help`. diff --git a/ani-cli.1 b/ani-cli.1 index e4f4e9419..d5e3934ee 100644 --- a/ani-cli.1 +++ b/ani-cli.1 @@ -1,4 +1,4 @@ -.TH "ANI-CLI" "1" "August 2026" "ani-cli" "User Commands" +.TH "ANI-CLI" "1" "September 2026" "ani-cli" "User Commands" .SH NAME ani-cli \- watch anime from the commandline .SH SYNOPSIS @@ -14,6 +14,10 @@ This tool scrapes the site hianime. .P .PD \f[B]ani-cli\f[R] without options defaults to iina on macOS, flatpak mpv on Steamdeck, mpv apk on android, vlc on iOS and mpv media player everywhere else. +.PD 0 +.P +.PD +Search results list the type, the number of episodes and how many of them have subtitles and a dub next to every title. With fzf, a preview pane shows the details of the highlighted anime. .SH OPTIONS .TP \fB\-e | --episode | -r | --range\fR \fI\,\/\fR @@ -52,6 +56,9 @@ Selects nth entry. \fB\-N | --nextep-countdown\fR Prints a countdown to the next episode as the last episode in the list. If in history is after the current episode. .TP +\fB\--info\fR \fI\,\/\fR +Print the details of an anime id: title, Japanese name, aired dates, status, score, genres, studios, rating, seasons and synopsis. The fzf preview pane uses this. +.TP \fB\--dub\fR Play the dubbed version. Without this flag, it'll always play the subbed version. .TP @@ -97,7 +104,7 @@ Sets the flags for the player ani-cli uses. Can be anything that the player supp Controls the frontend of ani-cli. Can be fzf, rofi, dmenu or any other program. Default is fzf. .TP \fBANI_CLI_MENU_FLAGS\fR -Controls the flags for the frontend. Last flag must take prompt as argument. Default is none +Controls the flags for the frontend. Last flag must take prompt as argument. With fzf, --no-preview hides the details pane. Default is none .TP \fBANI_CLI_LOG\fR Controls the logging feature for playback. Can be 1(logs) or 0(doesn't log). Default is 1. From 38318d571c9fb2d5421f04b0c904c2bfbbcab937 Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 08:13:55 +0000 Subject: [PATCH 06/12] Decode entities in the seasons line Season names with & or quotes showed their html entities. --- ani-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ani-cli b/ani-cli index 7ebef6eb9..e0514bc0a 100755 --- a/ani-cli +++ b/ani-cli @@ -258,7 +258,7 @@ hianime_info() { sed -nE 's|.*item-head">([^<:]*):(.*)|\1\t\2|p' | sed 's|<[^>]*>| |g; s/ */ /g; s/ *, */, /g; s/ *,* *$//; s/\t /\t/' | decode_entities)" [ -n "$_rating" ] && _items="$(printf "%s\nRating\t%s" "$_items" "$_rating")" _seasons="$(printf "%s" "$_page" | sed 's|class="os-item|\n&|g' | sed '1d; s|.*||' | - sed -nE 's|^class="os-item ?(active)?"[^>]*>.*
([^<]*)
.*|\2 \1|p' | sed 's| active$| (this)|; s| $||' | tr '\n' ',' | sed 's|,$||; s|,|, |g')" + sed -nE 's|^class="os-item ?(active)?"[^>]*>.*
([^<]*)
.*|\2 \1|p' | sed 's| active$| (this)|; s| $||' | tr '\n' ',' | sed 's|,$||; s|,|, |g' | decode_entities)" [ -n "$_seasons" ] && _items="$(printf "%s\nSeasons\t%s" "$_items" "$_seasons")" printf "\033[1m%s\033[0m\n\n" "$_title" printf "%s\n" "$_items" | grep -v "^Overview" | while IFS=" " read -r _key _value; do From e7d6682dd4498d94e46d9df87e4299b98ab8e89d Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 08:14:31 +0000 Subject: [PATCH 07/12] Keep the last search result The page ends without a newline, so read skipped the final result of every search. --- ani-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ani-cli b/ani-cli index e0514bc0a..e1e4fbb74 100755 --- a/ani-cli +++ b/ani-cli @@ -228,7 +228,7 @@ hianime_search() { tab_column 'tick-sub">[[:space:]]*]*>[[:space:]]*([0-9]+)' | tab_column 'tick-dub">[[:space:]]*]*>[[:space:]]*([0-9]+)' | sed -nE 's|.*

[[:space:]]* Date: Wed, 23 Sep 2026 08:14:31 +0000 Subject: [PATCH 08/12] Add offline tests for the html parsing Saved pages feed the search and details parsers, so the regexes can be checked without the network. - tests/parse.sh loads the functions of ani-cli and compares against expected output - Search fixture covers Japanese, Chinese and Korean titles, brackets, slashes, pipes, backslashes, quotes, ampersands, tabs, missing dub and unknown totals - Details fixture covers entities, multi-word genres, seasons and a distractor block - CI runs the tests, CONTRIBUTING and the PR template mention them --- .github/PULL_REQUEST_TEMPLATE.md | 1 + .github/workflows/ani-cli.yml | 9 + .github/workflows/inverse-ani.yml | 6 + CONTRIBUTING.md | 1 + tests/expected/info.txt | 15 ++ tests/expected/search.tsv | 12 + tests/fixtures/detail.html | 125 +++++++++ tests/fixtures/search.html | 407 ++++++++++++++++++++++++++++++ tests/parse.sh | 54 ++++ 9 files changed, 630 insertions(+) create mode 100644 tests/expected/info.txt create mode 100644 tests/expected/search.tsv create mode 100644 tests/fixtures/detail.html create mode 100644 tests/fixtures/search.html create mode 100755 tests/parse.sh diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 176190148..b1c5cfb16 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,6 +14,7 @@ - [ ] any anime playing - [ ] bumped version +- [ ] `sh tests/parse.sh` passes --- - [ ] next, prev, replay and select work - [ ] `-c` history and continue work diff --git a/.github/workflows/ani-cli.yml b/.github/workflows/ani-cli.yml index 8951aab4d..e5cf08ae8 100644 --- a/.github/workflows/ani-cli.yml +++ b/.github/workflows/ani-cli.yml @@ -6,6 +6,7 @@ on: pull_request: paths: - "ani-cli" + - "tests/**" jobs: sh-checker: @@ -44,3 +45,11 @@ jobs: - uses: actions/checkout@v4 - name: verify that noone added wget run: '! grep wget "./ani-cli"' + + parse-tests: + name: Parsing Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: run the offline parsing tests + run: sh tests/parse.sh diff --git a/.github/workflows/inverse-ani.yml b/.github/workflows/inverse-ani.yml index 00dd2e920..876530364 100644 --- a/.github/workflows/inverse-ani.yml +++ b/.github/workflows/inverse-ani.yml @@ -6,6 +6,7 @@ on: pull_request: paths-ignore: - "ani-cli" + - "tests/**" jobs: sh-checker: name: Shellcheck + Shfmt @@ -22,3 +23,8 @@ jobs: runs-on: ubuntu-latest steps: - run: 'echo "Not required: did not modify ani-cli"' + parse-tests: + name: Parsing Tests + runs-on: ubuntu-latest + steps: + - run: 'echo "Not required: did not modify ani-cli"' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b84544bff..88f6e2a9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,7 @@ - Appease the linter (run `shfmt -i 4 -ci -d -w ani-cli`) - Appease POSIX (run `shellcheck -s sh -o all -e 2250 ani-cli`) +- Run the offline parsing tests (`sh tests/parse.sh`) and update `tests/` when you change the scraping - Bump the version - Adjust the Readme according to your changes (if applicable) - No extra dependencies unless absolutely necessary diff --git a/tests/expected/info.txt b/tests/expected/info.txt new file mode 100644 index 000000000..07f9ba7b3 --- /dev/null +++ b/tests/expected/info.txt @@ -0,0 +1,15 @@ +Frieren: Beyond Journey's End + +Japanese 葬送のフリーレン +Synonyms Sousou no Frieren +Aired Sep 29, 2023 to Mar 22, 2024 +Duration 25m +Status Finished Airing +MAL Score 9.36 +Genres Adventure, Fantasy, Shounen, Drama, Award Winning +Studios Madhouse +Producers TOHO animation, Shogakukan +Rating PG-13 +Seasons Season 1 (this), Season 2, Season 3 & Beyond + +During their decade-long quest to defeat the Demon King, the members of the hero's party—Himmel himself, the priest Heiter, the dwarf warrior Eisen, and the elven mage Frieren—forge bonds through adventures and battles, creating unforgettable precious memories for most of them. However, the time that Frieren spends with her comrades is equivalent to merely a fraction of her life, which has lasted over a thousand years. When the party disbands after their victory, Frieren casually returns to her "usual" routine of collecting spells across the continent. Due to her different sense of time, she seemingly holds no strong feelings toward the experiences she went through. As the years pass, Frieren gradually realizes how her days in the hero's party truly impacted her. Witnessing the deaths of two of her former companions, Frieren begins to regret having taken their presence for granted; she vows to better understand humans and create real personal connections. Although the story of that once memorable journey has long ended, a new tale is about to begin. diff --git a/tests/expected/search.tsv b/tests/expected/search.tsv new file mode 100644 index 000000000..1d3a8f214 --- /dev/null +++ b/tests/expected/search.tsv @@ -0,0 +1,12 @@ +frieren-beyond-journeys-end-481 Frieren: Beyond Journey's End TV 28 ep sub 28 dub 28 +oshi-no-ko-final-7206 [Oshi no Ko] Final Season TV ? ep sub - dub - +one-piece-1 One Piece TV ? ep sub 1179 dub 1155 +fatestay-night-heavens-feel-i-presage-flower-1109 Fate/stay night: Heaven's Feel - I. Presage Flower Movie 1 ep sub 1 dub 1 +gintama-season-2-1153 Gintama Season 2 TV 51 ep sub 51 dub - +sousou-no-frieren-2nd-season-9001 葬送のフリーレン 第2期 TV 10 ep sub 10 dub 10 +doupo-cangqiong-nian-fan-9002 斗破苍穹 年番 ONA 52 ep sub 52 dub - +na-honjaman-level-up-9003 나 혼자만 레벨업 TV 12 ep sub 12 dub 12 +special-chars-9004 Tom & Jerry | Pipe \ Backslash "Quoted" 100% [Brackets] a/b Special 1 ep sub 1 dub - +tab-in-title-9005 Tab Inside Title OVA 2 ep sub 2 dub 2 +music-video-9006 Otaku no Uta Music 1 ep sub 1 dub - +dub-only-9007 Dub Only Show TV 6 ep sub - dub 6 diff --git a/tests/fixtures/detail.html b/tests/fixtures/detail.html new file mode 100644 index 000000000..318f21ac8 --- /dev/null +++ b/tests/fixtures/detail.html @@ -0,0 +1,125 @@ + + +Watch Frieren: Beyond Journey's End | HiAnime + +
+

+ Frieren: Beyond Journey's End +

+
+
+
PG-13
+
HD
+
+ 28 +
+
+ 28 +
+
+ 28 +
+ + + TV + + 25m +
+
+
+
+
+ +
+
This title div must not leak into the seasons
+
R+
+
+ diff --git a/tests/fixtures/search.html b/tests/fixtures/search.html new file mode 100644 index 000000000..1b54e4eab --- /dev/null +++ b/tests/fixtures/search.html @@ -0,0 +1,407 @@ + + +Browse Anime | HiAnime + +
+

Search results for: tests

+
+
+
+
+
28
+
28
+
28
+
+ + Frieren: Beyond Journey's End + + + +
+
+

+ + Frieren: Beyond Journey's End + +

+
+ During their decade-long quest to defeat the Demon King, the members of the hero's party—Himmel himself, the priest Heiter, the dwarf warrior Eisen, and the elven mage Frieren—forge bonds through adve... +
+
+ TV + + 25m +
+
+
+
+
+
+
+
+ + [Oshi no Ko] Final Season + + + +
+
+

+ + [Oshi no Ko] Final Season + +

+
+ The fourth and final season of [Oshi no Ko]. +
+
+ TV (? eps) + + ... +
+
+
+
+
+
+
+
1179
+
1155
+
+ + One Piece + + + +
+
+

+ + One Piece + +

+
+ Gold Roger was known as the "Pirate King," the strongest and most infamous being to have sailed the Grand Line. The capture and execution of Roger by the World Government brought a change throughout t... +
+
+ TV + + 24m +
+
+
+
+
+
+
+
1
+
1
+
1
+
+ + Fate/stay night: Heaven's Feel - I. Presage Flower + + + +
+ +
+
+
+
+
+
51
+
51
+
+ + Gintama Season 2 + + + +
+
+

+ + Gintama Season 2 + +

+
+ After a one-year hiatus, Shinpachi Shimura returns to Edo, only to stumble upon a shocking surprise: Gintoki and Kagura, his fellow Yorozuya members, have become completely different characters! Fleei... +
+
+ TV + + 24m +
+
+
+
+
+
+
+
10
+
10
+
10
+
+ 葬送のフリーレン 第2期 + +
+
+

+ + 葬送のフリーレン 第2期 + +

+
made up entry for the tests
+
+ TV + + 24m +
+
+
+
+
+
+
+
52
+
52
+
+ 斗破苍穹 年番 + +
+
+

+ + 斗破苍穹 年番 + +

+
made up entry for the tests
+
+ ONA + + 22m +
+
+
+
+
+
+
+
12
+
12
+
12
+
+ 나 혼자만 레벨업 + +
+
+

+ + 나 혼자만 레벨업 + +

+
made up entry for the tests
+
+ TV + + 24m +
+
+
+
+
+
+
+
1
+
1
+
+ Tom & Jerry | Pipe \ Backslash "Quoted" <Tag> 100% [Brackets] a/b + +
+
+

+ + Tom & Jerry | Pipe \ Backslash "Quoted" <Tag> 100% [Brackets] a/b + +

+
made up entry for the tests
+
+ SPECIAL + + 5m +
+
+
+
+
+
+
+
2
+
2
+
2
+
+ Tab	Inside Title + +
+
+

+ + Tab Inside Title + +

+
made up entry for the tests
+
+ OVA + + 30m +
+
+
+
+
+
+
+
1
+
1
+
+ Otaku no Uta + +
+
+

+ + Otaku no Uta + +

+
made up entry for the tests
+
+ MUSIC + + 4m +
+
+
+
+
+
+
+
6
+
6
+
+ Dub Only Show + +
+
+

+ + Dub Only Show + +

+
made up entry for the tests
+
+ TV + + 24m +
+
+
+
+
+
+
+
+
1
+

Sidebar

+
TV
+
+
+
+ diff --git a/tests/parse.sh b/tests/parse.sh new file mode 100755 index 000000000..3426e78b4 --- /dev/null +++ b/tests/parse.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# Offline tests for the html parsing of ani-cli. They feed saved pages to the +# parsing functions instead of the network. Run: sh tests/parse.sh + +cd "$(dirname "$0")/.." || exit 1 + +# load the functions only: everything before the "# MAIN" marker, which starts the setup and main part +grep -q '^# MAIN$' ani-cli || { + printf 'the "# MAIN" marker is missing in ani-cli, refusing to load it\n' + exit 1 +} +# shellcheck disable=SC2312 +eval "$(sed -n '1,/^# MAIN$/p' ani-cli)" + +# no network: every request returns the fixture named in $fixture +# shellcheck disable=SC2317 +hianime_curl() { + cat "tests/fixtures/$fixture" +} +# shellcheck disable=SC2034 +base_api="https://hianime.at" +# shellcheck disable=SC2034 +search_api="${base_api}/search?keyword=%s" +# shellcheck disable=SC2034 +curl_exe="curl" + +failed=0 +esc="$(printf '\033')" + +# $1 = name, $2 = expected file, stdin = actual output +check() { + if _diff="$(diff -u "$2" - 2>&1)"; then + printf 'ok %s\n' "$1" + else + printf 'FAIL %s\n%s\n' "$1" "$_diff" + failed=1 + fi +} + +fixture="search.html" +# the details column is padded with spaces, drop them so the expected file has no trailing whitespace +hianime_search "tests" | sed 's/ *$//' | check "search results carry id, title and details" tests/expected/search.tsv + +fixture="detail.html" +(hianime_info "frieren-beyond-journeys-end-481") | sed "s/${esc}\[[0-9;]*m//g" | check "details of an anime" tests/expected/info.txt + +if (hianime_info "bad/id") 2>&1 | grep -q "Invalid anime id"; then + printf 'ok %s\n' "invalid ids are refused" +else + printf 'FAIL %s\n' "invalid ids are refused" + failed=1 +fi + +exit "$failed" From d3f0221579ea1c36d5212eb3124c67d697bc3310 Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 08:59:40 +0000 Subject: [PATCH 09/12] Silence the never-invoked warning on the test stub shellcheck 0.10 and newer report SC2329 for the network stub that the loaded functions call indirectly. --- tests/parse.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/parse.sh b/tests/parse.sh index 3426e78b4..ca75371ff 100755 --- a/tests/parse.sh +++ b/tests/parse.sh @@ -13,7 +13,7 @@ grep -q '^# MAIN$' ani-cli || { eval "$(sed -n '1,/^# MAIN$/p' ani-cli)" # no network: every request returns the fixture named in $fixture -# shellcheck disable=SC2317 +# shellcheck disable=SC2317,SC2329 hianime_curl() { cat "tests/fixtures/$fixture" } From d86ab1b140a2e48df9d53aa59e7a37250735b4a1 Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 11:11:27 +0000 Subject: [PATCH 10/12] Search while typing The search prompt is an fzf list now: every change asks the site again and shows what it found, Enter picks a result straight away. - New --search option lists the results for a query, the prompt reloads it while typing - Works with fzf 0.25 and newer, older versions keep the plain prompt - A query on the command line, -S and -N keep the old flow - Bump version to 5.3.0 --- ani-cli | 57 ++++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/ani-cli b/ani-cli index e1e4fbb74..e218195de 100755 --- a/ani-cli +++ b/ani-cli @@ -1,6 +1,6 @@ #!/bin/sh -version_number="5.2.0" +version_number="5.3.0" # UI @@ -8,7 +8,7 @@ version_number="5.2.0" # $1 = prompt, $2 = multi select flag (for fzf/rofi), $3 = extra flags by user menu() { case "$menu_program" in - fzf) fzf --reverse --cycle --prompt "$1" --delimiter '\t' --with-nth 1,4,3 --tabstop 4 ${menu_preview:+--preview "$menu_preview" --preview-window "$preview_window"} $2 $3 ;; + fzf) fzf --reverse --cycle --prompt "$1" --delimiter '\t' --with-nth 1,4,3 --tabstop 4 ${menu_preview:+--preview "$menu_preview" --preview-window "$preview_window"} ${menu_reload:+--disabled --print-query --bind "change:reload($menu_reload)"} $2 $3 ;; rofi) rofi -sort -dmenu -i -p "$1" $2 $3 ;; dmenu) dmenu -l 20 -p "$1" $3 ;; *) "$menu_program" $3 "$1" ;; @@ -40,6 +40,27 @@ nth() { fi } +# fzf reloads a list while typing since 0.25 +fzf_reloads() { + _fzf_version=$(fzf --version | cut -d ' ' -f 1) + case "$_fzf_version" in + 0.[0-9].* | 0.1[0-9].* | 0.2[0-4].*) return 1 ;; + *) return 0 ;; + esac +} + +# search while typing: fzf asks the site again after every change and shows what it found, Enter picks a result. +# sets query (what was typed) and result (id \t title of the pick, empty when nothing was picked) +live_search() { + menu_reload="sleep 0.3; sh '$script_path' --search {q}" + _picked=$(: | menu "Search anime: " "" "$menu_extra_flags") + _fzf_status=$? + unset menu_reload + [ "$_fzf_status" -eq 130 ] && exit 1 + query=$(printf "%s\n" "$_picked" | head -n 1) + result=$(printf "%s\n" "$_picked" | sed -n '2p' | cut -f 2,3) +} + die() { printf "\33[2K\r\033[1;31m%s\033[0m\n" "$*" >&2 exit 1 @@ -95,6 +116,8 @@ help_info() { Display a countdown to the next episode --info Show the details of an anime id, this is what the preview pane displays + --search + List what the site finds for a query, this is what the search prompt reloads while typing -U, --update Update the script Some example usages: @@ -617,6 +640,12 @@ while [ $# -gt 0 ]; do [ $# -lt 2 ] && die "missing argument!" hianime_info "$2" ;; + --search) + [ $# -lt 2 ] && die "missing argument!" + query=$(printf "%s" "$2" | sed 's| |+|g') + [ "${#query}" -ge 2 ] && hianime_search "$query" | nl -w 2 | sed 's/^[[:space:]]//' + exit 0 + ;; -U | --update) dep_ch "patch" branch="${2:-$branch}" @@ -667,7 +696,12 @@ case "$source" in ;; *) if [ "$menu_program" = "fzf" ]; then - while [ -z "$query" ]; do + if [ -z "$query" ] && [ -z "$index" ] && [ "$source" != "nextep" ] && fzf_reloads; then + menu_preview="sh '$script_path' --info {2}" + while [ -z "$query" ] && [ -z "$result" ]; do live_search; done + unset menu_preview + fi + while [ -z "$query" ] && [ -z "$result" ]; do printf "\33[2K\r\033[1;36mSearch anime: \033[0m" && read -r query done else @@ -677,14 +711,15 @@ case "$source" in # for checking new releases by specifying anime name [ "$source" = "nextep" ] && time_until_next_ep "$query" - query=$(printf "%s" "$query" | sed 's| |+|g') - - anime_list=$(hianime_search "$query") || exit 1 - [ -z "$anime_list" ] && die "No results found!" - [ "$index" -eq "$index" ] 2>/dev/null && result=$(printf "%s" "$anime_list" | sed -n "${index}p") - menu_preview="sh '$script_path' --info {2}" - [ -z "$index" ] && result=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: ") - unset menu_preview + if [ -z "$result" ]; then + query=$(printf "%s" "$query" | sed 's| |+|g') + anime_list=$(hianime_search "$query") || exit 1 + [ -z "$anime_list" ] && die "No results found!" + [ "$index" -eq "$index" ] 2>/dev/null && result=$(printf "%s" "$anime_list" | sed -n "${index}p") + menu_preview="sh '$script_path' --info {2}" + [ -z "$index" ] && result=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: ") + unset menu_preview + fi [ -z "$result" ] && die "Invalid anime selection" anime_title="$(printf "%s" "$result" | cut -f 2)" anime_id="$(printf "%s" "$result" | cut -f 1)" From 0c2c436ae17afa9bd96f5f06b680a96c0e11ec71 Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 11:11:27 +0000 Subject: [PATCH 11/12] Document the search prompt README FAQ and man page mention the live results and the --search option. --- README.md | 1 + ani-cli.1 | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ee1db3082..a4847e807 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,7 @@ Ani-skip uses the external lua script function of mpv and as such – for now * How can I download? - Use `-d`, it will download into your working directory. * Can i change download folder? - Yes, set the `ANI_CLI_DOWNLOAD_DIR` to your desired location. * How can I bulk download? - `Use -d -e firstepisode-lastepisode`, for example `ani-cli onepiece -d -e 1-1000`. +* Do I have to type the full name? - No, the search prompt shows what the site finds while you type (fzf 0.25 or newer), pick a result with Enter. Type a name on the command line (`ani-cli dandadan`) to skip the prompt. * What do the columns in the search results mean? - The type (TV, Movie, OVA, ONA, Special), the number of episodes (`?` while a show is unreleased or still airing) and how many episodes have subtitles and a dub (`-` when there are none). * What is the pane next to the search results? - The details of the highlighted anime (Japanese name, aired dates, status, score, genres, studios, rating, seasons, synopsis). It exists with fzf only, moves below the list on narrow terminals and can be turned off with `export ANI_CLI_MENU_FLAGS="--no-preview"`. diff --git a/ani-cli.1 b/ani-cli.1 index d5e3934ee..65db3722e 100644 --- a/ani-cli.1 +++ b/ani-cli.1 @@ -17,7 +17,7 @@ This tool scrapes the site hianime. .PD 0 .P .PD -Search results list the type, the number of episodes and how many of them have subtitles and a dub next to every title. With fzf, a preview pane shows the details of the highlighted anime. +Search results list the type, the number of episodes and how many of them have subtitles and a dub next to every title. With fzf (0.25 or newer), the search prompt shows the results while typing and a preview pane shows the details of the highlighted anime. .SH OPTIONS .TP \fB\-e | --episode | -r | --range\fR \fI\,\/\fR @@ -59,6 +59,9 @@ Prints a countdown to the next episode as the last episode in the list. If in hi \fB\--info\fR \fI\,\/\fR Print the details of an anime id: title, Japanese name, aired dates, status, score, genres, studios, rating, seasons and synopsis. The fzf preview pane uses this. .TP +\fB\--search\fR \fI\,\/\fR +Print what the site finds for a query, one line per anime: number, id, title and details. The search prompt reloads this while typing. +.TP \fB\--dub\fR Play the dubbed version. Without this flag, it'll always play the subbed version. .TP From d0fa28ab9ebc6aed8893e8f00c948081930b139e Mon Sep 17 00:00:00 2001 From: U-L-M-S Date: Wed, 23 Sep 2026 11:15:25 +0000 Subject: [PATCH 12/12] Test the fzf version gate of the search prompt Old fzf versions must keep the plain prompt, newer ones get the live results. --- tests/parse.sh | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/parse.sh b/tests/parse.sh index ca75371ff..d98ec947f 100755 --- a/tests/parse.sh +++ b/tests/parse.sh @@ -51,4 +51,26 @@ else failed=1 fi +# the search prompt reloads its list while typing only with an fzf that can do it +# shellcheck disable=SC2317,SC2329 +fzf() { + printf '%s (test)\n' "$fzf_version" +} +for fzf_version in 0.9.0 0.19.1 0.24.0; do + if fzf_reloads; then + printf 'FAIL fzf %s must not use live search\n' "$fzf_version" + failed=1 + else + printf 'ok fzf %s keeps the plain prompt\n' "$fzf_version" + fi +done +for fzf_version in 0.25.0 0.44.1 1.0.0; do + if fzf_reloads; then + printf 'ok fzf %s searches while typing\n' "$fzf_version" + else + printf 'FAIL fzf %s must use live search\n' "$fzf_version" + failed=1 + fi +done + exit "$failed"