research api next up is starting to write it
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
# ani-cli Codebase Analysis: How It Retrieves Anime Episodes
|
||||
|
||||
Based on the exploration of the `ani-cli` repository, here is how the application works behind the scenes to fetch anime episodes. The application utilizes a `bash` script which operates primarily by scraping and making API requests to `allanime` endpoints.
|
||||
|
||||
### 1. The Target API
|
||||
`ani-cli` points to a GraphQL API backend:
|
||||
* **Base URL**: `https://api.allanime.day/api` (constructed from `allanime_base="allanime.day"`)
|
||||
* **Referrer Policy**: To bypass basic bot protections, it explicitly sets the HTTP Referer header to `https://allmanga.to` (`$allanime_refr`) and passes a user-agent.
|
||||
|
||||
### 2. Searching & Episode Lists via GraphQL
|
||||
The codebase uses specific embedded GraphQL queries encoded within the bash script, sent via `curl -X POST`.
|
||||
* **Search**: It queries `shows(search: ...)` using a query named `search_gql` to find titles and returns their respective `_id` and episode count.
|
||||
* **Episode Listing**: Once an `_id` is found, it queries `episodes_list_gql` to retrieve a list of available episodes (e.g., `availableEpisodesDetail`) for the chosen sub/dub setting (`translationType`).
|
||||
|
||||
### 3. Fetching the Episode Video Links
|
||||
When an episode is selected, `ani-cli` needs the embedded player source. It does this by making another GraphQL request using `episode_embed_gql`.
|
||||
* It passes the `$showId`, `$translationType` (sub or dub mode), and `$episodeString` (the episode number).
|
||||
* The API returns a JSON payload containing `sourceUrls`.
|
||||
|
||||
### 4. Bypassing Encryption (`tobeparsed`)
|
||||
Sometimes, `allanime` obfuscates the video source URLs to prevent scraping. The API returns an encrypted base64 payload under the key `"tobeparsed"`.
|
||||
* `ani-cli` catches this field with `grep -q '"tobeparsed"'`.
|
||||
* It then routes the blob to a decryption function `decode_tobeparsed()`.
|
||||
* **The Decryption Method**: It extracts the IV (first 12 bytes of the decoded base64 string) and uses `openssl` to run AES-256-CTR decryption against the rest of the payload.
|
||||
* **The Key**: The decryption key (`$allanime_key`) is dynamically generated by taking the SHA-256 hash of the hardcoded salt string: `Xot36i3lK3:v1`.
|
||||
|
||||
### 5. Link Generation & Processing
|
||||
Once the embed URLs are decrypted (or retrieved plain), they are mapped to respective video providers using `generate_link()`. Providers include `wixmp` (the default), `youtube`, `sharepoint`, and `hianime`.
|
||||
* The `get_links()` function takes the direct links, hits them, and uses `sed` to extract `.mp4` URLs or `.m3u8` playlist files based on the provider format.
|
||||
* Subtitle URLs are also isolated if available.
|
||||
|
||||
### 6. Streaming or Downloading
|
||||
Finally, these isolated stream links (along with the necessary referrer headers) are passed directly into standard media players like `mpv`, `vlc`, `android_vlc`, or downstream download managers like `aria2c`.
|
||||
|
||||
---
|
||||
|
||||
## Code Reference (Line Numbers)
|
||||
|
||||
Here are the exact line numbers in the `ani-cli` script where these specific mechanisms are implemented:
|
||||
|
||||
* **API Configuration & Keys**:
|
||||
* `allanime_refr="https://allmanga.to"`: **Line 405**
|
||||
* `allanime_base="allanime.day"`: **Line 406**
|
||||
* `allanime_api="https://api.${allanime_base}"`: **Line 407**
|
||||
* `allanime_key` (The hardcoded AES key hash): **Line 408**
|
||||
* **GraphQL Queries**:
|
||||
* `episode_embed_gql` (Fetching the video player URLs): **Line 227**
|
||||
* `search_gql` (Searching for anime titles): **Line 257**
|
||||
* `episodes_list_gql` (Getting available episodes): **Line 280**
|
||||
* **The Decryption Logic**:
|
||||
* The `decode_tobeparsed()` function where the AES-256-CTR decryption happens: **Lines 211 - 221**
|
||||
* The check that routes the response to the decryption function (`if printf "%s" "$api_resp" | grep -q '"tobeparsed"'; then`): **Line 230**
|
||||
|
||||
---
|
||||
|
||||
## Is this a partnership, or can you do it yourself?
|
||||
|
||||
**You can absolutely do this yourself.** This is **not** an official partnership.
|
||||
|
||||
What the developers of `ani-cli` have done is known as **Reverse Engineering** and **Web Scraping**. When you watch a video on a site like *allanime* in your normal web browser, your browser has to know how to talk to their servers to get the video files. Because all of this happens on the client-side (in your browser), the instructions are visible if you know where to look.
|
||||
|
||||
Here is how developers (and how you can) figure this out for almost any website:
|
||||
|
||||
1. **Network Tab Inspection**: If you open your browser's Developer Tools (F12) and go to the "Network" tab, you can see every request the website makes. If you search for an anime, you will see a `POST` request going to `https://api.allanime.day/api`.
|
||||
2. **Payload Analysis**: By clicking on that network request, you can see exactly what data was sent (the GraphQL query) and what the server responded with (the JSON payload).
|
||||
3. **Bypassing Basic Protections**: Websites try to stop automated scripts from doing this by checking headers. The developers saw that the site checks the `Referer` header to make sure the request is coming from `https://allmanga.to`. So, they simply programmed `ani-cli` to fake that header (`curl -e "https://allmanga.to"`).
|
||||
4. **Finding Encryption Keys**: When the site started returning encrypted `"tobeparsed"` blobs instead of plain video URLs, the developers of `ani-cli` likely opened the "Sources" tab in their browser's Developer Tools, downloaded the website's obfuscated JavaScript files, and reverse-engineered how the web player decrypts the video. That's how they found the exact AES algorithm (`aes-256-ctr`) and the hardcoded salt string (`Xot36i3lK3:v1`).
|
||||
|
||||
**Can you do this?**
|
||||
Yes! You can use tools like Python (with `requests` and `BeautifulSoup`), Bash (like this script uses `curl`, `grep`, and `sed`), or NodeJS to replicate these exact network requests for any site.
|
||||
|
||||
*Note: Because this is reverse-engineered, sites frequently change their API endpoints, encryption keys, or security measures to break scrapers, which is why tools like `ani-cli` require constant updates.*
|
||||
+609
@@ -0,0 +1,609 @@
|
||||
#!/bin/sh
|
||||
|
||||
version_number="4.13.0"
|
||||
|
||||
# UI
|
||||
|
||||
external_menu() {
|
||||
[ "$use_external_menu" = "1" ] && rofi "$1" -sort -dmenu -i -width 1500 -p "$2" "$3"
|
||||
[ "$use_external_menu" = "2" ] && dmenu -l 20 -p "$2"
|
||||
}
|
||||
|
||||
launcher() {
|
||||
[ "$use_external_menu" = "0" ] && [ -z "$1" ] && set -- "+m" "$2"
|
||||
[ "$use_external_menu" = "0" ] && fzf "$1" --reverse --cycle --prompt "$2"
|
||||
[ "$use_external_menu" = "1" ] && external_menu "$1" "$2" "$external_menu_args"
|
||||
[ "$use_external_menu" = "2" ] && external_menu "$1" "$2"
|
||||
}
|
||||
|
||||
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 -f2,3 && return 0
|
||||
prompt="$1"
|
||||
multi_flag=""
|
||||
[ $# -ne 1 ] && shift && multi_flag="$1"
|
||||
line=$(printf "%s" "$stdin" | cut -f1,3 | tr '\t' ' ' | launcher "$multi_flag" "$prompt" | cut -d " " -f 1)
|
||||
line_start=$(printf "%s" "$line" | head -n1)
|
||||
line_end=$(printf "%s" "$line" | tail -n1)
|
||||
[ -n "$line" ] || exit 1
|
||||
if [ "$line_start" = "$line_end" ]; then
|
||||
printf "%s" "$stdin" | grep -E '^'"${line}"'($|[[:space:]])' | cut -f2,3 || exit 1
|
||||
else
|
||||
printf "%s" "$stdin" | sed -n '/^'"${line_start}"'$/,/^'"${line_end}$"'/p' || exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
die() {
|
||||
printf "\33[2K\r\033[1;31m%s\033[0m\n" "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
help_info() {
|
||||
printf "
|
||||
Usage:
|
||||
%s [options] [query]
|
||||
%s [query] [options]
|
||||
%s [options] [query] [options]
|
||||
|
||||
Options:
|
||||
-c, --continue
|
||||
Continue watching from history
|
||||
-d, --download
|
||||
Download the video instead of playing it
|
||||
-D, --delete
|
||||
Delete history
|
||||
-l, --logview
|
||||
Show logs
|
||||
-s, --syncplay
|
||||
Use Syncplay to watch with friends
|
||||
-S, --select-nth
|
||||
Select nth entry
|
||||
-q, --quality
|
||||
Specify the video quality
|
||||
-v, --vlc
|
||||
Use VLC to play the video
|
||||
-V, --version
|
||||
Show the version of the script
|
||||
-h, --help
|
||||
Show this help message and exit
|
||||
-e, --episode, -r, --range
|
||||
Specify the number of episodes to watch
|
||||
--dub
|
||||
Play dubbed version
|
||||
--rofi
|
||||
Use rofi instead of fzf for the interactive menu
|
||||
--dmenu
|
||||
Use dmenu instead of fzf for the interactive menu
|
||||
--skip
|
||||
Use ani-skip to skip the intro of the episode (mpv only)
|
||||
--no-detach
|
||||
Don't detach the player (useful for in-terminal playback, mpv only)
|
||||
--exit-after-play
|
||||
Exit the player, and return the player exit code (useful for non interactive scenarios, mpv only)
|
||||
--skip-title <title>
|
||||
Use given title as ani-skip query
|
||||
-N, --nextep-countdown
|
||||
Display a countdown to the next episode
|
||||
-U, --update
|
||||
Update the script
|
||||
Some example usages:
|
||||
%s -q 720p banana fish
|
||||
%s --skip --skip-title \"one piece\" -S 2 one piece
|
||||
%s -d -e 2 cyberpunk edgerunners
|
||||
%s --vlc cyberpunk edgerunners -q 1080p -e 4
|
||||
%s blue lock -e 5-6
|
||||
%s -e \"5 6\" blue lock
|
||||
\n" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}" "${0##*/}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
version_info() {
|
||||
printf "%s\n" "$version_number"
|
||||
exit 0
|
||||
}
|
||||
|
||||
update_script() {
|
||||
update="$(curl -s -A "$agent" "https://raw.githubusercontent.com/pystardust/ani-cli/master/ani-cli")" || die "Connection error"
|
||||
update="$(printf '%s\n' "$update" | diff -u "$0" -)"
|
||||
if [ -z "$update" ]; then
|
||||
printf "Script is up to date :)\n"
|
||||
else
|
||||
if printf '%s\n' "$update" | patch "$0" -; then
|
||||
printf "Script has been updated\n"
|
||||
else
|
||||
die "Can't update for some reason!"
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
# checks if dependencies are present
|
||||
dep_ch() {
|
||||
for dep; do
|
||||
command -v "${dep%% *}" >/dev/null || die "Program \"${dep%% *}\" not found. Please install it."
|
||||
done
|
||||
}
|
||||
|
||||
where_iina() {
|
||||
[ -e "/Applications/IINA.app/Contents/MacOS/iina-cli" ] && echo "/Applications/IINA.app/Contents/MacOS/iina-cli" && return 0
|
||||
printf "%s" "iina" && return 0
|
||||
}
|
||||
|
||||
where_mpv() {
|
||||
command -v "flatpak" >/dev/null && flatpak info io.mpv.Mpv >/dev/null 2>&1 && printf "%s" "flatpak_mpv" && return 0
|
||||
printf "%s" "mpv" && return 0
|
||||
}
|
||||
|
||||
# SCRAPING
|
||||
|
||||
# extract the video links from response of embed urls, extract mp4 links form m3u8 lists
|
||||
get_links() {
|
||||
response="$(curl -e "$allanime_refr" -s "https://${allanime_base}$*" -A "$agent")"
|
||||
episode_link="$(printf '%s' "$response" | sed 's|},{|\
|
||||
|g' | sed -nE 's|.*link":"([^"]*)".*"resolutionStr":"([^"]*)".*|\2 >\1|p;s|.*hls","url":"([^"]*)".*"hardsub_lang":"en-US".*|\1|p')"
|
||||
|
||||
case "$episode_link" in
|
||||
*repackager.wixmp.com*)
|
||||
extract_link=$(printf "%s" "$episode_link" | cut -d'>' -f2 | sed 's|repackager.wixmp.com/||g;s|\.urlset.*||g')
|
||||
for j in $(printf "%s" "$episode_link" | sed -nE 's|.*/,([^/]*),/mp4.*|\1|p' | sed 's|,|\
|
||||
|g'); do
|
||||
printf "%s >%s\n" "$j" "$extract_link" | sed "s|,[^/]*|${j}|g"
|
||||
done | sort -nr
|
||||
;;
|
||||
*master.m3u8*)
|
||||
m3u8_refr=$(printf '%s' "$response" | sed -nE 's|.*Referer":"([^"]*)".*|\1|p') && printf '%s\n' "m3u8_refr >$m3u8_refr" >"$cache_dir/m3u8_refr"
|
||||
extract_link=$(printf "%s" "$episode_link" | head -1 | cut -d'>' -f2)
|
||||
relative_link=$(printf "%s" "$extract_link" | sed 's|[^/]*$||')
|
||||
m3u8_streams="$(curl -e "$m3u8_refr" -s "$extract_link" -A "$agent")"
|
||||
printf "%s" "$m3u8_streams" | grep -q "EXTM3U" && printf "%s" "$m3u8_streams" | sed 's|^#EXT-X-STREAM.*x||g; s|,.*|p|g; /^#/d; $!N; s|\n| >|;/EXT-X-I-FRAME/d' |
|
||||
sed "s|>|cc>${relative_link}|g" | sort -nr
|
||||
printf '%s' "$response" | sed -nE 's|.*"subtitles":\[\{"lang":"en","label":"English","default":"default","src":"([^"]*)".*|subtitle >\1|p' >"$cache_dir/suburl"
|
||||
;;
|
||||
*) [ -n "$episode_link" ] && printf "%s\n" "$episode_link" ;;
|
||||
esac
|
||||
|
||||
printf "%s" "$*" | grep -q "tools.fast4speed.rsvp" && printf "%s\n" "Yt >$*"
|
||||
printf "\033[1;32m%s\033[0m Links Fetched\n" "$provider_name" 1>&2
|
||||
}
|
||||
|
||||
# initialises provider_name and provider_id. First argument is the provider name, 2nd is the regex that matches that provider's link
|
||||
provider_init() {
|
||||
provider_name=$1
|
||||
provider_id=$(printf "%s" "$resp" | sed -n "$2" | head -1 | cut -d':' -f2 | sed 's/../&\
|
||||
/g' | sed 's/^79$/A/g;s/^7a$/B/g;s/^7b$/C/g;s/^7c$/D/g;s/^7d$/E/g;s/^7e$/F/g;s/^7f$/G/g;s/^70$/H/g;s/^71$/I/g;s/^72$/J/g;s/^73$/K/g;s/^74$/L/g;s/^75$/M/g;s/^76$/N/g;s/^77$/O/g;s/^68$/P/g;s/^69$/Q/g;s/^6a$/R/g;s/^6b$/S/g;s/^6c$/T/g;s/^6d$/U/g;s/^6e$/V/g;s/^6f$/W/g;s/^60$/X/g;s/^61$/Y/g;s/^62$/Z/g;s/^59$/a/g;s/^5a$/b/g;s/^5b$/c/g;s/^5c$/d/g;s/^5d$/e/g;s/^5e$/f/g;s/^5f$/g/g;s/^50$/h/g;s/^51$/i/g;s/^52$/j/g;s/^53$/k/g;s/^54$/l/g;s/^55$/m/g;s/^56$/n/g;s/^57$/o/g;s/^48$/p/g;s/^49$/q/g;s/^4a$/r/g;s/^4b$/s/g;s/^4c$/t/g;s/^4d$/u/g;s/^4e$/v/g;s/^4f$/w/g;s/^40$/x/g;s/^41$/y/g;s/^42$/z/g;s/^08$/0/g;s/^09$/1/g;s/^0a$/2/g;s/^0b$/3/g;s/^0c$/4/g;s/^0d$/5/g;s/^0e$/6/g;s/^0f$/7/g;s/^00$/8/g;s/^01$/9/g;s/^15$/-/g;s/^16$/./g;s/^67$/_/g;s/^46$/~/g;s/^02$/:/g;s/^17$/\//g;s/^07$/?/g;s/^1b$/#/g;s/^63$/\[/g;s/^65$/\]/g;s/^78$/@/g;s/^19$/!/g;s/^1c$/$/g;s/^1e$/&/g;s/^10$/\(/g;s/^11$/\)/g;s/^12$/*/g;s/^13$/+/g;s/^14$/,/g;s/^03$/;/g;s/^05$/=/g;s/^1d$/%/g' | tr -d '\n' | sed "s/\/clock/\/clock\.json/")
|
||||
}
|
||||
|
||||
# generates links based on given provider
|
||||
generate_link() {
|
||||
case $1 in
|
||||
1) provider_init "wixmp" "/Default :/p" ;; # wixmp(default)(m3u8)(multi) -> (mp4)(multi)
|
||||
2) provider_init "youtube" "/Yt-mp4 :/p" ;; # youtube(mp4)(single)
|
||||
3) provider_init "sharepoint" "/S-mp4 :/p" ;; # sharepoint(mp4)(single)
|
||||
*) provider_init "hianime" "/Luf-Mp4 :/p" ;; # hianime(m3u8)(multi)
|
||||
esac
|
||||
[ -n "$provider_id" ] && get_links "$provider_id"
|
||||
}
|
||||
|
||||
select_quality() {
|
||||
# removing urls which have soft subs to avoid playing on android, iSH and vlc (m3u8 streams don't get correct referrer)
|
||||
printf '%s' "$player_function" | cut -f1 -d" " | grep -qE '(android|iSH|vlc)' && links=$(printf '%s' "$links" | sed '/cc>/d;/subtitle >/d;/m3u8_refr >/d')
|
||||
printf '%s' "$player_function" | cut -f1 -d" " | grep -qE '(android|iSH)' && links=$(printf '%s' "$links" | sed '/Yt >/d')
|
||||
case "$1" in
|
||||
best) result=$(printf "%s" "$links" | head -n1) ;;
|
||||
worst) result=$(printf "%s" "$links" | grep -E '^[0-9]{3,4}' | tail -n1) ;;
|
||||
*) result=$(printf "%s" "$links" | grep -m 1 "$1") ;;
|
||||
esac
|
||||
[ -z "$result" ] && printf "Specified quality not found, defaulting to best\n" 1>&2 && result=$(printf "%s" "$links" | head -n1)
|
||||
|
||||
# add refr,sub flags for m3u8 and refr flag for yt
|
||||
printf '%s' "$result" | grep -q "cc>" && subtitle="$(printf '%s' "$links" | sed -nE 's|subtitle >(.*)|\1|p')" &&
|
||||
[ -n "$subtitle" ] && subs_flag="--sub-file=$subtitle"
|
||||
printf '%s' "$result" | grep -q "cc>" && m3u8_refr="$(printf '%s' "$links" | sed -nE 's|m3u8_refr >(.*)|\1|p')" && refr_flag="--referrer=$m3u8_refr"
|
||||
printf "%s" "$result" | grep -q "tools.fast4speed.rsvp" && refr_flag="--referrer=$allanime_refr"
|
||||
|
||||
! (printf '%s' "$result" | grep -qE "(cc>|tools.fast4speed.rsvp)") && unset refr_flag
|
||||
! (printf '%s' "$result" | grep -q "cc>") && unset subs_flag
|
||||
episode=$(printf "%s" "$result" | cut -d'>' -f2)
|
||||
}
|
||||
|
||||
decode_tobeparsed() {
|
||||
tmp="$(mktemp)"
|
||||
printf '%s' "$1" | base64 -d >"$tmp"
|
||||
file_size="$(wc -c <"$tmp")"
|
||||
iv="$(dd if="$tmp" bs=1 skip=1 count=12 2>/dev/null | od -A n -t x1 | tr -d ' \n')"
|
||||
ctr="${iv}00000002"
|
||||
ct_len=$((file_size - 13 - 16))
|
||||
plain="$(dd if="$tmp" bs=1 skip=13 count="$ct_len" 2>/dev/null | openssl enc -d -aes-256-ctr -K "$allanime_key" -iv "$ctr" -nosalt -nopad 2>/dev/null)"
|
||||
rm -f "$tmp"
|
||||
printf '%s' "$plain" | tr '{}' '\n' | sed -nE 's|.*"sourceUrl":"--([^"]*)".*"sourceName":"([^"]*)".*|\2 :\1|p'
|
||||
}
|
||||
|
||||
# gets embed urls, collects direct links into provider files, selects one with desired quality into $episode
|
||||
get_episode_url() {
|
||||
# get the embed urls of the selected episode
|
||||
#shellcheck disable=SC2016
|
||||
episode_embed_gql='query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { episode( showId: $showId translationType: $translationType episodeString: $episodeString ) { episodeString sourceUrls }}'
|
||||
|
||||
api_resp="$(curl -e "$allanime_refr" -s -H "Content-Type: application/json" -X POST "${allanime_api}/api" --data "{\"variables\":{\"showId\":\"$id\",\"translationType\":\"$mode\",\"episodeString\":\"$ep_no\"},\"query\":\"$episode_embed_gql\"}" -A "$agent")"
|
||||
if printf "%s" "$api_resp" | grep -q '"tobeparsed"'; then
|
||||
blob="$(printf "%s" "$api_resp" | sed -nE 's|.*"tobeparsed":"([^"]*)".*|\1|p')"
|
||||
resp="$(decode_tobeparsed "$blob")"
|
||||
else
|
||||
resp="$(printf "%s" "$api_resp" | tr '{}' '\n' | sed 's|\\u002F|\/|g;s|\\||g' | sed -nE 's|.*sourceUrl":"--([^"]*)".*sourceName":"([^"]*)".*|\2 :\1|p')"
|
||||
fi
|
||||
# generate links into sequential files
|
||||
cache_dir="$(mktemp -d)"
|
||||
providers="1 2 3 4"
|
||||
for provider in $providers; do
|
||||
generate_link "$provider" >"$cache_dir"/"$provider" &
|
||||
done
|
||||
wait
|
||||
# select the link with matching quality
|
||||
links=$(cat "$cache_dir"/* | sort -g -r -s)
|
||||
rm -r "$cache_dir"
|
||||
select_quality "$quality"
|
||||
if printf "%s" "$ep_list" | grep -q "^$ep_no$"; then
|
||||
[ -z "$episode" ] && die "Episode is released, but no valid sources!"
|
||||
else
|
||||
[ -z "$episode" ] && die "Episode not released!"
|
||||
fi
|
||||
}
|
||||
|
||||
# search the query and give results
|
||||
search_anime() {
|
||||
#shellcheck disable=SC2016
|
||||
search_gql='query( $search: SearchInput $limit: Int $page: Int $translationType: VaildTranslationTypeEnumType $countryOrigin: VaildCountryOriginEnumType ) { shows( search: $search limit: $limit page: $page translationType: $translationType countryOrigin: $countryOrigin ) { edges { _id name availableEpisodes __typename } }}'
|
||||
|
||||
curl -e "$allanime_refr" -s -H "Content-Type: application/json" -X POST "${allanime_api}/api" --data "{\"variables\":{\"search\":{\"allowAdult\":false,\"allowUnknown\":false,\"query\":\"$1\"},\"limit\":40,\"page\":1,\"translationType\":\"$mode\",\"countryOrigin\":\"ALL\"},\"query\":\"$search_gql\"}" -A "$agent" | sed 's|Show|\
|
||||
| g' | sed -nE "s|.*_id\":\"([^\"]*)\",\"name\":\"(.+)\",.*${mode}\":([1-9][^,]*).*|\1 \2 (\3 episodes)|p" | sed 's/\\"//g'
|
||||
}
|
||||
|
||||
time_until_next_ep() {
|
||||
animeschedule="https://animeschedule.net"
|
||||
query="$(printf "%s\n" "$*" | tr ' ' '+')"
|
||||
curl -s -G "$animeschedule/api/v3/anime" --data "q=${query}" | sed 's|"id"|\n|g' | sed -nE 's|.*,"route":"([^"]*)","premier.*|\1|p' | while read -r anime; do
|
||||
data=$(curl -s "$animeschedule/anime/$anime" | sed '1,/"anime-header-list-buttons-wrapper"/d' | sed -nE 's|.*countdown-time-raw" datetime="([^"]*)">.*|Next Raw Release: \1|p;s|.*countdown-time" datetime="([^"]*)">.*|Next Sub Release: \1|p;s|.*english-title">([^<]*)<.*|English Title: \1|p;s|.*main-title".*>([^<]*)<.*|Japanese Title: \1|p')
|
||||
status="Ongoing"
|
||||
color="33"
|
||||
printf "%s\n" "$data"
|
||||
! (printf "%s\n" "$data" | grep -q "Next Raw Release:") && status="Finished" && color="32"
|
||||
printf "Status: \033[1;%sm%s\033[0m\n---\n" "$color" "$status"
|
||||
done
|
||||
exit 0
|
||||
}
|
||||
|
||||
# get the episodes list of the selected anime
|
||||
episodes_list() {
|
||||
#shellcheck disable=SC2016
|
||||
episodes_list_gql='query ($showId: String!) { show( _id: $showId ) { _id availableEpisodesDetail }}'
|
||||
|
||||
curl -e "$allanime_refr" -s -H "Content-Type: application/json" -X POST "${allanime_api}/api" --data "{\"variables\":{\"showId\":\"$*\"},\"query\":\"$episodes_list_gql\"}" -A "$agent" | sed -nE "s|.*$mode\":\[([0-9.\",]*)\].*|\1|p" | sed 's|,|\
|
||||
|g; s|"||g' | sort -n -k 1
|
||||
}
|
||||
|
||||
# PLAYING
|
||||
|
||||
process_hist_entry() {
|
||||
ep_list=$(episodes_list "$id")
|
||||
latest_ep=$(printf "%s\n" "$ep_list" | tail -n1)
|
||||
title=$(printf "%s\n" "$title" | sed "s|[0-9]\+ episodes|${latest_ep} episodes|")
|
||||
ep_no=$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/{n;p;}") 2>/dev/null
|
||||
[ -n "$ep_no" ] && printf "%s\t%s - episode %s\n" "$id" "$title" "$ep_no"
|
||||
}
|
||||
|
||||
update_history() {
|
||||
if grep -q -- "$id" "$histfile"; then
|
||||
sed -E "s|^[^ ]+ ${id} [^ ]+$|${ep_no} ${id} ${title}|" "$histfile" >"${histfile}.new"
|
||||
else
|
||||
cp "$histfile" "${histfile}.new"
|
||||
printf "%s\t%s\t%s\n" "$ep_no" "$id" "$title" >>"${histfile}.new"
|
||||
fi
|
||||
mv "${histfile}.new" "$histfile"
|
||||
}
|
||||
|
||||
download() {
|
||||
# download subtitle if it's set
|
||||
[ -n "$subtitle" ] && curl -s "$subtitle" -o "$download_dir/$2.vtt"
|
||||
case $1 in
|
||||
*m3u8*)
|
||||
if command -v "yt-dlp" >/dev/null; then
|
||||
yt-dlp --referer "$m3u8_refr" "$1" --no-skip-unavailable-fragments --fragment-retries infinite -N 16 -o "$download_dir/$2.mp4"
|
||||
else
|
||||
ffmpeg -extension_picky 0 -referer "$m3u8_refr" -loglevel error -stats -i "$1" -c copy "$download_dir/$2.mp4"
|
||||
fi
|
||||
# embed subs into downloads
|
||||
# [ -e "$download_dir/$2.vtt" ] && ffmpeg -i "$download_dir/$2.mp4" -i "$download_dir/$2.vtt" -c copy -c:s mov_text "$download_dir/$2.bak.mp4" && mv "$download_dir/$2.bak.mp4" "$download_dir/$2.mp4"
|
||||
;;
|
||||
*)
|
||||
# shellcheck disable=SC2086
|
||||
aria2c --referer="$allanime_refr" --enable-rpc=false --check-certificate=false --continue $iSH_DownFix --summary-interval=0 -x 16 -s 16 "$1" --dir="$download_dir" -o "$2.mp4" --download-result=hide
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
play_episode() {
|
||||
[ "$log_episode" = 1 ] && [ "$player_function" != "debug" ] && [ "$player_function" != "download" ] && command -v logger >/dev/null && logger -t ani-cli "${allanime_title}${ep_no}"
|
||||
[ "$skip_intro" = 1 ] && skip_flag="$(ani-skip -q "$mal_id" -e "$ep_no")"
|
||||
[ -z "$episode" ] && get_episode_url
|
||||
# shellcheck disable=SC2086
|
||||
case "$player_function" in
|
||||
debug)
|
||||
printf "All links:\n%s\nSelected link:\n" "$links"
|
||||
printf "%s\n" "$episode"
|
||||
;;
|
||||
mpv*)
|
||||
if [ "$no_detach" = 0 ]; then
|
||||
nohup $player_function $skip_flag --force-media-title="${allanime_title}Episode ${ep_no}" "$episode" $subs_flag $refr_flag >/dev/null 2>&1 &
|
||||
else
|
||||
$player_function $skip_flag $subs_flag $refr_flag --force-media-title="${allanime_title}Episode ${ep_no}" "$episode"
|
||||
mpv_exitcode=$?
|
||||
[ "$exit_after_play" = 1 ] && [ -z "$range" ] && exit "$mpv_exitcode"
|
||||
fi
|
||||
;;
|
||||
android_mpv) nohup am start --user 0 -a android.intent.action.VIEW -d "$episode" -n is.xyz.mpv/.MPVActivity >/dev/null 2>&1 & ;;
|
||||
android_vlc) nohup am start --user 0 -a android.intent.action.VIEW -d "$episode" -n org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity -e "title" "${allanime_title}Episode ${ep_no}" >/dev/null 2>&1 & ;;
|
||||
*iina*)
|
||||
[ -n "$subs_flag" ] && subs_flag="--mpv-${subs_flag#--}"
|
||||
[ -n "$refr_flag" ] && refr_flag="--mpv-${refr_flag#--}"
|
||||
if pgrep -f "IINA" >/dev/null 2>&1; then
|
||||
# omit --keep-running when an IINA instance exists to prevent hanging
|
||||
nohup $player_function --no-stdin --mpv-force-media-title="${allanime_title}Episode ${ep_no}" $subs_flag $refr_flag "$episode" >/dev/null 2>&1 &
|
||||
else
|
||||
nohup $player_function --no-stdin --keep-running --mpv-force-media-title="${allanime_title}Episode ${ep_no}" $subs_flag $refr_flag "$episode" >/dev/null 2>&1 &
|
||||
fi
|
||||
;;
|
||||
flatpak_mpv) flatpak run io.mpv.Mpv --force-media-title="${allanime_title}Episode ${ep_no}" "$episode" $subs_flag $refr_flag >/dev/null 2>&1 & ;;
|
||||
vlc*) nohup $player_function --http-referrer="${allanime_refr}" --play-and-exit --meta-title="${allanime_title}Episode ${ep_no}" "$episode" >/dev/null 2>&1 & ;;
|
||||
*yncpla*) nohup $player_function "$episode" -- --force-media-title="${allanime_title}Episode ${ep_no}" $subs_flag $refr_flag >/dev/null 2>&1 & ;;
|
||||
download) "$player_function" "$episode" "${allanime_title}Episode ${ep_no}" "$subtitle" ;;
|
||||
catt) nohup catt cast "$episode" -s "$subtitle" >/dev/null 2>&1 & ;;
|
||||
iSH)
|
||||
printf "\e]8;;vlc://%s\a~~~~~~~~~~~~~~~~~~~~\n~ Tap to open VLC ~\n~~~~~~~~~~~~~~~~~~~~\e]8;;\a\n" "$episode"
|
||||
sleep 5
|
||||
;;
|
||||
*) nohup $player_function "$episode" >/dev/null 2>&1 & ;;
|
||||
esac
|
||||
replay="$episode"
|
||||
unset episode
|
||||
update_history
|
||||
[ "$use_external_menu" = "1" ] && wait
|
||||
[ "$use_external_menu" = "2" ] && wait
|
||||
}
|
||||
|
||||
play() {
|
||||
start=$(printf "%s" "$ep_no" | grep -Eo '^(-1|[0-9]+(\.[0-9]+)?)')
|
||||
end=$(printf "%s" "$ep_no" | grep -Eo '(-1|[0-9]+(\.[0-9]+)?)$')
|
||||
[ "$start" = "-1" ] && ep_no=$(printf "%s" "$ep_list" | tail -n1) && unset start
|
||||
[ -z "$end" ] || [ "$end" = "$start" ] && unset start end
|
||||
[ "$end" = "-1" ] && end=$(printf "%s" "$ep_list" | tail -n1)
|
||||
line_count=$(printf "%s\n" "$ep_no" | wc -l | tr -d "[:space:]")
|
||||
if [ "$line_count" != 1 ] || [ -n "$start" ]; then
|
||||
[ -z "$start" ] && start=$(printf "%s\n" "$ep_no" | head -n1)
|
||||
[ -z "$end" ] && end=$(printf "%s\n" "$ep_no" | tail -n1)
|
||||
range=$(printf "%s\n" "$ep_list" | sed -nE "/^${start}\$/,/^${end}\$/p")
|
||||
[ -z "$range" ] && die "Invalid range!"
|
||||
for i in $range; do
|
||||
tput clear
|
||||
ep_no=$i
|
||||
printf "\33[2K\r\033[1;34mPlaying episode %s...\033[0m\n" "$ep_no"
|
||||
[ "$i" = "$end" ] && unset range
|
||||
play_episode
|
||||
done
|
||||
else
|
||||
play_episode
|
||||
fi
|
||||
# moves up to stored position and deletes to end
|
||||
[ "$player_function" != "debug" ] && [ "$player_function" != "download" ] && tput rc && tput ed
|
||||
}
|
||||
|
||||
# MAIN
|
||||
|
||||
# setup
|
||||
agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0"
|
||||
allanime_refr="https://allmanga.to"
|
||||
allanime_base="allanime.day"
|
||||
allanime_api="https://api.${allanime_base}"
|
||||
allanime_key="$(printf '%s' 'Xot36i3lK3:v1' | openssl dgst -sha256 -binary | od -A n -t x1 | tr -d ' \n')"
|
||||
mode="${ANI_CLI_MODE:-sub}"
|
||||
download_dir="${ANI_CLI_DOWNLOAD_DIR:-.}"
|
||||
log_episode="${ANI_CLI_LOG:-1}"
|
||||
quality="${ANI_CLI_QUALITY:-best}"
|
||||
case "$(uname -a | cut -d " " -f 1,3-)" in
|
||||
*Darwin*) player_function="${ANI_CLI_PLAYER:-$(where_iina)}" ;; # mac OS
|
||||
*ndroid*) player_function="${ANI_CLI_PLAYER:-android_mpv}" ;; # Android OS (termux)
|
||||
*MINGW* | *WSL2*) player_function="${ANI_CLI_PLAYER:-mpv.exe}" ;; # Windows OS
|
||||
*ish*) player_function="${ANI_CLI_PLAYER:-iSH}" ;; # iOS (iSH)
|
||||
*) player_function="${ANI_CLI_PLAYER:-$(where_mpv)}" ;; # Linux OS
|
||||
esac
|
||||
|
||||
no_detach="${ANI_CLI_NO_DETACH:-0}"
|
||||
exit_after_play="${ANI_CLI_EXIT_AFTER_PLAY:-0}"
|
||||
use_external_menu="${ANI_CLI_EXTERNAL_MENU:-0}"
|
||||
external_menu_normal_window="${ANI_CLI_EXTERNAL_MENU_NORMAL_WINDOW:-0}"
|
||||
skip_intro="${ANI_CLI_SKIP_INTRO:-0}"
|
||||
# shellcheck disable=SC2154
|
||||
skip_title="$ANI_CLI_SKIP_TITLE"
|
||||
[ -t 0 ] || (command -v dmenu && use_external_menu=2)
|
||||
[ -t 0 ] || (command -v rofi && use_external_menu=1)
|
||||
hist_dir="${ANI_CLI_HIST_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/ani-cli}"
|
||||
[ ! -d "$hist_dir" ] && mkdir -p "$hist_dir"
|
||||
histfile="$hist_dir/ani-hsts"
|
||||
[ ! -f "$histfile" ] && : >"$histfile"
|
||||
search="${ANI_CLI_DEFAULT_SOURCE:-scrape}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-v | --vlc)
|
||||
case "$(uname -a | cut -d " " -f 1,3-)" in
|
||||
*ndroid*) player_function="android_vlc" ;;
|
||||
MINGW* | *WSL2*) player_function="vlc.exe" ;;
|
||||
*ish*) player_function="iSH" ;;
|
||||
*) player_function="vlc" ;;
|
||||
esac
|
||||
;;
|
||||
-s | --syncplay)
|
||||
case "$(uname -s)" in
|
||||
Darwin*) player_function="/Applications/Syncplay.app/Contents/MacOS/syncplay" ;;
|
||||
MINGW* | *Msys)
|
||||
export PATH="$PATH":"/c/Program Files (x86)/Syncplay/"
|
||||
player_function="syncplay.exe"
|
||||
;;
|
||||
*) player_function="syncplay" ;;
|
||||
esac
|
||||
;;
|
||||
-q | --quality)
|
||||
[ $# -lt 2 ] && die "missing argument!"
|
||||
quality="$2"
|
||||
shift
|
||||
;;
|
||||
-S | --select-nth)
|
||||
[ $# -lt 2 ] && die "missing argument!"
|
||||
index="$2"
|
||||
shift
|
||||
;;
|
||||
-c | --continue) search=history ;;
|
||||
-d | --download)
|
||||
[ "$player_function" = "iSH" ] && iSH_DownFix="--async-dns=false"
|
||||
player_function=download
|
||||
;;
|
||||
-D | --delete)
|
||||
: >"$histfile"
|
||||
exit 0
|
||||
;;
|
||||
-l | --logview)
|
||||
case "$(uname -s)" in
|
||||
Darwin*) log show --predicate 'process == "logger"' ;;
|
||||
Linux*) journalctl -t ani-cli ;;
|
||||
*) die "Logger not implemented for your platform" ;;
|
||||
esac
|
||||
exit 0
|
||||
;;
|
||||
-V | --version) version_info ;;
|
||||
-h | --help) help_info ;;
|
||||
-e | --episode | -r | --range)
|
||||
[ $# -lt 2 ] && die "missing argument!"
|
||||
ep_no="$2"
|
||||
shift
|
||||
;;
|
||||
--dub) mode="dub" ;;
|
||||
--no-detach) no_detach=1 ;;
|
||||
--exit-after-play) exit_after_play=1 && no_detach=1 ;;
|
||||
--rofi) use_external_menu=1 ;;
|
||||
--dmenu) use_external_menu=2 ;;
|
||||
--skip) skip_intro=1 ;;
|
||||
--skip-title)
|
||||
[ $# -lt 2 ] && die "missing argument!"
|
||||
skip_title="$2"
|
||||
shift
|
||||
;;
|
||||
-N | --nextep-countdown) search=nextep ;;
|
||||
-U | --update) update_script ;;
|
||||
*) query="$(printf "%s" "$query $1" | sed "s|^ ||;s| |+|g")" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ "$use_external_menu" = "0" ] && multi_selection_flag="${ANI_CLI_MULTI_SELECTION:-"-m"}"
|
||||
[ "$use_external_menu" = "1" ] && multi_selection_flag="${ANI_CLI_MULTI_SELECTION:-"-multi-select"}"
|
||||
[ "$external_menu_normal_window" = "1" ] && external_menu_args="-normal-window"
|
||||
printf "\33[2K\r\033[1;34mChecking dependencies...\033[0m\n"
|
||||
dep_ch "curl" "sed" "grep" || true
|
||||
case "$(uname -o 2>/dev/null)" in
|
||||
*ndroid*) command -v openssl >/dev/null || die 'Program "openssl" not found. On Termux, install with: pkg install openssl-tool' ;;
|
||||
*) dep_ch "openssl" || true ;;
|
||||
esac
|
||||
[ "$skip_intro" = 1 ] && (dep_ch "ani-skip" || true)
|
||||
dep_ch "fzf" || true
|
||||
case "$player_function" in
|
||||
debug) ;;
|
||||
download) dep_ch "ffmpeg" "aria2c" ;;
|
||||
android*) printf "\33[2K\rChecking of players on Android is disabled\n" ;;
|
||||
*iSH*) printf "\33[2K\rChecking of players on iOS is disabled\n" ;;
|
||||
flatpak_mpv) true ;; # handled out of band in where_mpv
|
||||
*) dep_ch "$player_function" ;;
|
||||
esac
|
||||
|
||||
# searching
|
||||
case "$search" in
|
||||
history)
|
||||
anime_list=$(while read -r ep_no id title; do process_hist_entry & done <"$histfile")
|
||||
wait
|
||||
[ -z "$anime_list" ] && die "No unwatched series in history!"
|
||||
[ -z "${index##*[!0-9]*}" ] && id=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: " | cut -f1)
|
||||
[ -z "${index##*[!0-9]*}" ] || id=$(printf "%s" "$anime_list" | sed -n "${index}p" | cut -f1)
|
||||
[ -z "$id" ] && exit 1
|
||||
title=$(printf "%s" "$anime_list" | grep "$id" | cut -f2 | sed 's/ - episode.*//')
|
||||
ep_list=$(episodes_list "$id")
|
||||
ep_no=$(printf "%s" "$anime_list" | grep "$id" | cut -f2 | sed -nE 's/.*- episode (.+)$/\1/p')
|
||||
allanime_title="$(printf "%s" "$title" | cut -d'(' -f1 | tr -d '[:punct:]')"
|
||||
;;
|
||||
*)
|
||||
if [ "$use_external_menu" = "0" ]; then
|
||||
while [ -z "$query" ]; do
|
||||
printf "\33[2K\r\033[1;36mSearch anime: \033[0m" && read -r query
|
||||
done
|
||||
else
|
||||
[ -z "$query" ] && query=$(printf "" | external_menu "" "Search anime: " "$external_menu_args")
|
||||
[ -z "$query" ] && exit 1
|
||||
fi
|
||||
# for checking new releases by specifying anime name
|
||||
[ "$search" = "nextep" ] && time_until_next_ep "$query"
|
||||
|
||||
query=$(printf "%s" "$query" | sed "s| |+|g")
|
||||
anime_list=$(search_anime "$query")
|
||||
[ -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: ")
|
||||
[ -z "$result" ] && exit 1
|
||||
title=$(printf "%s" "$result" | cut -f2)
|
||||
allanime_title="$(printf "%s" "$title" | cut -d'(' -f1 | tr -d '[:punct:]')"
|
||||
id=$(printf "%s" "$result" | cut -f1)
|
||||
ep_list=$(episodes_list "$id")
|
||||
[ -z "$ep_no" ] && ep_no=$(printf "%s" "$ep_list" | nth "Select episode: " "$multi_selection_flag")
|
||||
[ -z "$ep_no" ] && exit 1
|
||||
;;
|
||||
esac
|
||||
[ "$skip_intro" = 1 ] && mal_id="$(ani-skip -q "${skip_title:-${title}}")"
|
||||
|
||||
# moves the cursor up one line and clears that line
|
||||
tput cuu1 && tput el
|
||||
# stores the position of cursor
|
||||
tput sc
|
||||
|
||||
# playback & loop
|
||||
play
|
||||
[ "$player_function" = "download" ] || [ "$player_function" = "debug" ] && exit 0
|
||||
|
||||
while cmd=$(printf "next\nreplay\nprevious\nselect\nchange_quality\nquit" | nth "Playing episode $ep_no of $title... "); do
|
||||
case "$cmd" in
|
||||
next) ep_no=$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/{n;p;}") 2>/dev/null ;;
|
||||
replay) episode="$replay" ;;
|
||||
previous) ep_no=$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/{g;1!p;};h") 2>/dev/null ;;
|
||||
select) ep_no=$(printf "%s" "$ep_list" | nth "Select episode: " "$multi_selection_flag") ;;
|
||||
change_quality)
|
||||
new_quality="$(printf "%s" "$links" | launcher | cut -d\> -f1)"
|
||||
select_quality "$new_quality"
|
||||
;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
[ -z "$ep_no" ] && die "Out of range"
|
||||
play
|
||||
done
|
||||
|
||||
# ani-cli
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# Project repository: https://github.com/pystardust/ani-cli
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
# ani-cli Research
|
||||
|
||||
> Source: <https://github.com/pystardust/ani-cli>
|
||||
> Version at time of research: 4.11.0
|
||||
> Language: POSIX Shell (~500 lines)
|
||||
> License: GPL-3.0
|
||||
|
||||
## Overview
|
||||
|
||||
ani-cli is a CLI tool to browse and watch anime. It scrapes [allmanga.to](https://allmanga.to/) via a GraphQL API, decodes obfuscated video source URLs, and launches a media player (mpv, vlc, iina, etc.).
|
||||
|
||||
The **entire codebase is a single file**: `ani-cli` (a POSIX shell script).
|
||||
|
||||
## Architecture / Code Sections
|
||||
|
||||
The script is organized into these logical blocks:
|
||||
|
||||
### 1. UI Functions
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `external_menu()` | Wraps rofi/dmenu for external menu support |
|
||||
| `launcher()` | Dispatches to fzf (default), rofi, or dmenu |
|
||||
| `nth()` | Core selection function -- pipes items through launcher, supports multi-select |
|
||||
|
||||
### 2. Scraping / API
|
||||
|
||||
All API calls go to `https://api.allanime.day/api` as **GraphQL POST requests** with `Content-Type: application/json`. The referer is set to `https://allmanga.to`.
|
||||
|
||||
#### GraphQL Queries
|
||||
|
||||
**Search anime:**
|
||||
```graphql
|
||||
query(
|
||||
$search: SearchInput
|
||||
$limit: Int
|
||||
$page: Int
|
||||
$translationType: VaildTranslationTypeEnumType
|
||||
$countryOrigin: VaildCountryOriginEnumType
|
||||
) {
|
||||
shows(
|
||||
search: $search
|
||||
limit: $limit
|
||||
page: $page
|
||||
translationType: $translationType
|
||||
countryOrigin: $countryOrigin
|
||||
) {
|
||||
edges {
|
||||
_id
|
||||
name
|
||||
availableEpisodes
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variables:
|
||||
```json
|
||||
{
|
||||
"search": { "allowAdult": false, "allowUnknown": false, "query": "<search term>" },
|
||||
"limit": 40,
|
||||
"page": 1,
|
||||
"translationType": "sub",
|
||||
"countryOrigin": "ALL"
|
||||
}
|
||||
```
|
||||
|
||||
**Get episodes list:**
|
||||
```graphql
|
||||
query ($showId: String!) {
|
||||
show(_id: $showId) {
|
||||
_id
|
||||
availableEpisodesDetail
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Get episode embed URLs:**
|
||||
```graphql
|
||||
query (
|
||||
$showId: String!
|
||||
$translationType: VaildTranslationTypeEnumType!
|
||||
$episodeString: String!
|
||||
) {
|
||||
episode(
|
||||
showId: $showId
|
||||
translationType: $translationType
|
||||
episodeString: $episodeString
|
||||
) {
|
||||
episodeString
|
||||
sourceUrls
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Provider URL Decoding (Hex Cipher)
|
||||
|
||||
The API returns obfuscated source URLs. The `provider_init()` function decodes them using a **hex-pair substitution cipher**. Each two-character hex code maps to an ASCII character:
|
||||
|
||||
```
|
||||
79 -> A, 7a -> B, 7b -> C, ... (uppercase letters)
|
||||
59 -> a, 5a -> b, 5b -> c, ... (lowercase letters)
|
||||
08 -> 0, 09 -> 1, 0a -> 2, ... (digits)
|
||||
15 -> -, 16 -> ., 17 -> /, 02 -> :, etc. (symbols)
|
||||
```
|
||||
|
||||
Full mapping (hex -> char):
|
||||
|
||||
| Hex | Char | Hex | Char | Hex | Char | Hex | Char |
|
||||
|-----|------|-----|------|-----|------|-----|------|
|
||||
| 79 | A | 59 | a | 08 | 0 | 15 | - |
|
||||
| 7a | B | 5a | b | 09 | 1 | 16 | . |
|
||||
| 7b | C | 5b | c | 0a | 2 | 67 | _ |
|
||||
| 7c | D | 5c | d | 0b | 3 | 46 | ~ |
|
||||
| 7d | E | 5d | e | 0c | 4 | 02 | : |
|
||||
| 7e | F | 5e | f | 0d | 5 | 17 | / |
|
||||
| 7f | G | 5f | g | 0e | 6 | 07 | ? |
|
||||
| 70 | H | 50 | h | 0f | 7 | 1b | # |
|
||||
| 71 | I | 51 | i | 00 | 8 | 63 | [ |
|
||||
| 72 | J | 52 | j | 01 | 9 | 65 | ] |
|
||||
| 73 | K | 53 | k | | | 78 | @ |
|
||||
| 74 | L | 54 | l | | | 19 | ! |
|
||||
| 75 | M | 55 | m | | | 1c | $ |
|
||||
| 76 | N | 56 | n | | | 1e | & |
|
||||
| 77 | O | 57 | o | | | 10 | ( |
|
||||
| 68 | P | 48 | p | | | 11 | ) |
|
||||
| 69 | Q | 49 | q | | | 12 | * |
|
||||
| 6a | R | 4a | r | | | 13 | + |
|
||||
| 6b | S | 4b | s | | | 14 | , |
|
||||
| 6c | T | 4c | t | | | 03 | ; |
|
||||
| 6d | U | 4d | u | | | 05 | = |
|
||||
| 6e | V | 4e | v | | | 1d | % |
|
||||
| 6f | W | 4f | w | | | | |
|
||||
| 60 | X | 40 | x | | | | |
|
||||
| 61 | Y | 41 | y | | | | |
|
||||
| 62 | Z | 42 | z | | | | |
|
||||
|
||||
After decoding, `/clock` is replaced with `/clock.json`.
|
||||
|
||||
### 4. Video Providers
|
||||
|
||||
Four providers are tried in parallel:
|
||||
|
||||
| # | Name | Regex Match | Format |
|
||||
|---|---|---|---|
|
||||
| 1 | wixmp (Default) | `/Default :/p` | m3u8 -> mp4 (multi-quality) |
|
||||
| 2 | youtube | `/Yt-mp4 :/p` | mp4 (single) |
|
||||
| 3 | sharepoint | `/S-mp4 :/p` | mp4 (single) |
|
||||
| 4 | hianime | `/Luf-Mp4 :/p` | m3u8 (multi-quality) |
|
||||
|
||||
### 5. Link Extraction (`get_links`)
|
||||
|
||||
After fetching a provider URL, the response is parsed for video links. Three cases:
|
||||
|
||||
1. **wixmp (repackager.wixmp.com)** -- Extracts mp4 URLs with multiple quality options from a comma-separated URL pattern
|
||||
2. **m3u8 (master.m3u8)** -- Parses the m3u8 playlist for stream variants at different resolutions. Also extracts subtitle URLs and referrer headers
|
||||
3. **Direct links** -- Used as-is
|
||||
4. **fast4speed.rsvp** -- YouTube-like links, passed to yt-dlp
|
||||
|
||||
### 6. Quality Selection (`select_quality`)
|
||||
|
||||
- `best` -- first (highest resolution) link
|
||||
- `worst` -- last numeric resolution link
|
||||
- Specific (e.g., `1080`) -- grep for matching resolution
|
||||
- Falls back to `best` if specified quality not found
|
||||
- Sets `--referrer` and `--sub-file` flags for m3u8 streams
|
||||
|
||||
### 7. Playback (`play_episode`)
|
||||
|
||||
Launches the selected player with the video URL. Supported players:
|
||||
|
||||
| Player | Platform | How |
|
||||
|---|---|---|
|
||||
| mpv | Linux/Windows | `mpv` / `mpv.exe` (detached or attached) |
|
||||
| flatpak mpv | Linux | `flatpak run io.mpv.Mpv` |
|
||||
| vlc | Linux/Windows | `vlc` / `vlc.exe` |
|
||||
| iina | macOS | IINA app CLI |
|
||||
| android_mpv | Android | `am start` intent |
|
||||
| android_vlc | Android | `am start` intent |
|
||||
| syncplay | All | Syncplay wrapper |
|
||||
| download | All | `aria2c` (mp4) or `yt-dlp`/`ffmpeg` (m3u8) |
|
||||
| catt | All | Chromecast via catt |
|
||||
| iSH | iOS | VLC URL scheme |
|
||||
|
||||
### 8. History
|
||||
|
||||
- Stored in `~/.local/state/ani-cli/ani-hsts` (or `$ANI_CLI_HIST_DIR`)
|
||||
- Format: `<episode_no>\t<show_id>\t<title>`
|
||||
- Updated after each episode play
|
||||
- Used by `--continue` to resume watching
|
||||
|
||||
### 9. Main Flow
|
||||
|
||||
```
|
||||
1. Parse CLI arguments
|
||||
2. Check dependencies
|
||||
3. Search:
|
||||
a. If --continue: read history, show unwatched, pick one
|
||||
b. Otherwise: prompt for search query, call search_anime(), pick result
|
||||
4. Get episode list for selected anime
|
||||
5. Pick episode (or use --episode flag)
|
||||
6. play():
|
||||
a. get_episode_url() -> fetch embed URLs -> decode providers -> fetch links -> select quality
|
||||
b. play_episode() -> launch player
|
||||
7. Loop: next / replay / previous / select / change_quality / quit
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `curl` -- HTTP requests
|
||||
- `sed`, `grep` -- text processing / parsing
|
||||
- `fzf` -- interactive selection (or rofi/dmenu)
|
||||
- `mpv` / `vlc` / `iina` -- video playback
|
||||
- `aria2c` -- download manager (for mp4 downloads)
|
||||
- `yt-dlp` -- m3u8 downloads
|
||||
- `ffmpeg` -- m3u8 downloads (fallback)
|
||||
- `ani-skip` -- optional, skip anime intros (uses aniskip API + mpv lua scripts)
|
||||
- `patch` -- self-updating
|
||||
|
||||
## Key Constants
|
||||
|
||||
```
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0
|
||||
Referer: https://allmanga.to
|
||||
API Base: https://api.allanime.day
|
||||
Link Base: allanime.day
|
||||
```
|
||||
@@ -0,0 +1,74 @@
|
||||
# Animated Icon Plan
|
||||
|
||||
Animate a Discord emote (GIF) as the app's window/taskbar icon using eframe/egui.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Add the `image` crate
|
||||
|
||||
```bash
|
||||
cargo add image
|
||||
```
|
||||
|
||||
### 2. Place your GIF
|
||||
|
||||
Save your Discord emote as `assets/emote.gif` in the project root.
|
||||
|
||||
### 3. Load GIF frames
|
||||
|
||||
```rust
|
||||
use eframe::egui::{ViewportCommand, IconData};
|
||||
use image::codecs::gif::GifDecoder;
|
||||
use image::AnimationDecoder;
|
||||
use std::time::Instant;
|
||||
|
||||
fn load_gif_frames(gif_bytes: &[u8]) -> Vec<IconData> {
|
||||
let decoder = GifDecoder::new(gif_bytes).unwrap();
|
||||
decoder.into_frames()
|
||||
.collect_frames()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let rgba = f.into_buffer();
|
||||
let (w, h) = (rgba.width(), rgba.height());
|
||||
IconData { rgba: rgba.into_raw(), width: w, height: h }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
|
||||
Embed the GIF at compile time:
|
||||
|
||||
```rust
|
||||
let gif_bytes = include_bytes!("../assets/emote.gif");
|
||||
let icon_frames = load_gif_frames(gif_bytes);
|
||||
```
|
||||
|
||||
### 4. Add fields to the app struct
|
||||
|
||||
```rust
|
||||
icon_frames: Vec<IconData>,
|
||||
current_frame: usize,
|
||||
last_icon_swap: Instant,
|
||||
```
|
||||
|
||||
### 5. Cycle the icon in `update()`
|
||||
|
||||
```rust
|
||||
if !self.icon_frames.is_empty() {
|
||||
let elapsed = self.last_icon_swap.elapsed().as_millis();
|
||||
if elapsed > 100 { // ~10 FPS
|
||||
self.current_frame = (self.current_frame + 1) % self.icon_frames.len();
|
||||
let icon = self.icon_frames[self.current_frame].clone();
|
||||
ctx.send_viewport_cmd(ViewportCommand::Icon(Some(std::sync::Arc::new(icon))));
|
||||
self.last_icon_swap = Instant::now();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The icon animates in both the taskbar and the window title bar on Windows.
|
||||
- Adjust the `100`ms interval to control animation speed.
|
||||
- Discord emotes are typically small (48x48 or 128x128) which works fine for icons.
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
# Project Plan: Anime (Rust GUI)
|
||||
|
||||
A cross-platform (Arch Linux + Windows) GUI application for searching, browsing, and watching anime. Inspired by [ani-cli](https://github.com/pystardust/ani-cli).
|
||||
|
||||
## Features
|
||||
|
||||
### Core (MVP)
|
||||
- Search anime by name via the AllAnime GraphQL API
|
||||
- Display search results in a list
|
||||
- Select an anime and view its available episodes
|
||||
- Select an episode to play in the OS default media player
|
||||
- Watch history -- track what you've watched, resume from where you left off
|
||||
|
||||
### Extended
|
||||
- Download episodes (mp4 direct download via reqwest, m3u8 via yt-dlp/ffmpeg subprocess)
|
||||
- Download progress bar
|
||||
- Sub/Dub toggle
|
||||
- Quality selection
|
||||
- Episode range selection for batch downloads
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### GUI Framework
|
||||
|
||||
**egui / eframe**
|
||||
- Immediate-mode GPU-rendered GUI
|
||||
- Single binary, no runtime dependencies
|
||||
- Native look on both Linux and Windows
|
||||
- Easy to iterate on, good for list-based UIs
|
||||
|
||||
### Crates
|
||||
|
||||
| Crate | Version | Purpose |
|
||||
|---|---|---|
|
||||
| `eframe` | latest | Window management, OpenGL/wgpu backend for egui |
|
||||
| `egui` | latest | Immediate-mode UI widgets (comes with eframe) |
|
||||
| `reqwest` | latest | HTTP client for GraphQL API calls and mp4 downloads |
|
||||
| `tokio` | latest | Async runtime (required by reqwest, keeps GUI responsive) |
|
||||
| `serde` | latest | Serialization framework |
|
||||
| `serde_json` | latest | JSON parsing for GraphQL responses |
|
||||
| `open` | latest | Open video URL in OS default player (`xdg-open` on Linux, `start` on Windows) |
|
||||
| `directories` | latest | Cross-platform paths for config/state (history file) |
|
||||
| `anyhow` | latest | Ergonomic error handling with context |
|
||||
| `indicatif` | latest | Progress bars for downloads (can integrate into egui) |
|
||||
|
||||
### Standard Library (no extra crate needed)
|
||||
|
||||
| Module | Purpose |
|
||||
|---|---|
|
||||
| `std::process::Command` | Shell out to `yt-dlp` / `ffmpeg` for m3u8 downloads |
|
||||
| `std::fs` | Read/write history file |
|
||||
|
||||
### External Tools (runtime, not compile-time)
|
||||
|
||||
| Tool | Required? | Purpose |
|
||||
|---|---|---|
|
||||
| `mpv` / `vlc` / system default | Yes (any player) | Video playback |
|
||||
| `yt-dlp` | For m3u8 downloads | HLS stream downloading |
|
||||
| `ffmpeg` | Fallback for m3u8 | HLS stream downloading (if yt-dlp unavailable) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs -- App entry, eframe::run_native() setup
|
||||
├── app.rs -- Main App struct implementing eframe::App
|
||||
│ Holds app state, routes between views
|
||||
├── views/
|
||||
│ ├── mod.rs
|
||||
│ ├── search.rs -- Search input + results list view
|
||||
│ ├── episodes.rs -- Episode list view for a selected anime
|
||||
│ └── player.rs -- "Now playing" view (current episode, next/prev controls)
|
||||
├── api/
|
||||
│ ├── mod.rs
|
||||
│ ├── client.rs -- Shared reqwest::Client setup (user-agent, referer)
|
||||
│ ├── search.rs -- search_anime() -- GraphQL search query
|
||||
│ ├── episodes.rs -- episodes_list() -- GraphQL episodes query
|
||||
│ ├── sources.rs -- get_episode_url() -- GraphQL embed query + provider decoding
|
||||
│ ├── providers.rs -- Provider-specific link extraction (wixmp, hianime, etc.)
|
||||
│ └── cipher.rs -- Hex substitution cipher for decoding provider URLs
|
||||
├── download.rs -- Download logic: direct mp4 via reqwest, m3u8 via subprocess
|
||||
├── history.rs -- Read/write watch history to local state file
|
||||
└── player.rs -- Launch video in OS default player via `open` crate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ GUI (egui) │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌────────────┐ ┌────────────────────┐ │
|
||||
│ │ Search │───>│ Episodes │───>│ Play / Download │ │
|
||||
│ │ View │ │ View │ │ View │ │
|
||||
│ └─────────┘ └────────────┘ └────────────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
└───────┼───────────────┼──────────────────┼─────────┼────────┘
|
||||
│ │ │ │
|
||||
v v v v
|
||||
search_anime() episodes_list() get_episode_url()│
|
||||
│ │ │ │
|
||||
└───────┬───────┘ │ ┌────┘
|
||||
v v v
|
||||
AllAnime GraphQL API ┌──────────────┐
|
||||
(api.allanime.day) │ Playback │
|
||||
├──────────────┤
|
||||
│ open crate │ -> OS default player
|
||||
│ reqwest │ -> direct mp4 download
|
||||
│ yt-dlp/ffmpeg│ -> m3u8 download
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Details
|
||||
|
||||
See [ani-cli.md](./ani-cli.md) for full documentation of:
|
||||
- GraphQL queries and variables
|
||||
- Hex substitution cipher mapping
|
||||
- Provider-specific link extraction
|
||||
- Video source formats (mp4 vs m3u8)
|
||||
|
||||
### Key Constants
|
||||
|
||||
```rust
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0";
|
||||
const ALLANIME_REFERER: &str = "https://allmanga.to";
|
||||
const ALLANIME_API: &str = "https://api.allanime.day";
|
||||
const ALLANIME_BASE: &str = "allanime.day";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## History File
|
||||
|
||||
- **Linux**: `~/.local/state/anime/history.json`
|
||||
- **Windows**: `C:\Users\<user>\AppData\Local\anime\state\history.json`
|
||||
- Resolved via the `directories` crate (`ProjectDirs::from("", "", "anime")`)
|
||||
|
||||
Format (JSON for easy serde):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"show_id": "abc123",
|
||||
"title": "Cyberpunk Edgerunners",
|
||||
"episode": "5",
|
||||
"mode": "sub",
|
||||
"last_watched": "2026-04-14T12:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Download Strategy
|
||||
|
||||
### Direct MP4 links
|
||||
- Use `reqwest` to stream the response body to a file
|
||||
- Show progress via content-length header + bytes received
|
||||
- No external tools needed
|
||||
|
||||
### m3u8 / HLS streams
|
||||
- Shell out to `yt-dlp` (preferred) or `ffmpeg` (fallback) via `std::process::Command`
|
||||
- Pass referrer header as argument
|
||||
- Capture stdout/stderr for progress reporting
|
||||
- This avoids reimplementing HLS segment fetching, concatenation, and remuxing
|
||||
|
||||
### Download directory
|
||||
- Default: current working directory (or configurable)
|
||||
- File naming: `<anime_title> Episode <number>.mp4`
|
||||
|
||||
---
|
||||
|
||||
## Build Targets
|
||||
|
||||
| Platform | Target Triple | Notes |
|
||||
|---|---|---|
|
||||
| Arch Linux | `x86_64-unknown-linux-gnu` | Primary dev target |
|
||||
| Windows | `x86_64-pc-windows-msvc` | Cross-platform support |
|
||||
|
||||
Both are tier 1 Rust targets with full support.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Project setup** -- Add dependencies to `Cargo.toml`, basic eframe window
|
||||
2. **API client** -- reqwest client with correct headers, test connectivity
|
||||
3. **Cipher** -- Implement hex substitution decoder
|
||||
4. **Search** -- GraphQL search query, parse results
|
||||
5. **Episodes** -- GraphQL episode list query, parse results
|
||||
6. **Sources** -- GraphQL embed query, decode providers, extract video links
|
||||
7. **Playback** -- Open video URL in default player via `open` crate
|
||||
8. **GUI: Search view** -- Text input, results list, selection
|
||||
9. **GUI: Episodes view** -- Episode list, selection, play button
|
||||
10. **GUI: Now playing view** -- Current episode info, next/prev/replay controls
|
||||
11. **History** -- Read/write history file, continue watching flow
|
||||
12. **Downloads** -- Direct mp4 download, m3u8 via subprocess, progress reporting
|
||||
13. **Polish** -- Error handling, loading states, quality selection, sub/dub toggle
|
||||
Reference in New Issue
Block a user