Compare commits

...

47 Commits

Author SHA1 Message Date
Benex254
09ce90f8b0 chore: init depraction of providers 2024-08-11 11:36:35 +03:00
Benex254
ec40406437 chore: bump version 2024-08-11 11:33:13 +03:00
Benex254
a1d1fca538 docs: update readme 2024-08-11 11:32:55 +03:00
Benex254
88776ce134 feat(interface): implement quality selection and provider selection 2024-08-11 11:28:35 +03:00
Benex254
35caa93a56 feat(cli_download+search): implement quality selection and improve general interface 2024-08-11 11:27:54 +03:00
Benex254
4e19c2c108 refactor(downloader): use yt_dlps file sanitizer 2024-08-11 11:26:56 +03:00
Benex254
b7ba85fb96 feat(mpv): implement quality selection 2024-08-11 11:26:10 +03:00
Benex254
eb8b7ea534 feat(cli_utils): add function to select stream based on quality 2024-08-11 11:25:45 +03:00
Benex254
d2aae33654 feat(config): quality should now be an actual quality 2024-08-11 11:24:56 +03:00
Benex254
a2872db216 feat(allanime): give random qualities to strings 2024-08-11 11:24:29 +03:00
Benex254
030f77bbf3 refactor(allanime): wrong import path 2024-08-11 11:23:41 +03:00
Benex254
8f5e01295f feat(animeprovider): make types more robust 2024-08-11 11:23:11 +03:00
Benex254
7b2096e0eb feat(utils): add animeprovider util that assigns a random quality string if not present 2024-08-11 11:22:43 +03:00
Benex254
45ccaec458 refactor: default header to static 2024-08-11 11:21:47 +03:00
Benex254
b4cbb57f29 refactor: move available servers to toplevel of animeprovider package 2024-08-11 11:21:18 +03:00
Benex254
c4255fc748 feat(animepahe): complete 2024-08-11 11:20:08 +03:00
Benex254
fa42d0e403 feat(animepahe): init 2024-08-10 23:02:26 +03:00
Benex254
0e5cb56970 feat(cli): no need to reload config on provider 2024-08-10 23:01:44 +03:00
Benex254
33d69cb95a chore: remove empty providers 2024-08-10 15:40:39 +03:00
Benex254
1fbca22be8 feat(anilist): introduce type as a variable 2024-08-10 15:39:46 +03:00
Benex254
1a88b6e998 refactor(anilist): rename anilist_data_schema to types 2024-08-10 15:15:37 +03:00
Benex254
3c09268da9 feat(completions): remove shellingam 2024-08-10 11:19:10 +03:00
Benex254
c0e5f5dd49 docs: update readme 2024-08-10 01:16:43 +03:00
Benex254
1bbc9506c2 feat(cli): try to detect shell when generating completions 2024-08-10 01:12:33 +03:00
Benex254
c66cc52d53 tests: add tests for cache and completions command 2024-08-09 23:14:46 +03:00
Benex254
e5f4a61a4e chore:bump version 2024-08-09 23:08:55 +03:00
Benex254
739d041c58 chore: update readme 2024-08-09 23:08:35 +03:00
Benex254
f12d5ab06c feat(cli): add helper command completions 2024-08-09 22:59:26 +03:00
Benex254
c3a3041cfb feat(interface): use click.edit 2024-08-09 22:58:48 +03:00
Benex254
594c687c8b chore: remove art as dep 2024-08-09 16:02:29 +03:00
Benex254
91b5d3ea40 chore: bump version 2024-08-09 16:00:49 +03:00
Benex254
8c30a7667c feat(fzf): remove art and instead use a static header 2024-08-09 16:00:30 +03:00
Benex254
179fbe59ac feat(config): let python-mpv to bee disabled by default 2024-08-09 15:59:56 +03:00
Benex254
5bfc210f59 feat(cli): add option to enable or disable python-mpv 2024-08-09 15:59:31 +03:00
Benex254
eb9c200fca feat(mpv): remove useless print statement 2024-08-09 15:58:50 +03:00
Benex254
603efd56e8 chore: remove dbus-python 2024-08-09 13:48:36 +03:00
Benex254
4d74dfa339 feat(mpv): improve auto next 2024-08-09 13:47:23 +03:00
Benex254
4681e38153 chore: bump version 2024-08-09 01:09:34 +03:00
Benex254
242003500d chore: add dbus-python as dep for notifications 2024-08-09 01:07:32 +03:00
Benex254
66ab365657 feat(mpv): add ytdl to true 2024-08-09 01:06:53 +03:00
Benex254
bc8d7b2e28 chore: bump version 2024-08-08 19:43:40 +03:00
Benex254
db3a1f7175 feat(mpv): add select server script message 2024-08-08 19:21:19 +03:00
Benex254
3a51e0225e chore: bump version 2024-08-08 15:42:10 +03:00
Benex254
7b3388939c chore: bump version 2024-08-08 15:40:47 +03:00
Benex254
fcf875bdb2 feat(mpv): force window 2024-08-08 15:16:03 +03:00
Benex254
0e4624297c docs: update readme 2024-08-07 21:15:08 +03:00
Benex254
91b54dfcb9 feat(cache): open cache dir on no options 2024-08-07 21:14:57 +03:00
34 changed files with 908 additions and 313 deletions

113
README.md
View File

@@ -2,18 +2,17 @@
Welcome to **FastAnime**, anime site experience from the terminal.
**fzf mode**
[fa_fzf_demo.webm](https://github.com/user-attachments/assets/b1fecf25-e358-4e8b-a144-bcb7947210cf)
**other modes:**
<details>
<summary><b>rofi mode</b></summary>
[fa_rofi_mode.webm](https://github.com/user-attachments/assets/2ce669bf-b62f-4c44-bd79-cf0dcaddf37a)
</details>
<details>
@@ -23,11 +22,8 @@ Welcome to **FastAnime**, anime site experience from the terminal.
</details>
Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magic-tape](https://gitlab.com/christosangel/magic-tape/-/tree/main?ref_type=heads) and [ani-cli](https://github.com/pystardust/ani-cli).
<!--toc:start-->
- [FastAnime](#fastanime)
@@ -40,13 +36,18 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magi
- [External Dependencies](#external-dependencies)
- [Usage](#usage)
- [The Commandline interface :fire:](#the-commandline-interface-fire)
- [The anilist command](#the-anilist-command)
- [The anilist command :fire: :fire: :fire:](#the-anilist-command-fire-fire-fire)
- [Running without any subcommand](#running-without-any-subcommand)
- [Subcommands](#subcommands)
- [download subcommand](#download-subcommand)
- [search subcommand](#search-subcommand)
- [downloads subcommand](#downloads-subcommand)
- [config subcommand](#config-subcommand)
- [cache subcommand](#cache-subcommand)
- [completions subcommand](#completions-subcommand)
- [MPV specific commands](#mpv-specific-commands)
- [Added keybindings](#added-keybindings)
- [Added script messages](#added-script-messages)
- [Configuration](#configuration)
- [Contributing](#contributing)
- [Receiving Support](#receiving-support)
@@ -55,8 +56,7 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magi
> [!IMPORTANT]
>
> This project currently scrapes allanime and is in no way related to them. The site is in the public domain and can be access by any one with a browser.
> 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.
## Installation
@@ -75,12 +75,21 @@ Preferred method of installation since [Pipx](https://github.com/pypa/pipx) crea
```bash
pipx install fastanime
# -- or for the development version --
pipx install 'fastanime==<latest-pre-release-tag>.dev1'
# example
# pipx install 'fastanime==0.60.1.dev1'
```
#### Using pip
```bash
pip install fastanime
# -- or for the development version --
pip install 'fastanime==<latest-pre-release-tag>.dev1'
# example
# pip install 'fastanime==0.60.1.dev1'
```
### Installing the bleeding edge version
@@ -143,7 +152,7 @@ fastanime --version
### External Dependencies
The only required external dependency, unless you won't be streaming, is [MPV](https://mpv.io/installation/), which i recommend installing with [uosc](https://github.com/tomasklaen/uosc) and [thumbfast](https://github.com/po5/thumbfast) for the best experience since they add a better interface to it.
The only required external dependency, unless you won't be streaming, is [MPV](https://mpv.io/installation/), which i recommend installing with [uosc](https://github.com/tomasklaen/uosc) :fire: and [thumbfast](https://github.com/po5/thumbfast) for the best experience since they add a better interface to it.
> [!NOTE]
>
@@ -152,13 +161,13 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
> everything you could ever need with a small footprint.
> But if you have a reason feel free to encourage as to do so.
**Other dependencies that will just make your experience better:**
**Other external dependencies that will just make your experience better:**
- [fzf](https://github.com/junegunn/fzf) :fire: which is used as a better alternative to the 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) :fire: used for skipping the opening and ending theme songs
- [ani-skip](https://github.com/synacktraa/ani-skip) used for skipping the opening and ending theme songs
## Usage
@@ -181,6 +190,7 @@ Overview of main commands:
- `fastanime search`: Powerful command meant for binging since it doesn't require the interfaces
- `fastanime downloads`: View downloaded anime and watch with MPV.
- `fastanime config`: Quickly edit configuration settings.
- `fastanime cache`: Quickly manage the cache fastanime uses
Configuration is directly passed into this command at run time to override your config.
@@ -204,6 +214,10 @@ Available options include:
- `--rofi-theme <path>` theme to use with rofi
- `--rofi-theme-input <path>` theme to use with rofi input
- `--rofi-theme-confirm <path>` theme to use with rofi confirm
- `--log` allow logging to stdout
- `--log-file` allow logging to a file
- `--rich-traceback` allow rich traceback
- `--use-mpv-mod/--use-default-player` whether to use python-mpv
#### The anilist command :fire: :fire: :fire:
@@ -331,11 +345,79 @@ fastanime config
# to get config path which is useful if you want to use it for another program.
fastanime config --path
# add a desktop entry
fastanime config --desktop-entry
```
> [!Note]
>
> If it opens [vim](https://www.vim.org/download.php) you can exit by typing `:q` in case you don't know.
> If it opens [vim](https://www.vim.org/download.php) you can exit by typing `:q` .
#### cache subcommand
Easily manage the data fastanime has cached; for the previews.
**Syntax:**
```bash
# delete everything in the cache dir
fastanime cache --clean
# print the path to the cache dir and exit
fastanime cache --path
# print the current size of the cache dir and exit
fastanime cache --size
# open the cache dir and exit
fastanime cache
```
#### completions subcommand
Helper command to setup shell completions
**Syntax:**
```bash
# try to detect your shell and print completions
fastanime completions
# print fish completions
fastanime completions --fish
# print bash completions
fastanime completions --bash
# print zsh completions
fastanime completions --zsh
```
## 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
`<shift>+n` fetch the next episode
`<shift>+p` fetch the previous episode
`<shift>+t` toggle the translation type from dub to sub
`<shift>+a` toggle auto next episode
`<shit>+r` reload episode
### Added script messages
Examples:
```bash
# to select episode from mpv without window closing
script-message select-episode <episode-number>
# to select server from mpv without window closing
script-message select-server <server-name>
```
## Configuration
@@ -356,6 +438,7 @@ skip=false
# the maximum delta time in minutes after which the episode should be considered as completed
# used in the continue from time stamp
error=3
use_mpv_mod=False
# the format of downloaded anime and trailer
# based on yt-dlp format and passed directly to it
@@ -366,6 +449,9 @@ error=3
format=best[height<=1080]/bestvideo[height<=1080]+bestaudio/best # default
[general]
# can be [allanime,animepahe]
provider = allanime
preferred_language = romaji # Display language (options: english, romaji)
downloads_dir = <Default-videos-dir>/FastAnime # Download directory
preview=false # whether to show a preview window when using fzf or rofi
@@ -393,7 +479,7 @@ notification_duration=2
We welcome your issues and feature requests. However, due to time constraints, we currently do not plan to add another provider.
If you wish to contribute directly, please first open an issue describing your proposed changes so it can be discussed.
If you wish to contribute directly, please first open an issue describing your proposed changes so it can be discussed or if you are in a rush for the feature to be merged just open a pr.
## Receiving Support
@@ -405,7 +491,6 @@ For inquiries, join our [Discord Server](https://discord.gg/4NUTj5Pt).
</a>
</p>
## Supporting the Project
Show your support by starring our GitHub repository or [buying us a coffee](https://ko-fi.com/benex254).

View File

@@ -12,7 +12,7 @@ from .libs.anime_provider import anime_sources
if TYPE_CHECKING:
from typing import Iterator
from .libs.anilist.anilist_data_schema import AnilistBaseMediaDataSchema
from .libs.anilist.types import AnilistBaseMediaDataSchema
from .libs.anime_provider.types import Anime, SearchResults, Server
logger = logging.getLogger(__name__)

View File

@@ -1,9 +1,6 @@
from datetime import datetime
from ..libs.anilist.anilist_data_schema import (
AnilistDateObject,
AnilistMediaNextAiringEpisode,
)
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
# TODO: Add formating options for the final date

View File

@@ -3,8 +3,7 @@ from queue import Queue
from threading import Thread
import yt_dlp
from ..utils import sanitize_filename
from yt_dlp.utils import sanitize_filename
logger = logging.getLogger(__name__)

View File

@@ -5,7 +5,7 @@ from functools import lru_cache
from thefuzz import fuzz
from fastanime.libs.anilist.anilist_data_schema import AnilistBaseMediaDataSchema
from fastanime.libs.anilist.types import AnilistBaseMediaDataSchema
from .data import anime_normalizer

View File

@@ -6,7 +6,7 @@ if sys.version_info < (3, 10):
) # noqa: F541
__version__ = "v0.60.1"
__version__ = "v0.62.0"
APP_NAME = "FastAnime"
AUTHOR = "Benex254"

View File

@@ -3,8 +3,7 @@ import signal
import click
from .. import __version__
from ..libs.anime_provider import anime_sources
from ..libs.anime_provider.allanime.constants import SERVERS_AVAILABLE
from ..libs.anime_provider import SERVERS_AVAILABLE, anime_sources
from ..Utility.data import anilist_sort_normalizer
from .commands import LazyGroup
@@ -15,6 +14,7 @@ commands = {
"config": "config.config",
"downloads": "downloads.downloads",
"cache": "cache.cache",
"completions": "completions.completions",
}
@@ -52,7 +52,7 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option(
"-s",
"--server",
type=click.Choice([*SERVERS_AVAILABLE, "top"], case_sensitive=False),
type=click.Choice([*SERVERS_AVAILABLE, "top"]),
help="Server of choice",
)
@click.option(
@@ -76,7 +76,7 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option(
"-q",
"--quality",
type=click.IntRange(0, 3),
type=click.Choice(["360", "720", "1080", "unknown"]),
help="set the quality of the stream",
)
@click.option(
@@ -126,6 +126,9 @@ signal.signal(signal.SIGINT, handle_exit)
help="Rofi theme to use for the user input prompt",
type=click.Path(),
)
@click.option(
"--use-mpv-mod/--use-default-player", help="Whether to use python-mpv", type=bool
)
@click.pass_context
def run_cli(
ctx: click.Context,
@@ -155,6 +158,7 @@ def run_cli(
rofi_theme,
rofi_theme_confirm,
rofi_theme_input,
use_mpv_mod,
):
from .config import Config
@@ -196,7 +200,6 @@ def run_cli(
if provider:
ctx.obj.provider = provider
ctx.obj.load_config()
if server:
ctx.obj.server = server
if format:
@@ -217,6 +220,11 @@ def run_cli(
== click.core.ParameterSource.COMMANDLINE
):
ctx.obj.auto_select = auto_select
if (
ctx.get_parameter_source("use_mpv_mod")
== click.core.ParameterSource.COMMANDLINE
):
ctx.obj.use_mpv_mod = use_mpv_mod
if sort_by:
ctx.obj.sort_by = sort_by
if downloads_dir:

View File

@@ -33,3 +33,7 @@ def cache(clean, path, size):
fp = os.path.join(dirpath, f)
total_size += os.path.getsize(fp)
print("Total Size: ", sizeof_fmt(total_size))
else:
import click
click.launch(APP_CACHE_DIR)

View File

@@ -0,0 +1,124 @@
import click
@click.command(help="Helper command to get shell completions")
@click.option("--fish", is_flag=True)
@click.option("--zsh", is_flag=True)
@click.option("--bash", is_flag=True)
def completions(fish, zsh, bash):
if not fish or not zsh or not bash:
import os
shell_env = os.environ.get("SHELL", "")
if "fish" in shell_env:
current_shell = "fish"
elif "zsh" in shell_env:
current_shell = "zsh"
elif "bash" in shell_env:
current_shell = "bash"
else:
current_shell = None
else:
current_shell = None
if fish or current_shell == "fish" and not zsh and not bash:
print(
"""
function _fastanime_completion;
set -l response (env _FASTANIME_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) fastanime);
for completion in $response;
set -l metadata (string split "," $completion);
if test $metadata[1] = "dir";
__fish_complete_directories $metadata[2];
else if test $metadata[1] = "file";
__fish_complete_path $metadata[2];
else if test $metadata[1] = "plain";
echo $metadata[2];
end;
end;
end;
complete --no-files --command fastanime --arguments "(_fastanime_completion)";
"""
)
elif zsh or current_shell == "zsh" and not bash:
print(
"""
#compdef fastanime
_fastanime_completion() {
local -a completions
local -a completions_with_descriptions
local -a response
(( ! $+commands[fastanime] )) && return 1
response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _FASTANIME_COMPLETE=zsh_complete fastanime)}")
for type key descr in ${response}; do
if [[ "$type" == "plain" ]]; then
if [[ "$descr" == "_" ]]; then
completions+=("$key")
else
completions_with_descriptions+=("$key":"$descr")
fi
elif [[ "$type" == "dir" ]]; then
_path_files -/
elif [[ "$type" == "file" ]]; then
_path_files -f
fi
done
if [ -n "$completions_with_descriptions" ]; then
_describe -V unsorted completions_with_descriptions -U
fi
if [ -n "$completions" ]; then
compadd -U -V unsorted -a completions
fi
}
if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
# autoload from fpath, call function directly
_fastanime_completion "$@"
else
# eval/source/. command, register function for later
compdef _fastanime_completion fastanime
fi
"""
)
elif bash or current_shell == "bash":
print(
"""
_fastanime_completion() {
local IFS=$'\n'
local response
response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _FASTANIME_COMPLETE=bash_complete $1)
for completion in $response; do
IFS=',' read type value <<< "$completion"
if [[ $type == 'dir' ]]; then
COMPREPLY=()
compopt -o dirnames
elif [[ $type == 'file' ]]; then
COMPREPLY=()
compopt -o default
elif [[ $type == 'plain' ]]; then
COMPREPLY+=($value)
fi
done
return 0
}
_fastanime_completion_setup() {
complete -o nosort -F _fastanime_completion fastanime
}
_fastanime_completion_setup;
"""
)
else:
print("Could not detect shell")

View File

@@ -37,7 +37,7 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
from ...libs.fzf import fzf
from ...Utility.downloader.downloader import downloader
from ..utils.tools import exit_app
from ..utils.utils import fuzzy_inquirer
from ..utils.utils import filter_by_quality, fuzzy_inquirer
anime_provider = AnimeProvider(config.provider)
@@ -104,36 +104,37 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
if not streams:
print("No streams skipping")
continue
with Progress() as progress:
if highest_priority:
progress.add_task("Fetching highest priority stream", total=None)
streams = list(streams)
links = [
(link.get("priority", 0), link["link"])
for server in streams
for link in server["links"]
]
link = max(links, key=lambda x: x[0])[1]
episode_title = streams[0]["episode_title"]
elif config.server == "top":
progress.add_task("Fetching Top Server", total=None)
# ---- fetch servers ----
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams)
link = server["links"][config.quality]["link"]
episode_title = server["episode_title"]
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
input("Enter to continue")
continue
link = stream_link["link"]
episode_title = server["episode_title"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
else:
# TODO: Make this better but no rush whats the point of manual selection
progress.add_task("Fetching links", total=None)
streams = list(streams)
links = [
link["link"] for server in streams for link in server["links"]
]
episode_title = streams[0]["episode_title"]
if config.use_fzf:
link = fzf.run(links, "Select link", "Links")
else:
link = fuzzy_inquirer("Select link", links)
server = fuzzy_inquirer("Select link", servers_names)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
)
if not stream_link:
print("Quality not found")
continue
link = stream_link["link"]
episode_title = servers[server]["episode_title"]
print(f"[purple]Now Downloading:[/] {search_result} Episode {episode}")
downloader._download_file(

View File

@@ -26,7 +26,7 @@ def search(config: Config, anime_title: str, episode_range: str):
from ...libs.rofi import Rofi
from ..utils.mpv import run_mpv
from ..utils.tools import exit_app
from ..utils.utils import fuzzy_inquirer
from ..utils.utils import filter_by_quality, fuzzy_inquirer
anime_provider = AnimeProvider(config.provider)
@@ -102,6 +102,7 @@ def search(config: Config, anime_title: str, episode_range: str):
)
except StopIteration:
print("[green]Completed binge sequence[/]:smile:")
input("Enter to continue...")
if not episode or episode not in episodes:
if config.use_fzf:
@@ -121,25 +122,48 @@ def search(config: Config, anime_title: str, episode_range: str):
print("Failed to get streams")
return
# ---- fetch servers ----
with Progress() as progress:
try:
# ---- fetch servers ----
if config.server == "top":
progress.add_task("Fetching top server...", total=None)
server = next(streams)
link = server["links"][config.quality]["link"]
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams)
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
return
link = stream_link["link"]
episode_title = server["episode_title"]
else:
progress.add_task("Fetching servers", total=None)
links = [link["link"] for server in streams for link in server["links"]]
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.use_fzf:
link = fzf.run(links, "Select an link: ", header=search_result)
server = fzf.run(servers_names, "Select an link: ")
elif config.use_rofi:
link = Rofi.run(links, "Select an link")
server = Rofi.run(servers_names, "Select an link")
else:
link = fuzzy_inquirer("Select link", links)
server = fuzzy_inquirer("Select link", servers_names)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
)
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
return
link = stream_link["link"]
episode_title = servers[server]["episode_title"]
print(f"[purple]Now Playing:[/] {search_result} Episode {episode}")
print(f"[purple]Now Playing:[/] {search_result} Episode {episode}")
run_mpv(link, search_result)
run_mpv(link, episode_title)
except Exception as e:
print(e)
input("Enter to continue")
stream_anime()
stream_anime()

View File

@@ -28,7 +28,7 @@ class Config(object):
{
"server": "top",
"continue_from_history": "True",
"quality": "0",
"quality": "1080",
"auto_next": "False",
"auto_select": "True",
"sort_by": "search match",
@@ -47,7 +47,8 @@ class Config(object):
"rofi_theme": "",
"rofi_theme_input": "",
"rofi_theme_confirm": "",
"use_mpv_mod": "true",
"use_mpv_mod": "false",
"force_window": "immediate",
}
)
self.configparser.add_section("stream")
@@ -78,6 +79,7 @@ class Config(object):
self.error = self.get_error()
self.server = self.get_server()
self.format = self.get_format()
self.force_window = self.get_force_window()
self.preferred_language = self.get_preferred_language()
self.rofi_theme = self.get_rofi_theme()
Rofi.rofi_theme = self.rofi_theme
@@ -145,6 +147,9 @@ class Config(object):
def get_skip(self):
return self.configparser.getboolean("stream", "skip")
def get_force_window(self):
return self.configparser.get("stream", "force_window")
def get_icons(self):
return self.configparser.getboolean("general", "icons")
@@ -170,7 +175,7 @@ class Config(object):
return self.configparser.getboolean("stream", "auto_select")
def get_quality(self):
return self.configparser.getint("stream", "quality")
return self.configparser.get("stream", "quality")
def get_use_mpv_mod(self):
return self.configparser.getboolean("stream", "use_mpv_mod")

View File

@@ -11,20 +11,21 @@ from InquirerPy.validator import EmptyInputValidator
from rich import print
from rich.progress import Progress
from rich.prompt import Confirm, Prompt
from yt_dlp.utils import sanitize_filename
from ...anilist import AniList
from ...constants import USER_CONFIG_PATH
from ...libs.fzf import fzf
from ...libs.rofi import Rofi
from ...Utility.data import anime_normalizer
from ...Utility.utils import anime_title_percentage_match, sanitize_filename
from ...Utility.utils import anime_title_percentage_match
from ..utils.mpv import run_mpv
from ..utils.tools import QueryDict, exit_app
from ..utils.utils import fuzzy_inquirer
from ..utils.utils import filter_by_quality, fuzzy_inquirer
from .utils import aniskip
if TYPE_CHECKING:
from ...libs.anilist.anilist_data_schema import AnilistBaseMediaDataSchema
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...libs.anime_provider.types import Anime, SearchResult, Server
from ..config import Config
@@ -80,6 +81,7 @@ def player_controls(config: "Config", anilist_config: QueryDict):
from ..utils.player import player
mpv = player.create_player(
current_link,
config.anime_provider,
anilist_config,
config,
@@ -92,7 +94,6 @@ def player_controls(config: "Config", anilist_config: QueryDict):
mpv._set_property("chapters-file", chapters_file[1])
mpv._set_property("script-opts", script_opts[1])
mpv.start = start_time
mpv.play(current_link)
mpv.wait_for_shutdown()
mpv.terminate()
stop_time = player.last_stop_time
@@ -191,7 +192,7 @@ def player_controls(config: "Config", anilist_config: QueryDict):
def _change_quality():
# extract the actual link urls
options = [link["link"] for link in links]
options = [link["quality"] for link in links]
# prompt for new quality
if config.use_fzf:
@@ -202,7 +203,7 @@ def player_controls(config: "Config", anilist_config: QueryDict):
quality = Rofi.run(options, "Select Quality")
else:
quality = fuzzy_inquirer("Select Quality", options)
config.quality = options.index(quality) # set quality
config.quality = quality # set quality
player_controls(config, anilist_config)
def _change_translation_type():
@@ -261,7 +262,7 @@ def player_controls(config: "Config", anilist_config: QueryDict):
def fetch_streams(config: "Config", anilist_config: QueryDict):
# user config
quality: int = config.quality
quality: str = config.quality
# internal config
episode_number: str = anilist_config.episode_number
@@ -331,12 +332,14 @@ def fetch_streams(config: "Config", anilist_config: QueryDict):
selected_server = episode_streams_dict[server]
links = selected_server["links"]
if quality > len(links) - 1:
quality = config.quality = len(links) - 1
elif quality < 0:
quality = config.quality = 0
stream_link = links[quality]["link"]
stream_link_ = filter_by_quality(quality, links)
if not stream_link_:
print("Quality not found")
input("Enter to continue...")
anilist_options(config, anilist_config)
return
stream_link = stream_link_["link"]
# update internal config
anilist_config.current_stream_links = links
anilist_config.current_stream_link = stream_link
@@ -373,7 +376,11 @@ def fetch_streams(config: "Config", anilist_config: QueryDict):
from ..utils.player import player
mpv = player.create_player(
anime_provider, anilist_config, config, selected_server["episode_title"]
stream_link,
anime_provider,
anilist_config,
config,
selected_server["episode_title"],
)
if custom_args and None:
@@ -382,7 +389,6 @@ def fetch_streams(config: "Config", anilist_config: QueryDict):
mpv._set_property("chapters-file", chapters_file[1])
mpv._set_property("script-opts", script_opts[1])
mpv.start = start_time
mpv.play(stream_link)
mpv.wait_for_shutdown()
mpv.terminate()
stop_time = player.last_stop_time
@@ -750,7 +756,24 @@ def anilist_options(config, anilist_config: QueryDict):
anilist_options(config, anilist_config)
def _toggle_auto_next(config, anilist_config):
config.auto_select = not config.auto_select
config.auto_next = not config.auto_next
anilist_options(config, anilist_config)
def _change_provider(config: "Config", anilist_config):
options = ["allanime", "animepahe"]
if config.use_fzf:
provider = fzf.run(
options, prompt="Select Translation Type:", header="Language Options"
)
elif config.use_rofi:
provider = Rofi.run(options, "Select Translation Type")
else:
provider = fuzzy_inquirer("Select translation type", options)
config.provider = provider
config.anime_provider.provider = provider
config.anime_provider.lazyload_provider()
anilist_options(config, anilist_config)
icons = config.icons
@@ -762,6 +785,7 @@ def anilist_options(config, anilist_config: QueryDict):
f"{'📤 ' if icons else ''}Remove from List": _remove_from_list,
f"{'📖 ' if icons else ''}View Info": _view_info,
f"{'🎧 ' if icons else ''}Change Translation Type": _change_translation_type,
f"{'💽 ' if icons else ''}Change Provider": _change_provider,
f"{'🔘 ' if icons else ''}Toggle auto select anime": _toggle_auto_select, # problematic if you choose an anime that doesnt match id
f"{'💠 ' if icons else ''}Toggle auto next episode": _toggle_auto_next,
f"{'🔙 ' if icons else ''}Back": select_anime,
@@ -928,9 +952,9 @@ def anilist(config: "Config", anilist_config: QueryDict):
return AniList.search(id_in=anime_list)
def edit_config():
import subprocess
from click import edit
subprocess.run([os.environ.get("EDITOR", "open"), USER_CONFIG_PATH])
edit(filename=USER_CONFIG_PATH)
if config.use_rofi:
config.load_config()
config.use_rofi = True

View File

@@ -9,7 +9,7 @@ from threading import Thread
import requests
from ...constants import APP_CACHE_DIR
from ...libs.anilist.anilist_data_schema import AnilistBaseMediaDataSchema
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...Utility import anilist_data_helper
from ...Utility.utils import remove_html_tags
from ..utils.utils import get_true_fg

View File

@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING
import mpv
from ...anilist import AniList
from .utils import filter_by_quality
if TYPE_CHECKING:
from typing import Literal
@@ -27,9 +28,13 @@ class MpvPlayer(object):
last_stop_time_secs = 0
last_total_time_secs = 0
current_media_title = ""
player_fetching = False
def get_episode(
self, type: "Literal['next','previous','reload','custom']", ep_no=None
self,
type: "Literal['next','previous','reload','custom']",
ep_no=None,
server="top",
):
anilist_config = self.anilist_config
config = self.config
@@ -62,7 +67,9 @@ class MpvPlayer(object):
elif type == "custom":
if not ep_no or ep_no not in episodes:
self.mpv_player.show_text("Episode number not specified or invalid")
self.mpv_player.show_text(f"Acceptable episodes are: {episodes}")
self.mpv_player.show_text(
f"Acceptable episodes are: {episodes}",
)
return
self.mpv_player.show_text(f"Fetching episode {ep_no}")
@@ -97,18 +104,36 @@ class MpvPlayer(object):
return None
# always select the first
selected_server = next(episode_streams)
if server == "top":
selected_server = next(episode_streams)
else:
episode_streams_dict = {
episode_stream["server"]: episode_stream
for episode_stream in episode_streams
}
selected_server = episode_streams_dict.get(server)
if selected_server is None:
self.mpv_player.show_text(
f"Invalid server!!; servers available are: {episode_streams_dict.keys()}",
)
return None
self.current_media_title = selected_server["episode_title"]
links = selected_server["links"]
if quality > len(links) - 1:
quality = config.quality = len(links) - 1
elif quality < 0:
quality = config.quality = 0
stream_link = links[quality]["link"]
stream_link_ = filter_by_quality(quality, links)
if not stream_link_:
self.mpv_player.show_text("Quality not found")
return
stream_link = stream_link_["link"]
return stream_link
def create_player(
self, anime_provider: "AnimeProvider", anilist_config, config: "Config", title
self,
stream_link,
anime_provider: "AnimeProvider",
anilist_config,
config: "Config",
title,
):
self.anime_provider = anime_provider
self.anilist_config = anilist_config
@@ -120,13 +145,52 @@ class MpvPlayer(object):
self.current_media_title = ""
mpv_player = mpv.MPV(
log_handler=print,
loglevel="error",
config=True,
input_default_bindings=True,
input_vo_keyboard=True,
osc=True,
ytdl=True,
)
mpv_player.force_window = config.force_window
# mpv_player.cache = "yes"
# mpv_player.cache_pause = "no"
mpv_player.title = title
mpv_player.play(stream_link)
# -- events --
@mpv_player.event_callback("file-loaded")
def set_total_time(event, *args):
d = mpv_player._get_property("duration")
self.player_fetching = False
if isinstance(d, float):
self.last_total_time = format_time(d)
@mpv_player.property_observer("time-pos")
def handle_time_start_update(*args):
if len(args) > 1:
value = args[1]
if value is not None:
self.last_stop_time = format_time(value)
@mpv_player.property_observer("time-remaining")
def handle_time_remaining_update(
property, time_remaining: float | None = None, *args
):
if time_remaining is not None:
if time_remaining < 1 and config.auto_next and not self.player_fetching:
print("Auto Fetching Next Episode")
self.player_fetching = True
url = self.get_episode("next")
if url:
mpv_player.loadfile(
url,
)
mpv_player.title = self.current_media_title
# -- keybindings --
@mpv_player.on_key_press("shift+n")
def _next_episode():
url = self.get_episode("next")
@@ -134,18 +198,6 @@ class MpvPlayer(object):
mpv_player.loadfile(url, options=f"title={self.current_media_title}")
mpv_player.title = self.current_media_title
@mpv_player.event_callback("file-loaded")
def set_total_time(event, *args):
d = mpv_player._get_property("duration")
if isinstance(d, float):
self.last_total_time = format_time(d)
@mpv_player.event_callback("shutdown")
def set_total_time_on_shutdown(event, *args):
d = mpv_player._get_property("duration")
if isinstance(d, float):
self.last_total_time = format_time(d)
@mpv_player.on_key_press("shift+p")
def _previous_episode():
url = self.get_episode("previous")
@@ -190,31 +242,11 @@ class MpvPlayer(object):
)
mpv_player.title = self.current_media_title
@mpv_player.property_observer("time-pos")
def handle_time_start_update(*args):
if len(args) > 1:
value = args[1]
if value is not None:
self.last_stop_time_secs = value
self.last_stop_time = format_time(value)
@mpv_player.property_observer("time-remaining")
def handle_time_remaining_update(*args):
if len(args) > 1:
value = args[1]
if value is not None:
rem_time = value
if rem_time < 10 and config.auto_next:
url = self.get_episode("next")
if url:
mpv_player.loadfile(
url,
)
mpv_player.title = self.current_media_title
# -- script messages --
@mpv_player.message_handler("select-episode")
def select_episode(episode: bytes | None = None, *args):
if not episode:
mpv_player.show_text("No episode was selected")
return
url = self.get_episode("custom", episode.decode())
if url:
@@ -223,11 +255,51 @@ class MpvPlayer(object):
)
mpv_player.title = self.current_media_title
mpv_player.register_message_handler("select-episode", select_episode)
@mpv_player.message_handler("select-server")
def select_server(server: bytes | None = None, *args):
if not server:
mpv_player.show_text("No server was selected")
return
url = self.get_episode("reload", server=server.decode())
if url:
mpv_player.loadfile(
url,
)
mpv_player.title = self.current_media_title
else:
pass
@mpv_player.message_handler("select-quality")
def select_quality(quality_raw: bytes | None = None, *args):
if not quality_raw:
mpv_player.show_text("No quality was selected")
return
q = ["360", "720", "1080"]
quality = quality_raw.decode()
links: list = anilist_config.current_stream_links
q = [link["quality"] for link in links]
if quality in q:
config.quality = quality
stream_link_ = filter_by_quality(quality, links)
if not stream_link_:
mpv_player.show_text("Quality not found")
return
mpv_player.show_text(f"Changing to stream of quality {quality}")
stream_link = stream_link_["link"]
mpv_player.loadfile(stream_link)
else:
mpv_player.show_text(f"invalid quality!! Valid quality includes: {q}")
# -- events --
mpv_player.observe_property("time-pos", handle_time_start_update)
mpv_player.register_event_callback(set_total_time)
mpv_player.register_event_callback(set_total_time_on_shutdown)
mpv_player.observe_property("time-remaining", handle_time_remaining_update)
mpv_player.register_event_callback(set_total_time)
# --script-messages --
mpv_player.register_message_handler("select-episode", select_episode)
mpv_player.register_message_handler("select-server", select_server)
mpv_player.register_message_handler("select-quality", select_quality)
self.mpv_player = mpv_player
return mpv_player

View File

@@ -1,4 +1,5 @@
import logging
from typing import TYPE_CHECKING
from InquirerPy import inquirer
from thefuzz import fuzz
@@ -6,7 +7,8 @@ from thefuzz import fuzz
from ...Utility.data import anime_normalizer
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ...libs.anime_provider.types import EpisodeStream
# Define ANSI escape codes as constants
RESET = "\033[0m"
@@ -20,6 +22,12 @@ BG_GREEN = "\033[48;2;120;233;12;m"
GREEN = "\033[38;2;45;24;45;m"
def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]"):
for stream_link in stream_links:
if stream_link["quality"] == quality:
return stream_link
def sizeof_fmt(num, suffix="B"):
for unit in ("", "K", "M", "G", "T", "P", "E", "Z"):
if abs(num) < 1024.0:

View File

@@ -20,6 +20,8 @@ if PLATFORM == "Windows":
ICON_PATH = os.path.join(ASSETS_DIR, "logo.ico")
else:
ICON_PATH = os.path.join(ASSETS_DIR, "logo.png")
PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
# ----- user configs and data -----
APP_DATA_DIR = dirs.user_config_dir

View File

@@ -29,7 +29,7 @@ from .queries_graphql import (
)
if TYPE_CHECKING:
from .anilist_data_schema import (
from .types import (
AnilistDataSchema,
AnilistMediaLists,
AnilistMediaListStatus,
@@ -126,7 +126,9 @@ class AniListApi:
return self._make_authenticated_request(media_list_mutation, variables)
def get_anime_list(
self, status: "AnilistMediaListStatus"
self,
status: "AnilistMediaListStatus",
type="ANIME",
) -> tuple[bool, "AnilistMediaLists"] | tuple[bool, None]:
"""gets an anime list from your media list given the list status
@@ -136,7 +138,7 @@ class AniListApi:
Returns:
a media list
"""
variables = {"status": status, "userId": self.user_id}
variables = {"status": status, "userId": self.user_id, "type": type}
return self._make_authenticated_request(media_list_query, variables)
def get_medialist_entry(
@@ -310,6 +312,7 @@ class AniListApi:
start_greater: int | None = None,
start_lesser: int | None = None,
page: int | None = None,
type="ANIME",
**kwargs,
):
"""
@@ -329,65 +332,74 @@ class AniListApi:
variables = {"id": id}
return self.get_data(anime_query, variables)
def get_trending(self, *_, **kwargs):
def get_trending(self, type="ANIME", *_, **kwargs):
"""
Gets the currently trending anime
"""
trending = self.get_data(trending_query)
variables = {"type": type}
trending = self.get_data(trending_query, variables)
return trending
def get_most_favourite(self, *_, **kwargs):
def get_most_favourite(self, type="ANIME", *_, **kwargs):
"""
Gets the most favoured anime on anilist
"""
most_favourite = self.get_data(most_favourite_query)
variables = {"type": type}
most_favourite = self.get_data(most_favourite_query, variables)
return most_favourite
def get_most_scored(self, *_, **kwargs):
def get_most_scored(self, type="ANIME", *_, **kwargs):
"""
Gets most scored anime on anilist
"""
most_scored = self.get_data(most_scored_query)
variables = {"type": type}
most_scored = self.get_data(most_scored_query, variables)
return most_scored
def get_most_recently_updated(self, *_, **kwargs):
def get_most_recently_updated(self, type="ANIME", *_, **kwargs):
"""
Gets most recently updated anime from anilist
"""
most_recently_updated = self.get_data(most_recently_updated_query)
variables = {"type": type}
most_recently_updated = self.get_data(most_recently_updated_query, variables)
return most_recently_updated
def get_most_popular(self):
def get_most_popular(
self,
type="ANIME",
):
"""
Gets most popular anime on anilist
"""
most_popular = self.get_data(most_popular_query)
variables = {"type": type}
most_popular = self.get_data(most_popular_query, variables)
return most_popular
def get_upcoming_anime(self, page: int = 1, *_, **kwargs):
def get_upcoming_anime(self, type="ANIME", page: int = 1, *_, **kwargs):
"""
Gets upcoming anime from anilist
"""
variables = {"page": page}
variables = {"page": page, "type": type}
upcoming_anime = self.get_data(upcoming_anime_query, variables)
return upcoming_anime
# NOTE: THe following methods will probably be scraped soon
def get_recommended_anime_for(self, id: int, *_, **kwargs):
recommended_anime = self.get_data(recommended_query)
def get_recommended_anime_for(self, id: int, type="ANIME", *_, **kwargs):
variables = {"type": type}
recommended_anime = self.get_data(recommended_query, variables)
return recommended_anime
def get_charcters_of(self, id: int, *_, **kwargs):
def get_charcters_of(self, id: int, type="ANIME", *_, **kwargs):
variables = {"id": id}
characters = self.get_data(anime_characters_query, variables)
return characters
def get_related_anime_for(self, id: int, *_, **kwargs):
def get_related_anime_for(self, id: int, type="ANIME", *_, **kwargs):
variables = {"id": id}
related_anime = self.get_data(anime_relations_query, variables)
return related_anime
def get_airing_schedule_for(self, id: int, *_, **kwargs):
def get_airing_schedule_for(self, id: int, type="ANIME", *_, **kwargs):
variables = {"id": id}
airing_schedule = self.get_data(airing_schedule_query, variables)
return airing_schedule

View File

@@ -122,13 +122,13 @@ mutation($mediaId:Int,$scoreRaw:Int,$repeat:Int,$progress:Int,$status:MediaListS
"""
media_list_query = """
query ($userId: Int, $status: MediaListStatus) {
query ($userId: Int, $status: MediaListStatus,$type:MediaType) {
Page {
pageInfo {
currentPage
total
}
mediaList(userId: $userId, status: $status, type: ANIME) {
mediaList(userId: $userId, status: $status, type: $type) {
mediaId
media {
@@ -223,7 +223,8 @@ $averageScore_lesser:Int,\
$startDate_greater:FuzzyDateInt,\
$startDate_lesser:FuzzyDateInt,\
$endDate_greater:FuzzyDateInt,\
$endDate_lesser:FuzzyDateInt\
$endDate_lesser:FuzzyDateInt,\
$type:MediaType\
"
# FuzzyDateInt = (yyyymmdd)
# MediaStatus = (FINISHED,RELEASING,NOT_YET_RELEASED,CANCELLED,HIATUS)
@@ -255,7 +256,7 @@ query($query:String,%s){
endDate_greater:$endDate_greater,
endDate_lesser:$endDate_lesser,
sort:$sort,
type:ANIME
type:$type
)
{
id
@@ -316,10 +317,10 @@ query($query:String,%s){
)
trending_query = """
query{
query($type:MediaType){
Page(perPage:15){
media(sort:TRENDING_DESC,type:ANIME,genre_not_in:["hentai"]){
media(sort:TRENDING_DESC,type:$type,genre_not_in:["hentai"]){
id
idMal
title{
@@ -376,9 +377,9 @@ query{
# mosts
most_favourite_query = """
query{
query($type:MediaType){
Page(perPage:15){
media(sort:FAVOURITES_DESC,type:ANIME,genre_not_in:["hentai"]){
media(sort:FAVOURITES_DESC,type:$type,genre_not_in:["hentai"]){
id
idMal
title{
@@ -435,9 +436,9 @@ query{
"""
most_scored_query = """
query{
query($type:MediaType){
Page(perPage:15){
media(sort:SCORE_DESC,type:ANIME,genre_not_in:["hentai"]){
media(sort:SCORE_DESC,type:$type,genre_not_in:["hentai"]){
id
idMal
title{
@@ -494,9 +495,9 @@ query{
"""
most_popular_query = """
query{
query($type:MediaType){
Page(perPage:15){
media(sort:POPULARITY_DESC,type:ANIME,genre_not_in:["hentai"]){
media(sort:POPULARITY_DESC,type:$type,genre_not_in:["hentai"]){
id
idMal
title{
@@ -553,9 +554,9 @@ query{
"""
most_recently_updated_query = """
query{
query($type:MediaType){
Page(perPage:15){
media(sort:UPDATED_AT_DESC,type:ANIME,averageScore_greater:50,genre_not_in:["hentai"],status:RELEASING){
media(sort:UPDATED_AT_DESC,type:$type,averageScore_greater:50,genre_not_in:["hentai"],status:RELEASING){
id
idMal
title{
@@ -611,9 +612,9 @@ query{
"""
recommended_query = """
query {
query($type:MediaType){
Page(perPage:15) {
media( type: ANIME,genre_not_in:["hentai"]) {
media( type: $type,genre_not_in:["hentai"]) {
recommendations(sort:RATING_DESC){
nodes{
media{
@@ -671,9 +672,9 @@ query {
"""
anime_characters_query = """
query($id:Int){
query($id:Int,$type:MediaType){
Page {
media(id:$id, type: ANIME) {
media(id:$id, type: $type) {
characters {
nodes {
name {
@@ -706,9 +707,9 @@ query($id:Int){
anime_relations_query = """
query ($id: Int) {
query ($id: Int,$type:MediaType) {
Page(perPage: 20) {
media(id: $id, sort: POPULARITY_DESC, type: ANIME,genre_not_in:["hentai"]) {
media(id: $id, sort: POPULARITY_DESC, type: $type,genre_not_in:["hentai"]) {
relations {
nodes {
id
@@ -763,9 +764,9 @@ query ($id: Int) {
"""
airing_schedule_query = """
query ($id: Int) {
query ($id: Int,$type:MediaType) {
Page {
media(id: $id, sort: POPULARITY_DESC, type: ANIME) {
media(id: $id, sort: POPULARITY_DESC, type: $type) {
airingSchedule(notYetAired:true){
nodes{
airingAt
@@ -780,7 +781,7 @@ query ($id: Int) {
"""
upcoming_anime_query = """
query ($page: Int) {
query ($page: Int,$type:MediaType) {
Page(page: $page) {
pageInfo {
total
@@ -788,7 +789,7 @@ query ($page: Int) {
currentPage
hasNextPage
}
media(type: ANIME, status: NOT_YET_RELEASED,sort:POPULARITY_DESC,genre_not_in:["hentai"]) {
media(type: $type, status: NOT_YET_RELEASED,sort:POPULARITY_DESC,genre_not_in:["hentai"]) {
id
idMal
title {

View File

@@ -1,5 +1,12 @@
anime_sources = {
"allanime": "api.AllAnimeAPI",
"animepahe": "api.AnimePaheApi",
"aniwatch": "api.AniWatchApi",
}
SERVERS_AVAILABLE = [
"sharepoint",
"dropbox",
"gogoanime",
"weTransfer",
"wixmp",
"kwik",
]

View File

@@ -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
from ..utils import decode_hex_string, give_random_quality
from .constants import (
ALLANIME_API_ENDPOINT,
ALLANIME_BASE,
@@ -215,7 +215,7 @@ class AllAnimeAPI(AnimeProvider):
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "Kir":
logger.debug("allanime:Found streams from wetransfer")
@@ -225,7 +225,7 @@ class AllAnimeAPI(AnimeProvider):
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "S-mp4":
logger.debug("allanime:Found streams from sharepoint")
@@ -235,7 +235,7 @@ class AllAnimeAPI(AnimeProvider):
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "Sak":
logger.debug("allanime:Found streams from dropbox")
@@ -245,7 +245,7 @@ class AllAnimeAPI(AnimeProvider):
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "Default":
logger.debug("allanime:Found streams from wixmp")
@@ -255,7 +255,7 @@ class AllAnimeAPI(AnimeProvider):
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
except Timeout:
logger.error(

View File

@@ -1,6 +1,8 @@
from ..types import Anime, EpisodesDetail, SearchResults
from ..types import Anime, AnimeEpisodeDetails, SearchResults
from .types import AllAnimeEpisode, AllAnimeSearchResults, AllAnimeShow
# TODO: scrap this module and do the transformations directly from the provider class
def normalize_search_results(search_results: AllAnimeSearchResults) -> SearchResults:
page_info = search_results["shows"]["pageInfo"]
@@ -15,7 +17,7 @@ def normalize_search_results(search_results: AllAnimeSearchResults) -> SearchRes
results.append(normalized_result)
normalized_search_results: SearchResults = {
"pageInfo": page_info,
"pageInfo": page_info, # pyright:ignore
"results": results,
}
@@ -25,9 +27,11 @@ def normalize_search_results(search_results: AllAnimeSearchResults) -> SearchRes
def normalize_anime(anime: AllAnimeShow) -> Anime:
id: str = anime["_id"]
title: str = anime["name"]
availableEpisodesDetail: EpisodesDetail = anime["availableEpisodesDetail"]
availableEpisodesDetail: AnimeEpisodeDetails = anime[
"availableEpisodesDetail"
] # pyright:ignore
type = anime.get("__typename")
normalized_anime: Anime = {
normalized_anime: Anime = { # pyright:ignore
"id": id,
"title": title,
"availableEpisodesDetail": availableEpisodesDetail,

View File

@@ -1,63 +1,214 @@
import requests
import logging
import re
import shutil
import subprocess
from typing import TYPE_CHECKING
from .constants import ANIMEPAHE_BASE, ANIMEPAHE_ENDPOINT, REQUEST_HEADERS
from yt_dlp.utils import (
extract_attributes,
get_element_by_id,
get_element_text_and_html_by_tag,
get_elements_html_by_class,
)
from ..base_provider import AnimeProvider
from .constants import (
ANIMEPAHE_BASE,
ANIMEPAHE_ENDPOINT,
REQUEST_HEADERS,
SERVER_HEADERS,
)
if TYPE_CHECKING:
from ..types import Anime
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimeSearchResult
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
logger = logging.getLogger(__name__)
# TODO: hack this to completion
class AnimePaheApi:
def search_for_anime(self, user_query, *args):
class AnimePaheApi(AnimeProvider):
search_page: "AnimePaheSearchPage"
anime: "AnimePaheAnimePage"
def search_for_anime(self, user_query: str, *args):
try:
url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}"
headers = {**REQUEST_HEADERS}
response = requests.get(url, headers=headers)
response = self.session.get(url, headers=headers)
if not response.status_code == 200:
return
data = response.json()
data: "AnimePaheSearchPage" = response.json()
self.search_page = data
return {
"pageInfo": {"total": data["total"]},
"pageInfo": {
"total": data["total"],
"perPage": data["per_page"],
"currentPage": data["current_page"],
},
"results": [
{
"availableEpisodes": list(range(result["episodes"])),
"id": result["session"],
"title": result["title"],
"availableEpisodes": result["episodes"],
"type": result["type"],
"year": result["year"],
"score": result["score"],
"status": result["status"],
"season": result["season"],
"poster": result["poster"],
}
for result in data["data"]
],
}
except Exception as e:
print(e)
input()
logger.error(f"AnimePahe(search): {e}")
return {}
def get_anime(self, session_id: str, *args):
url = "https://animepahe.ru/api?m=release&id=&sort=episode_asc&page=1"
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page=1"
response = requests.get(url, headers=REQUEST_HEADERS)
if not response.status_code == 200:
return
data = response.json()
self.current = data
episodes = list(map(str, range(data["total"])))
return {
"id": session_id,
"title": "none",
"availableEpisodesDetail": {
"sub": episodes,
"dub": episodes,
"raw": episodes,
},
}
try:
anime_result: "AnimeSearchResult" = [
anime
for anime in self.search_page["data"]
if anime["session"] == session_id
][0]
url = (
f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page=1"
)
response = self.session.get(url, headers=REQUEST_HEADERS)
if not response.status_code == 200:
return {}
data: "AnimePaheAnimePage" = response.json()
self.anime = data
episodes = list(map(str, range(data["total"])))
title = ""
return {
"id": session_id,
"title": anime_result["title"],
"year": anime_result["year"],
"season": anime_result["season"],
"poster": anime_result["poster"],
"score": anime_result["score"],
"availableEpisodesDetail": {
"sub": episodes,
"dub": episodes,
"raw": episodes,
},
"episodesInfo": [
{
"title": episode["title"] or f"{title};{episode['episode']}",
"episode": episode["episode"],
"id": episode["session"],
"translation_type": episode["audio"],
"duration": episode["duration"],
"poster": episode["snapshot"],
}
for episode in data["data"]
],
}
except Exception as e:
logger.error(f"AnimePahe(anime): {e}")
return {}
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)
]
if not episode:
logger.error(f"AnimePahe(streams): episode {episode_number} doesn't exist")
raise Exception("Episode not found")
episode = episode[0]
def get_episode_streams(self, anime, episode, *args):
episode_id = self.current["data"][int(episode)]["session"]
anime_id = anime["id"]
url = f"{ANIMEPAHE_BASE}play/{anime_id}{episode_id}"
response = requests.get(url, headers=REQUEST_HEADERS)
print(response.status_code)
input()
if not response.status_code == 200:
print(response.text)
return
print(response.text)
input()
# 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"
)
raise Exception("Episode not found")
# 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, "")
continue
encoded_js = content
break
if not encoded_js:
logger.warn(
"AnimePahe: Encoded js not found please report to the developers"
)
raise Exception("Episode not found")
# 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"
)
raise Exception("Episode not found")
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"
)
raise Exception("Episode not found")
# 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"
)
raise Exception("Episode not found")
# 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

View File

@@ -2,7 +2,7 @@ from yt_dlp.utils.networking import random_user_agent
USER_AGENT = random_user_agent()
ANIMEPAHE = "animepahe.ru"
ANIMEPAHE_BASE = f"https://{ANIMEPAHE}/"
ANIMEPAHE_BASE = f"https://{ANIMEPAHE}"
ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api?"
REQUEST_HEADERS = {
@@ -20,3 +20,20 @@ REQUEST_HEADERS = {
"Sec-Fetch-Mode": "cors",
"TE": "trailers",
}
SERVER_HEADERS = {
"User-Agent": USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br, zstd",
"DNT": "1",
"Alt-Used": "kwik.si",
"Connection": "keep-alive",
"Referer": ANIMEPAHE_BASE,
"Cookie": "kwik_session=eyJpdiI6IlZ5UDd0c0lKTDB1NXlhTHZPeWxFc2c9PSIsInZhbHVlIjoieDJZbGhZUG1QZDNaeWtqR3lwWFNnREdhaHBxNVZRMWNDOHVucGpiMHRJOVdhVmpBc3lpTko1VExRMTFWcE1yUVJtVitoTWdOOU5ObTQ0Q0dHU0MzZU0yRUVvNmtWcUdmY3R4UWx4YklJTmpUL0ZodjhtVEpjWU96cEZoUUhUbVYiLCJtYWMiOiI2OGY2YThkOGU0MTgwOThmYzcyZThmNzFlZjlhMzQzMDgwNjlmMTc4NTIzMzc2YjE3YjNmMWQyNTk4NzczMmZiIiwidGFnIjoiIn0%3D; srv=s0; cf_clearance=QMoZtUpZrX0Mh4XJiFmFSSmoWndISPne5FcsGmKKvTQ-1723297585-1.0.1.1-6tVUnP.aef9XeNj0CnN.19D1el_r53t.lhqddX.J88gohH9UnsPWKeJ4yT0pTbcaGRbPuXTLOS.U72.wdy.gMg",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "iframe",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "cross-site",
"Sec-Fetch-User": "?1",
"Priority": "u=4",
}

View File

@@ -0,0 +1,61 @@
from typing import Literal, TypedDict
class AnimeSearchResult(TypedDict):
id: int
title: str
type: str
episodes: int
status: str
season: str
year: int
score: int
poster: str
session: str
class AnimePaheSearchPage(TypedDict):
total: int
per_page: int
current_page: int
last_page: int
_from: int
to: int
data: list[AnimeSearchResult]
class Episode(TypedDict):
id: int
anime_id: int
episode: int
episode2: int
edition: str
title: str
snapshot: str # episode image
disc: str
audio: Literal["eng", "jpn"]
duration: str # time 00:00:00
session: str
filler: int
created_at: str
class AnimePaheAnimePage(TypedDict):
total: int
per_page: int
current_page: int
last_page: int
next_page_url: str | None
prev_page_url: str | None
_from: int
to: int
data: list[Episode]
class Server:
type: str
data_src = "https://kwik.si/e/PImJ0u7Y3M0G"
data_fansub: str
data_resolution: Literal["360", "720", "1080"]
data_audio: Literal["eng", "jpn"]
data_av1: str

View File

@@ -1,49 +0,0 @@
from ...anilist.anilist_data_schema import AnilistBaseMediaDataSchema
from ..base_provider import AnimeProvider
"""
"Zoro": {
"27": {
"identifier": "27",
"image": "https://cdn.noitatnemucod.net/thumbnail/300x400/100/ce5e539af63e42431621fc66a47fbec1.jpg",
"malId": 1,
"aniId": 1,
"page": "Zoro",
"title": "Cowboy Bebop",
"type": "anime",
"url": "https://hianime.to/cowboy-bebop-27"
}
},
episode info = https://hianime.to/ajax/v2/episode/list/27
"""
# TODO: complete this
class AniWatchApi(AnimeProvider):
def search_for_anime(
self, anilist_selected_anime: AnilistBaseMediaDataSchema, *args
):
return {
"pageInfo": 1,
"results": [
{
"id": anilist_selected_anime["id"],
"title": anilist_selected_anime["title"],
"availableEpisodes": [],
}
],
}
def get_anime(self, id: int):
url = f"https://raw.githubusercontent.com/bal-mackup/mal-backup/master/anilist/anime/{id}.json"
response = self.session.get(url)
if response.status_code == 200:
data = response.json()
data["Sites"]["Zoro"]
return {"id": ""}
else:
return {}
def get_episode_streams(self, id: int, episode: str, translation_type: str):
pass

View File

@@ -1,8 +1,18 @@
from typing import TypedDict
from typing import Literal, TypedDict
class PageInfo(TypedDict):
total: int
perPage: int
currentPage: int
#
# class EpisodesDetail(TypedDict):
# dub: int
# sub: int
# raw: int
#
# search data
@@ -11,6 +21,10 @@ class SearchResult(TypedDict):
title: str
availableEpisodes: list[str]
type: str
score: int
status: str
season: str
poster: str
class SearchResults(TypedDict):
@@ -19,30 +33,39 @@ class SearchResults(TypedDict):
# anime data
class EpisodesDetail(TypedDict):
dub: int
sub: int
raw: int
class AnimeEpisodeDetails(TypedDict):
dub: list[str]
sub: list[str]
raw: list[str]
class AnimeEpisode(TypedDict):
id: str
title: str
class Anime(TypedDict):
id: str
title: str
availableEpisodesDetail: EpisodesDetail
availableEpisodesDetail: AnimeEpisodeDetails
type: str | None
episodesInfo: list[AnimeEpisode] | None
poster: str
year: str
class EpisodeStream(TypedDict):
resolution: str
resolution: str | None
link: str
hls: bool | None
mp4: bool
priority: int
headers: dict
fromCache: str
mp4: bool | None
priority: int | None
headers: dict | None
quality: Literal["360", "720", "1080", "unknown"]
translation_type: Literal["dub", "sub"]
class Server(TypedDict):
server: str
episode_title: str | None
links: list
links: list[EpisodeStream]

View File

@@ -1,4 +1,5 @@
import re
from itertools import cycle
# Dictionary to map hex values to characters
hex_to_char = {
@@ -34,6 +35,15 @@ hex_to_char = {
}
def give_random_quality(links: list[dict]):
qualities = cycle(["1080", "720", "360"])
return [
{"link": link["link"], "quality": quality}
for link, quality in zip(links, qualities)
]
def decode_hex_string(hex_string):
"""some of the sources encrypt the urls into hex codes this function decrypts the urls

View File

@@ -6,7 +6,6 @@ import sys
from typing import Callable, List
# TODO: will probably scrap art not to useful
from art import text2art
from click import clear
from rich import print
@@ -22,6 +21,17 @@ FZF_DEFAULT_OPTS = """
--marker=">" --pointer="" --separator="" --scrollbar=""
"""
HEADER = """
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝
"""
class FZF:
"""an abstraction over the fzf commandline utility
@@ -128,7 +138,7 @@ class FZF:
self,
fzf_input: list[str],
prompt: str,
header: str,
header: str = HEADER,
preview: str | None = None,
expect: str | None = None,
validator: Callable | None = None,
@@ -149,7 +159,7 @@ class FZF:
_commands = [
*self.default_options,
"--header",
text2art(header),
HEADER,
"--header-first",
"--prompt",
prompt.title(),

16
poetry.lock generated
View File

@@ -1,19 +1,5 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "art"
version = "6.2"
description = "ASCII Art Library For Python"
optional = false
python-versions = ">=3.5"
files = [
{file = "art-6.2-py3-none-any.whl", hash = "sha256:d632d1d3f5fabcaf8673abe934b51df0017bc914d106e89d45ae4ebef0e3149a"},
{file = "art-6.2.tar.gz", hash = "sha256:506a0c4f261289a0e0d088de7beffcb1835078c4e44b0c5353bdaf47b490e76f"},
]
[package.extras]
dev = ["bandit (>=1.5.1)", "coverage (>=4.1)", "pydocstyle (>=3.0.0)", "vulture (>=1.0)"]
[[package]]
name = "autoflake"
version = "2.3.1"
@@ -1421,4 +1407,4 @@ test = ["pytest (>=8.1,<9.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "5305621bc02d824065519913f0d754e269f1e4525ba8796be08504120614259d"
content-hash = "83ec7de7d9466dcd1fadef4b21eec2a879cc9a7d526992ed280b6af53b49d9f1"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "fastanime"
version = "0.60.1.dev1"
version = "0.62.0.dev1"
description = "A browser anime site experience from the terminal"
authors = ["Benextempest <benextempest@gmail.com>"]
license = "UNLICENSE"
@@ -13,7 +13,6 @@ rich = "^13.7.1"
click = "^8.1.7"
inquirerpy = "^0.3.4"
platformdirs = "^4.2.2"
art = "^6.2"
python-dotenv = "^1.0.1"
thefuzz = "^0.22.1"
requests = "^2.32.3"

View File

@@ -45,6 +45,16 @@ def test_search_help(runner: CliRunner):
assert result.exit_code == 0
def test_cache_help(runner: CliRunner):
result = runner.invoke(run_cli, ["cache", "--help"])
assert result.exit_code == 0
def test_completions_help(runner: CliRunner):
result = runner.invoke(run_cli, ["completions", "--help"])
assert result.exit_code == 0
def test_anilist_help(runner: CliRunner):
result = runner.invoke(run_cli, ["anilist", "--help"])
assert result.exit_code == 0