Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ani-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
pull_request:
paths:
- "ani-cli"
- "tests/**"

jobs:
sh-checker:
Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions .github/workflows/inverse-ani.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
pull_request:
paths-ignore:
- "ani-cli"
- "tests/**"
jobs:
sh-checker:
name: Shellcheck + Shfmt
Expand All @@ -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"'
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,9 @@ 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"`.

**Note:** All features are documented in `ani-cli --help`.

Expand Down
137 changes: 122 additions & 15 deletions ani-cli
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
#!/bin/sh

version_number="5.1.4"
version_number="5.3.0"

# UI

# shellcheck disable=SC2086
# $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 ${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" ;;
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
_line_count="$(printf "%s\n" "$_stdin" | wc -l | tr -d "[:space:]")"
[ "$_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
Expand All @@ -34,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
Expand Down Expand Up @@ -87,6 +114,10 @@ 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
--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:
Expand Down Expand Up @@ -195,7 +226,17 @@ deobfuscate_blob() (
printf "%b" "$_output"
)

# search the query and give results. format is (id \t name)
# turns the html entities the site uses for titles and descriptions back into characters
decode_entities() {
sed -e "s|&#039;|'|g" -e 's|&quot;|"|g' -e 's|&lt;|<|g' -e 's|&gt;|>|g' -e "s|&amp;|\&|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-|'
}

# 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
Expand All @@ -204,9 +245,50 @@ 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|<div class="film-detail">|\n<div class="film-detail">|g' |
sed -nE 's|.*<h3 class="film-name">[[:space:]]*<a href="[^"]*/([^"/]*)"[[:space:]]*title="([^"]*)".*|\1\t\2|p' |
sed -e "s|&#039;|'|g" -e 's|&quot;|"|g' -e "s|&amp;|\&|g"
# one line per result: the poster ticks (episode counts) come first, then the film-detail with id, title and type
printf "%s" "$_page" | sed '/id="main-sidebar"/,$d' | tr '\n\t' ' ' | sed 's|<div class="flw-item|\n<div class="flw-item|g' |
tab_column '<span class="fdi-item">([A-Za-z]+)' | tab_column 'tick-eps">[[:space:]]*([0-9]+)' |
tab_column 'tick-sub">[[:space:]]*<i[^>]*></i>[[:space:]]*([0-9]+)' | tab_column 'tick-dub">[[:space:]]*<i[^>]*></i>[[:space:]]*([0-9]+)' |
sed -nE 's|.*<h3 class="film-name">[[:space:]]*<a href="[^"]*/([^"/]*)"[[:space:]]*title="([^"]*)".*\t([A-Za-z-]+\t[0-9-]+\t[0-9-]+\t[0-9-]+)$|\1\t\2\t\3|p' |
decode_entities |
while IFS=" " read -r _id _title _type _eps _sub _dub || [ -n "$_id" ]; 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
}

# prints what follows the first occurrence of $1 on a single line
after_first() {
sed "s|$1|\\n|" | 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 '<h2 class="film-name' | sed -nE 's|^[^>]*> *([^<]*[^< ]) *<.*|\1|p' | decode_entities)"
_rating="$(printf "%s" "$_page" | after_first 'tick-pg">' | 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 '<div class="anisc-info">' | sed 's|<div class="item |\n<div class="item |g' | sed 's|</div>.*||; s|</a>|,|g' |
sed -nE 's|.*item-head">([^<:]*):</span>(.*)|\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|</a>.*||' |
sed -nE 's|^class="os-item ?(active)?"[^>]*>.*<div class="title">([^<]*)</div>.*|\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
[ -n "$_key" ] && printf "\033[1;34m%-10s\033[0m %s\n" "$_key" "$_value"
done
printf "%s\n" "$_items" | sed -n 's|^Overview\t|\n|p'
exit 0
}

# get the episodes list of the selected anime. format is (id \t ep_no)
Expand Down Expand Up @@ -474,6 +556,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, 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
Expand Down Expand Up @@ -549,6 +636,16 @@ 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"
;;
--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}"
Expand Down Expand Up @@ -587,7 +684,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.*||')
Expand All @@ -597,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
Expand All @@ -607,12 +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")
[ -z "$index" ] && result=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: ")
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)"
Expand Down
14 changes: 12 additions & 2 deletions ani-cli.1
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (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\,<episode>\/\fR
Expand Down Expand Up @@ -52,6 +56,12 @@ 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\,<id>\/\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\,<query>\/\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
Expand Down Expand Up @@ -97,7 +107,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.
Expand Down
15 changes: 15 additions & 0 deletions tests/expected/info.txt
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions tests/expected/search.tsv
Original file line number Diff line number Diff line change
@@ -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" <Tag> 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
Loading
Loading