ani-web: FastAPI + HLS proxy over anidb.app, vanilla UI

- upstream.py: Cloudflare-proof fetchers (curl_cffi), self-checking
- app.py: /api/* routes + /stream/{token} HLS proxy (CDN has no CORS)
- static/: search -> detail/seasons/episodes -> hls.js player, history
- verified end-to-end: search chainsaw man -> ep1 dub 1080p segment
This commit is contained in:
jozamudi committed 2026-08-19 15:06:43 +00:00
commit e4dde86d85
10 files changed
+1151

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
.venv/
__pycache__/
*.pyc
+100
View File
@@ -0,0 +1,100 @@
# ani-web — Web Front-End for ani-cli
Browse, search, and stream anime from the browser, using the same
anidb.app data source that ani-cli scrapes.
## Verified data flow (spiked 2026-07, all 200 OK with Chrome TLS impersonation)
| Step | Request | Yields |
|---|---|---|
| 1. Search | `GET anidb.app/browse?q={query}` | HTML cards: `href=".../anime/{slug}-{id}"`, `title="{name}"`, poster `img src` |
| 2. Details | `GET anidb.app/anime/{slug}-{id}` | MAL id, synopsis, related seasons (same-card links) |
| 3. Episodes | `GET anidb.app/api/frontend/anime/{id}/episodes` | JSON `{episodes: [{id, number, number2, filler}]}` |
| 4. Languages | `GET anidb.app/api/frontend/episode/{ep_id}/languages` | JSON `{languages: [{code: "jpn"\|"eng", name, embed_url}]}` — `jpn`=sub, `eng`=dub |
| 5. Embed page | `GET {embed_url}` | JS blob containing `file: '<master m3u8>'` |
| 6. Master playlist | `GET {master m3u8}` (hls.anidb.app) | variants: 1080p / 720p / 360p |
| 7. Variants + segments | `GET ...m3u8`, `.ts` | streamable HLS — **NO CORS headers** |
Key constraint: anidb.app sits behind Cloudflare (plain curl → 403).
All upstream requests must use TLS-fingerprint impersonation
(verified: `curl_cffi` `impersonate="chrome"` passes the challenge).
Since the CDN sends no CORS headers, the browser cannot fetch playlists
or segments directly — **all HLS traffic must go through a backend proxy**.
## Architecture
```
Browser (vanilla JS + hls.js)
│ /api/* JSON /stream/* proxied HLS
▼
Python backend (FastAPI, uvicorn)
│ curl_cffi (Chrome impersonation)
▼
anidb.app + hls.anidb.app
```
Two moving parts, no database, no framework on the frontend.
## Backend
Python 3.12, deps: `fastapi`, `uvicorn`, `curl_cffi`.
- `GET /api/search?q=` → `[{id, slug, name, poster}]` (parse step 1)
- `GET /api/anime/{slug_id}` → `{name, synopsis, mal_id, seasons:[{id,name}]}`
- `GET /api/anime/{id}/episodes` → `[{id, number, filler}]` (step 3, passthrough)
- `GET /api/episode/{ep_id}/languages` → `[{code, name, embed_url}]` (step 4)
- `GET /stream/{token}` → proxied HLS. Token = base64(nats) of the
upstream URL. Rewrites `#EXT-X-...` bodies: every relative/absolute
URL in the playlist is re-pointed at `/stream/...`. Serves master,
variants, and `.ts` segments through the same endpoint (bytes
proxied, no transcoding).
- Upstream cache: embed tokens expire — cache embed→m3u8 mapping for
~10 min; cache nothing else (search is cheap).
## Frontend
Single `index.html` + `app.js` + `style.css`. hls.js from CDN.
Flow: search box → results grid (poster + title, click) → detail view
(synopsis, season tabs) → episode list (number + filler badge,
sub/dub toggle) → `<video>` with hls.js pointed at
`/stream/{token}`; quality follows the master playlist (auto,
hls.js handles ABR).
- History: last 20 `{title, season, episode}` in `localStorage`
(mirrors ani-cli's `-c` continue feature).
- No login, no accounts, no build step.
## Run
```
uv venv .venv && uv pip install --python .venv/bin/python fastapi uvicorn curl_cffi
.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8321
```
Open `http://localhost:8321`.
## Out of scope (v1)
Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip,
ani-schedule integration, auth, multi-user. Add when someone asks.
## Risks
- **Cloudflare policy change** → upstream breaks. Mitigation: swap
`impersonate="chrome"` for `"safari"`/binary curl-impersonate;
all upstream access is isolated in one module (`upstream.py`).
- **anidb.app layout change** → parsers break. The 4 API endpoints
(steps 3–6) are clean JSON; only steps 1, 2, 5 are HTML/JS scraping.
- **ToS** — anidb.app is a third-party scraper site; personal use,
no redistribution, bind to LAN only by default.
## Milestones
1. **Spike (done)** — curl_cffi passes Cloudflare; all 7 steps verified.
2. **upstream.py** — the 6 fetch functions above, one assert-based
self-check (`python upstream.py` prints a chainsaw-man episode m3u8).
3. **API** — FastAPI routes over upstream.py.
4. **HLS proxy** — `/stream/{token}` with playlist rewriting.
5. **UI** — search → detail → episodes → player.
6. Polish — history, sub/dub toggle, error states.
Executable
+625
View File
@@ -0,0 +1,625 @@
#!/bin/sh
version_number="5.0.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 ;;
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)
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)
_line_start=$(printf "%s" "$_line" | head -n 1)
_line_end=$(printf "%s" "$_line" | tail -n 1)
[ -n "$_line" ] || return 1
if [ "$_line_start" = "$_line_end" ]; then
printf "%s" "$_stdin" | grep -E '^'"${_line}"'($|[[:space:]])' | cut -f 2,3 || return 1
else
printf "%s" "$_stdin" | sed -n '/^'"${_line_start}"'$/,/^'"${_line_end}$"'/p' || return 1
fi
}
die() {
printf "\33[2K\r\033[1;31m%s\033[0m\n" "$*" >&2
exit 1
}
info() {
printf "\33[2K\r\033[1;34m%s\033[0m\n" "$*"
}
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)
-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 -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
}
# Bookkeeping
update_script() {
info "[branch:$branch] Checking for Update.."
update="$($curl_exe --fail-with-body -sL -A "$agent" "https://raw.githubusercontent.com/pystardust/ani-cli/$branch/ani-cli")" || die "[branch:$branch] Connection error: $update"
new_version="$(printf "%s" "$update" | sed -nE 's|^version_number="([^"]+)"$|\1|p')"
[ -z "$new_version" ] && die "[branch:$branch] Invalid response."
printf "%s" "$update" | grep -q "^version_number" || die "[branch:$branch] Invalid response."
update="$(printf "%s\n" "$update" | diff -u "$0" -)"
if [ -z "$update" ]; then
info "[branch:$branch] Script is up to date :) ($version_number)"
else
if printf "%s\n" "$update" | patch "$0" -; then
info "[branch:$branch] Updated: $version_number -> $new_version"
else
die "[branch:$branch] Failed to update. Read the above line!"
fi
fi
exit 0
}
# checks if dependencies are present
dep_ch() {
command -v "$1" >/dev/null || die "Program $1 not found. Please install it."
}
# checks for deps in order and returns the first available. format is "prog_a,prog_b,prog_c"
dep_ch_failover() {
[ -z "$1" ] && return 1
prog=$(printf "%s" "$1" | cut -d "," -f 1)
rest=$(printf "%s" "$1" | cut -s -d "," -f 2-)
command -v "$prog" >/dev/null 2>&1 && printf "%s" "$prog" && return 0
[ -e "$prog" ] && printf "%s" "$prog" && return 0
dep_ch_failover "$rest"
}
cleanup() {
tput sgr0 # clears colors
rm -f "${histfile}.new" # remove temp logfile
}
# SCRAPING
#shellcheck disable=SC2086
anidb_curl() {
$curl_exe -sL -A "$agent" --max-time 10 $cipher_flag "$@"
}
# search the query and give results. format is (id \t name)
anidb_search() {
#shellcheck disable=SC2059
_page="$(anidb_curl "$(printf "$search_api" "$1")" | tr '\n' ' ' | sed 's|<a href|\n<a href|g')"
if printf "%s" "$_page" | grep -qi "Just a moment"; then
[ "$curl_exe" = "curl" ] && die "Blocked by cloudflare. Try installing curl-impersonate"
die "Blocked by cloudflare."
fi
printf "%s" "$_page" | sed -nE 's|.*anime/([a-z0-9-]+-[0-9]+)".*alt="([^"]+)".*|\1\t\2|p' | sed -e "s|&#039;|'|g" -e 's|&quot;|"|g'
}
# extract mal_id and seasons metadata
anidb_desc() {
#shellcheck disable=SC2059
_page="$(anidb_curl "$(printf "$desc_api" "$1")" | tr '\n' ' ' | sed 's|<a href|\n<a href|g')"
mal_id="$(printf "%s" "$_page" | sed -nE 's|.*https://myanimelist.net/anime/([0-9]+)/.*|\1|p')"
seasons="$(printf "%s" "$_page" | sed -n '/>Seasons</,/>Details</p' | sed -nE 's|.*anime/([^"]+-[0-9]+)"[^>]*title="([^"]+)".*|\1\t\2|p' | sed -e "s|&#039;|'|g")"
[ -n "$seasons" ] && seasons_option="\nchange_season"
}
# get the episodes list of the selected anime. format is (id \t ep_no)
anidb_episodes() {
#shellcheck disable=SC2059
_page="$(anidb_curl "$(printf "$episodes_api" "${1##*-}")")"
printf "%s" "$_page" | sed 's|},{|}\n{|g' | sed -nE 's|.*"id":([0-9]+).*"number":([0-9]+).*|\1\t\2|p'
}
# extract the individual m3u8 links from response of embed urls
anidb_m3u8() {
_ep_id=$(printf "%s" "$episode_maps" | sed -nE "s|^([0-9]+)\t${1}$|\1|p")
_lang="jpn"
[ "$2" = "dub" ] && _lang="eng"
#shellcheck disable=SC2059
_page="$(anidb_curl "$(printf "$m3u8_api" "${_ep_id}")")"
_embed="$(printf "%s" "$_page" | sed 's|},{|}\n{|g' | sed -nE 's|.*'"${_lang}"'.*embed_url":"([^"]+)".*|\1|p' | sed 's|\\/|/|g' | head -n 1)"
[ -z "$_embed" ] && return 1
_embed_page="$(anidb_curl "$_embed")"
_m3u8_master="$(printf "%s" "$_embed_page" | sed -nE "s|.*file: '([^']*)'.*|\1|p" | head -n 1)"
anidb_curl "${_m3u8_master}" | sed 's|^#EXT-X-STREAM.*x||g; s|,.*|p|g; /^#/d; $!N; s|\n| >|;/EXT-X-I-FRAME/d'
}
# select the quality according to the setting
select_quality() {
case "$1" in
best) _result=$(printf "%s" "$links" | head -n 1) ;;
worst) _result=$(printf "%s" "$links" | grep -E '^[0-9]{3,4}' | tail -n 1) ;;
*) _result=$(printf "%s" "$links" | grep -m 1 "$1") ;;
esac
[ -z "$_result" ] && printf "\33[2K\r\033[1;33m%s\033[0m\n" "$1 not found, defaulting to best" >&2 && _result=$(printf "%s" "$links" | head -n 1)
video_link=$(printf "%s" "$_result" | cut -d ">" -f 2)
}
# gets embed url, collects direct links and selects one with desired quality into $episode
get_video_link() {
links=$(anidb_m3u8 "$ep_no" "$mode" | sort -g -r -s)
[ -z "$links" ] && die "No sources found for $mode!"
info "anidb.app links fetched"
select_quality "$quality"
# should never trigger but just in case
if printf "%s" "$ep_list" | grep -q "^$ep_no$"; then
[ -z "$video_link" ] && die "Episode is released, but no valid sources!"
else
[ -z "$video_link" ] && die "Episode not released!"
fi
}
# provide countdown for the next episode release both sub and dub
time_until_next_ep() {
animeschedule="https://animeschedule.net"
_query="$(printf "%s" "$*" | tr ' ' '+')"
$curl_exe -s -G "$animeschedule/api/v3/anime" --data "q=${_query}" | sed 's|},{"id"|\n|g' | while read -r _entry; do
_route=$(printf "%s" "$_entry" | sed -nE 's|.*"route":"([^"]*)",.*|\1|p')
_eng_title=$(printf '%s' "$_entry" | sed -nE 's|.*"english":"([^"]*)".*|\1|p')
[ -z "$_eng_title" ] && _eng_title=$(printf '%s' "$_entry" | sed -nE 's|.*,"title":"([^"]*)".*|\1|p')
[ -z "$_eng_title" ] && _eng_title="N/A"
_jpn_title=$(printf '%s' "$_entry" | sed -nE 's|.*"romaji":"([^"]*)".*|\1|p')
[ -z "$_jpn_title" ] && _jpn_title="N/A"
_status=$(printf '%s' "$_entry" | sed -nE 's|.*"status":"([^"]*)".*|\1|p')
[ -z "$_status" ] && _status="Unknown"
case "$_status" in
Ongoing) _color="33" ;;
Finished) _color="32" ;;
*) _color="36" ;;
esac
printf "Eng: %s\n" "$_eng_title"
printf "Jpn: %s\n" "$_jpn_title"
printf "Status: \033[1;%sm%s\033[0m\n" "$_color" "$_status"
if [ "$_status" != "Finished" ]; then
_page="$($curl_exe -s "$animeschedule/anime/$_route")"
_sub_ep=$(printf "%s" "$_page" | sed -nE 's|.*release-time-type-subs"[^>]*><span class="release-time-episode-number">Episode ([0-9]+)</span>.*|\1|p' | head -n 1)
_dub_ep=$(printf "%s" "$_page" | sed -nE 's|.*release-time-type-dub"[^>]*><span class="release-time-episode-number">Episode ([0-9]+)</span>.*|\1|p' | head -n 1)
_sub_countdown=$(printf "%s" "$_page" | sed -nE 's|.*class="countdown-time" datetime="([^"]*)".*|\1|p' | head -n 1)
_dub_countdown=$(printf "%s" "$_page" | sed -nE 's|.*class="countdown-time countdown-time-dub" datetime="([^"]*)".*|\1|p' | head -n 1)
[ -n "$_sub_ep" ] && [ -n "$_sub_countdown" ] &&
printf "Next Sub: Episode %s in \033[1;%sm%s\033[0m\n" "$_sub_ep" "$_color" "$_sub_countdown" || printf "Next Sub: N/A\n"
[ -n "$_dub_ep" ] && [ -n "$_dub_countdown" ] &&
printf "Next Dub: Episode %s in \033[1;%sm%s\033[0m\n" "$_dub_ep" "$_color" "$_dub_countdown" || printf "Next Dub: N/A\n"
fi
printf "\n"
done
exit 0
}
# HISTORY
backup_old_hist() {
backupfile="${histfile}.v4"
while IFS=" " read -r ep_no anime_id anime_title; do
case "$anime_id" in
*-*) printf "%s\t%s\t%s\n" "$ep_no" "$anime_id" "$anime_title" >>"${histfile}.new" ;;
*) printf "%s\t%s\t%s\n" "$ep_no" "$anime_id" "$anime_title" >>"$backupfile" ;;
esac
done <"$histfile"
mv "${histfile}.new" "$histfile"
}
process_hist_entry() {
ep_list=$(anidb_episodes "$anime_id" | cut -f 2)
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" "$anime_id" "$anime_title" "$ep_no"
}
update_history() {
if grep -q " ${anime_id} " "$histfile"; then
# escape sed special chars in replacement: & and \ and delimiter |
safe_title=$(printf "%s" "$anime_title" | sed 's|[&\|]|\\&|g')
sed -E "s|^[^ ]+ ${anime_id} [^ ]+$|${ep_no} ${anime_id} ${safe_title}|" "$histfile" >"${histfile}.new"
else
cp "$histfile" "${histfile}.new"
printf "%s\t%s\t%s\n" "$ep_no" "$anime_id" "$anime_title" >>"${histfile}.new"
fi
mv "${histfile}.new" "$histfile"
}
# PLAYING
# shellcheck disable=SC2086
# $1 = video link, $2 = video name, $3 = extra flags
download() {
command -v "yt-dlp" >/dev/null && yt-dlp "$1" --no-skip-unavailable-fragments --fragment-retries infinite -N 16 -o "$download_dir/$2.mp4" $3 && return 0
ffmpeg -extension_picky 0 -loglevel error -stats -i "$1" -c copy "$download_dir/$2.mp4" $3
}
play_episode() {
[ "$log_episode" = 1 ] && [ "$player_function" != "debug" ] && [ "$player_function" != "download" ] && command -v logger >/dev/null && logger -t ani-cli "${anime_title} ${ep_no}"
[ "$skip_intro" = 1 ] && skip_flag="$(ani-skip -i "$mal_id" -e "$canon_ep_no")"
[ -z "$video_link" ] && get_video_link
# wait for the previous player to close (in range selection)
wait
# shellcheck disable=SC2086
case "$player_function" in
debug) printf "All links:\n%s\nSelected link:\n%s\n" "$links" "$video_link" ;;
android_mpv) nohup am start --user 0 -a android.intent.action.VIEW -d "$video_link" -n is.xyz.mpv/.MPVActivity -e "title" "${anime_title} Episode ${ep_no}" $player_extra_flags >/dev/null 2>&1 & ;;
android_vlc) nohup am start --user 0 -a android.intent.action.VIEW -d "$video_link" -n org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity -e "title" "${anime_title} Episode ${ep_no}" $player_extra_flags >/dev/null 2>&1 & ;;
"$HOME"/.local/share/flatpak/app/io.mpv/Mpv/) flatpak run io.mpv.Mpv $skip_flag $player_extra_flags --force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 & ;;
*mpv*)
if [ "$no_detach" = 0 ]; then
nohup $player_function $skip_flag $player_extra_flags --force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 &
else
$player_function $skip_flag $player_extra_flags --force-media-title="${anime_title} Episode ${ep_no}" "$video_link"
mpv_exitcode=$?
[ "$exit_after_play" = 1 ] && [ -z "$_range" ] && exit "$mpv_exitcode"
fi
;;
*iina*)
if pgrep -f "IINA" >/dev/null 2>&1; then
# omit --keep-running when an IINA instance exists to prevent hanging
nohup $player_function $player_extra_flags --no-stdin --mpv-force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 &
else
nohup $player_function $player_extra_flags --no-stdin --keep-running --mpv-force-media-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 &
fi
;;
*vlc*) nohup $player_function $player_extra_flags --play-and-exit --meta-title="${anime_title} Episode ${ep_no}" "$video_link" >/dev/null 2>&1 & ;;
*yncpla*) nohup $player_function $player_extra_flags "$video_link" -- --force-media-title="${anime_title} Episode ${ep_no}" >/dev/null 2>&1 & ;;
download) "$player_function" "$video_link" "${anime_title} Episode ${ep_no}" $player_extra_flags ;;
catt) nohup catt cast $player_extra_flags "$video_link" >/dev/null 2>&1 & ;;
iSH)
printf "\033]8;;vlc://%s\a~~~~~~~~~~~~~~~~~~~~\n~ Tap to open VLC ~\n~~~~~~~~~~~~~~~~~~~~\033]8;;\a\n" "$video_link"
sleep 5
;;
*) nohup $player_function $player_extra_flags "$video_link" >/dev/null 2>&1 & ;;
esac
replay="$video_link"
unset video_link
update_history
[ "$menu_program" != "fzf" ] && 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 -n 1) && unset _start
[ -z "$_end" ] || [ "$_end" = "$_start" ] && unset _start _end
[ "$_end" = "-1" ] && _end=$(printf "%s" "$ep_list" | tail -n 1)
_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 -n 1)
[ -z "$_end" ] && _end=$(printf "%s\n" "$ep_no" | tail -n 1)
_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
canon_ep_no="$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/=")"
info "Playing episode $ep_no..."
[ "$i" = "$_end" ] && unset _range
play_episode
done
else
printf "%s" "$ep_list" | grep -q "^$ep_no$" || die "Invalid episode!"
canon_ep_no="$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/=")"
play_episode
fi
# moves up to stored position and deletes to end
[ "$player_function" != "debug" ] && [ "$player_function" != "download" ] && tput rc && tput ed
}
# MAIN
# setup
base_api="https://anidb.app"
search_api="${base_api}/browse?q=%s"
# search_api_alt="${base_api}/search/suggestions?q=%s"
desc_api="${base_api}/anime/%s"
episodes_api="${base_api}/api/frontend/anime/%s/episodes"
m3u8_api="${base_api}/api/frontend/episode/%s/languages"
ciphers='ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305'
tls13_ciphers='TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256'
agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
mode="${ANI_CLI_MODE:-sub}"
download_dir="${ANI_CLI_DOWNLOAD_DIR:-.}"
log_episode="${ANI_CLI_LOG:-1}"
quality="${ANI_CLI_QUALITY:-best}"
curl_exe=$(dep_ch_failover "curl_firefox135,curl_chrome136,curl_chrome116,curl_ff117,curl") || die "Program curl not found"
case "$(uname -a | cut -d " " -f 1,3-)" in
*Darwin*)
cipher_flag="--ciphers $ciphers --tls13-ciphers $tls13_ciphers"
player_function="${ANI_CLI_PLAYER:-$(dep_ch_failover "iina,/Applications/IINA.app/Contents/MacOS/iina-cli,mpv,vlc")}" || die 'No player found. Looked for iina, mpv and vlc'
;; # 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}"
curl_exe=$(dep_ch_failover "curl_safari260_ios,$curl_exe")
;; # iOS (iSH)
*) player_function="${ANI_CLI_PLAYER:-$(dep_ch_failover "mpv,$HOME/.local/share/flatpak/app/io.mpv/Mpv/,vlc")}" || die 'No player found. Looked for mpv and vlc' ;; # Linux OS
esac
player_extra_flags="${ANI_CLI_PLAYER_FLAGS:-""}"
no_detach="${ANI_CLI_NO_DETACH:-0}"
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:-""}"
# not opening from a terminal
if [ ! -t 0 ]; then
command -v dmenu >/dev/null && menu_program=dmenu
command -v rofi >/dev/null && menu_program=rofi
fi
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"
source="${ANI_CLI_DEFAULT_SOURCE:-search}"
branch="${ANI_CLI_BRANCH:-master}"
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) source=history ;;
-d | --download)
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) menu_program=rofi ;;
--dmenu) menu_program=dmenu ;;
--skip) skip_intro=1 ;;
-N | --nextep-countdown) source=nextep ;;
-U | --update)
dep_ch "patch"
branch="${2:-$branch}"
update_script
;;
*) query="$(printf "%s" "$query $1" | sed 's|^ ||;s| |+|g')" ;;
esac
shift
done
[ "$menu_program" = "fzf" ] && menu_multi_flag="-m"
[ "$menu_program" = "rofi" ] && menu_multi_flag="-multi-select"
info "Checking dependencies..."
dep_ch "sed"
dep_ch "grep"
[ "$skip_intro" = 1 ] && dep_ch "ani-skip"
dep_ch "$menu_program" # TODO: use dep_ch_failover to iterate through options
case "$player_function" in
debug) ;;
download) dep_ch_failover "yt-dlp,ffmpeg" >/dev/null || die 'Neither yt-dlp nor ffmpeg found' ;;
android*) printf "\33[2K\rChecking of players on Android is disabled.\n" ;;
*iSH*) printf "\33[2K\rChecking of players on iOS is disabled\n" ;;
*) dep_ch "$player_function" ;;
esac
# idk where to put this, so I put it here...
trap 'cleanup; exit 1' INT HUP TERM
# searching
case "$source" in
history)
backup_old_hist
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!"
[ -z "${index##*[!0-9]*}" ] && anime_id=$(printf "%s" "$anime_list" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select anime: " | cut -f 1)
[ -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.*||')
anidb_desc "$anime_id" || true
episode_maps="$(anidb_episodes "$anime_id")"
ep_list="$(printf "%s" "$episode_maps" | cut -f 2)"
ep_no=$(printf "%s" "$anime_list" | grep "^$anime_id " | sed -nE 's/.*- episode (.+)$/\1/p')
;;
*)
if [ "$menu_program" = "fzf" ]; then
while [ -z "$query" ]; do
printf "\33[2K\r\033[1;36mSearch anime: \033[0m" && read -r query
done
else
[ -z "$query" ] && query=$(: | menu "Search anime: " "" "$menu_extra_flags")
[ -z "$query" ] && exit 1
fi
# for checking new releases by specifying anime name
[ "$source" = "nextep" ] && time_until_next_ep "$query"
query=$(printf "%s" "$query" | sed 's| |+|g')
anime_list=$(anidb_search "$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" ] && die "Invalid anime selection"
anime_title="$(printf "%s" "$result" | cut -f 2)"
anime_id="$(printf "%s" "$result" | cut -f 1)"
anidb_desc "$anime_id" || true
episode_maps="$(anidb_episodes "$anime_id")"
ep_list="$(printf '%s' "$episode_maps" | cut -f 2)"
[ -z "$ep_no" ] && ep_no=$(printf "%s" "$ep_list" | nth "Select episode: " "$menu_multi_flag")
[ -z "$ep_no" ] && die "Invalid episode selection"
;;
esac
# 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
#shellcheck disable=SC2059
while cmd=$(printf "next\nreplay\nprevious\nselect$seasons_option\nchange_quality\nquit" | nth "Playing episode $ep_no of $anime_title... "); do
case "$cmd" in
next) ep_no=$(printf "%s" "$ep_list" | sed -n "/^${ep_no}$/{n;p;}") 2>/dev/null ;;
replay) video_link="$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: " "$menu_multi_flag") ;;
change_season)
new_anime="$(printf "%s" "$seasons" | nl -w 2 | sed 's/^[[:space:]]//' | nth "Select season: ")"
[ -z "$new_anime" ] && die "Invalid season selection"
anime_title="$(printf "%s" "$new_anime" | cut -f 2)"
anime_id="$(printf "%s" "$new_anime" | cut -f 1)"
anidb_desc "$anime_id" || true
episode_maps="$(anidb_episodes "$anime_id")"
ep_list="$(printf '%s' "$episode_maps" | cut -f 2)"
ep_no=$(printf "%s" "$ep_list" | nth "Select episode: " "$menu_multi_flag")
[ -z "$ep_no" ] && die "Invalid episode selection"
;;
change_quality)
new_quality="$(printf "%s" "$links" | menu "Select Quality: " "" "$menu_extra_flags" | cut -d ">" -f 1)"
[ -z "$new_quality" ] && die "No quality selected"
select_quality "$new_quality"
;;
*)
cleanup
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
+80
View File
@@ -0,0 +1,80 @@
"""app.py — ani-web: FastAPI over upstream.py + HLS proxy + static UI.
Browser playback: hls.anidb.app sends no CORS headers, so the player is
pointed at /stream/{token}; the proxy fetches upstream (with the same
Cloudflare workaround) and rewrites playlist URIs to go through itself.
"""
import base64
from urllib.parse import urljoin
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
import upstream
app = FastAPI(title="ani-web")
def _b64(url: str) -> str:
return base64.urlsafe_b64encode(url.encode()).decode()
def _url(token: str) -> str:
return base64.urlsafe_b64decode(token.encode()).decode()
def _upstream(fn, *a, **kw):
try:
return fn(*a, **kw)
except HTTPException:
raise
except Exception as e:
raise HTTPException(502, f"upstream error: {e}")
@app.get("/api/search")
def api_search(q: str = Query(min_length=1)):
return _upstream(upstream.search, q)
@app.get("/api/anime/{slug_id}")
def api_anime(slug_id: str):
return _upstream(upstream.details, slug_id)
@app.get("/api/anime/{anime_id}/episodes")
def api_episodes(anime_id: int):
return _upstream(upstream.episodes, anime_id)
@app.get("/api/episode/{ep_id}/stream")
def api_stream(ep_id: int, lang: str = "eng"):
master = _upstream(upstream.master_m3u8, ep_id, lang)
if not master:
raise HTTPException(404, f"no {lang} stream for episode {ep_id}")
return {"token": _b64(master),
"qualities": _upstream(upstream.master_variants, master)}
def _rewrite_m3u8(text: str, base: str) -> str:
# ponytail: rewrites plain URIs only; URI-list attributes in
# #EXT-X-* tags (e.g. SUBTITLES) are ignored — fine for video-only playlists
return "\n".join(
line if line.startswith("#") or not line
else "/stream/" + _b64(urljoin(base, line))
for line in text.splitlines()) + "\n"
@app.get("/stream/{token}")
def stream(token: str):
# ponytail: buffers whole response in memory; .ts segments are small
url = _url(token)
r = _upstream(upstream._get, url)
body = r.content
if url.endswith(".m3u8") or "mpegurl" in r.headers.get("content-type", ""):
body = _rewrite_m3u8(body.decode(), url).encode()
return Response(body, media_type=r.headers.get("content-type", "application/octet-stream"))
app.mount("/", StaticFiles(directory="static", html=True), name="static")
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
fastapi
uvicorn
curl_cffi
+167
View File
@@ -0,0 +1,167 @@
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
const main = document.getElementById("main");
let hls = null, detail = null, lang = "eng", seasonId = null;
async function api(path) {
const r = await fetch(path);
if (!r.ok) {
let msg = r.statusText;
try { msg = (await r.json()).detail || msg; } catch {}
throw new Error(msg);
}
return r.json();
}
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function fail(err) {
main.innerHTML = "";
main.append(el("p", "error", "Error: " + err.message));
}
// ---------- history ----------
const HKEY = "aniweb.history";
const hist = () => JSON.parse(localStorage.getItem(HKEY) || "[]");
function pushHistory(entry) {
const h = hist().filter(x => !(x.slug_id === entry.slug_id && x.ep_id === entry.ep_id));
h.unshift(entry);
localStorage.setItem(HKEY, JSON.stringify(h.slice(0, 20)));
}
function showHome() {
main.innerHTML = "";
const h = hist();
if (!h.length) { main.append(el("p", "muted", "Search for an anime to get started.")); return; }
const sec = el("section");
sec.append(el("h2", null, "Recently played"));
for (const x of h) {
const b = el("button", "hist-item", `${x.title} — Ep ${x.ep} (${x.lang === "eng" ? "dub" : "sub"})`);
b.onclick = () => location.href = `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
sec.append(b);
}
main.append(sec);
}
// ---------- search ----------
async function showSearch(q) {
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
main.innerHTML = "";
if (!results.length) { main.append(el("p", "muted", "No results.")); return; }
const grid = el("div", "grid");
for (const r of results) {
const card = el("a", "card");
card.href = `?anime=${r.slug_id}`;
const img = el("img", null, r.name);
img.src = r.poster || "";
card.append(img, el("span", "card-name", r.name));
grid.append(card);
}
main.append(grid);
}
// ---------- detail + player ----------
async function showAnime(slug_id, opts = {}) {
detail = await api(`/api/anime/${slug_id}`);
lang = opts.lang || "eng";
seasonId = opts.season || detail.id;
detail.slug_id = slug_id;
main.innerHTML = "";
const head = el("section", "detail-head");
head.append(el("h2", null, detail.name), el("p", "synopsis", detail.synopsis || ""));
main.append(head);
const player = el("div", "player");
player.id = "player";
player.append(videoEl());
main.append(player);
const controls = el("div", "controls");
const langBtn = el("button", null, langLabel());
const setLang = l => { lang = l; langBtn.textContent = langLabel(); };
langBtn.onclick = () => {
setLang(lang === "eng" ? "jpn" : "eng");
const cur = main.querySelector(".ep.active");
if (cur) play(cur.dataset.ep, cur.dataset.no);
};
controls.append(langBtn, el("span", "muted", " — click an episode to play"));
main.append(controls);
const tabs = el("div", "tabs");
for (const s of detail.seasons) {
const t = el("button", "tab" + (s.id === seasonId ? " active" : ""), s.name);
t.dataset.id = s.id;
t.onclick = () => {
for (const x of tabs.children) x.classList.toggle("active", +x.dataset.id === s.id);
location.href = `?anime=${s.slug_id}&season=${s.id}&lang=${lang}`;
};
tabs.append(t);
}
main.append(tabs);
const eps = el("div", "eps");
eps.id = "eps";
main.append(eps);
const list = await api(`/api/anime/${seasonId}/episodes`);
for (const e of list) {
const b = el("button", "ep" + (e.filler ? " filler" : ""));
b.textContent = "E" + e.number;
if (e.filler) b.append(el("span", "badge", " filler"));
b.dataset.ep = e.id; b.dataset.no = e.number;
b.onclick = () => { markActive(b); play(e.id, e.number); };
eps.append(b);
}
if (opts.ep) {
const cur = [...eps.children].find(b => +b.dataset.ep === opts.ep);
if (cur) { markActive(cur); play(opts.ep, cur.dataset.no); }
}
}
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
function markActive(b) {
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
b.classList.add("active");
}
function videoEl() {
const v = document.createElement("video");
v.controls = true; v.autoplay = true;
return v;
}
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
async function play(ep_id, ep_no) {
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
stopHls();
const v = videoEl();
document.getElementById("player").replaceChildren(v);
if (!Hls.isSupported()) throw new Error("HLS not supported in this browser");
hls = new Hls();
hls.loadSource("/stream/" + s.token);
hls.attachMedia(v);
pushHistory({title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id, lang, ep: ep_no});
document.title = `${detail.name} E${ep_no} — ani-web`;
}
// ---------- boot ----------
(async () => {
const p = new URLSearchParams(location.search);
try {
if (p.get("q")) await showSearch(p.get("q"));
else if (p.get("anime")) await showAnime(p.get("anime"), {
season: p.get("season") ? +p.get("season") : null,
ep: p.get("ep") ? +p.get("ep") : null,
lang: p.get("lang") || "eng",
});
else showHome();
} catch (e) { fail(e); }
})();
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ani-web</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<a class="logo" href="/" onclick="location.replace('/');return false">ani-web</a>
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false">
<input id="q" placeholder="search anime…" autocomplete="off">
</form>
</header>
<main id="main"><p class="muted">Search for an anime to get started.</p></main>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js"></script>
<script src="app.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
:root { --bg: #0e0f13; --card: #171821; --bd: #262833; --fg: #e8e9f0; --muted: #8a8d9c; --acc: #f97316; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
a { color: inherit; text-decoration: none; }
header { display: flex; gap: 1rem; align-items: center; padding: 0.8rem 1.2rem;
border-bottom: 1px solid var(--bd); position: sticky; top: 0; background: var(--bg); z-index: 1; }
.logo { font-weight: 700; font-size: 1.2rem; color: var(--acc); }
header form { flex: 1; max-width: 420px; }
#q { width: 100%; padding: 0.5rem 0.8rem; border-radius: 8px; border: 1px solid var(--bd);
background: var(--card); color: var(--fg); }
main { padding: 1.2rem; max-width: 1100px; margin: 0 auto; }
.muted { color: var(--muted); }
.error { color: #f87171; }
h2 { margin: 0.4rem 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem; }
.card { background: var(--card); border: 1px solid var(--bd); border-radius: 10px; overflow: hidden;
display: flex; flex-direction: column; }
.card:hover { border-color: var(--acc); }
.card img { width: 100%; aspect-ratio: 2/3; object-fit: cover; background: #000; display: block; }
.card-name { padding: 0.5rem; font-size: 0.85rem; color: var(--muted); }
.detail-head { margin-bottom: 1rem; }
.synopsis { color: var(--muted); max-width: 70ch; margin: 0.3rem 0; }
.player video { width: 100%; max-height: 70vh; background: #000; border-radius: 10px; aspect-ratio: 16/9; }
.controls { display: flex; align-items: center; gap: 0.4rem; margin: 0.6rem 0; }
button { background: var(--card); color: var(--fg); border: 1px solid var(--bd);
border-radius: 8px; padding: 0.4rem 0.7rem; cursor: pointer; }
button:hover { border-color: var(--acc); }
.tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.6rem 0; }
.tab.active { border-color: var(--acc); color: var(--acc); }
.eps { display: grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); gap: 0.4rem; }
.ep { position: relative; font-size: 0.9rem; }
.ep.active { border-color: var(--acc); color: var(--acc); }
.ep.filler { opacity: 0.55; }
.badge { font-size: 0.6rem; color: var(--muted); }
.hist-item { display: block; width: 100%; text-align: left; margin: 0.3rem 0; padding: 0.6rem 0.8rem; }
+112
View File
@@ -0,0 +1,112 @@
"""upstream.py — the only module that talks to anidb.app.
anidb.app sits behind Cloudflare: plain curl gets 403. All requests use
curl_cffi Chrome TLS impersonation (verified to pass the challenge).
Parsers are regex against live markup — keep them here so the layout
breaks in one place, not across the app.
"""
import re
import sys
from curl_cffi import requests
BASE = "https://anidb.app"
H = dict(impersonate="chrome", timeout=20)
_ANIME_LINK = re.compile(r'anime/([a-z0-9-]+)-(\d+)"[^>]*title="([^"]*)"')
def _get(url, **kw):
r = requests.get(url, **H, **kw)
if r.status_code in (403, 503) or "Just a moment" in r.text[:500]:
raise RuntimeError(f"blocked by cloudflare: {url}")
r.raise_for_status()
return r
def search(q):
"""Step 1: browse page -> [{id, slug_id, name, poster}]"""
html = _get(f"{BASE}/browse", params={"q": q}).text
out, seen = [], set()
for m in re.finditer(
r'<a href="https://anidb\.app/anime/([a-z0-9-]+-\d+)"[^>]*title="([^"]*)"[^>]*>(.*?)</a>',
html, re.S):
slug_id, name, block = m.group(1), m.group(2), m.group(3)
if slug_id in seen:
continue
seen.add(slug_id)
img = re.search(r'<img src="([^"]+)"', block)
out.append({"id": int(slug_id.rsplit("-", 1)[1]), "slug_id": slug_id,
"name": name, "poster": img.group(1) if img else None})
return out
def details(slug_id):
"""Step 2: details page -> {name, synopsis, mal_id, seasons}"""
p = _get(f"{BASE}/anime/{slug_id}").text
mal = re.search(r'myanimelist\.net/anime/(\d+)', p)
syn = re.search(r'>Synopsis</h2>.*?<p[^>]*>(.*?)</p>', p, re.S)
h1 = re.search(r'<h1[^>]*>(.*?)</h1>', p, re.S)
seasons, seen = [], set()
i = p.find(">Seasons<")
if i > 0:
cnt = re.search(r">(\d+) entries in this series<", p[i:i + 400])
n = int(cnt.group(1)) if cnt else 10 # fall back to cap if count markup changes
for m in _ANIME_LINK.finditer(p[i:i + 20000]):
sid = f"{m.group(1)}-{m.group(2)}"
if sid in seen:
continue
seen.add(sid)
seasons.append({"slug_id": sid, "name": m.group(3), "id": int(m.group(2))})
if len(seasons) == n:
break
return {"id": int(slug_id.rsplit("-", 1)[1]),
"name": h1.group(1).strip() if h1 else None,
"synopsis": re.sub(r"<[^>]+>", "", syn.group(1)).strip() if syn else None,
"mal_id": int(mal.group(1)) if mal else None,
"seasons": seasons}
def episodes(anime_id):
"""Step 3: JSON API -> [{id, number, filler}]"""
d = _get(f"{BASE}/api/frontend/anime/{anime_id}/episodes").json()
return [{"id": e["id"], "number": e["number"], "filler": bool(e.get("filler"))}
for e in d["episodes"]]
def languages(ep_id):
"""Step 4: JSON API -> [{code, name, embed_url}] (jpn=sub, eng=dub)"""
return _get(f"{BASE}/api/frontend/episode/{ep_id}/languages").json()["languages"]
def master_m3u8(ep_id, lang="eng"):
"""Steps 5+6: embed page -> 'file: <master m3u8>' URL (None if unavailable)"""
for l in languages(ep_id):
if l["code"] == lang:
m = re.search(r"file: '([^']*)'", _get(l["embed_url"]).text)
if m:
return m.group(1)
return None
def master_variants(master_url):
"""Step 7: parse #EXT-X-STREAM-INF lines -> ['1920x1080', ...] (for display)"""
text = _get(master_url).text
return re.findall(r"RESOLUTION=(\d+x\d+)", text)
if __name__ == "__main__":
# ponytail: assert self-check instead of a test framework
results = search("chainsaw man")
assert results and any(x["name"] == "Chainsaw Man" for x in results), results
top = next(x for x in results if x["name"] == "Chainsaw Man")
d = details(top["slug_id"])
assert d["mal_id"] == 44511 and d["synopsis"], d
assert [s["id"] for s in d["seasons"]] == [922, 923], d["seasons"]
eps = episodes(top["id"])
assert eps and eps[0]["number"] == 1, eps
m = master_m3u8(eps[0]["id"], "eng")
assert m and "EXT-X-STREAM" in _get(m).text, m
print(f"OK: {top['name']} (MAL {d['mal_id']}, {len(d['seasons'])} seasons, "
f"{len(eps)} eps) -> ep1 dub: {m[:70]}...")
sys.exit(0)