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:
commit
e4dde86d85
10 files changed
+1151
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -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.
|
||||||
@@ -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|'|'|g" -e 's|"|"|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|'|'|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
|
||||||
@@ -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.
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
curl_cffi
|
||||||
+167
@@ -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); }
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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)
|
||||||
Reference in new issue
Block a user