mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-06 12:51:08 -08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d2cf7ed66 | ||
|
|
aa6dc2b98e | ||
|
|
2e5cde3365 | ||
|
|
d75a03e594 | ||
|
|
9268c02683 | ||
|
|
89913036c9 | ||
|
|
2244026c67 | ||
|
|
c70564474b | ||
|
|
74514c9fbc | ||
|
|
077e9ab8c4 | ||
|
|
b05f7f1640 | ||
|
|
3382b720e3 | ||
|
|
f72c2d4b17 | ||
|
|
ff027991e0 | ||
|
|
21cdc6b015 | ||
|
|
29a2e3e6d1 | ||
|
|
5b3b9f740b | ||
|
|
5bc0e52179 | ||
|
|
40f1c4fba5 | ||
|
|
454341eaf5 | ||
|
|
abab2540a3 | ||
|
|
b2bc8cbace | ||
|
|
90bbf3c033 | ||
|
|
ac91b1770a | ||
|
|
19d42b7924 | ||
|
|
9ec3136734 | ||
|
|
943fca43cf | ||
|
|
b2e00feb94 | ||
|
|
f726c8d55c |
153
README.md
153
README.md
@@ -41,15 +41,17 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magi
|
||||
- [Subcommands](#subcommands)
|
||||
- [download subcommand](#download-subcommand)
|
||||
- [search subcommand](#search-subcommand)
|
||||
- [grab subcommand](#grab-subcommand)
|
||||
- [downloads subcommand](#downloads-subcommand)
|
||||
- [config subcommand](#config-subcommand)
|
||||
- [cache subcommand](#cache-subcommand)
|
||||
- [update subcommand](#update-subcommand)
|
||||
- [completions subcommand](#completions-subcommand)
|
||||
- [MPV specific commands](#mpv-specific-commands)
|
||||
- [Added keybindings](#added-keybindings)
|
||||
- [Added script messages](#added-script-messages)
|
||||
- [MPV specific commands](#mpv-specific-commands)
|
||||
- [Key Bindings](#key-bindings)
|
||||
- [Script Messages](#script-messages)
|
||||
- [Configuration](#configuration)
|
||||
- [The python api](#the-python-api)
|
||||
- [Contributing](#contributing)
|
||||
- [Receiving Support](#receiving-support)
|
||||
- [Supporting the Project](#supporting-the-project)
|
||||
@@ -57,7 +59,7 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magi
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> This project currently scrapes allanime and animepahe and is in no way related to them nor does the project own any content servers. The site is in the public domain and can be access by any one with a browser.
|
||||
> This project currently scrapes allanime and animepahe. The site is in the public domain and can be accessed by any one with a browser.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -120,7 +122,7 @@ Requirements:
|
||||
|
||||
To build from the source, follow these steps:
|
||||
|
||||
1. Clone the repository: `git clone https://github.com/Benex254/FastAnime.git`
|
||||
1. Clone the repository: `git clone https://github.com/Benex254/FastAnime.git --depth 1`
|
||||
2. Navigate into the folder: `cd FastAnime`
|
||||
3. Then build and Install the app:
|
||||
|
||||
@@ -164,28 +166,25 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
|
||||
|
||||
**Other external dependencies that will just make your experience better:**
|
||||
|
||||
- [ffmpeg](https://www.ffmpeg.org/) is required to be in your path environment variables to properly download [hls](https://www.cloudflare.com/en-gb/learning/video/what-is-http-live-streaming/) streams.
|
||||
- [fzf](https://github.com/junegunn/fzf) 🔥 which is used as a better alternative to the ui.
|
||||
- [rofi](https://github.com/davatorium/rofi) 🔥 which is used as another alternative ui + the the desktop entry ui
|
||||
- [chafa](https://github.com/hpjansson/chafa) currently the best cross platform and cross terminal image viewer for the terminal.
|
||||
- [icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) an image viewer that only works in [kitty terminal](https://sw.kovidgoyal.net/kitty/), which is currently the best terminal in my opinion, and by far the best image renderer for the terminal thanks to kitty's terminal graphics protocol. Its terminal graphics is so op that you can [run a browser on it](https://github.com/chase/awrit?tab=readme-ov-file)!!
|
||||
- [bash](https://www.gnu.org/software/bash/) is used as the preview script language.
|
||||
- [ani-skip](https://github.com/synacktraa/ani-skip) used for skipping the opening and ending theme songs
|
||||
- [ffmpegthumbnailer](https://github.com/dirkvdb/ffmpegthumbnailer) used for local previews of downloaded anime
|
||||
- [syncplay](https://syncplay.pl/) to enable watch together.
|
||||
|
||||
## Usage
|
||||
|
||||
The app offers both a graphical interface (under development) and a robust command-line interface.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> The GUI is mostly in hiatus; use the CLI for now.
|
||||
> However, you can try it out before i decided to change my objective by checking out this [release](https://github.com/Benex254/FastAnime/tree/v0.20.0).
|
||||
> But be reassured for those who aren't terminal chads, i will still complete the GUI for the fun of it
|
||||
The project offers a featureful command-line interface and MPV interface through the use of python-mpv.
|
||||
|
||||
### The Commandline interface :fire:
|
||||
|
||||
Designed for power users who prefer efficiency over browser-based streaming and still want the experience in their terminal.
|
||||
Designed for efficiency and automation. Plus has a beautiful pseudo-TUI in some of the commands.
|
||||
|
||||
Overview of main commands:
|
||||
**Overview of main commands:**
|
||||
|
||||
- `fastanime anilist`: Powerful command for browsing and exploring anime due to AniList integration.
|
||||
- `fastanime download`: Download anime.
|
||||
@@ -193,10 +192,20 @@ Overview of main commands:
|
||||
- `fastanime downloads`: View downloaded anime and watch with MPV.
|
||||
- `fastanime config`: Quickly edit configuration settings.
|
||||
- `fastanime cache`: Quickly manage the cache fastanime uses
|
||||
- `fastanime update`: Quickly update fastanime
|
||||
- `fastanime grab`: print streams to stdout to use in non python application.
|
||||
|
||||
Configuration is directly passed into this command at run time to override your config.
|
||||
**Overview of options**
|
||||
|
||||
Available options for the fastanime command include:
|
||||
Most options are directly passed into fastanime directly and are shared by multiple subcommands.
|
||||
|
||||
Most of the options override your config file.
|
||||
|
||||
This is a convention to make the dev time faster since it reduces redundancy and also makes switching of subcommands with the same options easier to the end user.
|
||||
|
||||
In general `fastanime --<option-name>`
|
||||
|
||||
Available options for the fastanime include:
|
||||
|
||||
- `--server <server>` or `-s <server>` set the default server to auto select
|
||||
- `--continue/--no-continue` or `-c/-no-c` whether to continue from the last episode you were watching
|
||||
@@ -223,19 +232,19 @@ Available options for the fastanime command include:
|
||||
- `--log-file` allow logging to a file
|
||||
- `--rich-traceback` allow rich traceback
|
||||
- `--use-mpv-mod/--use-default-player` whether to use python-mpv
|
||||
- `--provider <allanime>` anime site of choice to scrape from
|
||||
- `--provider <allanime/animepahe>` anime site of choice to scrape from
|
||||
- `--sync-play` or `-sp` use syncplay for streaming anime so you can watch with your friends
|
||||
|
||||
Example usage of the above options
|
||||
|
||||
```bash
|
||||
# example of syncplay intergration
|
||||
fastanime --sync-play search -t <anime-title>
|
||||
fastanime --sync-play --server sharepoint search -t <anime-title>
|
||||
|
||||
# --- or ---
|
||||
|
||||
# to watch with anilist intergration
|
||||
fastanime --sync-play anilist
|
||||
fastanime --sync-play --server sharepoint anilist
|
||||
|
||||
# downloading dubbed anime
|
||||
fastanime --dub download <anime>
|
||||
@@ -289,7 +298,7 @@ fastanime --log anilist notifier
|
||||
fastanime --log-file anilist notifier
|
||||
```
|
||||
|
||||
The above commands will start a loop that checks every 2 minutes if any of the anime in your watch list that are aireing has just released a new episode.
|
||||
The above commands will start a loop that checks every 2 minutes if any of the anime in your watch list that are airing has just released a new episode.
|
||||
|
||||
The notification will consist of a cover image of the anime in none windows systems.
|
||||
|
||||
@@ -313,6 +322,7 @@ end
|
||||
|
||||
Download anime to watch later dub or sub with this one command.
|
||||
Its optimized for scripting due to fuzzy matching; basically you don't have to manually select search results.
|
||||
|
||||
So every step of the way has been and can be automated.
|
||||
Uses a list slicing syntax similar to that of python as the value for the `-r` option.
|
||||
|
||||
@@ -340,18 +350,24 @@ fastanime download -t <anime-title> -t <anime-title> -r '-5'
|
||||
|
||||
# Download specific episode range
|
||||
# be sure to observe the range Syntax
|
||||
fastanime download <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
|
||||
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
|
||||
|
||||
fastanime download <anime-title> -r '<episodes-start>:<episodes-end>'
|
||||
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>'
|
||||
|
||||
fastanime download <anime-title> -r '<episodes-start>:'
|
||||
fastanime download -t <anime-title> -r '<episodes-start>:'
|
||||
|
||||
fastanime download -t <anime-title> -r ':<episodes-end>'
|
||||
|
||||
# download specific episode
|
||||
# remember python indexing starts at 0
|
||||
fastanime download -t <anime-title> -r '<episode-1>:<episode>'
|
||||
|
||||
fastanime download <anime-title> -r ':<episodes-end>'
|
||||
```
|
||||
|
||||
#### search subcommand
|
||||
|
||||
Powerful command mainly aimed at binging anime. Since it doesn't require interaction with the interfaces.
|
||||
|
||||
Uses a list slicing syntax similar to that of python as the value of the `-r` option.
|
||||
|
||||
**Syntax:**
|
||||
@@ -378,6 +394,57 @@ fastanime search -t <anime-title> -r '<start>:'
|
||||
fastanime search -t <anime-title> -r ':<end>'
|
||||
```
|
||||
|
||||
#### grab subcommand
|
||||
|
||||
Helper command to print streams to stdout so it can be used by non-python applications.
|
||||
|
||||
The format of the printed out data is json and can be either an array or object depending on how many anime titles have been specified in the command-line or through a subprocess.
|
||||
|
||||
> [!TIP]
|
||||
> For python applications just use its python api, for even greater and easier control.
|
||||
> So just add fastanime as one of your dependencies.
|
||||
|
||||
Uses a list slicing syntax similar to that of python as the value of the `-r` option.
|
||||
|
||||
**Syntax:**
|
||||
|
||||
```bash
|
||||
# --- print anime info + episode streams ---
|
||||
|
||||
# multiple titles can be specified with the -t option
|
||||
fastanime grab -t <anime-title> -t <anime-title>
|
||||
|
||||
# -- or --
|
||||
|
||||
# print all available episodes
|
||||
fastanime grab -t <anime-title> -r ':'
|
||||
|
||||
# print the latest episode
|
||||
fastanime grab -t <anime-title> -r '-1'
|
||||
|
||||
# print a specific episode range
|
||||
# be sure to observe the range Syntax
|
||||
fastanime grab -t <anime-title> -r '<start>:<stop>'
|
||||
|
||||
fastanime grab -t <anime-title> -r '<start>:<stop>:<step>'
|
||||
|
||||
fastanime grab -t <anime-title> -r '<start>:'
|
||||
|
||||
fastanime grab -t <anime-title> -r ':<end>'
|
||||
|
||||
# --- grab options ---
|
||||
|
||||
# print search results only
|
||||
fastanime grab -t <anime-title> -r <range> --search-results-only
|
||||
|
||||
# print anime info only
|
||||
fastanime grab -t <anime-title> -r <range> --anime-info-only
|
||||
|
||||
# print episode streams only
|
||||
fastanime grab -t <anime-title> -r <range> --episode-streams-only
|
||||
|
||||
```
|
||||
|
||||
#### downloads subcommand
|
||||
|
||||
View and stream the anime you downloaded using MPV.
|
||||
@@ -478,12 +545,12 @@ fastanime completions --bash
|
||||
fastanime completions --zsh
|
||||
```
|
||||
|
||||
## MPV specific commands
|
||||
### MPV specific commands
|
||||
|
||||
The project now allows on the fly media controls directly from mpv. This means you can go to the next or previous episode without the window ever closing thus offering a seamless experience.
|
||||
This is all powered with [python-mpv]() which enables writing mpv scripts with python just like how it would be done in lua.
|
||||
|
||||
### Added keybindings
|
||||
#### Key Bindings
|
||||
|
||||
`<shift>+n` fetch the next episode
|
||||
|
||||
@@ -495,7 +562,9 @@ This is all powered with [python-mpv]() which enables writing mpv scripts with p
|
||||
|
||||
`<shit>+r` reload episode
|
||||
|
||||
### Added script messages
|
||||
#### Script Messages
|
||||
|
||||
Commands issued in the MPV console.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -510,7 +579,7 @@ script-message select-server <server-name>
|
||||
script-message select-quality <1080/720/480/360>
|
||||
```
|
||||
|
||||
## configuration
|
||||
## Configuration
|
||||
|
||||
The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on arch linux; for the other operating systems you can check by running `fastanime config --path`.
|
||||
|
||||
@@ -521,6 +590,10 @@ continue_from_history = True # Auto continue from watch history
|
||||
# which history to use [local/remote]
|
||||
preferred_history = local
|
||||
|
||||
# force mpv window
|
||||
# passed directly to mpv so values are same
|
||||
force_window = immediate
|
||||
|
||||
translation_type = sub # Preferred language for anime (options: dub, sub)
|
||||
|
||||
server = top # Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top, wixmp)
|
||||
@@ -581,6 +654,28 @@ notification_duration=2
|
||||
# Not implemented yet
|
||||
```
|
||||
|
||||
## The python api
|
||||
|
||||
The project offers a python api that can be used in other python programs.
|
||||
|
||||
```python
|
||||
from fastanime.AnimeProvider import AnimeProvider
|
||||
|
||||
# all output is typed, so will be easy to work with
|
||||
|
||||
# providers include [allanime, animepahe]
|
||||
provider = AnimeProvider(provider="allanime")
|
||||
|
||||
# to search for anime
|
||||
provider.search_for_anime()
|
||||
|
||||
# to get anime info
|
||||
provider.get_anime()
|
||||
|
||||
# to get streams of an episode
|
||||
provider.get_episode_streams()
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome your issues and feature requests. However, due to time constraints, we currently do not plan to add another provider.
|
||||
@@ -589,7 +684,7 @@ If you wish to contribute directly, please first open an issue describing your p
|
||||
|
||||
## Receiving Support
|
||||
|
||||
For inquiries, join our [Discord Server](https://discord.gg/4NUTj5Pt).
|
||||
For inquiries, join our [Discord Server](https://discord.gg/C4rhMA4mmK).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/C4rhMA4mmK">
|
||||
|
||||
@@ -35,6 +35,9 @@ class YtDLPDownloader:
|
||||
download_dir: str,
|
||||
silent: bool,
|
||||
vid_format: str = "best",
|
||||
force_unknown_ext=False,
|
||||
verbose=False,
|
||||
headers={},
|
||||
):
|
||||
"""Helper function that downloads anime given url and path details
|
||||
|
||||
@@ -50,10 +53,12 @@ class YtDLPDownloader:
|
||||
episode_title = sanitize_filename(episode_title)
|
||||
ydl_opts = {
|
||||
# Specify the output path and template
|
||||
"http_headers": headers,
|
||||
"outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s",
|
||||
"silent": silent,
|
||||
"verbose": False,
|
||||
"verbose": verbose,
|
||||
"format": vid_format,
|
||||
"compat_opts": ("allow-unsafe-ext",) if force_unknown_ext else tuple(),
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
|
||||
@@ -11,6 +11,13 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_by_episode_number(filename: str):
|
||||
import re
|
||||
|
||||
match = re.search(r"\d+", filename)
|
||||
return int(match.group()) if match else 0
|
||||
|
||||
|
||||
def anime_title_percentage_match(
|
||||
possible_user_requested_anime_title: str, anime: "AnilistBaseMediaDataSchema"
|
||||
) -> float:
|
||||
|
||||
@@ -6,7 +6,7 @@ if sys.version_info < (3, 10):
|
||||
) # noqa: F541
|
||||
|
||||
|
||||
__version__ = "v2.0.0"
|
||||
__version__ = "v2.2.4"
|
||||
|
||||
APP_NAME = "FastAnime"
|
||||
AUTHOR = "Benex254"
|
||||
|
||||
@@ -16,6 +16,7 @@ commands = {
|
||||
"cache": "cache.cache",
|
||||
"completions": "completions.completions",
|
||||
"update": "update.update",
|
||||
"grab": "grab.grab",
|
||||
}
|
||||
|
||||
|
||||
@@ -192,12 +193,12 @@ def run_cli(
|
||||
elif log_file:
|
||||
import logging
|
||||
|
||||
from ..constants import NOTIFIER_LOG_FILE_PATH
|
||||
from ..constants import LOG_FILE_PATH
|
||||
|
||||
format = "%(asctime)s%(levelname)s: %(message)s"
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
filename=NOTIFIER_LOG_FILE_PATH,
|
||||
filename=LOG_FILE_PATH,
|
||||
format=format,
|
||||
datefmt="[%d/%m/%Y@%H:%M:%S]",
|
||||
filemode="w",
|
||||
|
||||
@@ -26,7 +26,24 @@ def check_for_updates():
|
||||
|
||||
if request.status_code == 200:
|
||||
release_json = request.json()
|
||||
return (release_json["tag_name"] == __version__, release_json)
|
||||
remote_tag = list(
|
||||
map(int, release_json["tag_name"].replace("v", "").split("."))
|
||||
)
|
||||
local_tag = list(map(int, __version__.replace("v", "").split(".")))
|
||||
if (
|
||||
(remote_tag[0] > local_tag[0])
|
||||
or (remote_tag[1] > local_tag[1] and remote_tag[0] == local_tag[0])
|
||||
or (
|
||||
remote_tag[2] > local_tag[2]
|
||||
and remote_tag[0] == local_tag[0]
|
||||
and remote_tag[1] == local_tag[1]
|
||||
)
|
||||
):
|
||||
is_update = True
|
||||
else:
|
||||
is_update = False
|
||||
|
||||
return (is_update, release_json)
|
||||
else:
|
||||
print(request.text)
|
||||
return (False, {})
|
||||
|
||||
@@ -20,17 +20,35 @@ if TYPE_CHECKING:
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to download (start-end)",
|
||||
)
|
||||
@click.option(
|
||||
"--force-unknown-ext",
|
||||
"-f",
|
||||
help="This option forces yt-dlp to download extensions its not aware of",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--silent/--no-silent",
|
||||
"-q/-V",
|
||||
type=bool,
|
||||
help="Download silently (during download)",
|
||||
default=True,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Download verbosely (everywhere)")
|
||||
@click.pass_obj
|
||||
def download(
|
||||
config: "Config",
|
||||
anime_titles: list,
|
||||
episode_range,
|
||||
force_unknown_ext,
|
||||
silent,
|
||||
verbose,
|
||||
):
|
||||
from rich import print
|
||||
from rich.progress import Progress
|
||||
@@ -61,9 +79,7 @@ def download(
|
||||
print("Search results failed")
|
||||
input("Enter to retry")
|
||||
download(
|
||||
config,
|
||||
anime_title,
|
||||
episode_range,
|
||||
config, anime_title, episode_range, force_unknown_ext, silent, verbose
|
||||
)
|
||||
return
|
||||
search_results = search_results["results"]
|
||||
@@ -96,15 +112,14 @@ def download(
|
||||
print("Sth went wring anime no found")
|
||||
input("Enter to continue...")
|
||||
download(
|
||||
config,
|
||||
anime_title,
|
||||
episode_range,
|
||||
config, anime_title, episode_range, force_unknown_ext, silent, verbose
|
||||
)
|
||||
return
|
||||
|
||||
episodes = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
# where the magic happens
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
@@ -131,6 +146,7 @@ def download(
|
||||
else:
|
||||
episodes_range = sorted(episodes, key=float)
|
||||
|
||||
# lets download em
|
||||
for episode in episodes_range:
|
||||
try:
|
||||
episode = str(episode)
|
||||
@@ -155,10 +171,12 @@ def download(
|
||||
continue
|
||||
stream_link = filter_by_quality(config.quality, server["links"])
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
input("Enter to continue")
|
||||
print("[yellow bold]WARNING:[/] No streams found")
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
provider_headers = server["headers"]
|
||||
episode_title = server["episode_title"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
@@ -180,9 +198,12 @@ def download(
|
||||
config.quality, servers[server]["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
print("[yellow bold]WARNING:[/] No streams found")
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
provider_headers = servers[server]["headers"]
|
||||
|
||||
episode_title = servers[server]["episode_title"]
|
||||
print(f"[purple]Now Downloading:[/] {search_result} Episode {episode}")
|
||||
@@ -192,12 +213,15 @@ def download(
|
||||
anime["title"],
|
||||
episode_title,
|
||||
download_dir,
|
||||
True,
|
||||
silent,
|
||||
config.format,
|
||||
force_unknown_ext,
|
||||
verbose,
|
||||
headers=provider_headers,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
time.sleep(1)
|
||||
print("Continuing")
|
||||
print("Continuing...")
|
||||
print("Done Downloading")
|
||||
exit_app()
|
||||
|
||||
@@ -27,6 +27,7 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
|
||||
from ...cli.utils.mpv import run_mpv
|
||||
from ...libs.fzf import fzf
|
||||
from ...libs.rofi import Rofi
|
||||
from ...Utility.utils import sort_by_episode_number
|
||||
from ..utils.tools import exit_app
|
||||
from ..utils.utils import fuzzy_inquirer
|
||||
|
||||
@@ -39,7 +40,9 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
|
||||
if not os.path.exists(USER_VIDEOS_DIR):
|
||||
print("Downloads directory specified does not exist")
|
||||
return
|
||||
anime_downloads = sorted(os.listdir(USER_VIDEOS_DIR))
|
||||
anime_downloads = sorted(
|
||||
os.listdir(USER_VIDEOS_DIR),
|
||||
)
|
||||
anime_downloads.append("Exit")
|
||||
|
||||
def create_thumbnails(video_path, anime_title, downloads_thumbnail_cache_dir):
|
||||
@@ -76,6 +79,7 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
|
||||
|
||||
def get_previews_anime(workers=None, bg=True):
|
||||
import concurrent.futures
|
||||
import random
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
@@ -99,10 +103,16 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
|
||||
anime_path = os.path.join(USER_VIDEOS_DIR, anime_title)
|
||||
if not os.path.isdir(anime_path):
|
||||
continue
|
||||
playlist = sorted(os.listdir(anime_path))
|
||||
playlist = [
|
||||
anime
|
||||
for anime in sorted(
|
||||
os.listdir(anime_path),
|
||||
)
|
||||
if "mp4" in anime
|
||||
]
|
||||
if playlist:
|
||||
# actual link to download image from
|
||||
video_path = os.path.join(anime_path, playlist[0])
|
||||
video_path = os.path.join(anime_path, random.choice(playlist))
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
@@ -166,7 +176,9 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
|
||||
# anime_playlist_path = os.path.join(USER_VIDEOS_DIR, anime_playlist_path)
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
return
|
||||
anime_episodes = sorted(os.listdir(anime_playlist_path))
|
||||
anime_episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
@@ -223,7 +235,9 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
|
||||
print(anime_playlist_path, "is not dir")
|
||||
exit_app(1)
|
||||
return
|
||||
episodes = sorted(os.listdir(anime_playlist_path))
|
||||
episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
downloaded_episodes = [*episodes, "Back"]
|
||||
if config.use_fzf:
|
||||
if not config.preview:
|
||||
@@ -249,7 +263,12 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
|
||||
stream_anime()
|
||||
return
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
run_mpv(episode_path)
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(episode_path)
|
||||
else:
|
||||
run_mpv(episode_path)
|
||||
stream_episode(anime_playlist_path)
|
||||
|
||||
def stream_anime():
|
||||
@@ -282,7 +301,12 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
|
||||
playlist,
|
||||
)
|
||||
else:
|
||||
run_mpv(playlist)
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(playlist)
|
||||
else:
|
||||
run_mpv(playlist)
|
||||
stream_anime()
|
||||
|
||||
stream_anime()
|
||||
|
||||
162
fastanime/cli/commands/grab.py
Normal file
162
fastanime/cli/commands/grab.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import anime_titles_shell_complete
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Helper command to get streams for anime to use externally in a non-python application",
|
||||
short_help="Print anime streams to standard out",
|
||||
)
|
||||
@click.option(
|
||||
"--anime-titles",
|
||||
"--anime_title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to download (start-end)",
|
||||
)
|
||||
@click.option(
|
||||
"--search-results-only",
|
||||
"-s",
|
||||
help="print only the search results to stdout",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--anime-info-only", "-i", help="print only selected anime title info", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--episode-streams-only",
|
||||
"-e",
|
||||
help="print only selected anime episodes streams of given range",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.pass_obj
|
||||
def grab(
|
||||
config: "Config",
|
||||
anime_titles: tuple,
|
||||
episode_range,
|
||||
search_results_only,
|
||||
anime_info_only,
|
||||
episode_streams_only,
|
||||
):
|
||||
import json
|
||||
from logging import getLogger
|
||||
from sys import exit
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
|
||||
grabbed_animes = []
|
||||
for anime_title in anime_titles:
|
||||
# ---- search for anime ----
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, translation_type=config.translation_type
|
||||
)
|
||||
if not search_results:
|
||||
exit(1)
|
||||
if search_results_only:
|
||||
# grab only search results skipping all lines after this
|
||||
grabbed_animes.append(search_results)
|
||||
continue
|
||||
|
||||
search_results = search_results["results"]
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result for search_result in search_results
|
||||
}
|
||||
|
||||
search_result = max(
|
||||
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
|
||||
)
|
||||
|
||||
# ---- fetch anime ----
|
||||
anime = anime_provider.get_anime(search_results_[search_result]["id"])
|
||||
if not anime:
|
||||
exit(1)
|
||||
episodes = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
if anime_info_only:
|
||||
# grab only the anime data skipping all lines after this
|
||||
grabbed_animes.append(anime)
|
||||
continue
|
||||
|
||||
# where the magic happens
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
|
||||
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
|
||||
else:
|
||||
episodes_range = sorted(episodes, key=float)
|
||||
|
||||
if not episode_streams_only:
|
||||
grabbed_anime = dict(anime)
|
||||
grabbed_anime["requested_episodes"] = episodes_range
|
||||
grabbed_anime["translation_type"] = config.translation_type
|
||||
grabbed_anime["episodes_streams"] = {}
|
||||
else:
|
||||
grabbed_anime = {}
|
||||
|
||||
# lets download em
|
||||
for episode in episodes_range:
|
||||
try:
|
||||
if episode not in episodes:
|
||||
continue
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime, episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
continue
|
||||
episode_streams = {server["server"]: server for server in streams}
|
||||
|
||||
if episode_streams_only:
|
||||
grabbed_anime[episode] = episode_streams
|
||||
else:
|
||||
grabbed_anime["episodes_streams"][
|
||||
episode
|
||||
] = episode_streams # pyright:ignore
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
# grab the full data for single title and appen to final result or episode streams
|
||||
grabbed_animes.append(grabbed_anime)
|
||||
|
||||
# print out the final result either {} or [] depending if more than one title os requested
|
||||
if len(grabbed_animes) == 1:
|
||||
print(json.dumps(grabbed_animes[0]))
|
||||
else:
|
||||
print(json.dumps(grabbed_animes))
|
||||
@@ -15,6 +15,7 @@ from ..completion_functions import anime_titles_shell_complete
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
@@ -176,6 +177,7 @@ def search(config: Config, anime_titles: str, episode_range: str):
|
||||
stream_anime()
|
||||
return
|
||||
link = stream_link["link"]
|
||||
stream_headers = server["headers"]
|
||||
episode_title = server["episode_title"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
@@ -204,16 +206,17 @@ def search(config: Config, anime_titles: str, episode_range: str):
|
||||
stream_anime()
|
||||
return
|
||||
link = stream_link["link"]
|
||||
stream_headers = servers[server]["headers"]
|
||||
episode_title = servers[server]["episode_title"]
|
||||
print(f"[purple]Now Playing:[/] {search_result} Episode {episode}")
|
||||
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(link, episode_title)
|
||||
SyncPlayer(link, episode_title, headers=stream_headers)
|
||||
else:
|
||||
run_mpv(link, episode_title)
|
||||
except Exception as e:
|
||||
run_mpv(link, episode_title, headers=stream_headers)
|
||||
except IndexError as e:
|
||||
print(e)
|
||||
input("Enter to continue")
|
||||
stream_anime()
|
||||
|
||||
@@ -9,6 +9,7 @@ def update(
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from ... import __version__
|
||||
from ..app_updater import check_for_updates, update_app
|
||||
|
||||
def _print_release(release_data):
|
||||
@@ -26,11 +27,11 @@ def update(
|
||||
is_update, github_release_data = check_for_updates()
|
||||
if is_update:
|
||||
print(
|
||||
"You are running an older version of fastanime please update to get the latest features"
|
||||
f"You are running an older version ({__version__}) of fastanime please update to get the latest features"
|
||||
)
|
||||
_print_release(github_release_data)
|
||||
else:
|
||||
print("You are running the latest version of fastanime")
|
||||
print(f"You are running the latest version ({__version__}) of fastanime")
|
||||
_print_release(github_release_data)
|
||||
else:
|
||||
success, github_release_data = update_app()
|
||||
|
||||
@@ -296,6 +296,10 @@ error = {self.error}
|
||||
# adding more options to it
|
||||
use_mpv_mod = {self.use_mpv_mod}
|
||||
|
||||
# force mpv window
|
||||
# passed directly to mpv so values are same
|
||||
force_window = immediate
|
||||
|
||||
# the format of downloaded anime and trailer
|
||||
# based on yt-dlp format and passed directly to it
|
||||
# learn more by looking it up on their site
|
||||
|
||||
@@ -117,7 +117,9 @@ def media_player_controls(
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
stop_time, total_time = SyncPlayer(
|
||||
current_episode_stream_link, selected_server["episode_title"]
|
||||
current_episode_stream_link,
|
||||
selected_server["episode_title"],
|
||||
headers=selected_server["headers"],
|
||||
)
|
||||
elif config.use_mpv_mod:
|
||||
from ..utils.player import player
|
||||
@@ -128,6 +130,7 @@ def media_player_controls(
|
||||
fastanime_runtime_state,
|
||||
config,
|
||||
selected_server["episode_title"],
|
||||
headers=selected_server["headers"],
|
||||
)
|
||||
|
||||
# TODO: implement custom aniskip
|
||||
@@ -148,6 +151,7 @@ def media_player_controls(
|
||||
selected_server["episode_title"],
|
||||
start_time=start_time,
|
||||
custom_args=custom_args,
|
||||
headers=selected_server["headers"],
|
||||
)
|
||||
|
||||
# either update the watch history to the next episode or current depending on progress
|
||||
@@ -509,7 +513,9 @@ def provider_anime_episode_servers_menu(
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
stop_time, total_time = SyncPlayer(
|
||||
current_stream_link, selected_server["episode_title"]
|
||||
current_stream_link,
|
||||
selected_server["episode_title"],
|
||||
headers=selected_server["headers"],
|
||||
)
|
||||
elif config.use_mpv_mod:
|
||||
from ..utils.player import player
|
||||
@@ -520,6 +526,7 @@ def provider_anime_episode_servers_menu(
|
||||
fastanime_runtime_state,
|
||||
config,
|
||||
selected_server["episode_title"],
|
||||
headers=selected_server["headers"],
|
||||
)
|
||||
|
||||
# TODO: implement custom aniskip intergration
|
||||
@@ -543,6 +550,7 @@ def provider_anime_episode_servers_menu(
|
||||
selected_server["episode_title"],
|
||||
start_time=start_time,
|
||||
custom_args=custom_args,
|
||||
headers=selected_server["headers"],
|
||||
)
|
||||
print("Finished at: ", stop_time)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from fastanime.constants import S_PLATFORM
|
||||
|
||||
|
||||
def stream_video(MPV, url, mpv_args, custom_args):
|
||||
process = subprocess.Popen(
|
||||
@@ -52,6 +54,7 @@ def run_mpv(
|
||||
start_time: str = "0",
|
||||
ytdl_format="",
|
||||
custom_args=[],
|
||||
headers={},
|
||||
):
|
||||
# Determine if mpv is available
|
||||
MPV = shutil.which("mpv")
|
||||
@@ -61,7 +64,7 @@ def run_mpv(
|
||||
# Regex to check if the link is a YouTube URL
|
||||
youtube_regex = r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+"
|
||||
|
||||
if not MPV:
|
||||
if not MPV and not S_PLATFORM == "win32":
|
||||
# Determine if the link is a YouTube URL
|
||||
if re.match(youtube_regex, link):
|
||||
# Android specific commands to launch mpv with a YouTube URL
|
||||
@@ -100,6 +103,11 @@ def run_mpv(
|
||||
else:
|
||||
# General mpv command with custom arguments
|
||||
mpv_args = []
|
||||
if headers:
|
||||
mpv_headers = "--http-header-fields="
|
||||
for header_name, header_value in headers.items():
|
||||
mpv_headers += f"{header_name}:{header_value},"
|
||||
mpv_args.append(mpv_headers)
|
||||
if start_time != "0":
|
||||
mpv_args.append(f"--start={start_time}")
|
||||
if title:
|
||||
|
||||
@@ -151,6 +151,7 @@ class MpvPlayer(object):
|
||||
fastanime_runtime_state,
|
||||
config: "Config",
|
||||
title,
|
||||
headers={},
|
||||
):
|
||||
self.anime_provider = anime_provider
|
||||
self.fastanime_runtime_state = fastanime_runtime_state
|
||||
@@ -174,6 +175,11 @@ class MpvPlayer(object):
|
||||
# mpv_player.cache = "yes"
|
||||
# mpv_player.cache_pause = "no"
|
||||
mpv_player.title = title
|
||||
mpv_headers = ""
|
||||
if headers:
|
||||
for header_name, header_value in headers.items():
|
||||
mpv_headers += f"{header_name}:{header_value},"
|
||||
mpv_player.http_header_fields = mpv_headers
|
||||
|
||||
mpv_player.play(stream_link)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import subprocess
|
||||
from .tools import exit_app
|
||||
|
||||
|
||||
def SyncPlayer(url: str, anime_title, *args):
|
||||
def SyncPlayer(url: str, anime_title=None, headers={}, *args):
|
||||
# TODO: handle m3u8 multi quality streams
|
||||
#
|
||||
# check for SyncPlay
|
||||
@@ -14,8 +14,29 @@ def SyncPlayer(url: str, anime_title, *args):
|
||||
exit_app(1)
|
||||
return "0", "0"
|
||||
# start SyncPlayer
|
||||
subprocess.run(
|
||||
[SYNCPLAY_EXECUTABLE, url, "--", f"--force-media-title={anime_title}"]
|
||||
)
|
||||
mpv_args = []
|
||||
if headers:
|
||||
mpv_headers = "--http-header-fields="
|
||||
for header_name, header_value in headers.items():
|
||||
mpv_headers += f"{header_name}:{header_value},"
|
||||
mpv_args.append(mpv_headers)
|
||||
if not anime_title:
|
||||
subprocess.run(
|
||||
[
|
||||
SYNCPLAY_EXECUTABLE,
|
||||
url,
|
||||
]
|
||||
)
|
||||
else:
|
||||
subprocess.run(
|
||||
[
|
||||
SYNCPLAY_EXECUTABLE,
|
||||
url,
|
||||
"--",
|
||||
f"--force-media-title={anime_title}",
|
||||
*mpv_args,
|
||||
]
|
||||
)
|
||||
|
||||
# for compatability
|
||||
return "0", "0"
|
||||
|
||||
@@ -32,8 +32,8 @@ def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]", default
|
||||
for stream_link in stream_links:
|
||||
q = float(quality)
|
||||
Q = float(stream_link["quality"])
|
||||
# some providers have inaccurate eg qualities 718 instead of 720
|
||||
if Q < q + 80 and Q > q - 80:
|
||||
# some providers have inaccurate/weird/non-standard eg qualities 718 instead of 720
|
||||
if Q <= q + 80 and Q >= q - 80:
|
||||
return stream_link
|
||||
else:
|
||||
if stream_links and default:
|
||||
|
||||
@@ -76,7 +76,7 @@ Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
|
||||
# useful paths
|
||||
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
|
||||
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
|
||||
NOTIFIER_LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "notifier.log")
|
||||
LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log")
|
||||
|
||||
|
||||
USER_NAME = os.environ.get("USERNAME", "Anime fun")
|
||||
|
||||
@@ -9,4 +9,5 @@ SERVERS_AVAILABLE = [
|
||||
"weTransfer",
|
||||
"wixmp",
|
||||
"kwik",
|
||||
"Yt",
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING
|
||||
from requests.exceptions import Timeout
|
||||
|
||||
from ...anime_provider.base_provider import AnimeProvider
|
||||
from ..utils import decode_hex_string, give_random_quality
|
||||
from ..utils import give_random_quality, one_digit_symmetric_xor
|
||||
from .constants import (
|
||||
ALLANIME_API_ENDPOINT,
|
||||
ALLANIME_BASE,
|
||||
@@ -205,23 +205,45 @@ class AllAnimeAPI(AnimeProvider):
|
||||
# filter the working streams no need to get all since the others are mostly hsl
|
||||
# TODO: should i just get all the servers and handle the hsl??
|
||||
if embed.get("sourceName", "") not in (
|
||||
"Sak",
|
||||
"Kir",
|
||||
"S-mp4",
|
||||
"Luf-mp4",
|
||||
"Default",
|
||||
# priorities based on death note
|
||||
"Sak", # 7
|
||||
"S-mp4", # 7.9
|
||||
"Luf-mp4", # 7.7
|
||||
"Default", # 8.5
|
||||
"Yt-mp4", # 7.9
|
||||
"Kir", # NA
|
||||
# "Vid-mp4" # 4
|
||||
# "Ok", # 3.5
|
||||
# "Ss-Hls", # 5.5
|
||||
# "Mp4", # 4
|
||||
):
|
||||
continue
|
||||
url = embed.get("sourceUrl")
|
||||
|
||||
#
|
||||
if not url:
|
||||
continue
|
||||
if url.startswith("--"):
|
||||
url = url[2:]
|
||||
url = one_digit_symmetric_xor(56, url)
|
||||
|
||||
if "tools.fast4speed.rsvp" in url:
|
||||
yield {
|
||||
"server": "Yt",
|
||||
"episode_title": f'{anime["title"]}; Episode {episode_number}',
|
||||
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
|
||||
"links": [
|
||||
{
|
||||
"link": url,
|
||||
"quality": "1080",
|
||||
}
|
||||
],
|
||||
} # pyright:ignore
|
||||
continue
|
||||
|
||||
# get the stream url for an episode of the defined source names
|
||||
parsed_url = decode_hex_string(url)
|
||||
embed_url = f"https://{ALLANIME_BASE}{parsed_url.replace('clock', 'clock.json')}"
|
||||
embed_url = (
|
||||
f"https://{ALLANIME_BASE}{url.replace('clock', 'clock.json')}"
|
||||
)
|
||||
resp = self.session.get(
|
||||
embed_url,
|
||||
headers={
|
||||
@@ -230,12 +252,14 @@ class AllAnimeAPI(AnimeProvider):
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
match embed["sourceName"]:
|
||||
case "Luf-mp4":
|
||||
logger.debug("allanime:Found streams from gogoanime")
|
||||
yield {
|
||||
"server": "gogoanime",
|
||||
"headers": {},
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
@@ -246,6 +270,7 @@ class AllAnimeAPI(AnimeProvider):
|
||||
logger.debug("allanime:Found streams from wetransfer")
|
||||
yield {
|
||||
"server": "wetransfer",
|
||||
"headers": {},
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
@@ -256,6 +281,7 @@ class AllAnimeAPI(AnimeProvider):
|
||||
logger.debug("allanime:Found streams from sharepoint")
|
||||
yield {
|
||||
"server": "sharepoint",
|
||||
"headers": {},
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
@@ -266,6 +292,7 @@ class AllAnimeAPI(AnimeProvider):
|
||||
logger.debug("allanime:Found streams from dropbox")
|
||||
yield {
|
||||
"server": "dropbox",
|
||||
"headers": {},
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
@@ -276,20 +303,22 @@ class AllAnimeAPI(AnimeProvider):
|
||||
logger.debug("allanime:Found streams from wixmp")
|
||||
yield {
|
||||
"server": "wixmp",
|
||||
"headers": {},
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
} # pyright:ignore
|
||||
|
||||
except Timeout:
|
||||
logger.error(
|
||||
"Timeout has been exceeded this could mean allanime is down or you have lost internet connection"
|
||||
)
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"FA(Allanime): {e}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"FA(Allanime): {e}")
|
||||
return []
|
||||
@@ -301,7 +330,7 @@ if __name__ == "__main__":
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from InquirerPy import inquirer, validator
|
||||
from InquirerPy import inquirer, validator # pyright:ignore
|
||||
|
||||
anime = input("Enter the anime name: ")
|
||||
translation = input("Enter the translation type: ")
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from yt_dlp.utils import (
|
||||
extract_attributes,
|
||||
get_element_by_id,
|
||||
get_element_text_and_html_by_tag,
|
||||
get_elements_html_by_class,
|
||||
)
|
||||
|
||||
@@ -20,6 +17,7 @@ from .constants import (
|
||||
REQUEST_HEADERS,
|
||||
SERVER_HEADERS,
|
||||
)
|
||||
from .utils import process_animepahe_embed_page
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..types import Anime
|
||||
@@ -27,6 +25,8 @@ if TYPE_CHECKING:
|
||||
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KWIK_RE = re.compile(r"Player\|(.+?)'")
|
||||
|
||||
|
||||
# TODO: hack this to completion
|
||||
class AnimePaheApi(AnimeProvider):
|
||||
@@ -153,101 +153,79 @@ class AnimePaheApi(AnimeProvider):
|
||||
def get_episode_streams(
|
||||
self, anime: "Anime", episode_number: str, translation_type, *args
|
||||
):
|
||||
# extract episode details from memory
|
||||
episode = [
|
||||
episode
|
||||
for episode in self.anime["data"]
|
||||
if float(episode["episode"]) == float(episode_number)
|
||||
]
|
||||
try:
|
||||
# extract episode details from memory
|
||||
episode = [
|
||||
episode
|
||||
for episode in self.anime["data"]
|
||||
if float(episode["episode"]) == float(episode_number)
|
||||
]
|
||||
|
||||
if not episode:
|
||||
logger.error(f"AnimePahe(streams): episode {episode_number} doesn't exist")
|
||||
return []
|
||||
episode = episode[0]
|
||||
|
||||
anime_id = anime["id"]
|
||||
# fetch the episode page
|
||||
url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
|
||||
response = self.session.get(url, headers=REQUEST_HEADERS)
|
||||
# get the element containing links to juicy streams
|
||||
c = get_element_by_id("resolutionMenu", response.text)
|
||||
resolutionMenuItems = get_elements_html_by_class("dropdown-item", c)
|
||||
# convert the elements containing embed links to a neat dict containing:
|
||||
# data-src
|
||||
# data-audio
|
||||
# data-resolution
|
||||
res_dicts = [extract_attributes(item) for item in resolutionMenuItems]
|
||||
|
||||
# get the episode title
|
||||
episode_title = (
|
||||
episode["title"] or f"{anime['title']}; Episode {episode['episode']}"
|
||||
)
|
||||
# get all links
|
||||
streams = {"server": "kwik", "links": [], "episode_title": episode_title}
|
||||
for res_dict in res_dicts:
|
||||
# get embed url
|
||||
embed_url = res_dict["data-src"]
|
||||
data_audio = "dub" if res_dict["data-audio"] == "eng" else "sub"
|
||||
# filter streams by translation_type
|
||||
if data_audio != translation_type:
|
||||
continue
|
||||
|
||||
if not embed_url:
|
||||
logger.warn(
|
||||
"AnimePahe: embed url not found please report to the developers"
|
||||
if not episode:
|
||||
logger.error(
|
||||
f"AnimePahe(streams): episode {episode_number} doesn't exist"
|
||||
)
|
||||
return []
|
||||
# get embed page
|
||||
embed_response = self.session.get(embed_url, headers=SERVER_HEADERS)
|
||||
embed = embed_response.text
|
||||
# search for the encoded js
|
||||
encoded_js = None
|
||||
for _ in range(7):
|
||||
content, html = get_element_text_and_html_by_tag("script", embed)
|
||||
if not content:
|
||||
embed = embed.replace(html, "")
|
||||
episode = episode[0]
|
||||
|
||||
anime_id = anime["id"]
|
||||
# fetch the episode page
|
||||
url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
|
||||
response = self.session.get(url, headers=REQUEST_HEADERS)
|
||||
# get the element containing links to juicy streams
|
||||
c = get_element_by_id("resolutionMenu", response.text)
|
||||
resolutionMenuItems = get_elements_html_by_class("dropdown-item", c)
|
||||
# convert the elements containing embed links to a neat dict containing:
|
||||
# data-src
|
||||
# data-audio
|
||||
# data-resolution
|
||||
res_dicts = [extract_attributes(item) for item in resolutionMenuItems]
|
||||
|
||||
# get the episode title
|
||||
episode_title = (
|
||||
f"{episode["title"] or anime['title']}; Episode {episode['episode']}"
|
||||
)
|
||||
# get all links
|
||||
streams = {
|
||||
"server": "kwik",
|
||||
"links": [],
|
||||
"episode_title": episode_title,
|
||||
"headers": {},
|
||||
}
|
||||
for res_dict in res_dicts:
|
||||
# get embed url
|
||||
embed_url = res_dict["data-src"]
|
||||
data_audio = "dub" if res_dict["data-audio"] == "eng" else "sub"
|
||||
# filter streams by translation_type
|
||||
if data_audio != translation_type:
|
||||
continue
|
||||
encoded_js = content
|
||||
break
|
||||
if not encoded_js:
|
||||
logger.warn(
|
||||
"AnimePahe: Encoded js not found please report to the developers"
|
||||
|
||||
if not embed_url:
|
||||
logger.warn(
|
||||
"AnimePahe: embed url not found please report to the developers"
|
||||
)
|
||||
return []
|
||||
# get embed page
|
||||
embed_response = self.session.get(embed_url, headers=SERVER_HEADERS)
|
||||
embed_page = embed_response.text
|
||||
|
||||
decoded_js = process_animepahe_embed_page(embed_page)
|
||||
if not decoded_js:
|
||||
logger.error("Animepahe: failed to decode embed page")
|
||||
return
|
||||
juicy_stream = JUICY_STREAM_REGEX.search(decoded_js)
|
||||
if not juicy_stream:
|
||||
logger.error("Animepahe: failed to find juicy stream")
|
||||
return
|
||||
juicy_stream = juicy_stream.group(1)
|
||||
# add the link
|
||||
streams["links"].append(
|
||||
{
|
||||
"quality": res_dict["data-resolution"],
|
||||
"translation_type": data_audio,
|
||||
"link": juicy_stream,
|
||||
}
|
||||
)
|
||||
return []
|
||||
# execute the encoded js with node for now or maybe forever in odrder to get a more workable info
|
||||
NODE = shutil.which("node")
|
||||
if not NODE:
|
||||
logger.warn(
|
||||
"AnimePahe: animepahe currently requires node js to extract them juicy streams"
|
||||
)
|
||||
return []
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", encoded_js],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# decoded js
|
||||
evaluted_js = result.stderr
|
||||
if not evaluted_js:
|
||||
logger.warn(
|
||||
"AnimePahe: could not decode encoded js using node please report to developers"
|
||||
)
|
||||
return []
|
||||
# get that juicy stream
|
||||
match = JUICY_STREAM_REGEX.search(evaluted_js)
|
||||
if not match:
|
||||
logger.warn(
|
||||
"AnimePahe: could not find the juicy stream please report to developers"
|
||||
)
|
||||
return []
|
||||
# get the actual hls stream link
|
||||
juicy_stream = match.group(1)
|
||||
# add the link
|
||||
streams["links"].append(
|
||||
{
|
||||
"quality": res_dict["data-resolution"],
|
||||
"translation_type": data_audio,
|
||||
"link": juicy_stream,
|
||||
}
|
||||
)
|
||||
yield streams
|
||||
yield streams
|
||||
except Exception as e:
|
||||
logger.error(f"Animepahe: {e}")
|
||||
|
||||
81
fastanime/libs/anime_provider/animepahe/utils.py
Normal file
81
fastanime/libs/anime_provider/animepahe/utils.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# from ..utils import int2base
|
||||
import re
|
||||
|
||||
from yt_dlp.utils import encode_base_n, get_element_text_and_html_by_tag
|
||||
|
||||
|
||||
def animepahe_key_creator(c: int, a: int):
|
||||
if c < a:
|
||||
val_a = ""
|
||||
else:
|
||||
val_a = animepahe_key_creator(int(c / a), a)
|
||||
c = c % a
|
||||
if c > 35:
|
||||
val_b = chr(c + 29)
|
||||
else:
|
||||
val_b = encode_base_n(c, 36)
|
||||
return val_a + val_b
|
||||
|
||||
|
||||
def animepahe_embed_decoder(
|
||||
encoded_js_p: str,
|
||||
base_a: int,
|
||||
no_of_keys_c: int,
|
||||
key_values_k: list,
|
||||
decode_mapper_d: dict = {},
|
||||
):
|
||||
for i in range(no_of_keys_c):
|
||||
key = animepahe_key_creator(i, base_a)
|
||||
val = key_values_k[i] or key
|
||||
decode_mapper_d[key] = val
|
||||
return re.sub(
|
||||
r"\b\w+\b", lambda match: decode_mapper_d[match.group(0)], encoded_js_p
|
||||
)
|
||||
|
||||
|
||||
PARAMETERS_REGEX = re.compile(r"eval\(function\(p,a,c,k,e,d\)\{.*\}\((.*?)\)\)$")
|
||||
ENCODE_JS_REGEX = re.compile(r"'(.*?);',(\d+),(\d+),'(.*)'\.split")
|
||||
|
||||
|
||||
def process_animepahe_embed_page(embed_page: str):
|
||||
encoded_js_string = ""
|
||||
embed_page_content = embed_page
|
||||
for _ in range(8):
|
||||
text, html = get_element_text_and_html_by_tag("script", embed_page_content)
|
||||
if not text:
|
||||
embed_page_content = re.sub(html, "", embed_page_content)
|
||||
continue
|
||||
encoded_js_string = text.strip()
|
||||
break
|
||||
if not encoded_js_string:
|
||||
return
|
||||
obsfucated_js_parameter_match = PARAMETERS_REGEX.search(encoded_js_string)
|
||||
if not obsfucated_js_parameter_match:
|
||||
return
|
||||
parameter_string = obsfucated_js_parameter_match.group(1)
|
||||
encoded_js_parameter_string = ENCODE_JS_REGEX.search(parameter_string)
|
||||
if not encoded_js_parameter_string:
|
||||
return
|
||||
p: str = encoded_js_parameter_string.group(1)
|
||||
a: int = int(encoded_js_parameter_string.group(2))
|
||||
c: int = int(encoded_js_parameter_string.group(3))
|
||||
k: list = encoded_js_parameter_string.group(4).split("|")
|
||||
return animepahe_embed_decoder(p, a, c, k).replace("\\", "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
data = """<script>eval(function(p,a,c,k,e,d){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--){d[e(c)]=k[c]||e(c)}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('f $7={H:a(2){4 B(9.7.h(y z("(?:(?:^|.*;)\\\\s*"+d(2).h(/[\\-\\.\\+\\*]/g,"\\\\$&")+"\\\\s*\\\\=\\\\s*([^;]*).*$)|^.*$"),"$1"))||G},E:a(2,q,3,6,5,t){k(!2||/^(?:8|r\\-v|o|m|p)$/i.D(2)){4 w}f b="";k(3){F(3.J){j K:b=3===P?"; 8=O, I N Q M:u:u A":"; r-v="+3;n;j L:b="; 8="+3;n;j S:b="; 8="+3.Z();n}}9.7=d(2)+"="+d(q)+b+(5?"; m="+5:"")+(6?"; o="+6:"")+(t?"; p":"");4 x},Y:a(2,6,5){k(!2||!11.C(2)){4 w}9.7=d(2)+"=; 8=12, R 10 W l:l:l A"+(5?"; m="+5:"")+(6?"; o="+6:"");4 x},C:a(2){4(y z("(?:^|;\\\\s*)"+d(2).h(/[\\-\\.\\+\\*]/g,"\\\\$&")+"\\\\s*\\\\=")).D(9.7)},X:a(){f c=9.7.h(/((?:^|\\s*;)[^\\=]+)(?=;|$)|^\\s*|\\s*(?:\\=[^;]*)?(?:\\1|$)/g,"").T(/\\s*(?:\\=[^;]*)?;\\s*/);U(f e=0;e<c.V;e++){c[e]=B(c[e])}4 c}};',62,65,'||sKey|vEnd|return|sDomain|sPath|cookie|expires|document|function|sExpires|aKeys|encodeURIComponent|nIdx|var||replace||case|if|00|domain|break|path|secure|sValue|max||bSecure|59|age|false|true|new|RegExp|GMT|decodeURIComponent|hasItem|test|setItem|switch|null|getItem|31|constructor|Number|String|23|Dec|Fri|Infinity|9999|01|Date|split|for|length|1970|keys|removeItem|toUTCString|Jan|this|Thu'.split('|'),0,{}));eval(function(p,a,c,k,e,d){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--){d[e(c)]=k[c]||e(c)}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('h o=\'1D://1C-E.1B.1A.1z/1y/E/1x/1w/1v.1u\';h d=s.r(\'d\');h 0=B 1t(d,{\'1s\':{\'1r\':i},\'1q\':\'16:9\',\'D\':1,\'1p\':5,\'1o\':{\'1n\':\'1m\'},1l:[\'7-1k\',\'7\',\'1j\',\'1i-1h\',\'1g\',\'1f-1e\',\'1d\',\'D\',\'1c\',\'1b\',\'1a\',\'19\',\'C\',\'18\'],\'C\':{\'17\':i}});8(!A.15()){d.14=o}x{j z={13:12,11:10,Z:Y,X:i,W:i};h c=B A(z);c.V(o);c.U(d);g.c=c}0.3("T",6=>{g.S.R.Q("P")});0.O=1;k v(b,n,m){8(b.y){b.y(n,m,N)}x 8(b.w){b.w(\'3\'+n,m)}}j 4=k(l){g.M.L(l,\'*\')};v(g,\'l\',k(e){j a=e.a;8(a===\'7\')0.7();8(a===\'f\')0.f();8(a===\'u\')0.u()});0.3(\'t\',6=>{4(\'t\')});0.3(\'7\',6=>{4(\'7\')});0.3(\'f\',6=>{4(\'f\')});0.3(\'K\',6=>{4(0.q);s.r(\'.J-I\').H=G(0.q.F(2))});0.3(\'p\',6=>{4(\'p\')});',62,102,'player|||on|sendMessage||event|play|if||data|element|hls|video||pause|window|const|true|var|function|message|eventHandler|eventName|source|ended|currentTime|querySelector|document|ready|stop|bindEvent|attachEvent|else|addEventListener|config|Hls|new|fullscreen|volume|01|toFixed|String|innerHTML|timestamp|ss|timeupdate|postMessage|parent|false|speed|landscape|lock|orientation|screen|enterfullscreen|attachMedia|loadSource|lowLatencyMode|enableWorker|Infinity|backBufferLength|600|maxMaxBufferLength|180|maxBufferLength|src|isSupported||iosNative|capture|airplay|pip|settings|captions|mute|time|current|progress|forward|fast|rewind|large|controls|kwik|key|storage|seekTime|ratio|global|keyboard|Plyr|m3u8|uwu|b92a392054c041a3f9c6eecabeb0e127183f44e547828447b10bca8d77523e6f|03|stream|org|nextcdn|files|eu|https'.split('|'),0,{}))"""
|
||||
a = 62
|
||||
c = 102
|
||||
k = "player|||on|sendMessage||event|play|if||data|element|hls|video||pause|window|const|true|var|function|message|eventHandler|eventName|source|ended|currentTime|querySelector|document|ready|stop|bindEvent|attachEvent|else|addEventListener|config|Hls|new|fullscreen|volume|toFixed|String|innerHTML|timestamp|ss|timeupdate|postMessage|parent|false|speed|landscape|lock|orientation|screen|enterfullscreen|attachMedia|loadSource|lowLatencyMode|enableWorker|Infinity|backBufferLength|600|maxMaxBufferLength||180|maxBufferLength|src|isSupported||iosNative|capture|airplay|pip|settings|captions|mute|time|current|progress|forward|fast|rewind|large|controls|kwik|key|storage|seekTime|ratio|global|keyboard|Plyr|m3u8|uwu|cda74eaebce25a12f5e548f7c220bb5dc245700b0280bdb45ff98b2fe4803d2b|06|stream|org|nextcdn|files|eu|https".split(
|
||||
"|"
|
||||
)
|
||||
|
||||
p = "h o='1D://1C-11.1B.1A.1z/1y/11/1x/1w/1v.1u';h d=s.r('d');h 0=B 1t(d,{'1s':{'1r':i},'1q':'16:9','D':1,'1p':5,'1o':{'1n':'1m'},1l:['7-1k','7','1j','1i-1h','1g','1f-1e','1d','D','1c','1b','1a','19','C','18'],'C':{'17':i}});8(!A.15()){d.14=o}x{j z={13:12,10:Z,Y:X,W:i,V:i};h c=B A(z);c.U(o);c.T(d);g.c=c}0.3(\"S\",6=>{g.R.Q.P(\"O\")});0.N=1;k v(b,n,m){8(b.y){b.y(n,m,M)}x 8(b.w){b.w('3'+n,m)}}j 4=k(l){g.L.K(l,'*')};v(g,'l',k(e){j a=e.a;8(a==='7')0.7();8(a==='f')0.f();8(a==='u')0.u()});0.3('t',6=>{4('t')});0.3('7',6=>{4('7')});0.3('f',6=>{4('f')});0.3('J',6=>{4(0.q);s.r('.I-H').G=F(0.q.E(2))});0.3('p',6=>{4('p')});"
|
||||
result = animepahe_embed_decoder(
|
||||
p,
|
||||
a,
|
||||
c,
|
||||
k,
|
||||
)
|
||||
print(result) # Output: j player = B A();
|
||||
@@ -60,12 +60,12 @@ class EpisodeStream(TypedDict):
|
||||
hls: bool | None
|
||||
mp4: bool | None
|
||||
priority: int | None
|
||||
headers: dict | None
|
||||
quality: Literal["360", "720", "1080", "unknown"]
|
||||
translation_type: Literal["dub", "sub"]
|
||||
|
||||
|
||||
class Server(TypedDict):
|
||||
headers: dict
|
||||
server: str
|
||||
episode_title: str
|
||||
links: list[EpisodeStream]
|
||||
|
||||
@@ -44,6 +44,14 @@ def give_random_quality(links: list[dict]):
|
||||
]
|
||||
|
||||
|
||||
def one_digit_symmetric_xor(password: int, target: str):
|
||||
def genexp():
|
||||
for segment in bytearray.fromhex(target):
|
||||
yield segment ^ password
|
||||
|
||||
return bytes(genexp()).decode("utf-8")
|
||||
|
||||
|
||||
def decode_hex_string(hex_string):
|
||||
"""some of the sources encrypt the urls into hex codes this function decrypts the urls
|
||||
|
||||
|
||||
12
poetry.lock
generated
12
poetry.lock
generated
@@ -845,13 +845,13 @@ testing = ["covdefaults (>=2.3)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytes
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.375"
|
||||
version = "1.1.376"
|
||||
description = "Command line wrapper for pyright"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "pyright-1.1.375-py3-none-any.whl", hash = "sha256:4c5e27eddeaee8b41cc3120736a1dda6ae120edf8523bb2446b6073a52f286e3"},
|
||||
{file = "pyright-1.1.375.tar.gz", hash = "sha256:7765557b0d6782b2fadabff455da2014476404c9e9214f49977a4e49dec19a0f"},
|
||||
{file = "pyright-1.1.376-py3-none-any.whl", hash = "sha256:0f2473b12c15c46b3207f0eec224c3cea2bdc07cd45dd4a037687cbbca0fbeff"},
|
||||
{file = "pyright-1.1.376.tar.gz", hash = "sha256:bffd63b197cd0810395bb3245c06b01f95a85ddf6bfa0e5644ed69c841e954dd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1157,13 +1157,13 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "tox"
|
||||
version = "4.17.1"
|
||||
version = "4.18.0"
|
||||
description = "tox is a generic virtualenv management and test command line tool"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "tox-4.17.1-py3-none-any.whl", hash = "sha256:2974597c0353577126ab014f52d1a399fb761049e165ff34427f84e8cfe6c990"},
|
||||
{file = "tox-4.17.1.tar.gz", hash = "sha256:2c41565a571e34480bd401d668a4899806169a4633e972ac296c54406d2ded8a"},
|
||||
{file = "tox-4.18.0-py3-none-any.whl", hash = "sha256:0a457400cf70615dc0627eb70d293e80cd95d8ce174bb40ac011011f0c03a249"},
|
||||
{file = "tox-4.18.0.tar.gz", hash = "sha256:5dfa1cab9f146becd6e351333a82f9e0ade374451630ba65ee54584624c27b58"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "fastanime"
|
||||
version = "2.0.0"
|
||||
version = "2.2.4"
|
||||
description = "A browser anime site experience from the terminal"
|
||||
authors = ["Benextempest <benextempest@gmail.com>"]
|
||||
license = "UNLICENSE"
|
||||
|
||||
Reference in New Issue
Block a user