Compare commits

...

102 Commits

Author SHA1 Message Date
Benexl
bd0e7db73c chore: update lock files 2025-02-22 13:02:45 +03:00
Benexl
fb705b4ac2 chore: bump version (v2.8.8) 2025-02-22 13:02:36 +03:00
Benexl
93654be74f feat: enhance login experience 2025-02-22 12:58:45 +03:00
Benedict Xavier
9b14a4c723 Merge pull request #62 from crasband1/master
added support for macOS login
2025-02-22 11:34:38 +03:00
relive010
bce5acf7b5 added support for macOS login via key pasted into anilist_key.txt in Downloads 2025-02-19 02:16:38 -07:00
Benexl
228be7e1f7 feat: add support for the year 2025 in available years list 2025-02-18 11:23:40 +03:00
Benedict Xavier
2eb434e42a Update README.md 2025-02-04 09:16:34 +03:00
Benedict Xavier
fbb3a00ab0 Create FUNDING.yml 2025-01-30 07:51:46 +03:00
Benedict Xavier
8341ffe8fd Update README.md 2025-01-29 22:13:03 +03:00
benexl
0822e2e92c feat: improve preview logic 2025-01-29 19:47:56 +03:00
benexl
3b9fbd0665 chore: upgrade deps 2025-01-29 19:47:42 +03:00
benexl
60b74bee18 feat: update the preview script 2025-01-29 18:47:12 +03:00
Benedict Xavier
d7dc63e003 Merge pull request #59 from Benexl/minor-fixes
Minor fixes
2025-01-29 04:38:53 +03:00
Type-Delta
d40edb6ff6 🧹 cleanup: lint error E402, ehe ;p 2025-01-29 00:52:07 +07:00
Benedict Xavier
803712649f Update README.md 2025-01-27 17:26:07 +03:00
Benedict Xavier
7bc0d33f69 Update README.md 2025-01-27 17:25:15 +03:00
Benedict Xavier
5885d134df Update README.md 2025-01-26 10:33:55 +03:00
Type-Delta
5500ec49c8 💫 update: which_win32_gitbash() to handle git.exe in bin dir 2025-01-24 12:10:52 +07:00
Type-Delta
8c94380050 🛠️ fix: fzf preview use the wrong bash.exe on Windows 2025-01-23 19:40:45 +07:00
Type-Delta
87a97dd0c6 🧹 cleanup: fix typos 2025-01-23 07:40:42 +07:00
Type-Delta
80d9f732b1 🛠️ fix: handle ctrl+C termination of fzf 2025-01-23 07:03:21 +07:00
Benedict Xavier
051273dac9 Merge pull request #57 from Abdisto/master
Indent Fix for Anime Description in fzf preview
2025-01-21 09:25:47 +03:00
Abdist
036f448906 Update utils.py 2025-01-18 21:25:32 +01:00
Abdist
b5aeed9268 Update utils.py
strip() was not neccessary
2025-01-18 15:47:19 +01:00
Abdist
4257502b85 Update utils.py 2025-01-16 14:28:43 +01:00
Abdist
28a857520f Update utils.py 2025-01-16 14:26:12 +01:00
Abdist
4f9fff375c Update utils.py 2025-01-16 14:21:43 +01:00
Abdist
ce31f63788 Update utils.py 2025-01-16 14:01:53 +01:00
Abdist
9412c2491e Update utils.py 2025-01-16 13:56:20 +01:00
Abdist
8209adec62 Update utils.py 2025-01-16 13:54:07 +01:00
Abdist
39703d9eca Update utils.py 2025-01-16 13:44:01 +01:00
Abdist
57d16b3e18 Update utils.py 2025-01-16 13:10:06 +01:00
Abdist
73a99f8b96 Update utils.py 2025-01-16 13:07:54 +01:00
Abdist
309d7d5858 Update utils.py 2025-01-16 13:04:49 +01:00
Abdist
8d20e490ca Update utils.py 2025-01-16 13:00:26 +01:00
Abdist
3a6e005f3a Update utils.py 2025-01-16 12:55:04 +01:00
Benedict Xavier
bdf49bd7ce Merge pull request #52 from Benexl/fix-missing-meta
Fix(downloader):  handle hls stream's properly
2025-01-06 22:43:56 +03:00
Type-Delta
c4df2587d0 Merge branch 'master' into fix-missing-meta 2025-01-06 21:28:18 +07:00
Benedict Xavier
b38f66767f Merge pull request #50 from sudoAlphaX/notify-timeout
refactor!: change notifier timeout config to seconds
2025-01-05 23:31:39 +03:00
Benedict Xavier
6c0e0ccf72 Merge pull request #49 from sudoAlphaX/anilist-login-typo
docs: fix typo for anilist login command
2025-01-05 23:29:53 +03:00
Alpha
e39c992883 refactor!: change notifier timeout config to seconds
BREAKING CHANGE: change notification timeout from minutes to seconds in
config.
2025-01-05 22:04:09 +05:30
Alpha
a1744fc9b3 docs: fix typo for anilist login command 2025-01-05 21:59:32 +05:30
Benedict Xavier
3c5106c32c Merge pull request #48 from Aethar01/master
Add arch to installation instructions in README.md
2025-01-05 00:45:30 +03:00
Elliott Ashby
fd0d899f72 grammar 2025-01-04 21:31:32 +00:00
Elliott Ashby
c753873f61 Update README.md to include arch installation 2025-01-04 21:30:39 +00:00
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
Type-Delta
978d8d45ba 🛠️ fix: prevent clipping from HE-AAC to AAC convertion 2024-12-28 12:06:21 +07:00
Type-Delta
06575120d6 Merge branch 'master' into fix-missing-meta 2024-12-28 09:39:18 +07:00
Type-Delta
72cec28613 add: --hls-use-h264 to convert ts streams to mp4 2024-12-28 09:35:49 +07: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
Type-Delta
9981b3dec8 🛠️ fix: missing metadata when --force-ffmpeg is used 2024-12-19 07:11:48 +07: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
59 changed files with 2023 additions and 1364 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: benexl # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: benexl # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

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
}

107
README.md
View File

@@ -26,7 +26,7 @@
<details>
<summary>
<b>My Rice</b>
<b>Riced</b>
</summary>
**Anilist results menu:**
@@ -43,62 +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
![Windows](https://img.shields.io/badge/-Windows_x64-blue.svg?style=for-the-badge&logo=windows)
@@ -119,6 +63,26 @@ If you have any difficulty consult for help on the [discord channel](https://dis
nix profile install github:Benexl/fastanime
```
### Installation on Arch
![Static Badge](https://img.shields.io/badge/arch-black?style=flat&logo=archlinux)
Install from the AUR using an AUR helper such as [yay](https://github.com/Jguer/yay) or [paru](https://github.com/Morganamilo/paru), either the git version, which uses the latest commit:
![AUR Version](https://img.shields.io/aur/version/fastanime-git?label=git)
```bash
yay -S fastanime-git
```
or the stable version, which uses a tagged release:
![AUR Version](https://img.shields.io/aur/version/fastanime?label=stable)
```bash
yay -S fastanime
```
### Installation using your favourite package manager
Currently the app is only published on [pypi](https://pypi.org/project/fastanime/).
@@ -807,7 +771,7 @@ rofi_theme_input =
rofi_theme_confirm =
notification_duration = 2
notification_duration = 120
sub_lang = eng
@@ -848,29 +812,26 @@ format = best[height<=1080]/bestvideo[height<=1080]+bestaudio/best
player = mpv
```
### Other Terminal Browsers I Made
[yt-x](https://github.com/Benexl/yt-x) - browse youtube and other yt-dlp sites from the terminal
[lib-x](https://github.com/Benexl/lib-x) - browse your calibre library from the terminal
## 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

@@ -15,7 +15,7 @@ anime_normalizer_raw = {
"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

@@ -45,6 +45,7 @@ class YtDLPDownloader:
prompt=True,
force_ffmpeg=False,
hls_use_mpegts=False,
hls_use_h264=False,
):
"""Helper function that downloads anime given url and path details
@@ -97,16 +98,28 @@ 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"],
},
}
if hls_use_mpegts:
options = options | {
"hls_use_mpegts": hls_use_mpegts,
"hls_use_mpegts": True,
"outtmpl": ".".join(options["outtmpl"].split(".")[:-1]) + ".ts", # force .ts extension
}
elif hls_use_h264:
options = options | {
"external_downloader_args": options["external_downloader_args"] | {
"ffmpeg_o1": [
"-c:v", "copy",
"-c:a", "aac",
"-bsf:a", "aac_adtstoasc",
"-q:a", "1",
"-ac", "2",
"-af", "loudnorm=I=-22:TP=-2.5:LRA=11,alimiter=limit=-1.5dB", # prevent clipping from HE-AAC to AAC convertion
],
}
}
with yt_dlp.YoutubeDL(options) as ydl:

View File

@@ -6,7 +6,7 @@ if sys.version_info < (3, 10):
) # noqa: F541
__version__ = "v2.8.4"
__version__ = "v2.8.8"
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

@@ -142,7 +142,7 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option(
"--normalize-titles/--no-normalize-titles",
type=bool,
help="whether to normalize anime and episode titls given by providers",
help="whether to normalize anime and episode titles given by providers",
)
@click.option("-d", "--downloads-dir", type=click.Path(), help="Downloads location")
@click.option("--fzf", is_flag=True, help="Use fzf for the ui")
@@ -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

@@ -110,6 +110,7 @@ years_available = [
"2022",
"2023",
"2024",
"2025",
]
tags_available = {

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,
)
@@ -117,7 +116,12 @@ from .data import (
@click.option(
"--hls-use-mpegts",
is_flag=True,
help="Use mpegts for hls streams (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
help="Use mpegts for hls streams, resulted in .ts file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
)
@click.option(
"--hls-use-h264",
is_flag=True,
help="Use H.264 (MP4) for hls streams, resulted in .mp4 file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
)
@click.option(
"--max-results", "-M", type=int, help="The maximum number of results to show"
@@ -144,12 +148,14 @@ def download(
prompt,
force_ffmpeg,
hls_use_mpegts,
hls_use_h264,
max_results,
):
from ....anilist import AniList
from rich import print
force_ffmpeg |= hls_use_mpegts
from ....anilist import AniList
force_ffmpeg |= (hls_use_mpegts or hls_use_h264)
success, anilist_search_results = AniList.search(
query=title,
@@ -379,6 +385,7 @@ def download(
prompt=prompt,
force_ffmpeg=force_ffmpeg,
hls_use_mpegts=hls_use_mpegts,
hls_use_h264=hls_use_h264,
)
except Exception as e:
print(e)

View File

@@ -124,7 +124,7 @@ def downloads(
return
from ....constants import APP_CACHE_DIR
from ...utils.scripts import fzf_preview
from ...utils.scripts import bash_functions
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
@@ -183,7 +183,7 @@ def downloads(
else echo Loading...
fi
""" % (
fzf_preview,
bash_functions,
downloads_thumbnail_cache_dir,
downloads_thumbnail_cache_dir,
)
@@ -194,7 +194,7 @@ def downloads(
from pathlib import Path
from ....constants import APP_CACHE_DIR
from ...utils.scripts import fzf_preview
from ...utils.scripts import bash_functions
if not shutil.which("ffmpegthumbnailer"):
print("ffmpegthumbnailer not found")
@@ -256,7 +256,7 @@ def downloads(
else echo Loading...
fi
""" % (
fzf_preview,
bash_functions,
downloads_thumbnail_cache_dir,
downloads_thumbnail_cache_dir,
)

View File

@@ -11,15 +11,20 @@ if TYPE_CHECKING:
@click.option("--erase", "-e", help="Erase your login details", is_flag=True)
@click.pass_obj
def login(config: "Config", status, erase):
from os import path
from sys import exit
from rich import print
from rich.prompt import Confirm, Prompt
from ....constants import S_PLATFORM
if status:
is_logged_in = True if config.user else False
message = (
"You are logged in :smile:" if is_logged_in else "You arent logged in :cry:"
"You are logged in :smile:"
if is_logged_in
else "You aren't logged in :cry:"
)
print(message)
print(config.user)
@@ -46,8 +51,18 @@ def login(config: "Config", status, erase):
print(
f"A browser session will be opened ( [link]{config.fastanime_anilist_app_login_url}[/link] )",
)
launch(config.fastanime_anilist_app_login_url, wait=True)
print("Please paste the token provided here")
token = ""
if S_PLATFORM.startswith("darwin"):
anilist_key_file_path = path.expanduser("~") + "/Downloads/anilist_key.txt"
launch(config.fastanime_anilist_app_login_url, wait=False)
Prompt.ask(
"MacOS detected.\nPress any key once the token provided has been pasted into "
+ anilist_key_file_path
)
with open(anilist_key_file_path, "r") as key_file:
token = key_file.read().strip()
else:
launch(config.fastanime_anilist_app_login_url, wait=False)
token = Prompt.ask("Enter token")
user = AniList.login_user(token)
if not user:

View File

@@ -30,12 +30,12 @@ def notifier(config: "Config"):
notified = os.path.join(APP_DATA_DIR, "last_notification.json")
anime_image_path = os.path.join(APP_CACHE_DIR, "notification_image")
notification_duration = config.notification_duration * 60
notification_duration = config.notification_duration
notification_image_path = ""
if not config.user:
print("Not Authenticated")
print("Run the following to get started: fastanime anilist loggin")
print("Run the following to get started: fastanime anilist login")
exit(1)
run = True
# WARNING: Mess around with this value at your own risk

View File

@@ -2,7 +2,7 @@ import click
@click.command(
help="Get random anime from anilist based on a range of anilist anime ids that are seected at random",
help="Get random anime from anilist based on a range of anilist anime ids that are selected at random",
short_help="View random anime",
)
@click.option(

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

@@ -2,7 +2,7 @@ import click
@click.command(
help="Fetch the 15 most anticipited anime", short_help="View upcoming anime"
help="Fetch the 15 most anticipated anime", short_help="View upcoming anime"
)
@click.option(
"--dump-json",

View File

@@ -122,7 +122,12 @@ if TYPE_CHECKING:
@click.option(
"--hls-use-mpegts",
is_flag=True,
help="Use mpegts for hls streams (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
help="Use mpegts for hls streams, resulted in .ts file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
)
@click.option(
"--hls-use-h264",
is_flag=True,
help="Use H.264 (MP4) for hls streams, resulted in .mp4 file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
)
@click.pass_obj
def download(
@@ -139,6 +144,7 @@ def download(
prompt,
force_ffmpeg,
hls_use_mpegts,
hls_use_h264,
):
import time
@@ -158,7 +164,7 @@ def download(
move_preferred_subtitle_lang_to_top,
)
force_ffmpeg |= hls_use_mpegts
force_ffmpeg |= (hls_use_mpegts or hls_use_h264)
anime_provider = AnimeProvider(config.provider)
anilist_anime_info = None
@@ -201,6 +207,7 @@ def download(
prompt,
force_ffmpeg,
hls_use_mpegts,
hls_use_h264,
)
return
search_results = search_results["results"]
@@ -254,6 +261,7 @@ def download(
prompt,
force_ffmpeg,
hls_use_mpegts,
hls_use_h264,
)
return
@@ -389,6 +397,7 @@ def download(
prompt=prompt,
force_ffmpeg=force_ffmpeg,
hls_use_mpegts=hls_use_mpegts,
hls_use_h264=hls_use_h264,
)
except Exception as e:
print(e)

View File

@@ -124,7 +124,7 @@ def downloads(
return
from ...constants import APP_CACHE_DIR
from ..utils.scripts import fzf_preview
from ..utils.scripts import bash_functions
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
@@ -183,7 +183,7 @@ def downloads(
else echo Loading...
fi
""" % (
fzf_preview,
bash_functions,
downloads_thumbnail_cache_dir,
downloads_thumbnail_cache_dir,
)
@@ -194,7 +194,7 @@ def downloads(
from pathlib import Path
from ...constants import APP_CACHE_DIR
from ..utils.scripts import fzf_preview
from ..utils.scripts import bash_functions
if not shutil.which("ffmpegthumbnailer"):
print("ffmpegthumbnailer not found")
@@ -256,7 +256,7 @@ def downloads(
else echo Loading...
fi
""" % (
fzf_preview,
bash_functions,
downloads_thumbnail_cache_dir,
downloads_thumbnail_cache_dir,
)

View File

@@ -59,7 +59,7 @@ def get_anime_titles(query: str, variables: dict = {}):
else:
return []
except Exception as e:
logger.error(f"Something unexpected occured {e}")
logger.error(f"Something unexpected occurred {e}")
return []

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",
@@ -55,9 +56,11 @@ class Config(object):
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
"icons": "false",
"image_previews": "True" if S_PLATFORM != "win32" else "False",
"image_renderer": "icat" if os.environ.get("KITTY_WINDOW_ID") else "chafa",
"normalize_titles": "True",
"notification_duration": "2",
"notification_duration": "120",
"max_cache_lifetime": "03:00:00",
"per_page": "15",
"player": "mpv",
"preferred_history": "local",
"preferred_language": "english",
@@ -82,18 +85,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 +115,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"
@@ -129,6 +133,7 @@ class Config(object):
self.header_ascii_art = self.configparser.get("general", "header_ascii_art")
self.icons = self.configparser.getboolean("general", "icons")
self.image_previews = self.configparser.getboolean("general", "image_previews")
self.image_renderer = self.configparser.get("general", "image_renderer")
self.normalize_titles = self.configparser.getboolean(
"general", "normalize_titles"
)
@@ -144,6 +149,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 +281,117 @@ 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
# 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
# the image renderer to use [icat/chafa]
image_renderer = {self.image_renderer}
# 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 +400,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 +570,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
@@ -750,6 +764,7 @@ def provider_anime_episodes_menu(
if not current_episode_number or current_episode_number not in available_episodes:
choices = [*available_episodes, "Back"]
preview = None
if config.use_fzf:
if config.preview:
from .utils import get_fzf_episode_preview
@@ -761,7 +776,10 @@ def provider_anime_episodes_menu(
preview = get_fzf_episode_preview(
fastanime_runtime_state.selected_anime_anilist, eps
)
if config.use_fzf:
if not preview:
print("Failed to find bash executable which is necessary for preview with fzf.\nIf you are on Windows, please make sure Git is installed and available in PATH.")
current_episode_number = fzf.run(
choices, prompt="Select Episode", header=anime_title, preview=preview
)
@@ -1358,10 +1376,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)
@@ -1485,6 +1502,9 @@ def anilist_results_menu(
from .utils import get_fzf_anime_preview
preview = get_fzf_anime_preview(search_results, anime_data.keys())
if not preview:
print("Failed to find bash executable which is necessary for preview with fzf.\nIf you are on Windows, please make sure Git is installed and available in PATH.")
selected_anime_title = fzf.run(
choices,
prompt="Select Anime",
@@ -1728,34 +1748,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

@@ -5,15 +5,15 @@ import shutil
import subprocess
import textwrap
from threading import Thread
from hashlib import sha256
import requests
from yt_dlp.utils import clean_html, sanitize_filename
from yt_dlp.utils import clean_html
from ...constants import APP_CACHE_DIR, S_PLATFORM
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...Utility import anilist_data_helper
from ..utils.scripts import fzf_preview
from ..utils.utils import get_true_fg
from ..utils.scripts import bash_functions
from ..utils.utils import get_true_fg, which_bashlike
logger = logging.getLogger(__name__)
@@ -69,7 +69,12 @@ def save_image_from_url(url: str, file_name: str):
file_name: filename to use
"""
image = requests.get(url)
with open(os.path.join(IMAGES_CACHE_DIR, f"{file_name}.png"), "wb") as f:
with open(
os.path.join(
IMAGES_CACHE_DIR, f"{sha256(file_name.encode('utf-8')).hexdigest()}.png"
),
"wb",
) as f:
f.write(image.content)
@@ -83,7 +88,7 @@ def save_info_from_str(info: str, file_name: str):
with open(
os.path.join(
ANIME_INFO_CACHE_DIR,
file_name,
sha256(file_name.encode("utf-8")).hexdigest(),
),
"w",
encoding="utf-8",
@@ -108,8 +113,18 @@ def write_search_results(
future_to_task = {}
for anime, title in zip(anilist_results, titles):
# actual image url
image_url = ""
if os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower() == "true":
image_url = anime["coverImage"]["large"]
if not (
os.path.exists(
os.path.join(
IMAGES_CACHE_DIR,
f"{sha256(title.encode('utf-8')).hexdigest()}.png",
)
)
):
future_to_task[
executor.submit(save_image_from_url, image_url, title)
] = image_url
@@ -121,57 +136,55 @@ def write_search_results(
progress = anime_list["progress"]
# handle the text data
template = f"""
image_url={image_url}
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("", *SEPARATOR_COLOR, bold=False)}"
((ll++))
done
echo
echo "{get_true_fg('Title(jp):',*HEADER_COLOR)} {(anime['title']['romaji'] or "").replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Title(eng):',*HEADER_COLOR)} {(anime['title']['english'] or "").replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg("Title(jp):", *HEADER_COLOR)} {(anime["title"]["romaji"] or "").replace('"', SINGLE_QUOTE)}"
echo "{get_true_fg("Title(eng):", *HEADER_COLOR)} {(anime["title"]["english"] or "").replace('"', SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("", *SEPARATOR_COLOR, bold=False)}"
((ll++))
done
echo
echo "{get_true_fg('Popularity:',*HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime['popularity'])}"
echo "{get_true_fg('Favourites:',*HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime['favourites'])}"
echo "{get_true_fg('Status:',*HEADER_COLOR)} {str(anime['status']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg("Popularity:", *HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime["popularity"])}"
echo "{get_true_fg("Favourites:", *HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime["favourites"])}"
echo "{get_true_fg("Status:", *HEADER_COLOR)} {str(anime["status"]).replace('"', SINGLE_QUOTE)}"
echo "{get_true_fg("Next Episode:", *HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime["nextAiringEpisode"]).replace('"', SINGLE_QUOTE)}"
echo "{get_true_fg("Genres:", *HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime["genres"]).replace('"', SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("", *SEPARATOR_COLOR, bold=False)}"
((ll++))
done
echo
echo "{get_true_fg('Episodes:',*HEADER_COLOR)} {(anime['episodes']) or 'UNKNOWN'}"
echo "{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg("Episodes:", *HEADER_COLOR)} {(anime["episodes"]) or "UNKNOWN"}"
echo "{get_true_fg("Start Date:", *HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime["startDate"]).replace('"', SINGLE_QUOTE)}"
echo "{get_true_fg("End Date:", *HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime["endDate"]).replace('"', SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("", *SEPARATOR_COLOR, bold=False)}"
((ll++))
done
echo
echo "{get_true_fg('Media List:',*HEADER_COLOR)} {mediaListName.replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Progress:',*HEADER_COLOR)} {progress}"
echo "{get_true_fg("Media List:", *HEADER_COLOR)} {mediaListName.replace('"', SINGLE_QUOTE)}"
echo "{get_true_fg("Progress:", *HEADER_COLOR)} {progress}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("", *SEPARATOR_COLOR, bold=False)}"
((ll++))
done
echo
# echo "{get_true_fg('Description:',*HEADER_COLOR).replace('"',SINGLE_QUOTE)}"
# echo "{get_true_fg("Description:", *HEADER_COLOR).replace('"', SINGLE_QUOTE)}"
"""
template = textwrap.dedent(template)
template = f"""
{template}
echo "
{textwrap.fill(clean_html(
(anime['description']) or "").replace('"',SINGLE_QUOTE), width=45)}
"
echo "{textwrap.fill(clean_html((anime["description"]) or "").replace('"', SINGLE_QUOTE), width=45)}"
"""
future_to_task[executor.submit(save_info_from_str, template, title)] = title
@@ -202,9 +215,18 @@ def get_rofi_icons(
for anime, title in zip(anilist_results, titles):
# actual link to download image from
image_url = anime["coverImage"]["large"]
future_to_url[executor.submit(save_image_from_url, image_url, title)] = (
image_url
if not (
os.path.exists(
os.path.join(
IMAGES_CACHE_DIR,
f"{sha256(title.encode('utf-8')).hexdigest()}.png",
)
)
):
future_to_url[
executor.submit(save_image_from_url, image_url, title)
] = image_url
# execute the jobs
for future in concurrent.futures.as_completed(future_to_url):
@@ -232,11 +254,20 @@ def get_fzf_manga_preview(manga_results, workers=None, wait=False):
future_to_url = {}
for manga in manga_results:
image_url = manga["poster"]
if not (
os.path.exists(
os.path.join(
IMAGES_CACHE_DIR,
f"{sha256(manga['title'].encode('utf-8')).hexdigest()}.png",
)
)
):
future_to_url[
executor.submit(
save_image_from_url,
image_url,
sanitize_filename(manga["title"]),
manga["title"],
)
] = image_url
@@ -259,11 +290,13 @@ def get_fzf_manga_preview(manga_results, workers=None, wait=False):
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then fzf-preview %s/{}
title="$(echo -n {})"
title="$(echo -n "$title" |generate_sha256)"
if [ -s "%s/$title" ]; then fzf_preview "%s/title"
else echo Loading...
fi
""" % (
fzf_preview,
bash_functions,
IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR,
)
@@ -282,6 +315,9 @@ def get_fzf_episode_preview(
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
anilist_results: the anilist results from an anilist action
Returns:
The fzf preview script to use or None if the bash is not found
"""
# HEADER_COLOR = 215, 0, 95
@@ -303,7 +339,7 @@ def get_fzf_episode_preview(
if episode_title and image_url:
future_to_url[
executor.submit(save_image_from_url, image_url, episode)
executor.submit(save_image_from_url, image_url, str(episode))
] = image_url
template = textwrap.dedent(
f"""
@@ -312,14 +348,16 @@ def get_fzf_episode_preview(
echo -n -e "{get_true_fg("", *SEPARATOR_COLOR, bold=False)}"
((ll++))
done
echo "{get_true_fg('Anime Title(eng):',*HEADER_COLOR)} {('' or anilist_result['title']['english']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Anime Title(jp):',*HEADER_COLOR)} {(anilist_result['title']['romaji'] or '').replace('"',SINGLE_QUOTE)}"
echo
echo "{get_true_fg("Anime Title(eng):", *HEADER_COLOR)} {("" or anilist_result["title"]["english"]).replace('"', SINGLE_QUOTE)}"
echo "{get_true_fg("Anime Title(jp):", *HEADER_COLOR)} {(anilist_result["title"]["romaji"] or "").replace('"', SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("", *SEPARATOR_COLOR, bold=False)}"
((ll++))
done
echo
echo "{str(episode_title).replace('"', SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
@@ -348,14 +386,18 @@ def get_fzf_episode_preview(
background_worker.start()
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
os.environ["SHELL"] = shutil.which("bash") or "bash"
bash_path = which_bashlike()
if not bash_path:
return
os.environ["SHELL"] = bash_path
if S_PLATFORM == "win32":
preview = """
%s
title={}
show_image_previews="%s"
title="$(echo -n {})"
title="$(echo -n "$title" |generate_sha256)"
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [ $show_image_previews = "true" ];then
if [ "$FASTANIME_IMAGE_PREVIEWS" = "True" ];then
if [ -s "%s\\\\\\${title}.png" ]; then
if command -v "chafa">/dev/null;then
chafa -s $dim "%s\\\\\\${title}.png"
@@ -371,8 +413,7 @@ def get_fzf_episode_preview(
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
bash_functions,
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
@@ -380,11 +421,11 @@ def get_fzf_episode_preview(
)
else:
preview = """
title={}
%s
show_image_previews="%s"
if [ $show_image_previews = "true" ];then
if [ -s %s/${title}.png ]; then fzf-preview %s/${title}.png
title="$(echo -n {})"
title="$(echo -n "$title" |generate_sha256)"
if [ "$FASTANIME_IMAGE_PREVIEWS" = "True" ];then
if [ -s %s/${title}.png ]; then fzf_preview %s/${title}.png
else echo Loading...
fi
fi
@@ -392,8 +433,7 @@ def get_fzf_episode_preview(
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
bash_functions,
IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
@@ -415,7 +455,7 @@ def get_fzf_anime_preview(
anilist_results: the anilist results got from an anilist action
Returns:
THe fzf preview script to use
The fzf preview script to use or None if the bash is not found
"""
# ensure images and info exists
@@ -426,14 +466,19 @@ def get_fzf_anime_preview(
background_worker.start()
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
os.environ["SHELL"] = shutil.which("bash") or "bash"
bash_path = which_bashlike()
if not bash_path:
return
os.environ["SHELL"] = bash_path
if S_PLATFORM == "win32":
preview = """
%s
title={}
show_image_previews="%s"
title="$(echo -n {})"
title="$(echo -n "$title" |generate_sha256)"
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [ $show_image_previews = "true" ];then
if [ "$FASTANIME_IMAGE_PREVIEWS" = "True" ];then
if [ -s "%s\\\\\\${title}.png" ]; then
if command -v "chafa">/dev/null;then
chafa -s $dim "%s\\\\\\${title}.png"
@@ -449,8 +494,7 @@ def get_fzf_anime_preview(
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
bash_functions,
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
@@ -459,10 +503,10 @@ def get_fzf_anime_preview(
else:
preview = """
%s
title={}
show_image_previews="%s"
if [ $show_image_previews = "true" ];then
if [ -s "%s/${title}.png" ]; then fzf-preview "%s/${title}.png"
title="$(echo -n {})"
title="$(echo -n "$title" |generate_sha256)"
if [ "$FASTANIME_IMAGE_PREVIEWS" = "True" ];then
if [ -s "%s/${title}.png" ]; then fzf_preview "%s/${title}.png"
else echo Loading...
fi
fi
@@ -470,8 +514,7 @@ def get_fzf_anime_preview(
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
bash_functions,
IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR,
ANIME_INFO_CACHE_DIR,

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

@@ -5,7 +5,7 @@ import requests
def print_img(url: str):
"""helper funtion to print an image given its url
"""helper function to print an image given its url
Args:
url: [TODO:description]
@@ -25,7 +25,7 @@ def print_img(url: str):
return
img_bytes = res.content
"""
Change made in call to chafa. Chafa dev dropped abilty
Change made in call to chafa. Chafa dev dropped ability
to pull from urls. Keeping old line here just in case.
subprocess.run([EXECUTABLE, url, "--size=15x15"], input=img_bytes)

View File

@@ -1,53 +1,68 @@
fzf_preview = r"""
#
# Adapted from the preview script in the fzf repo
#
# Dependencies:
# - https://github.com/hpjansson/chafa
# - https://iterm2.com/utilities/imgcat
#
fzf-preview() {
file=${1/#\~\//$HOME/}
bash_functions = r"""
generate_sha256() {
local input
# Check if input is passed as an argument or piped
if [ -n "$1" ]; then
input="$1"
else
input=$(cat)
fi
if command -v sha256sum &>/dev/null; then
echo -n "$input" | sha256sum | awk '{print $1}'
elif command -v shasum &>/dev/null; then
echo -n "$input" | shasum -a 256 | awk '{print $1}'
elif command -v sha256 &>/dev/null; then
echo -n "$input" | sha256 | awk '{print $1}'
elif command -v openssl &>/dev/null; then
echo -n "$input" | openssl dgst -sha256 | awk '{print $2}'
else
echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n'
fi
}
fzf_preview() {
file=$1
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [[ $dim = x ]]; then
dim=$(stty size </dev/tty | awk '{print $2 "x" $1}')
elif ! [[ $KITTY_WINDOW_ID ]] && ((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES == $(stty size </dev/tty | awk '{print $1}'))); then
# Avoid scrolling issue when the Sixel image touches the bottom of the screen
# * https://github.com/junegunn/fzf/issues/2544
if [ "$dim" = x ]; then
dim=$(stty size </dev/tty | awk "{print \$2 \"x\" \$1}")
fi
if ! [ "$FASTANIME_IMAGE_RENDERER" = "icat" ] && [ -z "$KITTY_WINDOW_ID" ] && [ "$((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES))" -eq "$(stty size </dev/tty | awk "{print \$1}")" ]; then
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
fi
# 1. Use kitty icat on kitty terminal
if [[ $KITTY_WINDOW_ID ]]; then
# 1. 'memory' is the fastest option but if you want the image to be scrollable,
# you have to use 'stream'.
#
# 2. The last line of the output is the ANSI reset code without newline.
# This confuses fzf and makes it render scroll offset indicator.
# So we remove the last line and append the reset code to its previous line.
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed '$d' | sed $'$s/$/\e[m/'
if [ "$FASTANIME_IMAGE_RENDERER" = "icat" ] && [ -z "$GHOSTTY_BIN_DIR" ]; then
if command -v kitten >/dev/null 2>&1; then
kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
elif command -v icat >/dev/null 2>&1; then
icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
else
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
fi
# 2. Use chafa with Sixel output
elif command -v chafa >/dev/null; then
case "$(uname -a)" in
# termux does not support sixel graphics
# and produces weird output
*ndroid*) chafa -s "$dim" "$file";;
*) chafa -f sixel -s "$dim" "$file";;
elif [ -n "$GHOSTTY_BIN_DIR" ]; then
if command -v kitten >/dev/null 2>&1; then
kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
elif command -v icat >/dev/null 2>&1; then
icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
else
chafa -s "$dim" "$file"
fi
elif command -v chafa >/dev/null 2>&1; then
case "$PLATFORM" in
android) chafa -s "$dim" "$file" ;;
windows) chafa -f sixel -s "$dim" "$file" ;;
*) chafa -s "$dim" "$file" ;;
esac
# Add a new line character so that fzf can display multiple images in the preview window
echo
# 3. If chafa is not found but imgcat is available, use it on iTerm2
elif command -v imgcat >/dev/null; then
# NOTE: We should use https://iterm2.com/utilities/it2check to check if the
# user is running iTerm2. But for the sake of simplicity, we just assume
# that's the case here.
imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file"
# 4. Cannot find any suitable method to preview the image
else
echo install chafa or imgcat or install kitty terminal so you can enjoy image previews
echo please install a terminal image viewer
echo either icat for kitty terminal and wezterm or imgcat or chafa
fi
}
"""

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

@@ -1,8 +1,11 @@
import logging
import shutil
from typing import TYPE_CHECKING
from InquirerPy import inquirer
from fastanime.constants import S_PLATFORM
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ...libs.anime_provider.types import EpisodeStream
@@ -92,7 +95,7 @@ def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]", default
def format_bytes_to_human(num_of_bytes: float, suffix: str = "B"):
"""Helper function usedd to format bytes to human
"""Helper function used to format bytes to human
Args:
num_of_bytes: the number of bytes to format
@@ -155,3 +158,41 @@ def fuzzy_inquirer(choices: list, prompt: str, **kwargs):
**kwargs,
).execute()
return action
def which_win32_gitbash():
"""Helper function that returns absolute path to the git bash executable
(came with Git for Windows) on Windows
Returns:
the path to the git bash executable or None if not found
"""
from os import path
gb_path = shutil.which("bash")
# Windows came with its own bash.exe but it's just an entry point for WSL not Git Bash
if gb_path and not path.dirname(gb_path).lower().endswith("windows\\system32"):
return gb_path
git_path = shutil.which("git")
if git_path:
if path.dirname(git_path).endswith("cmd"):
gb_path = path.abspath(
path.join(path.dirname(git_path), "..", "bin", "bash.exe")
)
else:
gb_path = path.join(path.dirname(git_path), "bash.exe")
if path.exists(gb_path):
return gb_path
def which_bashlike():
"""Helper function that returns absolute path to the bash executable for the current platform
Returns:
the path to the bash executable or None if not found
"""
return (shutil.which("bash") or "bash") if S_PLATFORM != "win32" else which_win32_gitbash()

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
@@ -63,7 +64,7 @@ class AniListApi:
self.session = requests.session()
def login_user(self, token: str):
"""methosd used to login a new user enabling authenticated requests
"""method used to login a new user enabling authenticated requests
Args:
token: anilist app token
@@ -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)
@@ -240,7 +247,7 @@ class AniListApi:
return (False, None)
except Exception as e:
logger.error(f"Something unexpected occured {e}")
logger.error(f"Something unexpected occurred {e}")
return (False, None) # type: ignore
def get_data(
@@ -304,7 +311,7 @@ class AniListApi:
},
) # type: ignore
except Exception as e:
logger.error(f"Something unexpected occured {e}")
logger.error(f"Something unexpected occurred {e}")
return (False, {"Error": f"{e}"}) # type: ignore
def search(
@@ -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
@@ -408,7 +456,7 @@ class AniListApi:
recommended_anime = self.get_data(recommended_query, variables)
return recommended_anime
def get_charcters_of(self, id: int, type="ANIME", *_, **kwargs):
def get_characters_of(self, id: int, type="ANIME", *_, **kwargs):
variables = {"id": id}
characters = self.get_data(anime_characters_query, variables)
return characters

View File

@@ -1,5 +1,5 @@
"""
This module contains all the preset queries for the sake of neatness and convinience
This module contains all the preset queries for the sake of neatness and convenience
Mostly for internal usage
"""
@@ -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:
response.raise_for_status()
return response.json()["data"]
else:
logger.error("[ALLANIME-ERROR]: ", response.text)
return {}
@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
Args:
nsfw ([TODO:parameter]): [TODO:description]
unknown ([TODO:parameter]): [TODO:description]
user_query: [TODO:description]
translation_type: [TODO:description]
**kwargs: [TODO:args]
Returns:
[TODO:return]
"""
search = {"allowAdult": nsfw, "allowUnknown": unknown, "query": user_query}
limit = 40
translationtype = translation_type
countryorigin = "all"
page = 1
Search for anime based on given keywords and filters.
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:
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_results = self._execute_graphql_query(
SEARCH_GQL,
variables={
"search": search,
"search": {
"allowAdult": nsfw,
"allowUnknown": unknown,
"query": search_keywords,
},
"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 = {
"translationtype": translation_type,
"countryorigin": country_of_origin,
},
)
return {
"pageInfo": search_results["shows"]["pageInfo"],
"results": [
{
"id": result["_id"],
"title": result["name"],
"type": result["__typename"],
"availableEpisodes": result["availableEpisodes"],
}
results.append(normalized_result)
normalized_search_results = {
"pageInfo": page_info,
"results": results,
for result in search_results["shows"]["edges"]
],
}
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]
@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.
"""
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())
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"),
}
@debug_provider
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]
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": allanime_show_id,
"showId": anime_id,
"translationType": translation_type,
"episodeString": episode,
}
episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables)
return episode["episode"]
},
)["episode"]
@debug_provider(PROVIDER.upper())
def get_episode_streams(
self, anime_id, episode_number: str, translation_type="sub"
@debug_provider
def _get_server(
self,
embed,
anime_title: str,
allanime_episode: "AllAnimeEpisode",
episode_number,
):
"""get the streams of an episode
"""
Retrieves the streaming server information for a given anime episode based on the provided embed data.
Args:
translation_type ([TODO:parameter]): [TODO:description]
anime: [TODO:description]
episode_number: [TODO:description]
Yields:
[TODO:description]
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.
"""
anime_title = (self.store.get(anime_id, "anime_info", "") or {"title": ""})[
"title"
]
allanime_episode = self._get_anime_episode(
anime_id, episode_number, translation_type
)
if not allanime_episode:
return []
embeds = allanime_episode["sourceUrls"]
@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)
url = one_digit_symmetric_xor(56, url[2:])
if "tools.fast4speed.rsvp" in url:
# 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,
response = self.session.get(
f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}",
timeout=10,
)
if resp.ok:
response.raise_for_status()
# SECOND CASE
match embed["sourceName"]:
case "Luf-mp4":
logger.debug("allanime:Found streams from gogoanime")
logger.debug("Found streams from gogoanime")
return {
"server": "gogoanime",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
"links": give_random_quality(response.json()["links"]),
}
case "Kir":
logger.debug("allanime:Found streams from wetransfer")
logger.debug("Found streams from wetransfer")
return {
"server": "wetransfer",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"server": "weTransfer",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
"links": give_random_quality(response.json()["links"]),
}
case "S-mp4":
logger.debug("allanime:Found streams from sharepoint")
logger.debug("Found streams from sharepoint")
return {
"server": "sharepoint",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
"links": give_random_quality(response.json()["links"]),
}
case "Sak":
logger.debug("allanime:Found streams from dropbox")
logger.debug("Found streams from dropbox")
return {
"server": "dropbox",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
"links": give_random_quality(response.json()["links"]),
}
case "Default":
logger.debug("allanime:Found streams from wixmp")
logger.debug("Found streams from wixmp")
return {
"server": "wixmp",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
"links": give_random_quality(response.json()["links"]),
}
for embed in embeds:
if server := _get_server(embed):
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,27 +52,32 @@ 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):
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}"
@debug_provider
def _pages_loader(
url,
self,
data,
session_id,
params,
page,
):
response = self.session.get(
url,
)
if response.ok:
response = self.session.get(ANIMEPAHE_ENDPOINT, params=params)
response.raise_for_status()
if not data:
data.update(response.json())
else:
@@ -109,15 +97,36 @@ class AnimePaheApi(AnimeProvider):
)
)
page += 1
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
_pages_loader(
url,
page,
self._pages_loader(
data,
session_id,
params={
"m": "release",
"page": page,
"id": session_id,
"sort": "episode_asc",
},
page=page,
)
return data
_pages_loader(
url,
page,
@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
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,22 +5,20 @@ import os
logger = logging.getLogger(__name__)
def debug_provider(provider_name: str):
def _provider_function_decorator(provider_function):
def debug_provider(provider_function):
@functools.wraps(provider_function)
def _provider_function_wrapper(*args, **kwargs):
def _provider_function_wrapper(self, *args, **kwargs):
provider_name = self.__class__.__name__.upper()
if not os.environ.get("FASTANIME_DEBUG"):
try:
return provider_function(*args, **kwargs)
return provider_function(self, *args, **kwargs)
except Exception as e:
logger.error(f"[{provider_name}@{provider_function.__name__}]: {e}")
else:
return provider_function(*args, **kwargs)
return provider_function(self, *args, **kwargs)
return _provider_function_wrapper
return _provider_function_decorator
def ensure_internet_connection(provider_function):
@functools.wraps(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

@@ -246,7 +246,7 @@ def get_mal_id_and_anilist_id(anime_title: str) -> "dict[str,int] | None":
)
return {"id_anilist": anime["id"], "id_mal": anime["idMal"]}
except Exception as e:
logger.error(f"Something unexpected occured {e}")
logger.error(f"Something unexpected occurred {e}")
def get_basic_anime_info_by_title(anime_title: str):
@@ -320,4 +320,4 @@ def get_basic_anime_info_by_title(anime_title: str):
],
}
except Exception as e:
logger.error(f"Something unexpected occured {e}")
logger.error(f"Something unexpected occurred {e}")

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

@@ -8,6 +8,8 @@ from typing import Callable, List
from click import clear
from rich import print
from ...cli.utils.tools import exit_app
logger = logging.getLogger(__name__)
@@ -127,6 +129,9 @@ class FZF:
encoding="utf-8",
)
if not result or result.returncode != 0 or not result.stdout:
if result.returncode == 130: # fzf terminated by ctrl-c
exit_app()
print("sth went wrong :confused:")
input("press enter to try again...")
clear()

View File

@@ -14,7 +14,7 @@
pythonPackages = python.pkgs;
fastanimeEnv = pythonPackages.buildPythonApplication {
pname = "fastanime";
version = "2.8.4";
version = "2.8.8";
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.8"
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()

918
uv.lock generated

File diff suppressed because it is too large Load Diff