Compare commits

...

53 Commits

Author SHA1 Message Date
benexl
4c8ff2ae9b chore: update lock files 2025-01-04 23:47:48 +03:00
benexl
23274de367 chore: bump version (v2.8.7) 2025-01-04 23:47:45 +03:00
benexl
2aec40ead0 refactor: temporarily remove nix from make - release 2025-01-04 23:47:42 +03:00
benexl
172f2bb1de chore: bump version in uv.lock 2025-01-04 23:37:55 +03:00
benexl
2f5684a93a feat(cli): add option to disable user config 2025-01-04 23:37:30 +03:00
benexl
1d40160abf chore: update rofi configs 2025-01-04 23:37:11 +03:00
Benedict Xavier
af84d80137 Merge pull request #46 from piradata/patch-2
docs(config): correct some typos
2025-01-01 01:31:58 +03:00
Piradata
e6412631ae correct some typos 2024-12-31 19:06:53 -03:00
Benedict Xavier
8023edcf3a Update README.md 2024-12-23 20:45:07 +03:00
Benedict Xavier
0cb50cd506 Update README.md 2024-12-20 13:55:39 +03:00
Benedict Xavier
50c048e158 Merge pull request #40 from serialjaywalker/master
feat: Check if in venv before attempting user install/update.
2024-12-17 20:34:51 +03:00
Serial_Jaywalker
c0a57c7814 Check if in venv before attempting user install/update. 2024-12-16 01:52:03 -08:00
benexl
bcdd88c725 chore: bump version 2024-12-13 08:34:24 +03:00
benexl
d45d438663 chore: bump version (vv2.8.6) 2024-12-13 08:18:54 +03:00
Benexl
3d12059e27 Update README.md 2024-12-12 20:04:31 +03:00
benexl
677f4690fa feat(anilist): make perPage configurable 2024-12-07 21:49:37 +03:00
Benexl
a79b59f727 Merge pull request #38 from she11sh0cked/master
fix(nix): add pypresence to flake.nix
2024-12-03 15:54:12 +03:00
she11sh0cked
5641c245e7 fix(nix): add pypresence to flake.nix 2024-12-03 13:49:52 +01:00
Benexl
058fc285cd Merge pull request #36 from gand0rf/master
Added pypresence discord function
2024-12-03 12:47:46 +03:00
Benexl
71cfe667c9 Merge branch 'master' into master 2024-12-03 12:39:56 +03:00
benex
d9692201aa fix: type errors 2024-12-03 12:38:29 +03:00
benex
1fd4087b41 fix: type errors 2024-12-03 12:32:08 +03:00
benex
787eb0c9ca refactor: conform all provider functions 2024-12-03 12:29:47 +03:00
benex
acd937f8ab fix(allanime): argument parsing 2024-12-03 12:07:53 +03:00
Gand0rf
52af68d13f minor chagne to discord.py 2024-12-02 20:26:49 -05:00
Gand0rf
1ff3074fad updated discord.py 2024-12-02 20:11:40 -05:00
Gand0rf
debaa2ffa6 Moved where threading switch is created to remove error 2024-12-02 19:37:49 -05:00
Gand0rf
5b6ccbe748 Fixing erros per github python test 2024-12-02 19:32:07 -05:00
Gand0rf
d6ca923951 Delete line from config.py 2024-12-02 19:25:50 -05:00
Gand0rf
0e9bf7f2de added pypresence with uv command 2024-12-02 19:16:25 -05:00
Gand0rf
ccad2435b0 Added pypresence discord function 2024-12-02 19:08:51 -05:00
benex
30fa9851dd feat(animepahe): refactor API calls to use query parameters and improve stream retrieval logic 2024-12-03 00:25:18 +03:00
benex
000bae9bb7 refactor(animepahe): update import path for process_animepahe_embed_page 2024-12-02 23:57:18 +03:00
benex
8c2bb71e08 feat(animepahe): enhance search functionality and improve error handling in episode retrieval 2024-12-02 23:54:35 +03:00
benex
57393b085a feat(animepahe): refactor episode stream retrieval and enhance error handling 2024-12-02 23:46:28 +03:00
benex
5f721847d7 feat(allanime): improve error handling for stream source retrieval 2024-12-02 23:19:18 +03:00
benex
383cb62ede style: format files 2024-12-02 22:26:03 +03:00
benex
434ac947dd chore: add pre-commit as dev dep 2024-12-02 21:06:48 +03:00
benex
d0fb39cede style: format files with ruff 2024-12-02 18:01:20 +03:00
benex
f98ae77587 feat(allanime): enhance stream source handling with new cases and regex for MP4 server (mp4-upload: success) 2024-12-02 16:27:58 +03:00
benex
33e1b0fb6f refactor(allanime): introduce default constants for search parameters and improve code readability 2024-12-02 12:19:03 +03:00
benex
7134702eb9 feat(allanime): add command-line interface for anime search and streaming 2024-12-02 12:08:38 +03:00
benex
cac7586a86 refactor(constants): update API_ENDPOINT to use f-string for improved readability 2024-12-02 12:08:37 +03:00
benex
0b9da27def refactor(allanime): enhance error handling in API response processing 2024-12-02 12:08:37 +03:00
benex
ddbb4ca451 refactor(anime_provider): simplify URL processing in AllAnime class 2024-12-02 12:08:37 +03:00
benex
757393aa36 refactor(allanime): update constants and improve naming for clarity 2024-12-02 12:08:37 +03:00
benex
eb54d5e995 chore: add VSCode settings for Python auto import completions 2024-12-02 12:08:37 +03:00
benex
0d95a38321 refactor(anime_provider): replace PROVIDER attribute with class name for improved clarity 2024-12-02 12:08:37 +03:00
benex
8d2734db74 refactor(allanime): streamline API methods and improve naming conventions 2024-12-02 12:08:37 +03:00
benex
b3abcb958b refactor: simplify debug_provider decorator and remove redundant provider name usage 2024-12-02 12:08:37 +03:00
benex
0667749e4c refactor: rename API classes for consistency and clarity 2024-12-02 12:08:37 +03:00
Benexl
57e73e6799 Update README.md 2024-11-28 20:00:45 +03:00
Benexl
7d890b9719 Update README.md 2024-11-28 19:51:28 +03:00
43 changed files with 1200 additions and 781 deletions

View File

@@ -1,5 +1,5 @@
default_language_version:
python: python3.10
python: python3.12
repos:
- repo: https://github.com/pycqa/isort
@@ -7,7 +7,7 @@ repos:
hooks:
- id: isort
name: isort (python)
args: ["--profile", "black"] # Ensure compatibility with Black
args: ["--profile", "black"]
- repo: https://github.com/PyCQA/autoflake
rev: v2.2.1
@@ -19,17 +19,15 @@ repos:
"--remove-unused-variables",
"--remove-all-unused-imports",
]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.4.10
hooks:
# Run the linter.
- id: ruff
args: [--fix]
# - repo: https://github.com/astral-sh/ruff-pre-commit
# rev: v0.4.10
# hooks:
# - id: ruff
# args: [--fix]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.4.2
hooks:
- id: black
name: black
language_version: python3.10 # to ensure compatibilty
#language_version: python3.10

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"python.analysis.autoImportCompletions": true
}

View File

@@ -26,7 +26,7 @@
<details>
<summary>
<b>My Rice</b>
<b>How it looks</b>
</summary>
**Anilist results menu:**
@@ -43,61 +43,6 @@
</details>
<details>
<summary><b>fzf mode</b></summary>
[fastanime-fzf.webm](https://github.com/user-attachments/assets/90875a57-198b-4c78-98d5-10a459001edd)
</details>
<details>
<summary><b>rofi mode</b></summary>
[fa_rofi_mode.webm](https://github.com/user-attachments/assets/2ce669bf-b62f-4c44-bd79-cf0dcaddf37a)
</details>
<details>
<summary><b>Default mode</b></summary>
[fa_default_mode.webm](https://github.com/user-attachments/assets/1ce3a23d-f4a0-4bc1-8518-426ec7b3b69e)
</details>
<!--toc:start-->
- [**FastAnime**](#fastanime)
- [Installation](#installation)
- [Installation using your favourite package manager](#installation-using-your-favourite-package-manager)
- [Using uv](#using-uv)
- [Using pipx](#using-pipx)
- [Using pip](#using-pip)
- [Installing the bleeding edge version](#installing-the-bleeding-edge-version)
- [Building from the source](#building-from-the-source)
- [External Dependencies](#external-dependencies)
- [Usage](#usage)
- [The Commandline interface :fire:](#the-commandline-interface-fire)
- [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)
- [grab subcommand](#grab-subcommand)
- [downloads subcommand](#downloads-subcommand)
- [config subcommand](#config-subcommand)
- [cache subcommand](#cache-subcommand)
- [update subcommand](#update-subcommand)
- [completions subcommand](#completions-subcommand)
- [fastanime serve](#fastanime-serve)
- [MPV specific commands](#mpv-specific-commands)
- [Key Bindings](#key-bindings)
- [Script Messages](#script-messages)
- [styling the default interface](#styling-the-default-interface)
- [Configuration](#configuration)
- [Contributing](#contributing)
- [Receiving Support](#receiving-support)
- [Supporting the Project](#supporting-the-project)
<!--toc:end-->
## Installation
@@ -245,6 +190,7 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
- [syncplay](https://syncplay.pl/) to enable watch together.
- [feh](https://github.com/derf/feh) used in manga mode
## Usage
The project offers a featureful command-line interface and MPV interface through the use of python-mpv.
@@ -848,29 +794,20 @@ format = best[height<=1080]/bestvideo[height<=1080]+bestaudio/best
player = mpv
```
## Contributing
We welcome your issues and feature requests. However, due to time constraints, I currently do not plan to add another provider.
But if you are willing to add one yourself pr's are welcome.
pr's are highly welcome
If you find an anime title that does not correspond with a provider or is just weird just [edit the data file](https://github.com/Benexl/FastAnime/blob/master/fastanime/Utility/data.py) and open a pr, i will ignore issues 😝.
## Receiving Support
For inquiries, join our [Discord Server](https://discord.gg/HBEmAwvbHV).
<p align="center">
<a href="https://discord.gg/HBEmAwvbHV">
<img src="https://invidget.switchblade.xyz/C4rhMA4mmK">
</a>
</p>
If you find an anime title that does not correspond with a provider or is just weird just [edit the data file](https://github.com/Benexl/FastAnime/blob/master/fastanime/Utility/data.py) and open a pr, issues will be ignored 😝.
## Supporting the Project
More pr's less issues 🙃
Those who contribute at least five times will be able to make changes to the repo without my review.
Show your support by starring the GitHub repository or [buying me a coffee](https://ko-fi.com/benexl).
Show your support by starring the GitHub repository.
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y8ZAA7N)
## Disclaimer

View File

@@ -62,11 +62,7 @@ class AnimeProvider:
)
def search_for_anime(
self,
user_query,
translation_type,
nsfw=True,
unknown=True,
self, search_keywords, translation_type, **kwargs
) -> "SearchResults | None":
"""core abstraction over all providers search functionality
@@ -82,7 +78,7 @@ class AnimeProvider:
"""
anime_provider = self.anime_provider
results = anime_provider.search_for_anime(
user_query, translation_type, nsfw, unknown
search_keywords, translation_type, **kwargs
)
return results
@@ -90,6 +86,7 @@ class AnimeProvider:
def get_anime(
self,
anime_id: str,
**kwargs,
) -> "Anime | None":
"""core abstraction over getting info of an anime from all providers
@@ -101,7 +98,7 @@ class AnimeProvider:
[TODO:return]
"""
anime_provider = self.anime_provider
results = anime_provider.get_anime(anime_id)
results = anime_provider.get_anime(anime_id, **kwargs)
return results
@@ -110,6 +107,7 @@ class AnimeProvider:
anime_id,
episode: str,
translation_type: str,
**kwargs,
) -> "Iterator[Server] | None":
"""core abstractions for getting juicy streams from all providers
@@ -124,6 +122,6 @@ class AnimeProvider:
"""
anime_provider = self.anime_provider
results = anime_provider.get_episode_streams(
anime_id, episode, translation_type
anime_id, episode, translation_type, **kwargs
)
return results

View File

@@ -12,11 +12,11 @@ anime_normalizer_raw = {
"Re:Zero kara Hajimeru Isekai Seikatsu Season 3": "Re:Zero kara Hajimeru Isekai Seikatsu 3rd Season",
},
"hianime": {"My Star": "Oshi no Ko"},
"animepahe": {
"animepahe": {
"Azumanga Daiou The Animation": "Azumanga Daioh",
"Mairimashita! Iruma-kun 2nd Season": "Mairimashita! Iruma-kun 2",
"Mairimashita! Iruma-kun 3rd Season": "Mairimashita! Iruma-kun 3"
},
"Mairimashita! Iruma-kun 3rd Season": "Mairimashita! Iruma-kun 3",
},
"nyaa": {},
"yugen": {},
}

View File

@@ -97,9 +97,7 @@ class YtDLPDownloader:
if i == 0:
if force_ffmpeg:
options = options | {
"external_downloader": {
'default': 'ffmpeg'
},
"external_downloader": {"default": "ffmpeg"},
"external_downloader_args": {
"ffmpeg_i1": ["-v", "error", "-stats"],
},

View File

@@ -6,7 +6,7 @@ if sys.version_info < (3, 10):
) # noqa: F541
__version__ = "v2.8.4"
__version__ = "v2.8.7"
APP_NAME = "FastAnime"
AUTHOR = "Benexl"

View File

@@ -1,8 +1,3 @@
// https://github.com/Wraient/curd/blob/main/rofi/selectanime.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
configuration {
font: "Sans 12";
line-margin: 10;
@@ -20,12 +15,13 @@ configuration {
window {
fullscreen: false;
background-color: rgba(0, 0, 0, 1); /* Solid black background */
background-color: rgba(0, 0, 0, 0.8); /* Solid black transparent background */
border-radius: 50px;
}
mainbox {
padding: 50px 100px;
background-color: rgba(0, 0, 0, 1); /* Ensures black background fills entire main area */
padding: 50px 50px;
background-color: transparent; /* Ensures black background fills entire main area */
children: [inputbar, listview];
spacing: 20px;
}
@@ -47,7 +43,7 @@ prompt {
entry {
padding: 8px;
background-color: #444444; /* Slightly lighter gray for visibility */
background-color: transparent; /* Slightly lighter gray for visibility */
text-color: #FFFFFF; /* White text to make typing visible */
placeholder: "Search...";
placeholder-color: rgba(255, 255, 255, 0.5);
@@ -57,19 +53,19 @@ entry {
listview {
layout: vertical;
spacing: 8px;
lines: 10;
background-color: @background; /* Consistent black background for list items */
lines: 9;
background-color: transparent; /* Consistent black background for list items */
}
element {
padding: 12px;
border-radius: 4px;
background-color: @background; /* Uniform color for each list item */
background-color: transparent; /* Uniform color for each list item */
text-color: @foreground;
}
element normal.normal {
background-color: @background; /* Ensures no alternating color */
background-color: transparent; /* Ensures no alternating color */
}
element selected.normal {

View File

@@ -1,7 +1,3 @@
// https://github.com/Wraient/curd/blob/main/rofi/userinput.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
configuration {
font: "Sans 12";
}
@@ -14,17 +10,19 @@ configuration {
window {
fullscreen: true;
transparency: "real";
background-color: @background-color;
background-color: transparent;
}
mainbox {
children: [ message, listview, inputbar ];
padding: 40% 30%;
background-color: transparent;
}
message {
border: 0;
padding: 10px;
border-radius:20px;
margin: 0 0 20px 0;
font: "Sans Bold 24"; /* Increased font size and made it bold */
}
@@ -42,6 +40,7 @@ prompt {
entry {
padding: 8px;
background-color: transparent;
}
listview {
@@ -52,4 +51,5 @@ listview {
textbox {
horizontal-align: 0.5; /* Center the text */
font: "Sans Bold 24"; /* Match message font */
background-color: transparent;
}

View File

@@ -1,7 +1,3 @@
// https://github.com/Wraient/curd/blob/main/rofi/userinput.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
configuration {
font: "Sans 12";
}
@@ -14,17 +10,19 @@ configuration {
window {
fullscreen: true;
transparency: "real";
background-color: @background-color;
background-color: transparent;
}
mainbox {
children: [ message, listview, inputbar ];
padding: 40% 30%;
background-color: transparent;
}
message {
border: 0;
padding: 10px;
border-radius:20px;
margin: 0 0 20px 0;
font: "Sans Bold 24"; /* Increased font size and made it bold */
}
@@ -42,6 +40,7 @@ prompt {
entry {
padding: 8px;
background-color: transparent;
}
listview {
@@ -52,4 +51,5 @@ listview {
textbox {
horizontal-align: 0.5; /* Center the text */
font: "Sans Bold 24"; /* Match message font */
background-color: transparent;
}

View File

@@ -1,122 +1,120 @@
// Based on https://github.com/Wraient/curd/blob/main/rofi/selectanimepreview.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
// Colours
* {
background-color: transparent;
background: #1D2330;
background-transparent: #1D2330A0;
text-color: #BBBBBB;
text-color-selected: #FFFFFF;
primary: #BB77BB;
important: #BF616A;
background-color: transparent; /* Transparent background for the global UI */
background: #000000; /* Solid black background */
background-transparent: #1D2330A0; /* Semi-transparent background */
text-color: #BBBBBB; /* Default text color (light gray) */
text-color-selected: #FFFFFF; /* Text color when selected (white) */
primary: rgba(53, 132, 228, 0.75); /* Blusish primary color */
important: rgba(53, 132, 228, 0.75); /* Bluish primary color */
}
configuration {
font: "Roboto 17";
show-icons: true;
font: "Roboto 14"; /* Sets the global font to Roboto, size 14 */
show-icons: true; /* Option to display icons in the UI */
}
window {
fullscreen: true;
height: 100%;
width: 100%;
transparency: "real";
background-color: @background-transparent;
border: 0px;
border-color: @primary;
fullscreen: true; /* The window will open in fullscreen */
height: 100%; /* Full window height */
width: 100%; /* Full window width */
transparency: "real"; /* Real transparency effect */
background-color: @background-transparent; /* Transparent background */
border: 0px; /* No border around the window */
border-color: @primary; /* Border color set to the primary color */
}
mainbox {
children: [prompt, inputbar-box, listview];
padding: 0px;
children: [prompt, inputbar-box, listview]; /* Main box contains prompt, input bar, and list view */
padding: 0px; /* No padding around the main box */
}
prompt {
width: 100%;
margin: 10px 0px 0px 30px;
text-color: @important;
font: "Roboto Bold 27";
width: 100%; /* Prompt takes full width */
margin: 10px 0px 0px 30px; /* Margin around the prompt */
text-color: @important; /* Text color for prompt (important color) */
font: "Roboto Bold 27"; /* Bold Roboto font, size 27 */
}
listview {
layout: vertical;
padding: 60px;
dynamic: true;
columns: 7;
spacing: 20px;
horizontal-align: center; /* Center the list items */
layout: vertical; /* Vertical layout for list items */
padding: 10px; /* Padding inside the list view */
spacing: 20px; /* Space between items in the list */
columns: 8; /* Maximum 8 items per row */
dynamic: true; /* Allows the list to dynamically adjust */
orientation: horizontal; /* Horizontal orientation for list items */
}
inputbar-box {
children: [dummy, inputbar, dummy];
orientation: horizontal;
expand: false;
children: [dummy, inputbar, dummy]; /* Input bar is centered with dummy placeholders */
orientation: horizontal; /* Horizontal layout for input bar */
expand: false; /* Does not expand to fill the space */
}
inputbar {
children: [textbox-prompt, entry];
margin: 0px;
background-color: @primary;
border: 4px;
border-color: @primary;
border-radius: 8px;
children: [textbox-prompt, entry]; /* Contains a prompt and an entry field */
margin: 0px; /* No margin around the input bar */
background-color: @primary; /* Background color set to the primary color */
border: 4px; /* Border thickness around the input bar */
border-color: @primary; /* Border color matches the primary color */
border-radius: 8px; /* Rounded corners for the input bar */
}
textbox-prompt {
text-color: @background;
horizontal-align: 0.5;
vertical-align: 0.5;
expand: false;
text-color: @background; /* Text color inside prompt matches the background color */
horizontal-align: 0.5; /* Horizontally centered */
vertical-align: 0.5; /* Vertically centered */
expand: false; /* Does not expand to fill available space */
}
entry {
expand: false;
padding: 8px;
margin: -6px;
horizontal-align: 0;
width: 300;
background-color: @background;
border: 6px;
border-color: @primary;
border-radius: 8px;
cursor: text;
expand: false; /* Entry field does not expand */
padding: 8px; /* Padding inside the entry field */
margin: -6px; /* Negative margin to position entry properly */
horizontal-align: 0; /* Left-aligned text inside the entry field */
width: 300; /* Fixed width for the entry field */
background-color: @background; /* Entry background color matches the global background */
border: 6px; /* Border thickness around the entry field */
border-color: @primary; /* Border color matches the primary color */
border-radius: 8px; /* Rounded corners for the entry field */
cursor: text; /* Cursor changes to text input cursor inside the entry field */
}
element {
children: [dummy, element-box, dummy];
padding: 5px;
orientation: vertical;
border: 0px;
border-radius: 16px;
background-color: transparent; /* Default background */
children: [dummy, element-box, dummy]; /* Contains an element box with dummy placeholders */
padding: 5px; /* Padding around the element */
orientation: vertical; /* Vertical layout for element content */
border: 0px; /* No border around the element */
border-radius: 16px; /* Rounded corners for the element */
background-color: transparent; /* Transparent background */
width: 100px; /* Width of each element */
height: 50px; /* Height of each element */
}
element selected {
background-color: @primary; /* Solid color for selected item */
background-color: @primary; /* Background color of the element when selected */
}
element-box {
children: [element-icon, element-text];
orientation: vertical;
expand: false;
cursor: pointer;
children: [element-icon, element-text]; /* Element box contains an icon and text */
orientation: vertical; /* Vertical layout for icon and text */
expand: false; /* Does not expand to fill available space */
cursor: pointer; /* Cursor changes to a pointer when hovering over the element */
}
element-icon {
padding: 10px;
cursor: inherit;
size: 33%;
margin: 10px;
padding: 10px; /* Padding inside the icon */
cursor: inherit; /* Inherits cursor style from the parent */
size: 33%; /* Icon size is set to 33% of the parent element */
margin: 10px; /* Margin around the icon */
}
element-text {
horizontal-align: 0.5;
cursor: inherit;
text-color: @text-color;
horizontal-align: 0.5; /* Horizontally center-aligns the text */
cursor: inherit; /* Inherits cursor style from the parent */
text-color: @text-color; /* Text color for element text */
}
element-text selected {
text-color: @text-color-selected;
text-color: @text-color-selected; /* Text color when the element is selected */
}

View File

@@ -184,6 +184,7 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option(
"--fresh-requests", is_flag=True, help="Force the requests cache to be updated"
)
@click.option("--no-config", is_flag=True, help="Don't load the user config")
@click.pass_context
def run_cli(
ctx: click.Context,
@@ -220,13 +221,14 @@ def run_cli(
sync_play,
player,
fresh_requests,
no_config,
):
import os
import sys
from .config import Config
ctx.obj = Config()
ctx.obj = Config(no_config)
if (
ctx.obj.check_for_updates
and ctx.invoked_subcommand != "completions"
@@ -253,9 +255,10 @@ def run_cli(
if not is_latest:
from rich.console import Console
from rich.markdown import Markdown
from .app_updater import update_app
from rich.prompt import Confirm
from .app_updater import update_app
def _print_release(release_data):
console = Console()
body = Markdown(release_data["body"])

View File

@@ -1,10 +1,10 @@
import os
import pathlib
import re
import shlex
import shutil
import subprocess
import sys
import os
import requests
from rich import print
@@ -128,9 +128,13 @@ def update_app(force=False):
"install",
APP_NAME,
"-U",
"--user",
"--no-warn-script-location",
]
if sys.prefix == sys.base_prefix:
# ensure NOT in a venv, where --user flag can cause an error.
# TODO: Get value of 'include-system-site-packages' in pyenv.cfg.
args.append('--user')
process = subprocess.run(args)
if process.returncode == 0:
return True, release_json

View File

@@ -1,14 +1,13 @@
import click
from ...completion_functions import anime_titles_shell_complete
from .data import (
tags_available_list,
sorts_available,
media_statuses_available,
seasons_available,
genres_available,
media_formats_available,
media_statuses_available,
seasons_available,
sorts_available,
tags_available_list,
years_available,
)
@@ -146,9 +145,10 @@ def download(
hls_use_mpegts,
max_results,
):
from ....anilist import AniList
from rich import print
from ....anilist import AniList
force_ffmpeg |= hls_use_mpegts
success, anilist_search_results = AniList.search(

View File

@@ -2,12 +2,12 @@ import click
from ...completion_functions import anime_titles_shell_complete
from .data import (
tags_available_list,
sorts_available,
media_statuses_available,
seasons_available,
genres_available,
media_formats_available,
media_statuses_available,
seasons_available,
sorts_available,
tags_available_list,
years_available,
)

View File

@@ -3,16 +3,16 @@ import logging
import os
from configparser import ConfigParser
from typing import TYPE_CHECKING
from ..libs.fzf import FZF_DEFAULT_OPTS, HEADER
from ..constants import (
ASSETS_DIR,
S_PLATFORM,
USER_CONFIG_PATH,
USER_DATA_PATH,
USER_VIDEOS_DIR,
ASSETS_DIR,
USER_WATCH_HISTORY_PATH,
S_PLATFORM,
)
from ..libs.fzf import FZF_DEFAULT_OPTS, HEADER
from ..libs.rofi import Rofi
logger = logging.getLogger(__name__)
@@ -45,6 +45,7 @@ class Config(object):
"default_media_list_tracking": "None",
"downloads_dir": USER_VIDEOS_DIR,
"disable_mpv_popen": "True",
"discord": "False",
"episode_complete_at": "80",
"ffmpegthumbnailer_seek_time": "-1",
"force_forward_tracking": "true",
@@ -58,6 +59,7 @@ class Config(object):
"normalize_titles": "True",
"notification_duration": "2",
"max_cache_lifetime": "03:00:00",
"per_page": "15",
"player": "mpv",
"preferred_history": "local",
"preferred_language": "english",
@@ -82,18 +84,18 @@ class Config(object):
"use_rofi": "false",
}
def __init__(self) -> None:
def __init__(self, no_config) -> None:
self.initialize_user_data_and_watch_history_recent_anime()
self.load_config()
self.load_config(no_config)
def load_config(self):
def load_config(self, no_config=False):
self.configparser = ConfigParser(self.default_config)
self.configparser.add_section("stream")
self.configparser.add_section("general")
self.configparser.add_section("anilist")
# --- set config values from file or using defaults ---
if os.path.exists(USER_CONFIG_PATH):
if os.path.exists(USER_CONFIG_PATH) and not no_config:
self.configparser.read(USER_CONFIG_PATH, encoding="utf-8")
# get the configuration
@@ -112,6 +114,7 @@ class Config(object):
self.disable_mpv_popen = self.configparser.getboolean(
"stream", "disable_mpv_popen"
)
self.discord = self.configparser.getboolean("general", "discord")
self.downloads_dir = self.configparser.get("general", "downloads_dir")
self.episode_complete_at = self.configparser.getint(
"stream", "episode_complete_at"
@@ -144,6 +147,7 @@ class Config(object):
+ max_cache_lifetime[1] * 3600
+ max_cache_lifetime[2] * 60
)
self.per_page = self.configparser.get("anilist", "per_page")
self.player = self.configparser.get("stream", "player")
self.preferred_history = self.configparser.get("stream", "preferred_history")
self.preferred_language = self.configparser.get("general", "preferred_language")
@@ -275,114 +279,114 @@ class Config(object):
#
[general]
# Can you rice it?
# for the preview pane
# For the preview pane
preview_separator_color = {self.preview_separator_color}
preview_header_color = {self.preview_header_color}
# for the header
# be sure to indent
header_ascii_art = {new_line.join([tab+line for line in self.header_ascii_art.split(new_line)])}
# For the header
# Be sure to indent
header_ascii_art = {new_line.join([tab + line for line in self.header_ascii_art.split(new_line)])}
header_color = {self.header_color}
# to be passed to fzf
# be sure to indent
fzf_opts = {new_line.join([tab+line for line in self.fzf_opts.split(new_line)])}
# To be passed to fzf
# Be sure to indent
fzf_opts = {new_line.join([tab + line for line in self.fzf_opts.split(new_line)])}
# whether to show the icons in the tui [True/False]
# more like emojis
# by the way if you have any recommendations
# to which should be used where please
# Whether to show the icons in the TUI [True/False]
# More like emojis
# By the way, if you have any recommendations
# for which should be used where, please
# don't hesitate to share your opinion
# cause it's a lot of work
# because it's a lot of work
# to look for the right one for each menu option
# be sure to also give the replacement emoji
# Be sure to also give the replacement emoji
icons = {self.icons}
# whether to normalize provider titles [True/False]
# basically takes the provider titles and finds the corresponding anilist title then changes the title to that
# useful for uniformity especially when downloading from different providers
# this also applies to episode titles
# Whether to normalize provider titles [True/False]
# Basically takes the provider titles and finds the corresponding Anilist title, then changes the title to that
# Useful for uniformity, especially when downloading from different providers
# This also applies to episode titles
normalize_titles = {self.normalize_titles}
# whether to check for updates every time you run the script [True/False]
# this is useful for keeping your script up to date
# cause there are always new features being added 😄
# Whether to check for updates every time you run the script [True/False]
# This is useful for keeping your script up to date
# because there are always new features being added 😄
check_for_updates = {self.check_for_updates}
# can be [allanime, animepahe, hianime, nyaa, yugen]
# allanime is the most realible
# animepahe provides different links to streams of different quality so a quality can be selected reliably with --quality option
# hianime usually provides subs in different languuages and its servers are generally faster
# NOTE: currently they are encrypting the video links
# though am working on it
# however, you can still get the links to the subs
# Can be [allanime, animepahe, hianime, nyaa, yugen]
# Allanime is the most reliable
# Animepahe provides different links to streams of different quality, so a quality can be selected reliably with the --quality option
# Hianime usually provides subs in different languages, and its servers are generally faster
# NOTE: Currently, they are encrypting the video links
# though Im working on it
# However, you can still get the links to the subs
# with ```fastanime grab``` command
# yugen meh
# nyaa those who prefer torrents, though not reliable due to auto selection of results
# as most of the data in nyaa is not structured
# though works relatively well for new anime
# esp with subsplease and horriblesubs
# oh and you should have webtorrent cli to use this
# Yugen meh
# Nyaa for those who prefer torrents, though not reliable due to auto-selection of results
# as most of the data in Nyaa is not structured
# though it works relatively well for new anime
# especially with SubsPlease and HorribleSubs
# Oh, and you should have webtorrent CLI to use this
provider = {self.provider}
# Display language [english, romaji]
# this is passed to anilist directly and is used to set the language which the anime titles will be in
# when using the anilist interface
# This is passed to Anilist directly and is used to set the language for anime titles
# when using the Anilist interface
preferred_language = {self.preferred_language}
# Download directory
# where you will find your videos after downloading them with 'fastanime download' command
# Where you will find your videos after downloading them with 'fastanime download' command
downloads_dir = {self.downloads_dir}
# whether to show a preview window when using fzf or rofi [True/False]
# the preview requires you have a commandline image viewer as documented in the README
# this is only when using fzf or rofi
# if you dont care about image and text previews it doesnt matter
# though its awesome
# try it and you will see
# Whether to show a preview window when using fzf or rofi [True/False]
# The preview requires you to have a command-line image viewer as documented in the README
# This is only when using fzf or rofi
# If you don't care about image and text previews, it doesnt matter
# though its awesome
# Try it, and you will see
preview = {self.preview}
# whether to show images in the preview [true/false]
# windows users just swtich to linux 😄
# cause even if you enable it
# Whether to show images in the preview [True/False]
# Windows users: just switch to Linux 😄
# because even if you enable it
# it won't look pretty
# just be satisfied with the text previews
# so forget it exists 🤣
# Just be satisfied with the text previews
# So forget it exists 🤣
image_previews = {self.image_previews}
# the time to seek when using ffmpegthumbnailer [-1 to 100]
# -1 means random and is the default
# ffmpegthumbnailer is used to generate previews
# and you can select at what time in the video to extract an image
# random makes things quite exciting cause you never no at what time it will extract the image from
# used by the ```fastanime downloads``` command
# ffmpegthumbnailer is used to generate previews,
# allowing you to select the time in the video to extract an image.
# Random makes things quite exciting because you never know at what time it will extract the image.
# Used by the `fastanime downloads` command.
ffmpegthumbnailer_seek_time = {self.ffmpegthumbnailer_seek_time}
# specify the order of menu items in a comma-separated list.
# only include the base names of menu options (e.g., "Trending", "Recent").
# default value is 'Trending,Recent,Watching,Paused,Dropped,Planned,Completed,Rewatching,Recently Updated Anime,Search,Watch History,Random Anime,Most Popular Anime,Most Favourite Anime,Most Scored Anime,Upcoming Anime,Edit Config,Exit'
# leave blank to use the default menu order.
# you can also omit some options by not including them in the list
# Only include the base names of menu options (e.g., "Trending", "Recent").
# The default value is 'Trending,Recent,Watching,Paused,Dropped,Planned,Completed,Rewatching,Recently Updated Anime,Search,Watch History,Random Anime,Most Popular Anime,Most Favourite Anime,Most Scored Anime,Upcoming Anime,Edit Config,Exit'.
# Leave blank to use the default menu order.
# You can also omit some options by not including them in the list.
menu_order = {self.menu_order}
# whether to use fzf as the interface for the anilist command and others. [True/False]
use_fzf = {self.use_fzf}
# whether to use rofi for the ui [True/False]
# it's more useful if you want to create a desktop entry
# which can be setup with 'fastanime config --desktop-entry'
# though if you want it to be your sole interface even when fastanime is run directly from the terminal
# whether to use rofi for the UI [True/False]
# It's more useful if you want to create a desktop entry,
# which can be set up with 'fastanime config --desktop-entry'.
# If you want it to be your sole interface even when fastanime is run directly from the terminal, enable this.
use_rofi = {self.use_rofi}
# rofi themes to use <path>
# the values of this option is the path to the rofi config files to use
# i choose to split it into 4 since it gives the best look and feel
# you can refer to the rofi demo on github to see for your self
# i need help designing the default rofi themes
# if you fancy yourself a rofi ricer please contribute to making
# the default theme better
# The value of this option is the path to the rofi config files to use.
# I chose to split it into 4 since it gives the best look and feel.
# You can refer to the rofi demo on GitHub to see for yourself.
# I need help designing the default rofi themes.
# If you fancy yourself a rofi ricer, please contribute to improving
# the default theme.
rofi_theme = {self.rofi_theme}
rofi_theme_preview = {self.rofi_theme_preview}
@@ -391,52 +395,54 @@ rofi_theme_input = {self.rofi_theme_input}
rofi_theme_confirm = {self.rofi_theme_confirm}
# the duration in minutes a notification will stay in the screen
# used by notifier command
# the duration in minutes a notification will stay on the screen.
# Used by the notifier command.
notification_duration = {self.notification_duration}
# used when the provider gives subs of different languages
# currently its the case for:
# hianime
# the values for this option are the short names for languages
# regex is used to determine what you selected
# used when the provider offers subtitles in different languages.
# Currently, this is the case for:
# hianime.
# The values for this option are the short names for languages.
# Regex is used to determine what you selected.
sub_lang = {self.sub_lang}
# what is your default media list tracking [track/disabled/prompt]
# only affects your anilist anime list
# track - means your progress will always be reflected in your anilist anime list
# disabled - means progress tracking will no longer be reflected in your anime list
# prompt - means for every anime you will be prompted whether you want your progress to be tracked or not
# This only affects your anilist anime list.
# track - means your progress will always be reflected in your anilist anime list.
# disabled - means progress tracking will no longer be reflected in your anime list.
# prompt - means you will be prompted for each anime whether you want your progress to be tracked or not.
default_media_list_tracking = {self.default_media_list_tracking}
# whether media list tracking should only be updated when the next episode is greater than the previous
# this affects only your anilist anime list
# whether media list tracking should only be updated when the next episode is greater than the previous.
# This only affects your anilist anime list.
force_forward_tracking = {self.force_forward_tracking}
# whether to cache requests [true/false]
# this makes the experience better and more faster
# as data need not always be fetched from web server
# and instead can be gotten locally
# from the cached_requests_db
# This improves the experience by making it faster,
# as data doesn't always need to be fetched from the web server
# and can instead be retrieved locally from the cached_requests_db.
cache_requests = {self.cache_requests}
# the max lifetime for a cached request <days:hours:minutes>
# defaults to 3days = 03:00:00
# this is the time after which a cached request will be deleted (technically : )
# Defaults to 3 days = 03:00:00.
# This is the time after which a cached request will be deleted (technically).
max_cache_lifetime = {self._max_cache_lifetime}
# whether to use a persistent store (basically a sqlitedb) for storing some data the provider requires
# to enable a seamless experience [true/false]
# this option exists primarily because i think it may help in the optimization
# of fastanime as a library in a website project
# for now i don't recommend changing it
# leave it as is
# whether to use a persistent store (basically an SQLite DB) for storing some data the provider requires
# to enable a seamless experience. [true/false]
# This option exists primarily to optimize FastAnime as a library in a website project.
# For now, it's not recommended to change it. Leave it as is.
use_persistent_provider_store = {self.use_persistent_provider_store}
# no of recent anime to keep [0-50]
# 0 will disable recent anime tracking
# number of recent anime to keep [0-50].
# 0 will disable recent anime tracking.
recent = {self.recent}
# enable or disable Discord activity updater.
# If you want to enable it, please follow the link below to register the app with your Discord account:
# https://discord.com/oauth2/authorize?client_id=1292070065583165512
discord = {self.discord}
[stream]
# the quality of the stream [1080,720,480,360]
@@ -559,6 +565,9 @@ format = {self.format}
# since you will miss out on some features if you use the others
player = {self.player}
[anilist]
per_page = {self.per_page}
#
# HOPE YOU ENJOY FASTANIME AND BE SURE TO STAR THE PROJECT ON GITHUB
# https://github.com/Benexl/FastAnime

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import os
import random
import threading
from typing import TYPE_CHECKING
from click import clear
@@ -14,6 +15,7 @@ from yt_dlp.utils import sanitize_filename
from ...anilist import AniList
from ...constants import USER_CONFIG_PATH
from ...libs.discord import discord
from ...libs.fzf import fzf
from ...libs.rofi import Rofi
from ...Utility.data import anime_normalizer
@@ -54,6 +56,8 @@ def calculate_percentage_completion(start_time, end_time):
except Exception:
return 0
def discord_updater(show,episode,switch):
discord.discord_connect(show,episode,switch)
def media_player_controls(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
@@ -510,6 +514,12 @@ def provider_anime_episode_servers_menu(
"[bold magenta] Episode: [/]",
current_episode_number,
)
# update discord activity for user
switch = threading.Event()
if config.discord:
discord_proc = threading.Thread(target=discord_updater, args=(provider_anime_title,current_episode_number,switch))
discord_proc.start()
# try to get the timestamp you left off from if available
start_time = config.watch_history.get(str(anime_id_anilist), {}).get(
"episode_stopped_at", "0"
@@ -592,6 +602,10 @@ def provider_anime_episode_servers_menu(
)
print("Finished at: ", stop_time)
# stop discord activity updater
if config.discord:
switch.set()
# update_watch_history
# this will try to update the episode to be the next episode if delta has reached a specific threshhold
# this update will only apply locally
@@ -1358,10 +1372,9 @@ def media_actions_menu(
media_actions_menu(config, fastanime_runtime_state)
return
relations = relations[1]["data"]["Page"]["relations"] # pyright:ignore
fastanime_runtime_state.anilist_results_data = {
"data": {
"Page": {"media": relations[1]["data"]["Media"]["relations"]["nodes"]} # pyright:ignore
}
"data": {"Page": {"media": relations["nodes"]}} # pyright:ignore
}
anilist_results_menu(config, fastanime_runtime_state)
@@ -1728,34 +1741,22 @@ def fastanime_main_menu(
options = {
f"{'🔥 ' if icons else ''}Trending": AniList.get_trending,
f"{'🎞️ ' if icons else ''}Recent": _recent,
f"{'📺 ' if icons else ''}Watching": lambda config,
media_list_type="Watching",
page=1: _handle_animelist(
f"{'📺 ' if icons else ''}Watching": lambda config, media_list_type="Watching", page=1: _handle_animelist(
config, fastanime_runtime_state, media_list_type, page=page
),
f"{'' if icons else ''}Paused": lambda config,
media_list_type="Paused",
page=1: _handle_animelist(
f"{'' if icons else ''}Paused": lambda config, media_list_type="Paused", page=1: _handle_animelist(
config, fastanime_runtime_state, media_list_type, page=page
),
f"{'🚮 ' if icons else ''}Dropped": lambda config,
media_list_type="Dropped",
page=1: _handle_animelist(
f"{'🚮 ' if icons else ''}Dropped": lambda config, media_list_type="Dropped", page=1: _handle_animelist(
config, fastanime_runtime_state, media_list_type, page=page
),
f"{'📑 ' if icons else ''}Planned": lambda config,
media_list_type="Planned",
page=1: _handle_animelist(
f"{'📑 ' if icons else ''}Planned": lambda config, media_list_type="Planned", page=1: _handle_animelist(
config, fastanime_runtime_state, media_list_type, page=page
),
f"{'' if icons else ''}Completed": lambda config,
media_list_type="Completed",
page=1: _handle_animelist(
f"{'' if icons else ''}Completed": lambda config, media_list_type="Completed", page=1: _handle_animelist(
config, fastanime_runtime_state, media_list_type, page=page
),
f"{'🔁 ' if icons else ''}Rewatching": lambda config,
media_list_type="Rewatching",
page=1: _handle_animelist(
f"{'🔁 ' if icons else ''}Rewatching": lambda config, media_list_type="Rewatching", page=1: _handle_animelist(
config, fastanime_runtime_state, media_list_type, page=page
),
f"{'🔔 ' if icons else ''}Recently Updated Anime": AniList.get_most_recently_updated,

View File

@@ -1,8 +1,8 @@
import re
import logging
import os
import re
import shutil
import subprocess
import logging
import time
from ...constants import S_PLATFORM

View File

@@ -1,11 +1,10 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
from typing import Any, Callable
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...libs.anime_provider.types import Anime, EpisodeStream, SearchResult, Server
from typing import Callable
class FastAnimeRuntimeState(object):

View File

@@ -3,6 +3,7 @@ This is the core module availing all the abstractions of the anilist api
"""
import logging
import os
from typing import TYPE_CHECKING
import requests
@@ -139,7 +140,12 @@ class AniListApi:
return self._make_authenticated_request(media_list_mutation, variables)
def get_anime_list(
self, status: "AnilistMediaListStatus", type="ANIME", page=1, **kwargs
self,
status: "AnilistMediaListStatus",
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
**kwargs,
) -> tuple[bool, "AnilistMediaLists"] | tuple[bool, None]:
"""gets an anime list from your media list given the list status
@@ -154,6 +160,7 @@ class AniListApi:
"userId": self.user_id,
"type": type,
"page": page,
"perPage": int(perPage),
}
return self._make_authenticated_request(media_list_query, variables)
@@ -354,51 +361,92 @@ class AniListApi:
variables = {"id": id}
return self.get_data(anime_query, variables)
def get_trending(self, type="ANIME", page=1, *_, **kwargs):
def get_trending(
self,
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
*_,
**kwargs,
):
"""
Gets the currently trending anime
"""
variables = {"type": type, "page": page}
variables = {"type": type, "page": page, "perPage": int(perPage)}
trending = self.get_data(trending_query, variables)
return trending
def get_most_favourite(self, type="ANIME", page=1, *_, **kwargs):
def get_most_favourite(
self,
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
*_,
**kwargs,
):
"""
Gets the most favoured anime on anilist
"""
variables = {"type": type, "page": page}
variables = {"type": type, "page": page, "perPage": int(perPage)}
most_favourite = self.get_data(most_favourite_query, variables)
return most_favourite
def get_most_scored(self, type="ANIME", page=1, *_, **kwargs):
def get_most_scored(
self,
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
*_,
**kwargs,
):
"""
Gets most scored anime on anilist
"""
variables = {"type": type, "page": page}
variables = {"type": type, "page": page, "perPage": int(perPage)}
most_scored = self.get_data(most_scored_query, variables)
return most_scored
def get_most_recently_updated(self, type="ANIME", page=1, *_, **kwargs):
def get_most_recently_updated(
self,
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
*_,
**kwargs,
):
"""
Gets most recently updated anime from anilist
"""
variables = {"type": type, "page": page}
variables = {"type": type, "page": page, "perPage": int(perPage)}
most_recently_updated = self.get_data(most_recently_updated_query, variables)
return most_recently_updated
def get_most_popular(self, type="ANIME", page=1, **kwargs):
def get_most_popular(
self,
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
**kwargs,
):
"""
Gets most popular anime on anilist
"""
variables = {"type": type, "page": page}
variables = {"type": type, "page": page, "perPage": int(perPage)}
most_popular = self.get_data(most_popular_query, variables)
return most_popular
def get_upcoming_anime(self, type="ANIME", page: int = 1, *_, **kwargs):
def get_upcoming_anime(
self,
type="ANIME",
page: int = 1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
*_,
**kwargs,
):
"""
Gets upcoming anime from anilist
"""
variables = {"page": page, "type": type}
variables = {"page": page, "type": type, "perPage": int(perPage)}
upcoming_anime = self.get_data(upcoming_anime_query, variables)
return upcoming_anime

View File

@@ -193,8 +193,8 @@ mutation (
"""
media_list_query = """
query ($userId: Int, $status: MediaListStatus, $type: MediaType, $page: Int) {
Page(perPage: 15, page: $page) {
query ($userId: Int, $status: MediaListStatus, $type: MediaType, $page: Int, $perPage: Int) {
Page(perPage: $perPage, page: $page) {
pageInfo {
currentPage
total
@@ -406,8 +406,8 @@ query($query:String,%s){
)
trending_query = """
query ($type: MediaType, $page: Int) {
Page(perPage: 15, page: $page) {
query ($type: MediaType, $page: Int,$perPage:Int) {
Page(perPage: $perPage, page: $page) {
media(sort: TRENDING_DESC, type: $type, genre_not_in: ["hentai"]) {
id
idMal
@@ -471,8 +471,8 @@ query ($type: MediaType, $page: Int) {
# mosts
most_favourite_query = """
query ($type: MediaType, $page: Int) {
Page(perPage: 15, page: $page) {
query ($type: MediaType, $page: Int,$perPage:Int) {
Page(perPage: $perPage, page: $page) {
media(sort: FAVOURITES_DESC, type: $type, genre_not_in: ["hentai"]) {
id
idMal
@@ -539,8 +539,8 @@ query ($type: MediaType, $page: Int) {
"""
most_scored_query = """
query ($type: MediaType, $page: Int) {
Page(perPage: 15, page: $page) {
query ($type: MediaType, $page: Int,$perPage:Int) {
Page(perPage: $perPage, page: $page) {
media(sort: SCORE_DESC, type: $type, genre_not_in: ["hentai"]) {
id
idMal
@@ -603,8 +603,8 @@ query ($type: MediaType, $page: Int) {
"""
most_popular_query = """
query ($type: MediaType, $page: Int) {
Page(perPage: 15, page: $page) {
query ($type: MediaType, $page: Int,$perPage:Int) {
Page(perPage: $perPage, page: $page) {
media(sort: POPULARITY_DESC, type: $type, genre_not_in: ["hentai"]) {
id
idMal
@@ -667,8 +667,8 @@ query ($type: MediaType, $page: Int) {
"""
most_recently_updated_query = """
query ($type: MediaType, $page: Int) {
Page(perPage: 15, page: $page) {
query ($type: MediaType, $page: Int,$perPage:Int) {
Page(perPage: $perPage, page: $page) {
media(
sort: UPDATED_AT_DESC
type: $type
@@ -918,8 +918,8 @@ query ($id: Int,$type:MediaType) {
"""
upcoming_anime_query = """
query ($page: Int, $type: MediaType) {
Page(perPage: 15, page: $page) {
query ($page: Int, $type: MediaType,$perPage:Int) {
Page(perPage: $perPage, page: $page) {
pageInfo {
total
perPage

View File

@@ -3,10 +3,10 @@ from .animepahe.constants import SERVERS_AVAILABLE as ANIMEPAHE_SERVERS
from .hianime.constants import SERVERS_AVAILABLE as HIANIME_SERVERS
anime_sources = {
"allanime": "api.AllAnimeAPI",
"animepahe": "api.AnimePaheApi",
"hianime": "api.HiAnimeApi",
"nyaa": "api.NyaaApi",
"yugen": "api.YugenApi"
"allanime": "api.AllAnime",
"animepahe": "api.AnimePahe",
"hianime": "api.HiAnime",
"nyaa": "api.Nyaa",
"yugen": "api.Yugen",
}
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS]

View File

@@ -1,8 +1,3 @@
"""a module that handles the scraping of allanime
abstraction over allanime api
"""
import json
import logging
from typing import TYPE_CHECKING
@@ -10,207 +5,215 @@ from typing import TYPE_CHECKING
from ...anime_provider.base_provider import AnimeProvider
from ..decorators import debug_provider
from ..utils import give_random_quality, one_digit_symmetric_xor
from .constants import ALLANIME_API_ENDPOINT, ALLANIME_BASE, ALLANIME_REFERER
from .gql_queries import ALLANIME_EPISODES_GQL, ALLANIME_SEARCH_GQL, ALLANIME_SHOW_GQL
from .constants import (
API_BASE_URL,
API_ENDPOINT,
API_REFERER,
DEFAULT_COUNTRY_OF_ORIGIN,
DEFAULT_NSFW,
DEFAULT_PAGE,
DEFAULT_PER_PAGE,
DEFAULT_UNKNOWN,
MP4_SERVER_JUICY_STREAM_REGEX,
)
from .gql_queries import EPISODES_GQL, SEARCH_GQL, SHOW_GQL
if TYPE_CHECKING:
from .types import AllAnimeEpisode
logger = logging.getLogger(__name__)
# TODO: create tests for the api
#
# ** Based on ani-cli **
class AllAnimeAPI(AnimeProvider):
class AllAnime(AnimeProvider):
"""
Provides a fast and effective interface to AllAnime site.
AllAnime is a provider class for fetching anime data from the AllAnime API.
Attributes:
HEADERS (dict): Default headers for API requests.
Methods:
_execute_graphql_query(query: str, variables: dict) -> dict:
Executes a GraphQL query and returns the response data.
search_for_anime(
**kwargs
) -> dict:
Searches for anime based on the provided keywords and other parameters.
get_anime(show_id: str) -> dict:
Retrieves detailed information about a specific anime by its ID.
_get_anime_episode(
show_id: str, episode, translation_type: str = "sub"
Retrieves information about a specific episode of an anime.
get_episode_streams(
) -> generator:
Retrieves streaming links for a specific episode of an anime.
"""
PROVIDER = "allanime"
api_endpoint = ALLANIME_API_ENDPOINT
HEADERS = {
"Referer": ALLANIME_REFERER,
"Referer": API_REFERER,
}
def _fetch_gql(self, query: str, variables: dict):
"""main abstraction over all requests to the allanime api
def _execute_graphql_query(self, query: str, variables: dict):
"""
Executes a GraphQL query using the provided query string and variables.
Args:
query: [TODO:description]
variables: [TODO:description]
query (str): The GraphQL query string to be executed.
variables (dict): A dictionary of variables to be used in the query.
Returns:
[TODO:return]
dict: The JSON response data from the GraphQL API.
Raises:
requests.exceptions.HTTPError: If the HTTP request returned an unsuccessful status code.
"""
response = self.session.get(
self.api_endpoint,
API_ENDPOINT,
params={
"variables": json.dumps(variables),
"query": query,
},
timeout=10,
)
if response.ok:
return response.json()["data"]
else:
logger.error("[ALLANIME-ERROR]: ", response.text)
return {}
response.raise_for_status()
return response.json()["data"]
@debug_provider(PROVIDER.upper())
@debug_provider
def search_for_anime(
self,
user_query: str,
translation_type: str = "sub",
nsfw=True,
unknown=True,
search_keywords: str,
translation_type: str,
*,
nsfw=DEFAULT_NSFW,
unknown=DEFAULT_UNKNOWN,
limit=DEFAULT_PER_PAGE,
page=DEFAULT_PAGE,
country_of_origin=DEFAULT_COUNTRY_OF_ORIGIN,
**kwargs,
):
"""search for an anime title using allanime provider
"""
Search for anime based on given keywords and filters.
Args:
nsfw ([TODO:parameter]): [TODO:description]
unknown ([TODO:parameter]): [TODO:description]
user_query: [TODO:description]
translation_type: [TODO:description]
**kwargs: [TODO:args]
search_keywords (str): The keywords to search for.
translation_type (str, optional): The type of translation to search for (e.g., "sub" or "dub"). Defaults to "sub".
limit (int, optional): The maximum number of results to return. Defaults to 40.
page (int, optional): The page number to return. Defaults to 1.
country_of_origin (str, optional): The country of origin filter. Defaults to "all".
nsfw (bool, optional): Whether to include adult content in the search results. Defaults to True.
unknown (bool, optional): Whether to include unknown content in the search results. Defaults to True.
**kwargs: Additional keyword arguments.
Returns:
[TODO:return]
dict: A dictionary containing the page information and a list of search results. Each result includes:
- id (str): The ID of the anime.
- title (str): The title of the anime.
- type (str): The type of the anime.
- availableEpisodes (int): The number of available episodes.
"""
search = {"allowAdult": nsfw, "allowUnknown": unknown, "query": user_query}
limit = 40
translationtype = translation_type
countryorigin = "all"
page = 1
variables = {
"search": search,
"limit": limit,
"page": page,
"translationtype": translationtype,
"countryorigin": countryorigin,
}
search_results = self._fetch_gql(ALLANIME_SEARCH_GQL, variables)
page_info = search_results["shows"]["pageInfo"]
results = []
for result in search_results["shows"]["edges"]:
normalized_result = {
"id": result["_id"],
"title": result["name"],
"type": result["__typename"],
"availableEpisodes": result["availableEpisodes"],
}
results.append(normalized_result)
normalized_search_results = {
"pageInfo": page_info,
"results": results,
}
return normalized_search_results
@debug_provider(PROVIDER.upper())
def get_anime(self, allanime_show_id: str):
"""get an anime details given its id
Args:
allanime_show_id: [TODO:description]
Returns:
[TODO:return]
"""
variables = {"showId": allanime_show_id}
anime = self._fetch_gql(ALLANIME_SHOW_GQL, variables)
id: str = anime["show"]["_id"]
title: str = anime["show"]["name"]
availableEpisodesDetail = anime["show"]["availableEpisodesDetail"]
self.store.set(allanime_show_id, "anime_info", {"title": title})
type = anime.get("__typename")
normalized_anime = {
"id": id,
"title": title,
"availableEpisodesDetail": availableEpisodesDetail,
"type": type,
}
return normalized_anime
@debug_provider(PROVIDER.upper())
def _get_anime_episode(
self, allanime_show_id: str, episode, translation_type: str = "sub"
) -> "AllAnimeEpisode | dict":
"""get the episode details and sources info
Args:
allanime_show_id: [TODO:description]
episode_string: [TODO:description]
translation_type: [TODO:description]
Returns:
[TODO:return]
"""
variables = {
"showId": allanime_show_id,
"translationType": translation_type,
"episodeString": episode,
}
episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables)
return episode["episode"]
@debug_provider(PROVIDER.upper())
def get_episode_streams(
self, anime_id, episode_number: str, translation_type="sub"
):
"""get the streams of an episode
Args:
translation_type ([TODO:parameter]): [TODO:description]
anime: [TODO:description]
episode_number: [TODO:description]
Yields:
[TODO:description]
"""
anime_title = (self.store.get(anime_id, "anime_info", "") or {"title": ""})[
"title"
]
allanime_episode = self._get_anime_episode(
anime_id, episode_number, translation_type
search_results = self._execute_graphql_query(
SEARCH_GQL,
variables={
"search": {
"allowAdult": nsfw,
"allowUnknown": unknown,
"query": search_keywords,
},
"limit": limit,
"page": page,
"translationtype": translation_type,
"countryorigin": country_of_origin,
},
)
if not allanime_episode:
return []
return {
"pageInfo": search_results["shows"]["pageInfo"],
"results": [
{
"id": result["_id"],
"title": result["name"],
"type": result["__typename"],
"availableEpisodes": result["availableEpisodes"],
}
for result in search_results["shows"]["edges"]
],
}
embeds = allanime_episode["sourceUrls"]
@debug_provider
def get_anime(self, id: str, **kwargs):
"""
Fetches anime details using the provided show ID.
Args:
id (str): The ID of the anime show to fetch details for.
Returns:
dict: A dictionary containing the anime details, including:
- id (str): The unique identifier of the anime show.
- title (str): The title of the anime show.
- availableEpisodesDetail (list): A list of available episodes details.
- type (str, optional): The type of the anime show.
"""
@debug_provider(self.PROVIDER.upper())
def _get_server(embed):
# 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 (
# 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
):
return
url = embed.get("sourceUrl")
#
if not url:
return
if url.startswith("--"):
url = url[2:]
url = one_digit_symmetric_xor(56, url)
anime = self._execute_graphql_query(SHOW_GQL, variables={"showId": id})
self.store.set(id, "anime_info", {"title": anime["show"]["name"]})
return {
"id": anime["show"]["_id"],
"title": anime["show"]["name"],
"availableEpisodesDetail": anime["show"]["availableEpisodesDetail"],
"type": anime.get("__typename"),
}
if "tools.fast4speed.rsvp" in url:
@debug_provider
def _get_anime_episode(
self, anime_id: str, episode, translation_type: str = "sub"
) -> "AllAnimeEpisode":
"""
Fetches a specific episode of an anime by its ID and episode number.
Args:
anime_id (str): The unique identifier of the anime.
episode (str): The episode number or string identifier.
translation_type (str, optional): The type of translation for the episode. Defaults to "sub".
Returns:
AllAnimeEpisode: The episode details retrieved from the GraphQL query.
"""
return self._execute_graphql_query(
EPISODES_GQL,
variables={
"showId": anime_id,
"translationType": translation_type,
"episodeString": episode,
},
)["episode"]
@debug_provider
def _get_server(
self,
embed,
anime_title: str,
allanime_episode: "AllAnimeEpisode",
episode_number,
):
"""
Retrieves the streaming server information for a given anime episode based on the provided embed data.
Args:
embed (dict): A dictionary containing the embed data, including the source URL and source name.
anime_title (str): The title of the anime.
allanime_episode (AllAnimeEpisode): An object representing the episode details.
Returns:
dict: A dictionary containing server information, headers, subtitles, episode title, and links to the stream.
Returns None if no valid URL or stream is found.
Raises:
requests.exceptions.RequestException: If there is an issue with the HTTP request.
"""
url = embed.get("sourceUrl")
#
if not url:
return
if url.startswith("--"):
url = one_digit_symmetric_xor(56, url[2:])
# FIRST CASE
match embed["sourceName"]:
case "Yt-mp4":
logger.debug("Found streams from Yt")
return {
"server": "Yt",
"episode_title": f"{anime_title}; Episode {episode_number}",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"links": [
{
@@ -219,77 +222,280 @@ class AllAnimeAPI(AnimeProvider):
}
],
}
case "Mp4":
logger.debug("Found streams from Mp4")
response = self.session.get(
url,
fresh=1, # pyright: ignore
timeout=10,
)
response.raise_for_status()
embed_html = response.text.replace(" ", "").replace("\n", "")
vid = MP4_SERVER_JUICY_STREAM_REGEX.search(embed_html)
if not vid:
return
return {
"server": "mp4-upload",
"headers": {"Referer": "https://www.mp4upload.com/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": [{"link": vid.group(1), "quality": "1080"}],
}
case "Fm-Hls":
# TODO: requires decoding obsfucated js (filemoon)
logger.debug("Found streams from Fm-Hls")
response = self.session.get(
url,
timeout=10,
)
response.raise_for_status()
embed_html = response.text.replace(" ", "").replace("\n", "")
vid = MP4_SERVER_JUICY_STREAM_REGEX.search(embed_html)
if not vid:
return
return {
"server": "filemoon",
"headers": {"Referer": "https://www.mp4upload.com/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": [{"link": vid.group(1), "quality": "1080"}],
}
case "Ok":
# TODO: requires decoding the obsfucated js (filemoon)
response = self.session.get(
url,
timeout=10,
)
response.raise_for_status()
embed_html = response.text.replace(" ", "").replace("\n", "")
vid = MP4_SERVER_JUICY_STREAM_REGEX.search(embed_html)
logger.debug("Found streams from Ok")
return {
"server": "filemoon",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Vid-mp4":
# TODO: requires some serious work i think : )
response = self.session.get(
url,
timeout=10,
)
response.raise_for_status()
embed_html = response.text.replace(" ", "").replace("\n", "")
logger.debug("Found streams from vid-mp4")
return {
"server": "Vid-mp4",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Ss-Hls":
# TODO: requires some serious work i think : )
response = self.session.get(
url,
timeout=10,
)
response.raise_for_status()
embed_html = response.text.replace(" ", "").replace("\n", "")
logger.debug("Found streams from Ss-Hls")
return {
"server": "StreamSb",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
# get the stream url for an episode of the defined source names
embed_url = f"https://{ALLANIME_BASE}{url.replace('clock', 'clock.json')}"
resp = self.session.get(
embed_url,
timeout=10,
)
# get the stream url for an episode of the defined source names
response = self.session.get(
f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}",
timeout=10,
)
if resp.ok:
match embed["sourceName"]:
case "Luf-mp4":
logger.debug("allanime:Found streams from gogoanime")
return {
"server": "gogoanime",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
case "Kir":
logger.debug("allanime:Found streams from wetransfer")
return {
"server": "wetransfer",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
case "S-mp4":
logger.debug("allanime:Found streams from sharepoint")
return {
"server": "sharepoint",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
case "Sak":
logger.debug("allanime:Found streams from dropbox")
return {
"server": "dropbox",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
case "Default":
logger.debug("allanime:Found streams from wixmp")
return {
"server": "wixmp",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
response.raise_for_status()
for embed in embeds:
if server := _get_server(embed):
# SECOND CASE
match embed["sourceName"]:
case "Luf-mp4":
logger.debug("Found streams from gogoanime")
return {
"server": "gogoanime",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Kir":
logger.debug("Found streams from wetransfer")
return {
"server": "weTransfer",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "S-mp4":
logger.debug("Found streams from sharepoint")
return {
"server": "sharepoint",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Sak":
logger.debug("Found streams from dropbox")
return {
"server": "dropbox",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Default":
logger.debug("Found streams from wixmp")
return {
"server": "wixmp",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Ak":
# TODO: works but needs further probing
logger.debug("Found streams from Ak")
return {
"server": "Ak",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
@debug_provider
def get_episode_streams(
self, anime_id, episode_number: str, translation_type="sub", **kwargs
):
"""
Retrieve streaming information for a specific episode of an anime.
Args:
anime_id (str): The unique identifier for the anime.
episode_number (str): The episode number to retrieve streams for.
translation_type (str, optional): The type of translation for the episode (e.g., "sub" for subtitles). Defaults to "sub".
Yields:
dict: A dictionary containing streaming information for the episode, including:
- server (str): The name of the streaming server.
- episode_title (str): The title of the episode.
- headers (dict): HTTP headers required for accessing the stream.
- subtitles (list): A list of subtitles available for the episode.
- links (list): A list of dictionaries containing streaming links and their quality.
"""
anime_title = (self.store.get(anime_id, "anime_info", "") or {"title": ""})[
"title"
]
allanime_episode = self._get_anime_episode(
anime_id, episode_number, translation_type
)
for embed in allanime_episode["sourceUrls"]:
if embed.get("sourceName", "") not in (
# priorities based on death note
"Sak", # 7
"S-mp4", # 7.9
"Luf-mp4", # 7.7
"Default", # 8.5
"Yt-mp4", # 7.9
"Kir", # NA
"Mp4", # 4
# "Ak",#
# "Vid-mp4", # 4
# "Ok", # 3.5
# "Ss-Hls", # 5.5
# "Fm-Hls",#
):
logger.debug(f"Found {embed['sourceName']} but ignoring")
continue
if server := self._get_server(
embed, anime_title, allanime_episode, episode_number
):
yield server
if __name__ == "__main__":
import subprocess
allanime = AllAnime(cache_requests="True", use_persistent_provider_store="False")
search_term = input("Enter the search term for the anime: ")
translation_type = input("Enter the translation type (sub/dub): ")
search_results = allanime.search_for_anime(
search_keywords=search_term, translation_type=translation_type
)
if not search_results["results"]:
print("No results found.")
exit()
print("Search Results:")
for idx, result in enumerate(search_results["results"], start=1):
print(f"{idx}. {result['title']} (ID: {result['id']})")
anime_choice = int(input("Enter the number of the anime you want to watch: ")) - 1
anime_id = search_results["results"][anime_choice]["id"]
anime_details = allanime.get_anime(anime_id)
print(f"Selected Anime: {anime_details['title']}")
print("Available Episodes:")
for idx, episode in enumerate(
sorted(anime_details["availableEpisodesDetail"][translation_type], key=float),
start=1,
):
print(f"{idx}. Episode {episode}")
episode_choice = (
int(input("Enter the number of the episode you want to watch: ")) - 1
)
episode_number = anime_details["availableEpisodesDetail"][translation_type][
episode_choice
]
streams = list(
allanime.get_episode_streams(anime_id, episode_number, translation_type)
)
if not streams:
print("No streams available.")
exit()
print("Available Streams:")
for idx, stream in enumerate(streams, start=1):
print(f"{idx}. Server: {stream['server']}")
server_choice = int(input("Enter the number of the server you want to use: ")) - 1
selected_stream = streams[server_choice]
stream_link = selected_stream["links"][0]["link"]
mpv_args = ["mpv", stream_link]
headers = selected_stream["headers"]
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)
subprocess.run(mpv_args)

View File

@@ -1,4 +1,27 @@
SERVERS_AVAILABLE = ["sharepoint", "dropbox", "gogoanime", "weTransfer", "wixmp", "Yt"]
ALLANIME_BASE = "allanime.day"
ALLANIME_REFERER = "https://allanime.to/"
ALLANIME_API_ENDPOINT = "https://api.{}/api/".format(ALLANIME_BASE)
import re
SERVERS_AVAILABLE = [
"sharepoint",
"dropbox",
"gogoanime",
"weTransfer",
"wixmp",
"Yt",
"mp4-upload",
]
API_BASE_URL = "allanime.day"
API_REFERER = "https://allanime.to/"
API_ENDPOINT = f"https://api.{API_BASE_URL}/api/"
# search constants
DEFAULT_COUNTRY_OF_ORIGIN = "all"
DEFAULT_NSFW = True
DEFAULT_UNKNOWN = True
DEFAULT_PER_PAGE = 40
DEFAULT_PAGE = 1
# regex stuff
MP4_SERVER_JUICY_STREAM_REGEX = re.compile(
r"video/mp4\",src:\"(https?://.*/video\.mp4)\""
)

View File

@@ -1,4 +1,4 @@
ALLANIME_SEARCH_GQL = """
SEARCH_GQL = """
query (
$search: SearchInput
$limit: Int
@@ -27,7 +27,7 @@ query (
"""
ALLANIME_EPISODES_GQL = """\
EPISODES_GQL = """\
query (
$showId: String!
$translationType: VaildTranslationTypeEnumType!
@@ -45,7 +45,7 @@ query (
}
"""
ALLANIME_SHOW_GQL = """
SHOW_GQL = """
query ($showId: String!) {
show(_id: $showId) {
_id

View File

@@ -1,6 +1,5 @@
import logging
import random
import re
import time
from typing import TYPE_CHECKING
@@ -15,49 +14,33 @@ from ..decorators import debug_provider
from .constants import (
ANIMEPAHE_BASE,
ANIMEPAHE_ENDPOINT,
JUICY_STREAM_REGEX,
REQUEST_HEADERS,
SERVER_HEADERS,
)
from .utils import process_animepahe_embed_page
from .extractors import process_animepahe_embed_page
if TYPE_CHECKING:
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimePaheSearchResult
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
logger = logging.getLogger(__name__)
KWIK_RE = re.compile(r"Player\|(.+?)'")
class AnimePaheApi(AnimeProvider):
class AnimePahe(AnimeProvider):
search_page: "AnimePaheSearchPage"
anime: "AnimePaheAnimePage"
HEADERS = REQUEST_HEADERS
PROVIDER = "animepahe"
@debug_provider(PROVIDER.upper())
def search_for_anime(self, user_query: str, *args):
url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}"
@debug_provider
def search_for_anime(self, search_keywords: str, translation_type, **kwargs):
response = self.session.get(
url,
ANIMEPAHE_ENDPOINT, params={"m": "search", "q": search_keywords}
)
if not response.ok:
return
response.raise_for_status()
data: "AnimePaheSearchPage" = response.json()
self.search_page = data
for animepahe_search_result in data["data"]:
self.store.set(
str(animepahe_search_result["session"]),
"search_result",
animepahe_search_result,
)
return {
"pageInfo": {
"total": data["total"],
"perPage": data["per_page"],
"currentPage": data["current_page"],
},
"results": [
results = []
for result in data["data"]:
results.append(
{
"availableEpisodes": list(range(result["episodes"])),
"id": result["session"],
@@ -69,55 +52,81 @@ class AnimePaheApi(AnimeProvider):
"season": result["season"],
"poster": result["poster"],
}
for result in data["data"]
],
)
self.store.set(
str(result["session"]),
"search_result",
result,
)
return {
"pageInfo": {
"total": data["total"],
"perPage": data["per_page"],
"currentPage": data["current_page"],
},
"results": results,
}
@debug_provider(PROVIDER.upper())
def get_anime(self, session_id: str, *args):
@debug_provider
def _pages_loader(
self,
data,
session_id,
params,
page,
):
response = self.session.get(ANIMEPAHE_ENDPOINT, params=params)
response.raise_for_status()
if not data:
data.update(response.json())
else:
if ep_data := response.json().get("data"):
data["data"].extend(ep_data)
if response.json()["next_page_url"]:
# TODO: Refine this
time.sleep(
random.choice(
[
0.25,
0.1,
0.5,
0.75,
1,
]
)
)
page += 1
self._pages_loader(
data,
session_id,
params={
"m": "release",
"page": page,
"id": session_id,
"sort": "episode_asc",
},
page=page,
)
return data
@debug_provider
def get_anime(self, session_id: str, **kwargs):
page = 1
if d := self.store.get(str(session_id), "search_result"):
anime_result: "AnimePaheSearchResult" = d
data: "AnimePaheAnimePage" = {} # pyright:ignore
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
def _pages_loader(
url,
page,
):
response = self.session.get(
url,
)
if response.ok:
if not data:
data.update(response.json())
else:
if ep_data := response.json().get("data"):
data["data"].extend(ep_data)
if response.json()["next_page_url"]:
# TODO: Refine this
time.sleep(
random.choice(
[
0.25,
0.1,
0.5,
0.75,
1,
]
)
)
page += 1
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
_pages_loader(
url,
page,
)
_pages_loader(
url,
page,
data = self._pages_loader(
data,
session_id,
params={
"m": "release",
"id": session_id,
"sort": "episode_asc",
"page": page,
},
page=page,
)
if not data:
@@ -151,47 +160,13 @@ class AnimePaheApi(AnimeProvider):
],
}
@debug_provider(PROVIDER.upper())
def get_episode_streams(
self, anime_id, episode_number: str, translation_type, *args
):
anime_title = ""
episode = None
# extract episode details from memory
if d := self.store.get(str(anime_id), "anime_info"):
anime_title = d["title"]
episode = [
episode
for episode in d["data"]
if float(episode["episode"]) == float(episode_number)
]
if not episode:
logger.error(f"[ANIMEPAHE-ERROR]: episode {episode_number} doesn't exist")
return []
episode = episode[0]
# fetch the episode page
url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
response = self.session.get(url)
# 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']}"
)
@debug_provider
def _get_server(self, episode, res_dicts, anime_title, translation_type):
# get all links
streams = {
"server": "kwik",
"links": [],
"episode_title": episode_title,
"episode_title": f"{episode['title'] or anime_title}; Episode {episode['episode']}",
"subtitles": [],
"headers": {},
}
@@ -207,23 +182,22 @@ class AnimePaheApi(AnimeProvider):
logger.warning(
"[ANIMEPAHE-WARN]: embed url not found please report to the developers"
)
return []
continue
# get embed page
embed_response = self.session.get(
embed_url, headers={"User-Agent": self.USER_AGENT, **SERVER_HEADERS}
)
if not response.ok:
continue
embed_response.raise_for_status()
embed_page = embed_response.text
decoded_js = process_animepahe_embed_page(embed_page)
if not decoded_js:
logger.error("[ANIMEPAHE-ERROR]: failed to decode embed page")
return
continue
juicy_stream = JUICY_STREAM_REGEX.search(decoded_js)
if not juicy_stream:
logger.error("[ANIMEPAHE-ERROR]: failed to find juicy stream")
return
continue
juicy_stream = juicy_stream.group(1)
# add the link
streams["links"].append(
@@ -233,4 +207,119 @@ class AnimePaheApi(AnimeProvider):
"link": juicy_stream,
}
)
yield streams
return streams
@debug_provider
def get_episode_streams(
self, anime_id, episode_number: str, translation_type, **kwargs
):
anime_title = ""
# extract episode details from memory
anime_info = self.store.get(str(anime_id), "anime_info")
if not anime_info:
logger.error(
f"[ANIMEPAHE-ERROR]: Anime with ID {anime_id} not found in store"
)
return
anime_title = anime_info["title"]
episode = next(
(
ep
for ep in anime_info["data"]
if float(ep["episode"]) == float(episode_number)
),
None,
)
if not episode:
logger.error(
f"[ANIMEPAHE-ERROR]: Episode {episode_number} doesn't exist for anime {anime_title}"
)
return
# fetch the episode page
url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
response = self.session.get(url)
response.raise_for_status()
# 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]
if _server := self._get_server(
episode, res_dicts, anime_title, translation_type
):
yield _server
if __name__ == "__main__":
import subprocess
animepahe = AnimePahe(cache_requests="True", use_persistent_provider_store="False")
search_term = input("Enter the search term for the anime: ")
translation_type = input("Enter the translation type (sub/dub): ")
search_results = animepahe.search_for_anime(
search_keywords=search_term, translation_type=translation_type
)
if not search_results or not search_results["results"]:
print("No results found.")
exit()
print("Search Results:")
for idx, result in enumerate(search_results["results"], start=1):
print(f"{idx}. {result['title']} (ID: {result['id']})")
anime_choice = int(input("Enter the number of the anime you want to watch: ")) - 1
anime_id = search_results["results"][anime_choice]["id"]
anime_details = animepahe.get_anime(anime_id)
if anime_details is None:
print("Failed to get anime details.")
exit()
print(f"Selected Anime: {anime_details['title']}")
print("Available Episodes:")
for idx, episode in enumerate(
sorted(anime_details["availableEpisodesDetail"][translation_type], key=float),
start=1,
):
print(f"{idx}. Episode {episode}")
episode_choice = (
int(input("Enter the number of the episode you want to watch: ")) - 1
)
episode_number = anime_details["availableEpisodesDetail"][translation_type][
episode_choice
]
streams = list(
animepahe.get_episode_streams(anime_id, episode_number, translation_type)
)
if not streams:
print("No streams available.")
exit()
print("Available Streams:")
for idx, stream in enumerate(streams, start=1):
print(f"{idx}. Server: {stream['server']}")
server_choice = int(input("Enter the number of the server you want to use: ")) - 1
selected_stream = streams[server_choice]
stream_link = selected_stream["links"][0]["link"]
mpv_args = ["mpv", stream_link]
headers = selected_stream["headers"]
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)
subprocess.run(mpv_args)

View File

@@ -1,6 +1,8 @@
import re
ANIMEPAHE = "animepahe.ru"
ANIMEPAHE_BASE = f"https://{ANIMEPAHE}"
ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api?"
ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api"
SERVERS_AVAILABLE = ["kwik"]
REQUEST_HEADERS = {
@@ -31,3 +33,5 @@ SERVER_HEADERS = {
"Priority": "u=4",
"TE": "trailers",
}
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
KWIK_RE = re.compile(r"Player\|(.+?)'")

View File

@@ -10,7 +10,6 @@ from .providers_store import ProviderStore
class AnimeProvider:
session: requests.Session
PROVIDER = ""
USER_AGENT = random_user_agent()
HEADERS = {}
@@ -30,7 +29,7 @@ class AnimeProvider:
if use_persistent_provider_store.lower() == "true":
self.store = ProviderStore(
"persistent",
self.PROVIDER,
self.__class__.__name__,
os.path.join(APP_CACHE_DIR, "anime_providers_store.db"),
)
else:

View File

@@ -5,21 +5,19 @@ import os
logger = logging.getLogger(__name__)
def debug_provider(provider_name: str):
def _provider_function_decorator(provider_function):
@functools.wraps(provider_function)
def _provider_function_wrapper(*args, **kwargs):
if not os.environ.get("FASTANIME_DEBUG"):
try:
return provider_function(*args, **kwargs)
except Exception as e:
logger.error(f"[{provider_name}@{provider_function.__name__}]: {e}")
else:
return provider_function(*args, **kwargs)
def debug_provider(provider_function):
@functools.wraps(provider_function)
def _provider_function_wrapper(self, *args, **kwargs):
provider_name = self.__class__.__name__.upper()
if not os.environ.get("FASTANIME_DEBUG"):
try:
return provider_function(self, *args, **kwargs)
except Exception as e:
logger.error(f"[{provider_name}@{provider_function.__name__}]: {e}")
else:
return provider_function(self, *args, **kwargs)
return _provider_function_wrapper
return _provider_function_decorator
return _provider_function_wrapper
def ensure_internet_connection(provider_function):

View File

@@ -3,7 +3,6 @@ import re
from html.parser import HTMLParser
from itertools import cycle
from urllib.parse import quote_plus
from .extractors import MegaCloud
from yt_dlp.utils import (
clean_html,
@@ -18,6 +17,7 @@ from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from ..utils import give_random_quality
from .constants import SERVERS_AVAILABLE
from .extractors import MegaCloud
from .types import HiAnimeStream
logger = logging.getLogger(__name__)
@@ -39,13 +39,11 @@ class ParseAnchorAndImgTag(HTMLParser):
self.a_tag = {attr[0]: attr[1] for attr in attrs}
class HiAnimeApi(AnimeProvider):
class HiAnime(AnimeProvider):
# HEADERS = {"Referer": "https://hianime.to/home"}
PROVIDER = "hianime"
@debug_provider(PROVIDER.upper())
def search_for_anime(self, anime_title: str, *args):
@debug_provider
def search_for_anime(self, anime_title: str, translation_type, **kwargs):
query = quote_plus(anime_title)
url = f"https://hianime.to/search?keyword={query}"
response = self.session.get(url)
@@ -92,8 +90,8 @@ class HiAnimeApi(AnimeProvider):
self.store.set(result["id"], "search_result", result)
return {"pageInfo": {}, "results": results}
@debug_provider(PROVIDER.upper())
def get_anime(self, hianime_id, *args):
@debug_provider
def get_anime(self, hianime_id, **kwargs):
anime_result = {}
if d := self.store.get(str(hianime_id), "search_result"):
anime_result = d
@@ -145,8 +143,8 @@ class HiAnimeApi(AnimeProvider):
"episodes_info": episodes_info,
}
@debug_provider(PROVIDER.upper())
def get_episode_streams(self, anime_id, episode, translation_type, *args):
@debug_provider
def get_episode_streams(self, anime_id, episode, translation_type, **kwargs):
if d := self.store.get(str(anime_id), "anime_info"):
episodes_info = d
episode_details = [
@@ -192,7 +190,7 @@ class HiAnimeApi(AnimeProvider):
if not servers_html:
return
@debug_provider(self.PROVIDER.upper())
@debug_provider
def _get_server(server_name, server_html):
# keys: [ data-type: translation_type, data-id: embed_id, data-server-id: server_id ]
servers_info = extract_attributes(server_html)

View File

@@ -1,11 +1,11 @@
import hashlib
import time
import re
import json
from typing import List, Dict
from Crypto.Cipher import AES
import re
import time
from base64 import b64decode
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Dict, List
from Crypto.Cipher import AES
if TYPE_CHECKING:
from ...common.requests_cacher import CachedRequestsSession

View File

@@ -27,11 +27,10 @@ EXTRACT_USEFUL_INFO_PATTERN_2 = re.compile(
)
class NyaaApi(AnimeProvider):
class Nyaa(AnimeProvider):
search_results: SearchResults
PROVIDER = "nyaa"
@debug_provider(PROVIDER.upper())
@debug_provider
def search_for_anime(self, user_query: str, *args, **_):
self.search_results = search_for_anime_with_anilist(
user_query, True
@@ -39,7 +38,7 @@ class NyaaApi(AnimeProvider):
self.user_query = user_query
return self.search_results
@debug_provider(PROVIDER.upper())
@debug_provider
def get_anime(self, anilist_id: str, *_):
for anime in self.search_results["results"]:
if anime["id"] == anilist_id:
@@ -55,7 +54,7 @@ class NyaaApi(AnimeProvider):
},
}
@debug_provider(PROVIDER.upper())
@debug_provider
def get_episode_streams(
self,
anime_id: str,

View File

@@ -1,32 +1,32 @@
import base64
import re
from itertools import cycle
from yt_dlp.utils import (
get_element_text_and_html_by_tag,
get_elements_text_and_html_by_attribute,
extract_attributes,
get_element_by_attribute,
get_element_text_and_html_by_tag,
get_elements_text_and_html_by_attribute,
)
import re
from yt_dlp.utils.traversal import get_element_html_by_attribute
from .constants import YUGEN_ENDPOINT, SEARCH_URL
from ..decorators import debug_provider
from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from .constants import SEARCH_URL, YUGEN_ENDPOINT
# ** Adapted from anipy-cli **
class YugenApi(AnimeProvider):
class Yugen(AnimeProvider):
"""
Provides a fast and effective interface to YugenApi site.
"""
PROVIDER = "yugen"
api_endpoint = YUGEN_ENDPOINT
# HEADERS = {
# "Referer": ALLANIME_REFERER,
# }
@debug_provider(PROVIDER.upper())
@debug_provider
def search_for_anime(
self,
user_query: str,
@@ -94,7 +94,7 @@ class YugenApi(AnimeProvider):
"results": results,
}
@debug_provider(PROVIDER.upper())
@debug_provider
def get_anime(self, anime_id: str, **kwargs):
identifier = base64.b64decode(anime_id).decode()
response = self.session.get(f"{YUGEN_ENDPOINT}/anime/{identifier}")
@@ -118,7 +118,9 @@ class YugenApi(AnimeProvider):
if sub_match:
eps = int(sub_match.group(1))
data_map["availableEpisodesDetail"]["sub"] = list(map(str,range(1, eps + 1)))
data_map["availableEpisodesDetail"]["sub"] = list(
map(str, range(1, eps + 1))
)
dub_match = re.search(
r'<div class="ap-.+?">Episodes \(Dub\)</div><span class="description" .+?>(\d+)</span></div>',
@@ -127,7 +129,9 @@ class YugenApi(AnimeProvider):
if dub_match:
eps = int(dub_match.group(1))
data_map["availableEpisodesDetail"]["dub"] = list(map(str,range(1, eps + 1)))
data_map["availableEpisodesDetail"]["dub"] = list(
map(str, range(1, eps + 1))
)
name = get_element_text_and_html_by_tag("h1", html_page)
if name is not None:
@@ -174,7 +178,7 @@ class YugenApi(AnimeProvider):
return data_map
@debug_provider(PROVIDER.upper())
@debug_provider
def get_episode_streams(
self, anime_id, episode_number: str, translation_type="sub"
):
@@ -212,5 +216,10 @@ class YugenApi(AnimeProvider):
"episode_title": f"{anime_title}; Episode {episode_number}",
"headers": {},
"subtitles": [],
"links": [{"quality": quality, "link": link} for quality,link in zip(cycle(["1080","720","480","360"]),res["hls"])],
"links": [
{"quality": quality, "link": link}
for quality, link in zip(
cycle(["1080", "720", "480", "360"]), res["hls"]
)
],
}

View File

@@ -1,4 +1,3 @@
YUGEN_ENDPOINT: str = "https://yugenanime.tv"
SEARCH_URL = YUGEN_ENDPOINT + "/api/discover/"

View File

@@ -0,0 +1,11 @@
from pypresence import Presence
import time
def discord_connect(show, episode, switch):
presence = Presence(client_id = '1292070065583165512')
presence.connect()
if not switch.is_set():
presence.update(details = show, state = "Watching episode "+episode)
time.sleep(10)
else:
presence.close()

View File

@@ -14,7 +14,7 @@
pythonPackages = python.pkgs;
fastanimeEnv = pythonPackages.buildPythonApplication {
pname = "fastanime";
version = "2.8.4";
version = "2.8.7";
src = ./.;
@@ -37,6 +37,7 @@
mpv
fastapi
pycryptodome
pypresence
];
# Ensure compatibility with the pyproject.toml

View File

@@ -8,7 +8,7 @@ sed -i "s/^version.*/version = \"$VERSION\"/" "$CLI_DIR/pyproject.toml" &&
sed -i "s/version = .*/version = \"$VERSION\";/" "$CLI_DIR/flake.nix" &&
git stage "$CLI_DIR/pyproject.toml" "$CLI_DIR/fastanime/__init__.py" "$CLI_DIR/flake.nix" &&
git commit -m "chore: bump version (v$VERSION)" &&
nix flake lock &&
# nix flake lock &&
uv lock &&
git stage "$CLI_DIR/flake.lock" "$CLI_DIR/uv.lock" &&
git commit -m "chore: update lock files" &&

View File

@@ -1,6 +1,6 @@
[project]
name = "fastanime"
version = "2.8.4"
version = "2.8.7"
description = "A browser anime site experience from the terminal"
license = "UNLICENSE"
readme = "README.md"
@@ -9,6 +9,7 @@ dependencies = [
"click>=8.1.7",
"inquirerpy>=0.3.4",
"pycryptodome>=3.21.0",
"pypresence>=4.3.0",
"requests>=2.32.3",
"rich>=13.9.2",
"thefuzz>=0.22.1",
@@ -30,6 +31,7 @@ build-backend = "hatchling.build"
[tool.uv]
dev-dependencies = [
"pre-commit>=4.0.1",
"pyinstaller>=6.11.1",
"pyright>=1.1.384",
"pytest>=8.3.3",

View File

@@ -1,6 +1,7 @@
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from unittest.mock import patch
from fastanime.cli import run_cli
@@ -151,7 +152,7 @@ def test_anilist_watching_help(runner: CliRunner):
def test_check_for_updates_not_called_on_completions(runner):
with patch('fastanime.cli.app_updater.check_for_updates') as mock_check_for_updates:
with patch("fastanime.cli.app_updater.check_for_updates") as mock_check_for_updates:
result = runner.invoke(run_cli, ["completions"])
assert result.exit_code == 0
mock_check_for_updates.assert_not_called()

92
uv.lock generated
View File

@@ -196,6 +196,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
]
[[package]]
name = "cfgv"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.0"
@@ -270,7 +279,7 @@ name = "click"
version = "8.1.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "platform_system == 'Windows'" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
wheels = [
@@ -286,6 +295,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "distlib"
version = "0.3.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 },
]
[[package]]
name = "dnspython"
version = "2.7.0"
@@ -319,12 +337,13 @@ wheels = [
[[package]]
name = "fastanime"
version = "2.8.4"
version = "2.8.7"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "inquirerpy" },
{ name = "pycryptodome" },
{ name = "pypresence" },
{ name = "requests" },
{ name = "rich" },
{ name = "thefuzz" },
@@ -349,6 +368,7 @@ standard = [
[package.dev-dependencies]
dev = [
{ name = "pre-commit" },
{ name = "pyinstaller" },
{ name = "pyright" },
{ name = "pytest" },
@@ -366,6 +386,7 @@ requires-dist = [
{ name = "plyer", marker = "extra == 'notifications'", specifier = ">=2.1.0" },
{ name = "plyer", marker = "extra == 'standard'", specifier = ">=2.1.0" },
{ name = "pycryptodome", specifier = ">=3.21.0" },
{ name = "pypresence", specifier = ">=4.3.0" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "rich", specifier = ">=13.9.2" },
{ name = "thefuzz", specifier = ">=0.22.1" },
@@ -374,6 +395,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "pre-commit", specifier = ">=4.0.1" },
{ name = "pyinstaller", specifier = ">=6.11.1" },
{ name = "pyright", specifier = ">=1.1.384" },
{ name = "pytest", specifier = ">=8.3.3" },
@@ -422,6 +444,15 @@ standard = [
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "filelock"
version = "3.16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 },
]
[[package]]
name = "h11"
version = "0.14.0"
@@ -496,6 +527,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 },
]
[[package]]
name = "identify"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/5f/05f0d167be94585d502b4adf8c7af31f1dc0b1c7e14f9938a88fdbbcf4a7/identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02", size = 99179 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/f5/09644a3ad803fae9eca8efa17e1f2aef380c7f0b02f7ec4e8d446e51d64a/identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd", size = 99049 },
]
[[package]]
name = "idna"
version = "3.10"
@@ -684,6 +724,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96", size = 8537 },
]
[[package]]
name = "platformdirs"
version = "4.3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 },
]
[[package]]
name = "pluggy"
version = "1.5.0"
@@ -702,6 +751,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/89/a41c2643fc8eabeb84791acb9d0e4d139b1e4b53473cc4dae947b5fa33ed/plyer-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b1772060df8b3045ed4f08231690ec8f7de30f5a004aa1724665a9074eed113", size = 142266 },
]
[[package]]
name = "pre-commit"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.48"
@@ -902,6 +967,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/64/445861ee7a5fd32874c0f6cfe8222aacc8feda22539332e0d8ff50dadec6/pyinstaller_hooks_contrib-2024.10-py3-none-any.whl", hash = "sha256:ad47db0e153683b4151e10d231cb91f2d93c85079e78d76d9e0f57ac6c8a5e10", size = 338417 },
]
[[package]]
name = "pypresence"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/2e/d110f862720b5e3ba1b0b719657385fc4151929befa2c6981f48360aa480/pypresence-4.3.0.tar.gz", hash = "sha256:a6191a3af33a9667f2a4ef0185577c86b962ee70aa82643c472768a6fed1fbf3", size = 10696 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/40/1d30b30e18f81eb71365681223971a9822a89b3d6ee5269dd2aa955bc228/pypresence-4.3.0-py2.py3-none-any.whl", hash = "sha256:af878c6d49315084f1b108aec86b31915080614d9421d6dd3a44737aba9ff13f", size = 11778 },
]
[[package]]
name = "pyright"
version = "1.1.388"
@@ -1281,6 +1355,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018 },
]
[[package]]
name = "virtualenv"
version = "20.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bf/75/53316a5a8050069228a2f6d11f32046cfa94fbb6cc3f08703f59b873de2e/virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa", size = 7650368 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/f9/0919cf6f1432a8c4baa62511f8f8da8225432d22e83e3476f5be1a1edc6e/virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0", size = 4276702 },
]
[[package]]
name = "watchfiles"
version = "0.24.0"