Compare commits

...

67 Commits

Author SHA1 Message Date
Benex254
2d921b86c4 docs: update readme 2024-08-02 16:47:22 +03:00
Benex254
603179673d chore: bump version to v0.50.0 2024-08-02 16:47:01 +03:00
Benex254
0ea3cc87ee chore: readd type checking 2024-08-02 16:35:25 +03:00
Benex254
ab9f237c19 fix: correct typing issues 2024-08-02 16:34:36 +03:00
Benex254
1dd7c72b4c feat(interface): improve rofi experience 2024-08-02 15:28:37 +03:00
Benex254
f7cef0eb25 feat(interface): improve the speed of image previews through concurrency 2024-08-02 15:26:52 +03:00
Benex254
07900b3bf8 feat(anilist): lower the maximum allowed calls to the anilist api 2024-08-02 15:23:37 +03:00
Benex254
6479012072 feat: user a better default username 2024-08-02 15:21:50 +03:00
Benex254
0808b8dd38 feat(anilist interface): add rofi as tertiary option for the interface 2024-08-01 12:31:43 +03:00
Benex254
3e2a22612d feat(mpv): use the executable path instead of string 2024-08-01 12:31:03 +03:00
Benex254
45ff21b1af feat(anilist interface): make this more nicer 2024-08-01 12:30:06 +03:00
Benex254
affed01840 feat(anilist api): restrict number of notification results to 5 2024-08-01 12:29:32 +03:00
Benex254
71e707400a feat(notifier)?: add notification bell 2024-08-01 12:28:40 +03:00
Benex254
f0133f718c feat: ui improvements 2024-07-31 14:11:58 +03:00
Benex254
0dc5bfc06b feat: add quick opts for t type 2024-07-31 14:11:36 +03:00
Benex254
a95a118e27 feat: switch to player controls on refuse next episode 2024-07-31 11:08:32 +03:00
Benex254
ace11bc63e feat: update script that runs app for you 2024-07-31 10:58:16 +03:00
Benex254
cc5d65eee3 feat(interface): add breaks for auto next feature 2024-07-31 10:57:44 +03:00
Benex254
ba0de50925 feat: add dockerfile 2024-07-31 09:43:43 +03:00
Benex254
d373ba3bf6 fix: wrong names for options 2024-07-31 09:42:13 +03:00
Benex254
36ce504873 feat: dont force progress onto watchlist 2024-07-31 09:41:40 +03:00
Benex254
47420bedc9 feat: change notification message 2024-07-31 09:40:58 +03:00
Benex254
0f60324fd0 chore: bump version 2024-07-30 19:01:31 +03:00
Benex254
1431edb52a fix: mpv funtion returning less 2024-07-30 18:33:23 +03:00
Benex254
f9fc210264 chore: bump app version 2024-07-30 18:24:37 +03:00
Benex254
68fdb7835f docs: update readme 2024-07-30 18:24:22 +03:00
Benex254
531929aab5 build: with py3.10 2024-07-30 18:21:47 +03:00
Benex254
2ca5985b9a fix: wrong code formating 2024-07-30 18:09:28 +03:00
Benex254
684f8c57a8 build: add tox to ensure compatibility 2024-07-30 18:09:02 +03:00
Benex254
324fef36ac chore: bump app version 2024-07-30 17:34:14 +03:00
Benex254
9d62915f2b chore: bump version 2024-07-30 17:14:19 +03:00
Benex254
a4e9e5f29e chore: bump version 2024-07-30 17:12:27 +03:00
Benex254
d00c958ff2 docs: update readme 2024-07-30 17:12:11 +03:00
Benex254
bc2ac69b9a fix(interface): escape sequence warning 2024-07-30 17:01:59 +03:00
Benex254
01fa96c27a feat: update fa script 2024-07-30 16:53:36 +03:00
Benex254
6c1bbfe50a feat: add aniskip intergration and scoring of anime 2024-07-30 16:52:33 +03:00
Benex254
ecc4e85079 feat(anilist): ensure rate limit is not exceeded 2024-07-30 16:34:18 +03:00
Benex254
1cd743acdf feat(anilist): include mal ids in queries 2024-07-30 16:27:43 +03:00
Benex254
23dd969d37 feat?: create custom aniskip functionality 2024-07-30 16:26:43 +03:00
Benex254
d21f6b5ab0 feat: ensure correct python version 2024-07-30 16:25:58 +03:00
Benex254
640bb12c44 feat: remove unused functions 2024-07-30 16:25:28 +03:00
Benex254
453e4c1b74 feat(notifier): improve error handling 2024-07-30 16:24:34 +03:00
Benex254
4dc3d1b0bb feat: rename watchlist to watching and repeating to rewatching 2024-07-30 16:23:52 +03:00
Benex254
4df57f9410 feat: remove unused print statement 2024-07-30 16:22:53 +03:00
Benex254
baa94efc24 docs: update readme 2024-07-30 10:33:06 +03:00
Benex254
f5d18512f8 feat(cli): add top as an option for servers 2024-07-30 10:32:53 +03:00
Benex254
72037eea07 feat: show anime cover image for notifications on none windows systems 2024-07-30 09:37:17 +03:00
Benex254
f5c120ebb8 feat: handle none logged in user 2024-07-30 09:36:23 +03:00
Benex254
5f2b88bd9b feat(anilist_api): handle none 200 status code 2024-07-30 09:35:28 +03:00
Benex254
b346801dba feat(allanime): handle none 200 status code 2024-07-30 09:34:45 +03:00
benex
1b1a05e2b3 feat(notifier): add icon 2024-07-29 13:21:32 +03:00
benex
8716fb2e1d fix: use platform.system to correctly detect the os 2024-07-29 12:38:37 +03:00
benex
12a38d6d48 feat(anilist): make icons optional 2024-07-29 12:29:27 +03:00
Benex254
e6aa508644 chore: update pyproject.toml 2024-07-29 13:42:45 +03:00
Benex254
584a2ee3f1 feat(allanime): add server 2024-07-29 13:42:45 +03:00
Benex254
385dd4337d feat(anilist): add notifier command 2024-07-29 13:42:45 +03:00
Benex254
1c70a2122d feat(anilist): add notifier command 2024-07-29 13:42:45 +03:00
BenedictX
46b9b844d4 Update README.md 2024-07-29 13:11:25 +03:00
Benex254
272042ec35 fix(anilist_interface): trailer not loading 2024-07-28 16:43:38 +03:00
Benex254
56632cf77c feat(tui): improve the ui 2024-07-28 15:32:31 +03:00
Benex254
e8dacf0722 feat(anilist): only update episode progress in their is actual progress 2024-07-28 11:24:31 +03:00
Benex254
b95d49429c feat(anilist): add update your anilist feature 2024-07-28 10:42:32 +03:00
Benex254
ca087b2e94 feat(player): implement continue from timestamp 2024-07-28 02:23:54 +03:00
Benex254
3f33ae3738 feat(anilist): change media animelist status for anime you currently watching 2024-07-28 00:32:24 +03:00
Benex254
94a282a320 feat(anilist): implement viewing of your anilist animelist 2024-07-28 00:08:44 +03:00
Benex254
0b379ec813 feat(anilist): add account intergration 2024-07-27 22:57:40 +03:00
Benex254
6b0a013705 feat(provider): add animepahe as new provider 2024-07-27 22:54:17 +03:00
42 changed files with 2103 additions and 194 deletions

View File

@@ -25,7 +25,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.10"
- name: Build release distributions
run: |

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM ubuntu
RUN apt-get update
RUN apt-get -y install python3
RUN apt-get update
RUN apt-get -y install pipx
RUN pipx ensurepath
COPY . /fastanime
WORKDIR /fastanime
RUN pipx install .
CMD ["bash"]

148
README.md
View File

@@ -1,11 +1,36 @@
# Fast Anime
# FastAnime
Welcome to **FastAnime**, an anime scrapper that brings a browser experience to the terminal.
Welcome to **FastAnime**, anime site experience from the terminal.
[intro.webm](https://github.com/user-attachments/assets/036af7fc-83ff-4f9b-bda6-0c913f7d0f38)
[fa_demo.webm](https://github.com/user-attachments/assets/bb46642c-176e-42b3-a533-ff55d4dac111)
Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magic-tape](https://gitlab.com/christosangel/magic-tape/-/tree/main?ref_type=heads) and [ani-cli](https://github.com/pystardust/ani-cli).
<!--toc:start-->
- [FastAnime](#fastanime)
- [Installation](#installation)
- [Installation using your favourite package manager](#installation-using-your-favourite-package-manager)
- [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](#the-anilist-command)
- [Running without any subcommand](#running-without-any-subcommand)
- [Subcommands](#subcommands)
- [download subcommand](#download-subcommand)
- [search subcommand](#search-subcommand)
- [downloads subcommand](#downloads-subcommand)
- [config subcommand](#config-subcommand)
- [Configuration](#configuration)
- [Contributing](#contributing)
- [Receiving Support](#receiving-support)
- [Supporting the Project](#supporting-the-project)
<!--toc:end-->
> [!IMPORTANT]
>
> This project currently scrapes allanime and is in no way related to them. The site is in the public domain and can be access by any one with a browser.
@@ -14,27 +39,6 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magi
>
> The docs are still being worked on and are far from completion.
## Table of Contents
- [Installation](#installation)
- [Installation using your favourite package manager](#installation-using-your-favourite-package-manager)
- [Using pipx](#using-pipx)
- [Using pip](#using-pip)
- [Installing the building edge version](#installing-the-bleeding-edge-version)
- [Building from the source](#building-from-the-source)
- [External Dependencies](#external-dependencies)
- [Usage](#usage)
- [The Commandline interface](#the-commandline-interface-fire)
- [The anilist command](#the-anilist-command)
- [download subcommand](#download-subcommand)
- [search subcommand](#search-subcommand)
- [downloads subcommand](#downloads-subcommand)
- [config subcommand](#config-subcommand)
- [Configuration](#configuration)
- [Contributing](#contributing)
- [Receiving Support](#receiving-support)
- [Supporting the Project](#supporting-the-project)
## Installation
The app can run wherever python can run. So all you need to have is python installed on your device.
@@ -63,7 +67,7 @@ pip install fastanime
### Installing the bleeding edge version
To install the latest build which are created on every push by Github actions, download the [fastanime_debug_build](https://github.com/Benex254/FastAnime/actions) of your choosing from the Github actions page.
To install the latest build which are created on every push by GitHub actions, download the [fastanime_debug_build](https://github.com/Benex254/FastAnime/actions) of your choosing from the GitHub actions page.
Then:
```bash
@@ -130,12 +134,13 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
> everything you could ever need with a small footprint.
> But if you have a reason feel free to encourage as to do so.
**Other dependecies that will just make your experience better:**
**Other dependencies that will just make your experience better:**
- [fzf](https://github.com/junegunn/fzf) :fire: which is used as a better alternative to the ui.
- [chafa](https://github.com/hpjansson/chafa) currently the best cross platform and cross terminal image viewer for the terminal.
- [icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) an image viewer that only works in [kitty terminal](https://sw.kovidgoyal.net/kitty/), which is currently the best terminal in my opinion, and by far the best image renderer for the terminal thanks to kitty's terminal graphics protocol. Its terminal graphics is so op that you can [run a browser on it]()!!
- [icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) an image viewer that only works in [kitty terminal](https://sw.kovidgoyal.net/kitty/), which is currently the best terminal in my opinion, and by far the best image renderer for the terminal thanks to kitty's terminal graphics protocol. Its terminal graphics is so op that you can [run a browser on it](https://github.com/chase/awrit?tab=readme-ov-file)!!
- [bash](https://www.gnu.org/software/bash/) is used as the preview script language.
- [ani-skip](https://github.com/synacktraa/ani-skip) :fire: used for skipping the opening and ending theme songs
## Usage
@@ -143,7 +148,7 @@ The app offers both a graphical interface (under development) and a robust comma
> [!NOTE]
>
> The GUI is in development; use the CLI for now.
> The GUI is mostly in hiatus; use the CLI for now.
> However, you can try it out before i decided to change my objective by checking out this [release](https://github.com/Benex254/FastAnime/tree/v0.20.0).
> But be reassured for those who aren't terminal chads, i will still complete the GUI for the fun of it
@@ -153,13 +158,13 @@ Designed for power users who prefer efficiency over browser-based streaming and
Overview of main commands:
- `fastanime anilist`: Powerful command for browsing and exploring anime due to Anilist intergration.
- `fastanime anilist`: Powerful command for browsing and exploring anime due to AniList integration.
- `fastanime download`: Download anime.
- `fastanime search`: Powerful command meant for binging since it doesn't require the interfaces
- `fastanime downloads`: View downloaded anime and watch with mpv.
- `fastanime downloads`: View downloaded anime and watch with MPV.
- `fastanime config`: Quickly edit configuration settings.
Configuration is directly passed into this command at run time to overide your config.
Configuration is directly passed into this command at run time to override your config.
Available options include:
@@ -174,9 +179,15 @@ Available options include:
- `--default` use the default ui
- `--preview` show a preview when using fzf
- `--no-preview` dont show a preview when using fzf
- `--format <yt-dlp format string>` set the format of anime downloaded and streamed based on yt-dlp format. works when `--server gogoanime`
- `--format <yt-dlp format string>` set the format of anime downloaded and streamed based on yt-dlp format. Works when `--server gogoanime`
- `--icons/--no-icons` toggle the visibility of the icons
- `--skip/--no-skip` whether to skip the opening and ending theme songs.
- `--rofi` use rofi for the ui
- `--rofi-theme <path>` theme to use with rofi
- `--rofi-theme-input <path>` theme to use with rofi input
- `--rofi-theme-confirm <path>` theme to use with rofi confirm
#### The anilist command
#### The anilist command :fire: :fire: :fire:
Stream, browse, and discover anime efficiently from the terminal using the [AniList API](https://github.com/AniList/ApiV2-GraphQL-Docs).
@@ -196,6 +207,47 @@ The subcommands are mainly their as convenience. Since all the features already
- `fastanime anilist favourites`: Top 15 favorite anime.
- `fastanime anilist random`: get random anime
The following are commands you can only run if you are signed in to your AniList account:
- `fastanime anilist watching`
- `fastanime anilist planning`
- `fastanime anilist rewatching`
- `fastanime anilist dropped`
- `fastanime anilist paused`
- `fastanime anilist completed`
Plus: `fastanime anilist notifier` :fire:
```bash
# basic form
fastanime anilist notifier
# with logging to stdout
fastanime --log anilist notifier
# with logging to a file. stored in the same place as your config
fastanime --log-file anilist notifier
```
The above commands will start a loop that checks every 2 minutes if any of the anime in your watch list that are aireing has just released a new episode.
The notification will consist of a cover image of the anime in none windows systems.
You can place the command among your machines startup scripts.
For fish users for example you can decide to put this in your `~/.config/fish/config.fish`:
```fish
if ! ps aux | grep -q '[f]astanime .* notifier'
echo initializing fastanime anilist notifier
nohup fastanime --log-file anilist notifier>/dev/null &
end
```
> [!NOTE]
> To sign in just run `fastanime anilist login` and follow the instructions.
> To view your login status `fastanime anilist login --status`
#### download subcommand
Download anime to watch later dub or sub with this one command.
@@ -269,17 +321,23 @@ fastanime config --path
## Configuration
The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on linux and mac or somewhere on windows.
The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on Linux and mac or somewhere on windows; you can check by running `fastanime config --path`.
```ini
[stream]
continue_from_history = True # Auto continue from watch history
translation_type = sub # Preferred language for anime (options: dub, sub)
server = top # Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top)
server = top # Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top, wixmp)
auto_next = False # Auto-select next episode
# Auto select the anime provider results with fuzzyfind.
# Auto select the anime provider results with fuzzy find.
# Note this wont always be correct.But 99% of the time will be.
auto_select=True
# whether to skip the opening and ending theme songs
# note requires ani-skip to be in path
skip=false
# the maximum delta time in minutes after which the episode should be considered as completed
# used in the continue from time stamp
error=3
# the format of downloaded anime and trailer
# based on yt-dlp format and passed directly to it
@@ -292,8 +350,22 @@ format=best[height<=1080]/bestvideo[height<=1080]+bestaudio/best # default
[general]
preferred_language = romaji # Display language (options: english, romaji)
downloads_dir = <Default-videos-dir>/FastAnime # Download directory
preview=false # whether to show a preview window when using fzf or rofi
use_fzf=False # whether to use fzf as the interface for the anilist command and others.
preview=false # whether to show a preview window when using fzf
use_rofi=false # whether to use rofi for the ui
rofi_theme=<path-to-rofi-theme-file>
rofi_theme_input=<path-to-rofi-theme-file>
rofi_theme_confirm=<path-to-rofi-theme-file>
# whether to show the icons
icons=false
# the duration in minutes a notification will stay in the screen
# used by notifier command
notification_duration=2
[anilist]
# Not implemented yet
@@ -311,7 +383,7 @@ For inquiries, join our [Discord Server](https://discord.gg/4NUTj5Pt).
<p align="center">
<a href="https://discord.gg/HRjySFjQ">
<img src="https://invidget.switchblade.xyz/HRjySFjQ">
<img src="https://invidget.switchblade.xyz/HRjySFjQ"/>
</a>
</p>

6
fa
View File

@@ -1,2 +1,4 @@
#! /usr/bin/bash
poetry run fastanime $*
#!/usr/bin/env sh
# exec "${PYTHON:-python3}" -Werror -Xdev -m "$(dirname "$(realpath "$0")")/fastanime" "$@"
cd "$(dirname "$(realpath "$0")")" || exit 1
exec python -m fastanime "$@"

View File

@@ -53,7 +53,8 @@ class YtDLPDownloader:
anime_title = sanitize_filename(title[0])
episode_title = sanitize_filename(title[1])
ydl_opts = {
"outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s", # Specify the output path and template
# Specify the output path and template
"outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s",
"progress_hooks": [
main_progress_hook,
], # Progress hook

View File

@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
class UserData:
user_data = {"watch_history": {}, "animelist": []}
user_data = {"watch_history": {}, "animelist": [], "user": {}}
def __init__(self):
try:
@@ -23,6 +23,10 @@ class UserData:
self.user_data["watch_history"] = watch_history
self._update_user_data()
def update_user_info(self, user: dict):
self.user_data["user"] = user
self._update_user_data()
def update_animelist(self, anime_list: list):
self.user_data["animelist"] = list(set(anime_list))
self._update_user_data()

View File

@@ -1,6 +1,12 @@
import sys
if sys.version_info < (3, 10):
raise ImportError(
"You are using an unsupported version of Python. Only Python versions 3.8 and above are supported by yt-dlp"
) # noqa: F541
import logging
import os
import sys
from dotenv import load_dotenv
@@ -13,7 +19,7 @@ if os.environ.get("FA_RICH_TRACEBACK", False):
# initiate constants
__version__ = "v0.32.0"
__version__ = "v0.50.0"
APP_NAME = "FastAnime"
AUTHOR = "Benex254"
@@ -39,6 +45,22 @@ def FastAnime():
handlers=[RichHandler()], # Use RichHandler to format the logs
)
sys.argv.remove("--log")
if "--log-file" in sys.argv:
# Configure logging
from rich.logging import RichHandler
from .constants import NOTIFIER_LOG_FILE_PATH
logging.getLogger(__name__)
logging.basicConfig(
level=logging.DEBUG, # Set the logging level to DEBUG
# Use a simple message format
format="%(asctime)s%(levelname)s: %(message)s",
datefmt="[%d/%m/%Y@%H:%M:%S]", # Use a custom date format
filename=NOTIFIER_LOG_FILE_PATH,
filemode="a", # Use RichHandler to format the logs
)
sys.argv.remove("--log-file")
from .cli import run_cli

View File

@@ -1,4 +1,3 @@
import os
import sys
if __package__ is None and not getattr(sys, "frozen", False):
@@ -10,15 +9,6 @@ if __package__ is None and not getattr(sys, "frozen", False):
if __name__ == "__main__":
in_development = bool(os.environ.get("FA_DEVELOPMENT", False))
from . import FastAnime
if in_development:
FastAnime()
else:
try:
FastAnime()
except Exception as e:
from .Utility.utils import write_crash
write_crash(e)
FastAnime()

BIN
fastanime/assets/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
fastanime/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

View File

@@ -50,7 +50,7 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option(
"-s",
"--server",
type=click.Choice(SERVERS_AVAILABLE, case_sensitive=False),
type=click.Choice([*SERVERS_AVAILABLE, "top"], case_sensitive=False),
help="Server of choice",
)
@click.option(
@@ -66,6 +66,11 @@ signal.signal(signal.SIGINT, handle_exit)
type=bool,
help="Continue from last episode?",
)
@click.option(
"--skip/--no-skip",
type=bool,
help="Skip opening and ending theme songs?",
)
@click.option(
"-q",
"--quality",
@@ -100,6 +105,25 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option("--default", is_flag=True, help="Use the default interface")
@click.option("--preview", is_flag=True, help="Show preview when using fzf")
@click.option("--no-preview", is_flag=True, help="Dont show preview when using fzf")
@click.option(
"--icons/--no-icons",
type=bool,
help="Use icons in the interfaces",
)
@click.option("--dub", help="Set the translation type to dub", is_flag=True)
@click.option("--sub", help="Set the translation type to sub", is_flag=True)
@click.option("--rofi", help="Use rofi for the ui", is_flag=True)
@click.option("--rofi-theme", help="Rofi theme to use", type=click.Path())
@click.option(
"--rofi-theme-confirm",
help="Rofi theme to use for the confirm prompt",
type=click.Path(),
)
@click.option(
"--rofi-theme-input",
help="Rofi theme to use for the user input prompt",
type=click.Path(),
)
@click.pass_context
def run_cli(
ctx: click.Context,
@@ -107,6 +131,7 @@ def run_cli(
server,
format,
continue_,
skip,
translation_type,
quality,
auto_next,
@@ -117,6 +142,13 @@ def run_cli(
default,
preview,
no_preview,
icons,
dub,
sub,
rofi,
rofi_theme,
rofi_theme_confirm,
rofi_theme_input,
):
ctx.obj = Config()
if provider:
@@ -128,12 +160,17 @@ def run_cli(
ctx.obj.format = format
if ctx.get_parameter_source("continue_") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.continue_from_history = continue_
if ctx.get_parameter_source("skip") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.skip = skip
if quality:
ctx.obj.quality = quality
if ctx.get_parameter_source("auto-next") == click.core.ParameterSource.COMMANDLINE:
if ctx.get_parameter_source("auto_next") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.auto_next = auto_next
if ctx.get_parameter_source("icons") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.icons = icons
if (
ctx.get_parameter_source("auto_select")
ctx.get_parameter_source("--auto_select")
== click.core.ParameterSource.COMMANDLINE
):
ctx.obj.auto_select = auto_select
@@ -151,3 +188,24 @@ def run_cli(
ctx.obj.preview = True
if no_preview:
ctx.obj.preview = False
if dub:
ctx.obj.translation_type = "dub"
if sub:
ctx.obj.translation_type = "sub"
if rofi:
ctx.obj.use_fzf = False
ctx.obj.use_rofi = True
if rofi:
from ..libs.rofi import Rofi
if rofi_theme:
ctx.obj.rofi_theme = rofi_theme
Rofi.rofi_theme = rofi_theme
if rofi_theme_input:
ctx.obj.rofi_theme_input = rofi_theme_input
Rofi.rofi_theme_input = rofi_theme_input
if rofi_theme_confirm:
ctx.obj.rofi_theme_confirm = rofi_theme_confirm
Rofi.rofi_theme_confirm = rofi_theme_confirm

View File

@@ -1,15 +1,24 @@
import click
from ....anilist import AniList
from ...interfaces.anilist_interfaces import anilist as anilist_interface
from ...utils.tools import QueryDict
from .completed import completed
from .dropped import dropped
from .favourites import favourites
from .login import login
from .notifier import notifier
from .paused import paused
from .planning import planning
from .popular import popular
from .random_anime import random_anime
from .recent import recent
from .rewatching import rewatching
from .scores import scores
from .search import search
from .trending import trending
from .upcoming import upcoming
from .watching import watching
commands = {
"trending": trending,
@@ -20,6 +29,14 @@ commands = {
"popular": popular,
"favourites": favourites,
"random": random_anime,
"login": login,
"watching": watching,
"paused": paused,
"rewatching": rewatching,
"dropped": dropped,
"completed": completed,
"planning": planning,
"notifier": notifier,
}
@@ -31,6 +48,8 @@ commands = {
)
@click.pass_context
def anilist(ctx: click.Context):
if user := ctx.obj.user:
AniList.update_login_info(user, user["token"])
if ctx.invoked_subcommand is None:
anilist_config = QueryDict()
anilist_interface(ctx.obj, anilist_config)

View File

@@ -0,0 +1,29 @@
import click
from fastanime.cli.config import Config
from fastanime.cli.interfaces import anilist_interfaces
from fastanime.cli.utils.tools import QueryDict, exit_app
from ....anilist import AniList
@click.command(help="View anime you completed")
@click.pass_obj
def completed(config: Config):
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
anime_list = AniList.get_anime_list("COMPLETED")
if not anime_list or not anime_list[1]:
return
if not anime_list[0]:
return
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)

View File

@@ -0,0 +1,29 @@
import click
from fastanime.cli.config import Config
from fastanime.cli.interfaces import anilist_interfaces
from fastanime.cli.utils.tools import QueryDict, exit_app
from ....anilist import AniList
@click.command(help="View anime you dropped")
@click.pass_obj
def dropped(config: Config):
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
anime_list = AniList.get_anime_list("DROPPED")
if not anime_list:
return
if not anime_list[0] or not anime_list[1]:
return
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)

View File

@@ -0,0 +1,44 @@
import webbrowser
import click
from rich import print
from rich.prompt import Confirm, Prompt
from ....anilist import AniList
from ...config import Config
from ...utils.tools import exit_app
@click.command(help="Login to your anilist account")
@click.option("--status", "-s", help="Whether you are logged in or not", is_flag=True)
@click.pass_obj
def login(config: Config, status):
if status:
is_logged_in = True if config.user else False
message = (
"You are logged in :happy:" if is_logged_in else "You arent logged in :cry"
)
print(message)
print(config.user)
exit_app()
if config.user:
print("Already logged in :confused:")
if not Confirm.ask("or would you like to reloggin", default=True):
exit_app()
# ---- new loggin -----
print(
f"A browser session will be opened ( [link]{config.fastanime_anilist_app_login_url}[/link] )",
)
webbrowser.open(config.fastanime_anilist_app_login_url)
print("Please paste the token provided here")
token = Prompt.ask("Enter token")
user = AniList.login_user(token)
if not user:
print("Sth went wrong", user)
exit_app()
return
user["token"] = token
config.update_user(user)
print("Successfully saved credentials")
print(user)
exit_app()

View File

@@ -0,0 +1,124 @@
import json
import logging
import os
import time
import click
import requests
from plyer import notification
from ....anilist import AniList
from ....constants import (
APP_CACHE_DIR,
APP_DATA_DIR,
APP_NAME,
ICON_PATH,
NOTIFICATION_BELL,
PLATFORM,
)
from ..config import Config
logger = logging.getLogger(__name__)
# plyer.notification(title="anime",message="Update",app_name=APP_NAME)
@click.command(help="Check for notifications on anime you currently watching")
@click.pass_obj
def notifier(config: Config):
notified = os.path.join(APP_DATA_DIR, "last_notification.json")
anime_image = os.path.join(APP_CACHE_DIR, "notification_image")
notification_duration = config.notification_duration * 60
app_icon = ""
if not config.user:
print("Not Authenticated")
print("Run the following to get started: fastanime anilist loggin")
return
run = True
timeout = 2
if os.path.exists(notified):
with open(notified, "r") as f:
past_notifications = json.load(f)
else:
past_notifications = {}
with open(notified, "w") as f:
json.dump(past_notifications, f)
while run:
try:
logger.info("checking for notifications")
result = AniList.get_notification()
if not result[0]:
print(result)
logger.warning(
"Something went wrong this could mean anilist is down or you have lost internet connection"
)
logger.info("sleeping...")
time.sleep(timeout * 60)
continue
data = result[1]
if not data:
print(result)
logger.warning(
"Something went wrong this could mean anilist is down or you have lost internet connection"
)
logger.info("sleeping...")
time.sleep(timeout * 60)
continue
# pyright:ignore
notifications = data["data"]["Page"]["notifications"]
if not notifications:
logger.info("Nothing to notify")
else:
for notification_ in notifications:
anime_episode = notification_["episode"]
anime_title = notification_["media"]["title"][
config.preferred_language
]
title = f"{anime_title} Episode {anime_episode} just aired"
# pyright:ignore
message = "Be sure to watch so you are not left out of the loop."
# message = str(textwrap.wrap(message, width=50))
id = notification_["media"]["id"]
if past_notifications.get(str(id)) == notification_["episode"]:
logger.info(
f"skipping id={id} title={anime_title} episode={anime_episode} already notified"
)
else:
if PLATFORM != "Windows":
image_link = notification_["media"]["coverImage"]["medium"]
print(image_link)
logger.info("Downloading image")
resp = requests.get(image_link)
if resp.status_code == 200:
with open(anime_image, "wb") as f:
f.write(resp.content)
app_icon = anime_image
else:
app_icon = ICON_PATH
past_notifications[f"{id}"] = notification_["episode"]
with open(notified, "w") as f:
json.dump(past_notifications, f)
logger.info(message)
notification.notify( # pyright:ignore
title=title,
message=message,
app_name=APP_NAME,
app_icon=app_icon,
hints={
"image-path": app_icon,
"sound-file": NOTIFICATION_BELL,
},
timeout=notification_duration,
)
# os.system(f"play {NOTIFICATION_BELL}")
time.sleep(30)
except Exception as e:
logger.error(e)
logger.info("sleeping...")
time.sleep(timeout * 60)

View File

@@ -0,0 +1,29 @@
import click
from fastanime.cli.config import Config
from fastanime.cli.interfaces import anilist_interfaces
from fastanime.cli.utils.tools import QueryDict, exit_app
from ....anilist import AniList
@click.command(help="View anime you paused on watching")
@click.pass_obj
def paused(config: Config):
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
anime_list = AniList.get_anime_list("PAUSED")
if not anime_list:
return
if not anime_list[0] or not anime_list[1]:
return
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)

View File

@@ -0,0 +1,29 @@
import click
from fastanime.cli.config import Config
from fastanime.cli.interfaces import anilist_interfaces
from fastanime.cli.utils.tools import QueryDict, exit_app
from ....anilist import AniList
@click.command(help="View anime you are planning on watching")
@click.pass_obj
def planning(config: Config):
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
anime_list = AniList.get_anime_list("PLANNING")
if not anime_list:
return
if not anime_list[0] or not anime_list[1]:
return
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)

View File

@@ -0,0 +1,29 @@
import click
from fastanime.cli.config import Config
from fastanime.cli.interfaces import anilist_interfaces
from fastanime.cli.utils.tools import QueryDict, exit_app
from ....anilist import AniList
@click.command(help="View anime you are rewatching")
@click.pass_obj
def rewatching(config: Config):
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
anime_list = AniList.get_anime_list("REPEATING")
if not anime_list:
return
if not anime_list[0] or not anime_list[1]:
return
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)

View File

@@ -0,0 +1,29 @@
import click
from fastanime.cli.config import Config
from fastanime.cli.interfaces import anilist_interfaces
from fastanime.cli.utils.tools import QueryDict, exit_app
from ....anilist import AniList
@click.command(help="View anime you are watching")
@click.pass_obj
def watching(config: Config):
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
anime_list = AniList.get_anime_list("CURRENT")
if not anime_list:
return
if not anime_list[0] or not anime_list[1]:
return
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)

View File

@@ -5,12 +5,16 @@ from rich import print
from ..AnimeProvider import AnimeProvider
from ..constants import USER_CONFIG_PATH, USER_VIDEOS_DIR
from ..libs.rofi import Rofi
from ..Utility.user_data_helper import user_data_helper
class Config(object):
anime_list: list
watch_history: dict
fastanime_anilist_app_login_url = (
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
)
def __init__(self) -> None:
self.load_config()
@@ -31,6 +35,14 @@ class Config(object):
"preview": "False",
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
"provider": "allanime",
"error": "3",
"icons": "false",
"notification_duration": "2",
"skip": "false",
"use_rofi": "false",
"rofi_theme": "",
"rofi_theme_input": "",
"rofi_theme_confirm": "",
}
)
self.configparser.add_section("stream")
@@ -39,12 +51,16 @@ class Config(object):
if not os.path.exists(USER_CONFIG_PATH):
with open(USER_CONFIG_PATH, "w") as config:
self.configparser.write(config)
self.configparser.read(USER_CONFIG_PATH)
# --- set defaults ---
self.downloads_dir = self.get_downloads_dir()
self.provider = self.get_provider()
self.use_fzf = self.get_use_fzf()
self.use_rofi = self.get_use_rofi()
self.skip = self.get_skip()
self.icons = self.get_icons()
self.preview = self.get_preview()
self.translation_type = self.get_translation_type()
self.sort_by = self.get_sort_by()
@@ -52,18 +68,40 @@ class Config(object):
self.auto_next = self.get_auto_next()
self.auto_select = self.get_auto_select()
self.quality = self.get_quality()
self.notification_duration = self.get_notification_duration()
self.error = self.get_error()
self.server = self.get_server()
self.format = self.get_format()
self.preferred_language = self.get_preferred_language()
self.rofi_theme = self.get_rofi_theme()
Rofi.rofi_theme = self.rofi_theme
self.rofi_theme_input = self.get_rofi_theme_input()
Rofi.rofi_theme_input = self.rofi_theme_input
self.rofi_theme_confirm = self.get_rofi_theme_confirm()
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
# ---- setup user data ------
self.watch_history: dict = user_data_helper.user_data.get("watch_history", {})
self.anime_list: list = user_data_helper.user_data.get("animelist", [])
self.user: dict = user_data_helper.user_data.get("user", {})
self.anime_provider = AnimeProvider(self.provider)
def update_watch_history(self, anime_id: int, episode: str | None):
self.watch_history.update({str(anime_id): episode})
def update_user(self, user):
self.user = user
user_data_helper.update_user_info(user)
def update_watch_history(
self, anime_id: int, episode: str | None, start_time="0", total_time="0"
):
self.watch_history.update(
{
str(anime_id): {
"episode": episode,
"start_time": start_time,
"total_time": total_time,
}
}
)
user_data_helper.update_watch_history(self.watch_history)
def update_anime_list(self, anime_id: int, remove=False):
@@ -82,12 +120,30 @@ class Config(object):
def get_provider(self):
return self.configparser.get("general", "provider")
def get_rofi_theme(self):
return self.configparser.get("general", "rofi_theme")
def get_rofi_theme_input(self):
return self.configparser.get("general", "rofi_theme_input")
def get_rofi_theme_confirm(self):
return self.configparser.get("general", "rofi_theme_confirm")
def get_downloads_dir(self):
return self.configparser.get("general", "downloads_dir")
def get_use_fzf(self):
return self.configparser.getboolean("general", "use_fzf")
def get_use_rofi(self):
return self.configparser.getboolean("general", "use_rofi")
def get_skip(self):
return self.configparser.getboolean("stream", "skip")
def get_icons(self):
return self.configparser.getboolean("general", "icons")
def get_preview(self):
return self.configparser.getboolean("general", "preview")
@@ -112,6 +168,12 @@ class Config(object):
def get_quality(self):
return self.configparser.getint("stream", "quality")
def get_notification_duration(self):
return self.configparser.getint("general", "notification_duration")
def get_error(self):
return self.configparser.getint("stream", "error")
def get_server(self):
return self.configparser.get("stream", "server")

View File

@@ -2,22 +2,40 @@ from __future__ import annotations
import os
import random
from datetime import datetime
from InquirerPy import inquirer
from InquirerPy.validator import EmptyInputValidator
from rich import print
from rich.progress import Progress
from rich.prompt import Prompt
from rich.prompt import Confirm, Prompt
from ...anilist import AniList
from ...constants import USER_CONFIG_PATH
from ...libs.anilist.anilist_data_schema import AnilistBaseMediaDataSchema
from ...libs.anime_provider.types import Anime, SearchResult, Server
from ...libs.fzf import fzf
from ...libs.rofi import Rofi
from ...Utility.data import anime_normalizer
from ...Utility.utils import anime_title_percentage_match, sanitize_filename
from ..config import Config
from ..utils.mpv import mpv
from ..utils.tools import QueryDict, exit_app
from ..utils.utils import clear, fuzzy_inquirer
from .utils import aniskip
def calculate_time_delta(start_time, end_time):
time_format = "%H:%M:%S"
# Convert string times to datetime objects
start = datetime.strptime(start_time, time_format)
end = datetime.strptime(end_time, time_format)
# Calculate the difference
delta = end - start
return delta
def player_controls(config: Config, anilist_config: QueryDict):
@@ -46,16 +64,77 @@ def player_controls(config: Config, anilist_config: QueryDict):
current_episode,
)
mpv(current_link, selected_server["episode_title"])
start_time = config.watch_history[str(anime_id)]["start_time"]
print("[green]Continuing from:[/] ", start_time)
custom_args = []
if config.skip:
if args := aniskip(
anilist_config.selected_anime_anilist["idMal"], current_episode
):
custom_args = args
stop_time, total_time = mpv(
current_link,
selected_server["episode_title"],
start_time=start_time,
custom_args=custom_args,
)
if stop_time == "0":
episode = str(int(current_episode) + 1)
else:
error = 5 * 60
delta = calculate_time_delta(stop_time, total_time)
if delta.total_seconds() > error:
episode = current_episode
else:
episode = str(int(current_episode) + 1)
stop_time = "0"
total_time = "0"
clear()
config.update_watch_history(anime_id, episode, stop_time, total_time)
player_controls(config, anilist_config)
def _next_episode():
# ensures you dont accidentally erase your progress for an in complete episode
stop_time = config.watch_history.get(str(anime_id), {}).get("start_time", "0")
total_time = config.watch_history.get(str(anime_id), {}).get("total_time", "0")
error = config.error * 60
if stop_time == "0" or total_time == "0":
dt = 0
else:
delta = calculate_time_delta(stop_time, total_time)
dt = delta.total_seconds()
if dt > error:
if config.auto_next:
if config.use_rofi:
if not Rofi.confirm(
"Are you sure you wish to continue to the next episode you haven't completed the current episode?"
):
anilist_options(config, anilist_config)
return
else:
if not Confirm.ask(
"Are you sure you wish to continue to the next episode you haven't completed the current episode?",
default=False,
):
anilist_options(config, anilist_config)
return
elif not config.use_rofi:
if not Confirm.ask(
"Are you sure you wish to continue to the next episode, your progress for the current episodes will be erased?",
default=True,
):
player_controls(config, anilist_config)
return
# all checks have passed lets go to the next episode
next_episode = episodes.index(current_episode) + 1
if next_episode >= len(episodes):
next_episode = len(episodes) - 1
# update internal config
# updateinternal config
anilist_config.episode_number = episodes[next_episode]
# update user config
@@ -93,6 +172,8 @@ def player_controls(config: Config, anilist_config: QueryDict):
quality = fzf.run(
options, prompt="Select Quality:", header="Quality Options"
)
elif config.use_rofi:
quality = Rofi.run(options, "Select Quality")
else:
quality = fuzzy_inquirer("Select Quality", options)
config.quality = options.index(quality) # set quality
@@ -105,6 +186,8 @@ def player_controls(config: Config, anilist_config: QueryDict):
translation_type = fzf.run(
options, prompt="Select Translation Type: ", header="Lang Options"
).lower()
elif config.use_rofi:
translation_type = Rofi.run(options, "Select Translation Type")
else:
translation_type = fuzzy_inquirer(
"Select Translation Type", options
@@ -116,27 +199,35 @@ def player_controls(config: Config, anilist_config: QueryDict):
# reload to controls
player_controls(config, anilist_config)
icons = config.icons
options = {
"Replay": _replay,
"Next Episode": _next_episode,
"Previous Episode": _previous_episode,
"Episodes": _episodes,
"Change Quality": _change_quality,
"Change Translation Type": _change_translation_type,
"Servers": _servers,
"Main Menu": lambda: anilist(config, anilist_config),
"Anime Options Menu": lambda: anilist_options(config, anilist_config),
"Search Results": lambda: select_anime(config, anilist_config),
"Exit": exit_app,
f"{'🔂 ' if icons else ''}Replay": _replay,
f"{'' if icons else ''}Next Episode": _next_episode,
f"{'' if icons else ''}Previous Episode": _previous_episode,
f"{'🗃️ ' if icons else ''}Episodes": _episodes,
f"{'📀 ' if icons else ''}Change Quality": _change_quality,
f"{'🎧 ' if icons else ''}Change Translation Type": _change_translation_type,
f"{'💽 ' if icons else ''}Servers": _servers,
f"{'📱 ' if icons else ''}Main Menu": lambda: anilist(config, anilist_config),
f"{'📜 ' if icons else ''}Anime Options Menu": lambda: anilist_options(
config, anilist_config
),
f"{'🔎 ' if icons else ''}Search Results": lambda: select_anime(
config, anilist_config
),
f"{'' if icons else ''}Exit": exit_app,
}
if config.auto_next:
print("Auto selecting next episode")
_next_episode()
return
if config.use_fzf:
action = fzf.run(
list(options.keys()), prompt="Select Action:", header="Player Controls"
)
elif config.use_rofi:
action = Rofi.run(list(options.keys()), "Select Action")
else:
action = fuzzy_inquirer("Select Action", options.keys())
options[action]()
@@ -161,8 +252,12 @@ def fetch_streams(config: Config, anilist_config: QueryDict):
anime, episode_number, translation_type
)
if not episode_streams:
print("Failed to fetch :cry:")
input("Enter to retry...")
if not config.use_rofi:
print("Failed to fetch :cry:")
input("Enter to retry...")
else:
if not Rofi.confirm("Sth went wrong!!Enter to continue..."):
exit(1)
return fetch_streams(config, anilist_config)
episode_streams = {
@@ -184,6 +279,8 @@ def fetch_streams(config: Config, anilist_config: QueryDict):
prompt="Select Server: ",
header="Servers",
)
elif config.use_rofi:
server = Rofi.run(choices, "Select Server")
else:
server = fuzzy_inquirer("Select Server", choices)
if server == "Back":
@@ -214,11 +311,50 @@ def fetch_streams(config: Config, anilist_config: QueryDict):
"[bold magenta] Episode: [/]",
episode_number,
)
# -- update anilist info if user --
if config.user and episode_number:
AniList.update_anime_list(
{
"mediaId": anime_id,
# "status": "CURRENT",
"progress": episode_number,
}
)
mpv(stream_link, selected_server["episode_title"])
start_time = config.watch_history.get(str(anime_id), {}).get("start_time", "0")
if start_time != "0":
print("[green]Continuing from:[/] ", start_time)
custom_args = []
if config.skip:
if args := aniskip(
anilist_config.selected_anime_anilist["idMal"], episode_number
):
custom_args = args
stop_time, total_time = mpv(
stream_link,
selected_server["episode_title"],
start_time=start_time,
custom_args=custom_args,
)
print("Finished at: ", stop_time)
# update_watch_history
config.update_watch_history(anime_id, str(int(episode_number) + 1))
if stop_time == "0":
episode = str(int(episode_number) + 1)
else:
error = config.error * 60
delta = calculate_time_delta(stop_time, total_time)
if delta.total_seconds() > error:
episode = episode_number
else:
episode = str(int(episode_number) + 1)
stop_time = "0"
total_time = "0"
config.update_watch_history(
anime_id, episode, start_time=stop_time, total_time=total_time
)
# switch to controls
clear()
@@ -240,8 +376,11 @@ def fetch_episode(config: Config, anilist_config: QueryDict):
# prompt for episode number
episodes = anime["availableEpisodesDetail"][translation_type]
if continue_from_history and user_watch_history.get(str(anime_id)) in episodes:
episode_number = user_watch_history[str(anime_id)]
if (
continue_from_history
and user_watch_history.get(str(anime_id), {}).get("episode") in episodes
):
episode_number = user_watch_history[str(anime_id)]["episode"]
print(f"[bold cyan]Continuing from Episode:[/] [bold]{episode_number}[/]")
else:
choices = [*episodes, "Back"]
@@ -251,13 +390,16 @@ def fetch_episode(config: Config, anilist_config: QueryDict):
prompt="Select Episode:",
header=anime_title,
)
elif config.use_rofi:
episode_number = Rofi.run(choices, "Select Episode")
else:
episode_number = fuzzy_inquirer("Select Episode", choices)
if episode_number == "Back":
anilist_options(config, anilist_config)
return
config.update_watch_history(anime_id, episode_number)
start_time = user_watch_history.get(str(anime_id), {}).get("start_time", "0")
config.update_watch_history(anime_id, episode_number, start_time=start_time)
# update internal config
anilist_config.episodes = episodes
@@ -275,11 +417,14 @@ def fetch_anime_episode(config, anilist_config: QueryDict):
progress.add_task("Fetching Anime Info...", total=None)
anilist_config.anime = anime_provider.get_anime(selected_anime["id"])
if not anilist_config.anime:
print(
"Sth went wrong :cry: this could mean the provider is down or your internet"
)
input("Enter to continue...")
if not config.use_rofi:
input("Enter to continue...")
else:
if not Rofi.confirm("Sth went wrong!!Enter to continue..."):
exit(1)
fetch_anime_episode(config, anilist_config)
return
@@ -306,7 +451,11 @@ def provide_anime(config: Config, anilist_config: QueryDict):
print(
"Sth went wrong :cry: while fetching this could mean you have poor internet connection or the provider is down"
)
input("Enter to continue...")
if not config.use_rofi:
input("Enter to continue...")
else:
if not Rofi.confirm("Sth went wrong!!Enter to continue..."):
exit(1)
provide_anime(config, anilist_config)
return
@@ -337,6 +486,8 @@ def provide_anime(config: Config, anilist_config: QueryDict):
header="Anime Search Results",
)
elif config.use_rofi:
anime_title = Rofi.run(choices, "Select Search Result")
else:
anime_title = fuzzy_inquirer("Select Search Result", choices)
if anime_title == "Back":
@@ -355,19 +506,96 @@ def anilist_options(config, anilist_config: QueryDict):
if trailer := selected_anime.get("trailer"):
trailer_url = "https://youtube.com/watch?v=" + trailer["id"]
print("[bold magenta]Watching Trailer of:[/]", selected_anime_title)
mpv(trailer_url, selected_anime_title, f"--ytdl-format={config.format}")
mpv(
trailer_url,
ytdl_format=config.format,
)
anilist_options(config, anilist_config)
else:
print("no trailer available :confused:")
input("Enter to continue...")
if not config.use_rofi:
print("no trailer available :confused:")
input("Enter to continue...")
else:
if not Rofi.confirm("No trailler found!!Enter to continue"):
exit(0)
anilist_options(config, anilist_config)
def _add_to_list(config: Config, anilist_config: QueryDict):
config.update_anime_list(anilist_config.anime_id)
# config.update_anime_list(anilist_config.anime_id)
anime_lists = {
"Watching": "CURRENT",
"Paused": "PAUSED",
"Planning": "PLANNING",
"Dropped": "DROPPED",
"Rewatching": "REPEATING",
"Completed": "COMPLETED",
}
if config.use_fzf:
anime_list = fzf.run(
list(anime_lists.keys()),
"Choose the list you want to add to",
"Add your animelist",
)
elif config.use_rofi:
anime_list = Rofi.run(
list(anime_lists.keys()), "Choose list you want to add to"
)
else:
anime_list = fuzzy_inquirer(
"Choose the list you want to add to", list(anime_lists.keys())
)
result = AniList.update_anime_list(
{"status": anime_lists[anime_list], "mediaId": selected_anime["id"]}
)
if not result[0]:
print("Failed to update", result)
else:
print(
f"Successfully added {selected_anime_title} to your {anime_list} list :smile:"
)
if not config.use_rofi:
input("Enter to continue...")
anilist_options(config, anilist_config)
def _score_anime(config: Config, anilist_config: QueryDict):
if config.use_rofi:
score = Rofi.ask("Enter Score", is_int=True)
score = max(100, min(0, score))
else:
score = inquirer.number(
message="Enter the score:",
min_allowed=0,
max_allowed=100,
validate=EmptyInputValidator(),
).execute()
result = AniList.update_anime_list(
{"scoreRaw": score, "mediaId": selected_anime["id"]}
)
if not result[0]:
print("Failed to update", result)
else:
print(f"Successfully scored {selected_anime_title}; score: {score}")
if not config.use_rofi:
input("Enter to continue...")
anilist_options(config, anilist_config)
def _remove_from_list(config: Config, anilist_config: QueryDict):
config.update_anime_list(anilist_config.anime_id, True)
if Confirm.ask(
f"Are you sure you want to procede, the folowing action will permanently remove {selected_anime_title} from your list and your progress will be erased",
default=False,
):
success, data = AniList.delete_medialist_entry(selected_anime["id"])
if not success or not data:
print("Failed to delete", data)
elif not data.get("deleted"):
print("Failed to delete", data)
else:
print("Successfully deleted :cry:", selected_anime_title)
else:
print(selected_anime_title, ":relieved:")
if not config.use_rofi:
input("Enter to continue...")
anilist_options(config, anilist_config)
def _change_translation_type(config: Config, anilist_config: QueryDict):
@@ -377,6 +605,8 @@ def anilist_options(config, anilist_config: QueryDict):
translation_type = fzf.run(
options, prompt="Select Translation Type:", header="Language Options"
)
elif config.use_rofi:
translation_type = Rofi.run(options, "Select Translation Type")
else:
translation_type = fuzzy_inquirer("Select translation type", options)
@@ -438,20 +668,34 @@ def anilist_options(config, anilist_config: QueryDict):
anilist_options(config, anilist_config)
return
def _toggle_auto_select(config, anilist_config):
config.auto_select = not config.auto_select
anilist_options(config, anilist_config)
def _toggle_auto_next(config, anilist_config):
config.auto_select = not config.auto_select
anilist_options(config, anilist_config)
icons = config.icons
options = {
"Stream": provide_anime,
"Watch Trailer": _watch_trailer,
"Add to List": _add_to_list,
"Remove from List": _remove_from_list,
"View Info": _view_info,
"Change Translation Type": _change_translation_type,
"Back": select_anime,
"Exit": exit_app,
f"{'📽️ ' if icons else ''}Stream": provide_anime,
f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer,
f"{'' if icons else ''}Score Anime": _score_anime,
f"{'📥 ' if icons else ''}Add to List": _add_to_list,
f"{'📤 ' if icons else ''}Remove from List": _remove_from_list,
f"{'📖 ' if icons else ''}View Info": _view_info,
f"{'🎧 ' if icons else ''}Change Translation Type": _change_translation_type,
f"{'🔘 ' if icons else ''}Toggle auto select anime": _toggle_auto_select, # problematic if you choose an anime that doesnt match id
f"{'💠 ' if icons else ''}Toggle auto next episode": _toggle_auto_next,
f"{'🔙 ' if icons else ''}Back": select_anime,
f"{'' if icons else ''}Exit": exit_app,
}
if config.use_fzf:
action = fzf.run(
list(options.keys()), prompt="Select Action:", header="Anime Menu"
)
elif config.use_rofi:
action = Rofi.run(list(options.keys()), "Select Action")
else:
action = fuzzy_inquirer("Select Action", options.keys())
options[action](config, anilist_config)
@@ -484,6 +728,26 @@ def select_anime(config: Config, anilist_config: QueryDict):
prompt="Select Anime: ",
header="Search Results",
)
elif config.use_rofi:
# TODO: Make this faster
if config.preview:
from .utils import IMAGES_DIR, get_icons
get_icons(search_results, config)
choices = []
for anime in search_results:
title = sanitize_filename(
str(
anime["title"][config.preferred_language]
or anime["title"]["romaji"]
)
)
icon_path = os.path.join(IMAGES_DIR, title)
choices.append(f"{title}\0icon\x1f{icon_path}")
choices.append("Back")
selected_anime_title = Rofi.run_with_icons(choices, "Select Anime")
else:
selected_anime_title = Rofi.run(choices, "Select Anime")
else:
selected_anime_title = fuzzy_inquirer("Select Anime", choices)
# "bat %s/{}" % SEARCH_RESULTS_CACHE
@@ -501,9 +765,65 @@ def select_anime(config: Config, anilist_config: QueryDict):
anilist_options(config, anilist_config)
def handle_animelist(anilist_config, config: Config, list_type: str):
if not config.user:
if not config.use_rofi:
print("You haven't logged in please run: fastanime anilist login")
input("Enter to continue...")
else:
if not Rofi.confirm("You haven't logged in!!Enter to continue"):
exit(1)
anilist(config, anilist_config)
return
match list_type:
case "Watching":
status = "CURRENT"
case "Planned":
status = "PLANNING"
case "Completed":
status = "COMPLETED"
case "Dropped":
status = "DROPPED"
case "Paused":
status = "PAUSED"
case "Repeating":
status = "REPEATING"
case _:
return
anime_list = AniList.get_anime_list(status)
if not anime_list:
print("Sth went wrong", anime_list)
if not config.use_rofi:
input("Enter to continue")
else:
if not Rofi.confirm("Sth went wrong!!Enter to continue..."):
exit(1)
anilist(config, anilist_config)
return
if not anime_list[0] or not anime_list[1]:
print("Sth went wrong", anime_list)
if not config.use_rofi:
input("Enter to continue")
else:
if not Rofi.confirm("Sth went wrong!!Enter to continue..."):
exit(1)
anilist(config, anilist_config)
return
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
return anime_list
def anilist(config: Config, anilist_config: QueryDict):
def _anilist_search():
search_term = Prompt.ask("[cyan]Search for[/]")
if config.use_rofi:
search_term = str(Rofi.ask("Search for"))
else:
search_term = Prompt.ask("[cyan]Search for[/]")
return AniList.search(query=search_term)
@@ -525,31 +845,56 @@ def anilist(config: Config, anilist_config: QueryDict):
import subprocess
subprocess.run([os.environ.get("EDITOR", "open"), USER_CONFIG_PATH])
config.load_config()
if config.use_rofi:
config.load_config()
config.use_rofi = True
config.use_fzf = False
else:
config.load_config()
anilist(config, anilist_config)
icons = config.icons
options = {
"Trending": AniList.get_trending,
"Recently Updated Anime": AniList.get_most_recently_updated,
"Search": _anilist_search,
"Watch History": _watch_history,
"AnimeList": _anime_list,
"Random Anime": _anilist_random,
"Most Popular Anime": AniList.get_most_popular,
"Most Favourite Anime": AniList.get_most_favourite,
"Most Scored Anime": AniList.get_most_scored,
"Upcoming Anime": AniList.get_upcoming_anime,
"Edit Config": edit_config,
"Exit": exit_app,
f"{'🔥 ' if icons else ''}Trending": AniList.get_trending,
f"{'📺 ' if icons else ''}Watching": lambda x="Watching": handle_animelist(
anilist_config, config, x
),
f"{'' if icons else ''}Paused": lambda x="Paused": handle_animelist(
anilist_config, config, x
),
f"{'🚮 ' if icons else ''}Dropped": lambda x="Dropped": handle_animelist(
anilist_config, config, x
),
f"{'📑 ' if icons else ''}Planned": lambda x="Planned": handle_animelist(
anilist_config, config, x
),
f"{'' if icons else ''}Completed": lambda x="Completed": handle_animelist(
anilist_config, config, x
),
f"{'🔁 ' if icons else ''}Rewatching": lambda x="Repeating": handle_animelist(
anilist_config, config, x
),
f"{'🔔 ' if icons else ''}Recently Updated Anime": AniList.get_most_recently_updated,
f"{'🔎 ' if icons else ''}Search": _anilist_search,
f"{'🎞️ ' if icons else ''}Watch History": _watch_history,
# "AnimeList": _anime_list💯,
f"{'🎲 ' if icons else ''}Random Anime": _anilist_random,
f"{'🌟 ' if icons else ''}Most Popular Anime": AniList.get_most_popular,
f"{'💖 ' if icons else ''}Most Favourite Anime": AniList.get_most_favourite,
f"{'' if icons else ''}Most Scored Anime": AniList.get_most_scored,
f"{'🎬 ' if icons else ''}Upcoming Anime": AniList.get_upcoming_anime,
f"{'📝 ' if icons else ''}Edit Config": edit_config,
f"{'' if icons else ''}Exit": exit_app,
}
if config.use_fzf:
action = fzf.run(
list(options.keys()),
prompt="Select Action: ",
header="Anilist Menu",
)
elif config.use_rofi:
action = Rofi.run(list(options.keys()), "Select Action")
else:
action = fuzzy_inquirer("Select Action", options.keys())
anilist_data = options[action]()
@@ -559,5 +904,9 @@ def anilist(config: Config, anilist_config: QueryDict):
else:
print(anilist_data[1])
input("Enter to continue...")
if not config.use_rofi:
input("Enter to continue...")
else:
if not Rofi.confirm("Sth went wrong!!Enter to continue..."):
exit(1)
anilist(config, anilist_config)

View File

@@ -1,5 +1,8 @@
import concurrent.futures
import logging
import os
import shutil
import subprocess
import textwrap
from threading import Thread
@@ -10,8 +13,11 @@ from ...libs.anilist.anilist_data_schema import AnilistBaseMediaDataSchema
from ...Utility import anilist_data_helper
from ...Utility.utils import remove_html_tags, sanitize_filename
from ..config import Config
from ..utils.utils import get_true_fg
fzf_preview = """
logger = logging.getLogger(__name__)
fzf_preview = r"""
#
# The purpose of this script is to demonstrate how to preview a file or an
# image in the preview window of fzf.
@@ -89,81 +95,147 @@ fzf-preview(){
"""
SEARCH_RESULTS_CACHE = os.path.join(APP_CACHE_DIR, "search_results")
# ---- aniskip intergration ----
def aniskip(mal_id, episode):
ANISKIP = shutil.which("ani-skip")
if not ANISKIP:
print("Aniskip not found, please install and try again")
return
args = [ANISKIP, "-q", str(mal_id), "-e", str(episode)]
aniskip_result = subprocess.run(args, text=True, stdout=subprocess.PIPE)
if aniskip_result.returncode != 0:
return
mpv_skip_args = aniskip_result.stdout.strip()
return mpv_skip_args.split(" ")
# ---- prevew stuff ----
# import tempfile
WORKING_DIR = APP_CACHE_DIR # tempfile.gettempdir()
IMAGES_DIR = os.path.join(WORKING_DIR, "images")
if not os.path.exists(IMAGES_DIR):
os.mkdir(IMAGES_DIR)
INFO_DIR = os.path.join(WORKING_DIR, "info")
if not os.path.exists(INFO_DIR):
os.mkdir(INFO_DIR)
def save_image_from_url(url: str, file_name: str):
image = requests.get(url)
with open(f"{IMAGES_DIR}/{file_name}", "wb") as f:
f.write(image.content)
def save_info_from_str(info: str, file_name: str):
with open(f"{INFO_DIR}/{file_name}", "w") as f:
f.write(info)
def write_search_results(
search_results: list[AnilistBaseMediaDataSchema], config: Config
search_results: list[AnilistBaseMediaDataSchema], config: Config, workers=None
):
for anime in search_results:
if not os.path.exists(SEARCH_RESULTS_CACHE):
os.mkdir(SEARCH_RESULTS_CACHE)
anime_title = (
anime["title"][config.preferred_language] or anime["title"]["romaji"]
)
anime_title = sanitize_filename(anime_title)
ANIME_CACHE = os.path.join(SEARCH_RESULTS_CACHE, anime_title)
if not os.path.exists(ANIME_CACHE):
os.mkdir(ANIME_CACHE)
with open(
f"{ANIME_CACHE}/image",
"wb",
) as f:
try:
image = requests.get(anime["coverImage"]["large"], timeout=5)
f.write(image.content)
except Exception:
pass
H_COLOR = 215, 0, 95
S_COLOR = 208, 208, 208
S_WIDTH = 45
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
future_to_task = {}
for anime in search_results:
anime_title = (
anime["title"][config.preferred_language] or anime["title"]["romaji"]
)
anime_title = sanitize_filename(anime_title)
image_url = anime["coverImage"]["large"]
future_to_task[
executor.submit(save_image_from_url, image_url, anime_title)
] = image_url
with open(f"{ANIME_CACHE}/data", "w") as f:
# data = json.dumps(anime, sort_keys=True, indent=2, separators=(',', ': '))
# handle the text data
template = f"""
{"-"*40}
Anime Title(jp): {anime['title']['romaji']}
Anime Title(eng): {anime['title']['english']}
{"-"*40}
Popularity: {anime['popularity']}
Favourites: {anime['favourites']}
Status: {anime['status']}
Episodes: {anime['episodes']}
Genres: {anilist_data_helper.format_list_data_with_comma(anime['genres'])}
Next Episode: {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode'])}
Start Date: {anilist_data_helper.format_anilist_date_object(anime['startDate'])}
End Date: {anilist_data_helper.format_anilist_date_object(anime['endDate'])}
{"-"*40}
Description:
{get_true_fg("-"*S_WIDTH,*S_COLOR,bold=False)}
{get_true_fg('Title(jp):',*H_COLOR)} {anime['title']['romaji']}
{get_true_fg('Title(eng):',*H_COLOR)} {anime['title']['english']}
{get_true_fg('Popularity:',*H_COLOR)} {anime['popularity']}
{get_true_fg('Favourites:',*H_COLOR)} {anime['favourites']}
{get_true_fg('Status:',*H_COLOR)} {anime['status']}
{get_true_fg('Episodes:',*H_COLOR)} {anime['episodes']}
{get_true_fg('Genres:',*H_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres'])}
{get_true_fg('Next Episode:',*H_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode'])}
{get_true_fg('Start Date:',*H_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate'])}
{get_true_fg('End Date:',*H_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate'])}
{get_true_fg("-"*S_WIDTH,*S_COLOR,bold=False)}
{get_true_fg('Description:',*H_COLOR)}
"""
template = textwrap.dedent(template)
template = f"""
{template}
{textwrap.fill(remove_html_tags(str(anime['description'])),width=45)}
{textwrap.fill(remove_html_tags(
str(anime['description'])), width=45)}
"""
f.write(template)
future_to_task[
executor.submit(save_info_from_str, template, anime_title)
] = anime_title
# execute the jobs
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]
try:
future.result()
except Exception as exc:
logger.error("%r generated an exception: %s" % (task, exc))
def get_preview(search_results: list[AnilistBaseMediaDataSchema], config: Config):
# get rofi icons
def get_icons(search_results: list[AnilistBaseMediaDataSchema], config, workers=None):
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs
future_to_url = {}
for anime in search_results:
anime_title = (
anime["title"][config.preferred_language] or anime["title"]["romaji"]
)
anime_title = sanitize_filename(anime_title)
image_url = anime["coverImage"]["large"]
future_to_url[
executor.submit(save_image_from_url, image_url, anime_title)
] = image_url
# execute the jobs
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
future.result()
except Exception as exc:
logger.error("%r generated an exception: %s" % (url, exc))
def get_preview(
search_results: list[AnilistBaseMediaDataSchema], config: Config, wait=False
):
# ensure images and info exists
background_worker = Thread(
target=write_search_results, args=(search_results, config)
)
background_worker.daemon = True
background_worker.start()
os.environ["SHELL"] = shutil.which("bash") or "bash"
os.environ["SHELL"] = shutil.which("bash") or "sh"
preview = """
%s
if [ -s %s/{}/image ]; then fzf-preview %s/{}/image
if [ -s %s/{} ]; then fzf-preview %s/{}
else echo Loading...
fi
if [ -s %s/{}/data ]; then cat %s/{}/data
if [ -s %s/{} ]; then cat %s/{}
else echo Loading...
fi
""" % (
fzf_preview,
SEARCH_RESULTS_CACHE,
SEARCH_RESULTS_CACHE,
SEARCH_RESULTS_CACHE,
SEARCH_RESULTS_CACHE,
IMAGES_DIR,
IMAGES_DIR,
INFO_DIR,
INFO_DIR,
)
# preview.replace("\n", ";")
if wait:
background_worker.join()
return preview

View File

@@ -3,7 +3,6 @@ import shutil
import subprocess
from typing import Optional
# legacy
# def mpv(link, title: None | str = "anime", *custom_args):
# MPV = shutil.which("mpv")
@@ -25,14 +24,60 @@ from typing import Optional
# else:
# subprocess.run([MPV, *custom_args, f"--title={title}", link])
#
#
def mpv(link: str, title: Optional[str] = "anime", *custom_args):
def stream_video(MPV, url, mpv_args, custom_args):
process = subprocess.Popen(
[MPV, url, *mpv_args, *custom_args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
last_time = None
av_time_pattern = re.compile(r"AV: ([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)")
last_time = "0"
total_time = "0"
try:
while True:
output = process.stderr.readline()
if output:
# Match the timestamp in the output
match = av_time_pattern.search(output.strip())
if match:
current_time = match.group(1)
total_time = match.group(2)
match.group(3)
last_time = current_time
# print(f"Current stream time: {current_time}, Total time: {total_time}, Progress: {percentage}%")
# Check if the process has terminated
retcode = process.poll()
if retcode is not None:
print("Finshed at: ", last_time)
break
except Exception as e:
print(f"An error occurred: {e}")
finally:
process.terminate()
return last_time, total_time
def mpv(
link: str,
title: Optional[str] = "",
start_time: str = "0",
ytdl_format="",
custom_args=[],
):
# Determine if mpv is available
MPV = shutil.which("mpv")
# If title is None, set a default value
if title is None:
title = "anime"
# Regex to check if the link is a YouTube URL
youtube_regex = r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+"
@@ -54,6 +99,7 @@ def mpv(link: str, title: Optional[str] = "anime", *custom_args):
"-n",
"com.google.android.youtube/.UrlActivity",
]
return "0", "0"
else:
# Android specific commands to launch mpv with a regular URL
args = [
@@ -71,10 +117,18 @@ def mpv(link: str, title: Optional[str] = "anime", *custom_args):
]
subprocess.run(args)
return "0", "0"
else:
# General mpv command with custom arguments
mpv_args = [MPV, *custom_args, f"--title={title}", link]
subprocess.run(mpv_args)
mpv_args = []
if start_time != "0":
mpv_args.append(f"--start={start_time}")
if title:
mpv_args.append(f"--title={title}")
if ytdl_format:
mpv_args.append(f"--ytdl-format={ytdl_format}")
stop_time, total_time = stream_video(MPV, link, mpv_args, custom_args)
return stop_time, total_time
# Example usage

View File

@@ -14,13 +14,36 @@ class QueryDict(dict):
def exit_app(*args):
import os
import shutil
import sys
from rich import print
from ...constants import APP_NAME, ICON_PATH, USER_NAME
from ...constants import USER_NAME
def is_running_in_terminal():
try:
shutil.get_terminal_size()
return (
sys.stdin.isatty()
and sys.stdout.isatty()
and os.getenv("TERM") is not None
)
except OSError:
return False
print("Have a good day :smile:", USER_NAME)
if not is_running_in_terminal():
from plyer import notification
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,
message=f"Have a good day {USER_NAME}",
title="Shutting down",
) # pyright:ignore
else:
from rich import print
print("Have a good day :smile:", USER_NAME)
sys.exit(0)

View File

@@ -10,6 +10,29 @@ from ...Utility.data import anime_normalizer
logger = logging.getLogger(__name__)
# Define ANSI escape codes as constants
RESET = "\033[0m"
BOLD = "\033[1m"
INVISIBLE_CURSOR = "\033[?25l"
VISIBLE_CURSOR = "\033[?25h"
UNDERLINE = "\033[4m"
# ESC[38;2;{r};{g};{b}m
BG_GREEN = "\033[48;2;120;233;12;m"
GREEN = "\033[38;2;45;24;45;m"
def get_true_fg(string: str, r: int, g: int, b: int, bold=True) -> str:
if bold:
return f"{BOLD}\033[38;2;{r};{g};{b};m{string}{RESET}"
else:
return f"\033[38;2;{r};{g};{b};m{string}{RESET}"
def get_true_bg(string, r: int, g: int, b: int) -> str:
return f"\033[48;2;{r};{g};{b};m{string}{RESET}"
def clear():
if PLATFORM == "Windows":
os.system("cls")

View File

@@ -1,11 +1,11 @@
import os
from platform import platform
from platform import system
from platformdirs import PlatformDirs
from . import APP_NAME, AUTHOR
PLATFORM = platform()
PLATFORM = system()
dirs = PlatformDirs(appname=APP_NAME, appauthor=AUTHOR, ensure_exists=True)
@@ -14,6 +14,15 @@ APP_DIR = os.path.abspath(os.path.dirname(__file__))
CONFIGS_DIR = os.path.join(APP_DIR, "configs")
ASSETS_DIR = os.path.join(APP_DIR, "assets")
# --- notification bell ---
NOTIFICATION_BELL = os.path.join(ASSETS_DIR, "tut_turu.mp3")
# --- icon stuff ---
if PLATFORM == "Windows":
ICON_PATH = os.path.join(ASSETS_DIR, "logo.ico")
else:
ICON_PATH = os.path.join(ASSETS_DIR, "logo.png")
# ----- user configs and data -----
APP_DATA_DIR = dirs.user_config_dir
if not APP_DATA_DIR:
@@ -21,6 +30,7 @@ if not APP_DATA_DIR:
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
NOTIFIER_LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "notifier.log")
# cache dir
APP_CACHE_DIR = dirs.user_cache_dir
@@ -29,4 +39,4 @@ APP_CACHE_DIR = dirs.user_cache_dir
USER_VIDEOS_DIR = os.path.join(dirs.user_videos_dir, APP_NAME)
USER_NAME = os.environ.get("USERNAME", f"{APP_NAME} user")
USER_NAME = os.environ.get("USERNAME", "Anime fun")

View File

@@ -17,6 +17,14 @@ class AnilistImage(TypedDict):
large: str
class AnilistUser(TypedDict):
id: int
name: str
bannerImage: str | None
avatar: AnilistImage
token: str
class AnilistMediaTrailer(TypedDict):
id: str
site: str
@@ -49,11 +57,6 @@ class AnilistMediaNextAiringEpisode(TypedDict):
episode: int
class AnilistUser(TypedDict):
name: str
avatar: AnilistImage
class AnilistReview(TypedDict):
summary: str
user: AnilistUser
@@ -110,7 +113,8 @@ class AnilistBaseMediaDataSchema(TypedDict):
This a convenience class is used to type the received Anilist data to enhance dev experience
"""
id: str
id: int
idMal: int
title: AnilistMediaTitle
coverImage: AnilistImage
trailer: AnilistMediaTrailer | None
@@ -161,3 +165,50 @@ class AnilistPages(TypedDict):
class AnilistDataSchema(TypedDict):
data: AnilistPages
Error: str
class AnilistNotification(TypedDict):
id: int
type: str
episode: int
context: str
createdAt: str
media: AnilistBaseMediaDataSchema
class AnilistNotificationPage(TypedDict):
pageInfo: AnilistPageInfo
notifications: list[AnilistNotification]
class AnilistNotificationPages(TypedDict):
Page: AnilistNotificationPage
class AnilistNotifications(TypedDict):
data: AnilistNotificationPages
class AnilistMediaList(TypedDict):
media: AnilistBaseMediaDataSchema
status: str
progress: int
score: int
repeat: int
notes: str
startDate: AnilistDateObject
completedAt: AnilistDateObject
createdAt: str
class AnilistMediaListPage(TypedDict):
pageInfo: AnilistPageInfo
mediaList: list[AnilistMediaList]
class AnilistMediaListPages(TypedDict):
Page: AnilistMediaListPage
class AnilistMediaLists(TypedDict):
data: AnilistMediaListPages

View File

@@ -2,25 +2,42 @@
This is the core module availing all the abstractions of the anilist api
"""
import logging
from typing import Literal
import requests
from .anilist_data_schema import AnilistDataSchema
from .anilist_data_schema import (
AnilistDataSchema,
AnilistMediaLists,
AnilistNotifications,
AnilistUser,
)
from .queries_graphql import (
airing_schedule_query,
anime_characters_query,
anime_query,
anime_relations_query,
delete_list_entry_query,
get_logged_in_user_query,
get_medialist_item_query,
mark_as_read_mutation,
media_list_mutation,
media_list_query,
most_favourite_query,
most_popular_query,
most_recently_updated_query,
most_scored_query,
notification_query,
recommended_query,
search_query,
trending_query,
upcoming_anime_query,
)
logger = logging.getLogger(__name__)
# from kivy.network.urlrequest import UrlRequestRequests
ANILIST_ENDPOINT = "https://graphql.anilist.co"
class AniListApi:
@@ -28,6 +45,125 @@ class AniListApi:
This class provides an abstraction for the anilist api
"""
def login_user(self, token: str):
self.token = token
self.headers = {"Authorization": f"Bearer {self.token}"}
user = self.get_logged_in_user()
if not user:
return
if not user[0]:
return
user_info: AnilistUser = user[1]["data"]["Viewer"] # pyright:ignore
self.user_id = user_info["id"] # pyright:ignore
return user_info
def get_notification(
self,
) -> tuple[bool, AnilistNotifications] | tuple[bool, None]:
return self._make_authenticated_request(notification_query)
def reset_notification_count(self):
return self._make_authenticated_request(mark_as_read_mutation)
def update_login_info(self, user: AnilistUser, token: str):
self.token = token
self.headers = {"Authorization": f"Bearer {self.token}"}
self.user_id = user["id"]
def get_logged_in_user(self):
if not self.headers:
return
return self._make_authenticated_request(get_logged_in_user_query)
def update_anime_list(self, values_to_update: dict):
variables = {"userId": self.user_id, **values_to_update}
return self._make_authenticated_request(media_list_mutation, variables)
def get_anime_list(
self,
status: Literal[
"CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"
],
) -> tuple[bool, AnilistMediaLists] | tuple[bool, None]:
variables = {"status": status, "userId": self.user_id}
return self._make_authenticated_request(media_list_query, variables)
def get_medialist_entry(
self, mediaId: int
) -> tuple[bool, dict] | tuple[bool, None]:
variables = {"mediaId": mediaId}
return self._make_authenticated_request(get_medialist_item_query, variables)
def delete_medialist_entry(self, mediaId: int):
result = self.get_medialist_entry(mediaId)
data = result[1]
if not result[0] or not data:
return result
id = data["data"]["MediaList"]["id"]
variables = {"id": id}
return self._make_authenticated_request(delete_list_entry_query, variables)
def _make_authenticated_request(self, query: str, variables: dict = {}):
"""
The core abstraction for getting authenticated data from the anilist api
Parameters:
----------
query:str
a valid anilist graphql query
variables:dict
variables to pass to the anilist api
"""
# req=UrlRequestRequests(url, self.got_data,)
try:
# TODO: check if data is as expected
response = requests.post(
ANILIST_ENDPOINT,
json={"query": query, "variables": variables},
timeout=10,
headers=self.headers,
)
anilist_data = response.json()
# ensuring you dont get blocked
if (
int(response.headers.get("X-RateLimit-Remaining", 0)) < 30
and not response.status_code == 500
):
print(
"Warning you are exceeding the allowed number of calls per minute"
)
logger.warning(
"You are exceeding the allowed number of calls per minute for the AniList api enforcing timeout"
)
print("Forced timeout will now be initiated")
import time
print("sleeping...")
time.sleep(1 * 60)
if response.status_code == 200:
return (True, anilist_data)
else:
return (False, anilist_data)
except requests.exceptions.Timeout:
logger.warning(
"Timeout has been exceeded this could mean anilist is down or you have lost internet connection"
)
return (False, None)
except requests.exceptions.ConnectionError:
logger.warning(
"ConnectionError this could mean anilist is down or you have lost internet connection"
)
return (False, None)
except Exception as e:
logger.error(f"Something unexpected occured {e}")
return (False, None) # type: ignore
def get_watchlist(self):
variables = {"status": "CURRENT", "userId": self.user_id}
return self._make_authenticated_request(media_list_query, variables)
def get_data(
self, query: str, variables: dict = {}
) -> tuple[bool, AnilistDataSchema]:
@@ -41,16 +177,40 @@ class AniListApi:
variables:dict
variables to pass to the anilist api
"""
url = "https://graphql.anilist.co"
# req=UrlRequestRequests(url, self.got_data,)
try:
# TODO: check if data is as expected
response = requests.post(
url, json={"query": query, "variables": variables}, timeout=10
ANILIST_ENDPOINT,
json={"query": query, "variables": variables},
timeout=10,
)
anilist_data: AnilistDataSchema = response.json()
return (True, anilist_data)
# ensuring you dont get blocked
if (
int(response.headers.get("X-RateLimit-Remaining", 0)) < 30
and not response.status_code == 500
):
print(
"Warning you are exceeding the allowed number of calls per minute"
)
logger.warning(
"You are exceeding the allowed number of calls per minute for the AniList api enforcing timeout"
)
print("Forced timeout will now be initiated")
import time
print("sleeping...")
time.sleep(1 * 60)
if response.status_code == 200:
return (True, anilist_data)
else:
return (False, anilist_data)
except requests.exceptions.Timeout:
logger.warning(
"Timeout has been exceeded this could mean anilist is down or you have lost internet connection"
)
return (
False,
{
@@ -58,6 +218,9 @@ class AniListApi:
},
) # type: ignore
except requests.exceptions.ConnectionError:
logger.warning(
"ConnectionError this could mean anilist is down or you have lost internet connection"
)
return (
False,
{
@@ -65,6 +228,7 @@ class AniListApi:
},
) # type: ignore
except Exception as e:
logger.error(f"Something unexpected occured {e}")
return (False, {"Error": f"{e}"}) # type: ignore
def search(

View File

@@ -3,6 +3,203 @@ This module contains all the preset queries for the sake of neatness and convini
Mostly for internal usage
"""
mark_as_read_mutation = """
mutation{
UpdateUser{
unreadNotificationCount
}
}
"""
reviews_query = """
query($id:Int){
Page{
pageInfo{
total
}
reviews(mediaId:$id){
summary
user{
name
avatar {
large
medium
}
}
body
}
}
}
"""
notification_query = """
query{
Page(perPage:5){
pageInfo {
total
}
notifications(resetNotificationCount:true,type:AIRING) {
... on AiringNotification {
id
type
episode
contexts
createdAt
media {
id
idMal
title {
romaji
english
}
coverImage{
medium
}
}
}
}
}
}
"""
get_medialist_item_query = """
query($mediaId:Int){
MediaList(mediaId:$mediaId){
id
}
}
"""
delete_list_entry_query = """
mutation($id:Int){
DeleteMediaListEntry(id:$id){
deleted
}
}
"""
get_logged_in_user_query = """
query{
Viewer{
id
name
bannerImage
avatar {
large
medium
}
}
}
"""
media_list_mutation = """
mutation($mediaId:Int,$scoreRaw:Int,$repeat:Int,$progress:Int,$status:MediaListStatus){
SaveMediaListEntry(mediaId:$mediaId,scoreRaw:$scoreRaw,progress:$progress,repeat:$repeat,status:$status){
id
status
mediaId
score
progress
repeat
startedAt {
year
month
day
}
completedAt {
year
month
day
}
}
}
"""
media_list_query = """
query ($userId: Int, $status: MediaListStatus) {
Page {
pageInfo {
currentPage
total
}
mediaList(userId: $userId, status: $status) {
mediaId
media {
id
idMal
title {
romaji
english
}
coverImage {
medium
large
}
trailer {
site
id
}
popularity
favourites
averageScore
episodes
genres
studios {
nodes {
name
isAnimationStudio
}
}
tags {
name
}
startDate {
year
month
day
}
endDate {
year
month
day
}
status
description
nextAiringEpisode {
timeUntilAiring
airingAt
episode
}
}
status
progress
score
repeat
notes
startedAt {
year
month
day
}
completedAt {
year
month
day
}
createdAt
}
}
}
"""
optional_variables = "\
$page:Int,\
$sort:[MediaSort],\
@@ -57,6 +254,7 @@ query($query:String,%s){
)
{
id
idMal
title{
romaji
english
@@ -114,6 +312,7 @@ query{
media(sort:TRENDING_DESC,type:ANIME,genre_not_in:["hentai"]){
id
idMal
title{
romaji
english
@@ -168,6 +367,7 @@ query{
Page(perPage:15){
media(sort:FAVOURITES_DESC,type:ANIME,genre_not_in:["hentai"]){
id
idMal
title{
romaji
english
@@ -222,6 +422,7 @@ query{
Page(perPage:15){
media(sort:SCORE_DESC,type:ANIME,genre_not_in:["hentai"]){
id
idMal
title{
romaji
english
@@ -276,6 +477,7 @@ query{
Page(perPage:15){
media(sort:POPULARITY_DESC,type:ANIME,genre_not_in:["hentai"]){
id
idMal
title{
romaji
english
@@ -330,6 +532,7 @@ query{
Page(perPage:15){
media(sort:UPDATED_AT_DESC,type:ANIME,averageScore_greater:50,genre_not_in:["hentai"],status:RELEASING){
id
idMal
title{
romaji
english
@@ -386,6 +589,7 @@ query {
nodes{
media{
id
idMal
title{
english
romaji
@@ -475,6 +679,7 @@ query ($id: Int) {
relations {
nodes {
id
idMal
title {
english
romaji
@@ -548,6 +753,7 @@ query ($page: Int) {
}
media(type: ANIME, status: NOT_YET_RELEASED,sort:POPULARITY_DESC,genre_not_in:["hentai"]) {
id
idMal
title {
romaji
english
@@ -601,6 +807,7 @@ query($id:Int){
Page{
media(id:$id) {
id
idMal
title {
romaji
english

View File

@@ -1,6 +1,7 @@
from .allanime.api import AllAnimeAPI
from .animepahe.api import AnimePaheApi
anime_sources = {"allanime": AllAnimeAPI}
anime_sources = {"allanime": AllAnimeAPI, "animepahe": AnimePaheApi}
class Anime_Provider:

View File

@@ -22,6 +22,7 @@ Logger = logging.getLogger(__name__)
# TODO: create tests for the api
#
# ** Based on ani-cli **
class AllAnimeAPI:
"""
Provides a fast and effective interface to AllAnime site.
@@ -40,7 +41,11 @@ class AllAnimeAPI:
headers={"Referer": ALLANIME_REFERER, "User-Agent": USER_AGENT},
timeout=10,
)
return response.json()["data"]
if response.status_code == 200:
return response.json()["data"]
else:
Logger.error("allanime(ERROR): ", response.text)
return {}
except Timeout:
Logger.error(
"allanime(Error):Timeout exceeded this could mean allanime is down or you have lost internet connection"
@@ -122,6 +127,7 @@ class AllAnimeAPI:
"Kir",
"S-mp4",
"Luf-mp4",
"Default",
):
continue
url = embed.get("sourceUrl")
@@ -133,7 +139,7 @@ class AllAnimeAPI:
# get the stream url for an episode of the defined source names
parsed_url = decode_hex_string(url)
embed_url = f"https://{ALLANIME_BASE}{parsed_url.replace('clock','clock.json')}"
embed_url = f"https://{ALLANIME_BASE}{parsed_url.replace('clock', 'clock.json')}"
resp = requests.get(
embed_url,
headers={
@@ -184,6 +190,16 @@ class AllAnimeAPI:
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
} # pyright:ignore
case "Default":
Logger.debug("allanime:Found streams from wixmp")
yield {
"server": "wixmp",
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
} # pyright:ignore
except Timeout:
Logger.error(
"Timeout has been exceeded this could mean allanime is down or you have lost internet connection"

View File

@@ -4,4 +4,4 @@ ALLANIME_BASE = "allanime.day"
ALLANIME_REFERER = "https://allanime.to/"
ALLANIME_API_ENDPOINT = "https://api.{}/api/".format(ALLANIME_BASE)
USER_AGENT = random_user_agent()
SERVERS_AVAILABLE = ["sharepoint", "dropbox", "gogoanime", "weTransfer"]
SERVERS_AVAILABLE = ["sharepoint", "dropbox", "gogoanime", "weTransfer", "wixmp"]

View File

@@ -0,0 +1,62 @@
import requests
from .constants import ANIMEPAHE_BASE, ANIMEPAHE_ENDPOINT, REQUEST_HEADERS
class AnimePaheApi:
def search_for_anime(self, user_query, *args):
try:
url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}"
headers = {**REQUEST_HEADERS}
response = requests.get(url, headers=headers)
if not response.status_code == 200:
return
data = response.json()
return {
"pageInfo": {"total": data["total"]},
"results": [
{
"id": result["session"],
"title": result["title"],
"availableEpisodes": result["episodes"],
"type": result["type"],
}
for result in data["data"]
],
}
except Exception as e:
print(e)
input()
def get_anime(self, session_id: str, *args):
url = "https://animepahe.ru/api?m=release&id=&sort=episode_asc&page=1"
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page=1"
response = requests.get(url, headers=REQUEST_HEADERS)
if not response.status_code == 200:
return
data = response.json()
self.current = data
episodes = list(map(str, range(data["total"])))
return {
"id": session_id,
"title": "none",
"availableEpisodesDetail": {
"sub": episodes,
"dub": episodes,
"raw": episodes,
},
}
def get_episode_streams(self, anime, episode, *args):
episode_id = self.current["data"][int(episode)]["session"]
anime_id = anime["id"]
url = f"{ANIMEPAHE_BASE}play/{anime_id}{episode_id}"
response = requests.get(url, headers=REQUEST_HEADERS)
print(response.status_code)
input()
if not response.status_code == 200:
print(response.text)
return
print(response.text)
input()

View File

@@ -0,0 +1,22 @@
from yt_dlp.utils.networking import random_user_agent
USER_AGENT = random_user_agent()
ANIMEPAHE = "animepahe.ru"
ANIMEPAHE_BASE = f"https://{ANIMEPAHE}/"
ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api?"
REQUEST_HEADERS = {
"Cookie": "__ddgid_=VvX0ebHrH2DsFZo4; __ddgmark_=3savRpSVFhvZcn5x; __ddg2_=buBJ3c4pNBYKFZNp; __ddg1_=rbVADKr9URtt55zoIGFa; SERVERID=janna; XSRF-TOKEN=eyJpdiI6IjV5bFNtd0phUHgvWGJxc25wL0VJSUE9PSIsInZhbHVlIjoicEJTZktlR2hxR2JZTWhnL0JzazlvZU5TQTR2bjBWZ2dDb0RwUXVUUWNSclhQWUhLRStYSmJmWmUxWkpiYkFRYU12RjFWejlSWHorME1wZG5qQ1U0TnFlNnBFR2laQjN1MjdyNjc5TjVPdXdJb2o5VkU1bEduRW9pRHNDTHh6Sy8iLCJtYWMiOiI0OTc0ZmNjY2UwMGJkOWY2MWNkM2NlMjk2ZGMyZGJmMWE0NTdjZTdkNGI2Y2IwNTIzZmFiZWU5ZTE2OTk0YmU4IiwidGFnIjoiIn0%3D; laravel_session=eyJpdiI6ImxvdlpqREFnTjdaeFJubUlXQWlJVWc9PSIsInZhbHVlIjoiQnE4R3VHdjZ4M1NDdEVWM1ZqMUxtNnVERnJCcmtCUHZKNzRPR2RFbzNFcStTL29xdnVTbWhsNVRBUXEybVZWNU1UYVlTazFqYlN5UjJva1k4czNGaXBTbkJJK01oTUd3VHRYVHBoc3dGUWxHYnFlS2NJVVNFbTFqMVBWdFpuVUgiLCJtYWMiOiI1NDdjZTVkYmNhNjUwZTMxZmRlZmVmMmRlMGNiYjAwYjlmYjFjY2U0MDc1YTQzZThiMTIxMjJlYTg1NTA4YjBmIiwidGFnIjoiIn0%3D; latest=5592 ",
"Host": ANIMEPAHE,
"User-Agent": USER_AGENT,
"Accept": "application , text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Referer": ANIMEPAHE_BASE,
"X-Requested-With": "XMLHttpRequest",
"DNT": "1",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "cors",
"TE": "trailers",
}

View File

View File

@@ -0,0 +1,22 @@
import requests
ANISKIP_ENDPOINT = "https://api.aniskip.com/v1/skip-times"
# TODO: Finish own implementation of aniskip script
class AniSkip:
@classmethod
def get_skip_times(
cls, mal_id: int, episode_number: float | int, types=["op", "ed"]
):
url = f"{ANISKIP_ENDPOINT}/{mal_id}/{episode_number}?types=op&types=ed"
response = requests.get(url)
print(response.text)
return response.json()
if __name__ == "__main__":
mal_id = input("Mal id: ")
episode_number = input("episode_number: ")
skip_times = AniSkip.get_skip_times(int(mal_id), float(episode_number))
print(skip_times)

View File

@@ -0,0 +1,138 @@
import subprocess
from shutil import which
from sys import exit
from plyer import notification
from fastanime import APP_NAME
from ...constants import ICON_PATH
class RofiApi:
ROFI_EXECUTABLE = which("rofi")
rofi_theme = ""
rofi_theme_confirm = ""
rofi_theme_input = ""
def run_with_icons(self, options: list[str], prompt_text: str) -> str:
rofi_input = "\n".join(options)
if not self.ROFI_EXECUTABLE:
raise Exception("Rofi not found")
args = [self.ROFI_EXECUTABLE]
if self.rofi_theme:
args.extend(["-no-config", "-theme", self.rofi_theme])
args.extend(["-p", prompt_text, "-i", "-show-icons", "-dmenu"])
result = subprocess.run(
args,
input=rofi_input,
stdout=subprocess.PIPE,
text=True,
)
choice = result.stdout.strip()
if not choice:
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,
message="FastAnime is shutting down",
title="No Valid Input Provided",
) # pyright:ignore
exit(1)
return choice
def run(self, options: list[str], prompt_text: str) -> str:
rofi_input = "\n".join(options)
if not self.ROFI_EXECUTABLE:
raise Exception("Rofi not found")
args = [self.ROFI_EXECUTABLE]
if self.rofi_theme:
args.extend(["-no-config", "-theme", self.rofi_theme])
args.extend(["-p", prompt_text, "-i", "-dmenu"])
result = subprocess.run(
args,
input=rofi_input,
stdout=subprocess.PIPE,
text=True,
)
choice = result.stdout.strip()
if not choice or choice not in options:
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,
message="FastAnime is shutting down",
title="No Valid Input Provided",
) # pyright:ignore
exit(1)
return choice
def confirm(self, prompt_text: str) -> bool:
rofi_choices = "Yes\nNo"
if not self.ROFI_EXECUTABLE:
raise Exception("Rofi not found")
args = [self.ROFI_EXECUTABLE]
if self.rofi_theme_confirm:
args.extend(["-no-config", "-theme", self.rofi_theme_confirm])
args.extend(["-p", prompt_text, "-i", "", "-no-fixed-num-lines", "-dmenu"])
result = subprocess.run(
args,
input=rofi_choices,
stdout=subprocess.PIPE,
text=True,
)
choice = result.stdout.strip()
if not choice:
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,
message="FastAnime is shutting down",
title="No Valid Input Provided",
) # pyright:ignore
exit(1)
if choice == "Yes":
return True
else:
return False
def ask(
self, prompt_text: str, is_int: bool = False, is_float: bool = False
) -> str | float | int:
if not self.ROFI_EXECUTABLE:
raise Exception("Rofi not found")
args = [self.ROFI_EXECUTABLE]
if self.rofi_theme_input:
args.extend(["-no-config", "-theme", self.rofi_theme_input])
args.extend(["-p", prompt_text, "-i", "-no-fixed-num-lines", "-dmenu"])
result = subprocess.run(
args,
stdout=subprocess.PIPE,
text=True,
)
user_input = result.stdout.strip()
if not user_input:
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,
message="FastAnime is shutting down",
title="No Valid Input Provided",
) # pyright:ignore
exit(1)
if is_float:
user_input = float(user_input)
elif is_int:
user_input = int(user_input)
return user_input
Rofi = RofiApi()

87
poetry.lock generated
View File

@@ -206,6 +206,17 @@ files = [
[package.dependencies]
cffi = ">=1.0.0"
[[package]]
name = "cachetools"
version = "5.4.0"
description = "Extensible memoizing collections and decorators"
optional = false
python-versions = ">=3.7"
files = [
{file = "cachetools-5.4.0-py3-none-any.whl", hash = "sha256:3ae3b49a3d5e28a77a0be2b37dbcb89005058959cb2323858c2657c4a8cab474"},
{file = "cachetools-5.4.0.tar.gz", hash = "sha256:b8adc2e7c07f105ced7bc56dbb6dfbe7c4a00acce20e2227b3f355be89bc6827"},
]
[[package]]
name = "certifi"
version = "2024.7.4"
@@ -292,6 +303,17 @@ files = [
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
]
[[package]]
name = "chardet"
version = "5.2.0"
description = "Universal encoding detector for Python 3"
optional = false
python-versions = ">=3.7"
files = [
{file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
{file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
]
[[package]]
name = "charset-normalizer"
version = "3.3.2"
@@ -660,6 +682,23 @@ files = [
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "plyer"
version = "2.1.0"
description = "Platform-independent wrapper for platform-dependent APIs"
optional = false
python-versions = "*"
files = [
{file = "plyer-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b1772060df8b3045ed4f08231690ec8f7de30f5a004aa1724665a9074eed113"},
{file = "plyer-2.1.0.tar.gz", hash = "sha256:65b7dfb7e11e07af37a8487eb2aa69524276ef70dad500b07228ce64736baa61"},
]
[package.extras]
android = ["pyjnius"]
dev = ["flake8", "mock"]
ios = ["pyobjus"]
macosx = ["pyobjus"]
[[package]]
name = "pre-commit"
version = "3.7.1"
@@ -769,6 +808,25 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyproject-api"
version = "1.7.1"
description = "API to interact with the python pyproject.toml based projects"
optional = false
python-versions = ">=3.8"
files = [
{file = "pyproject_api-1.7.1-py3-none-any.whl", hash = "sha256:2dc1654062c2b27733d8fd4cdda672b22fe8741ef1dde8e3a998a9547b071eeb"},
{file = "pyproject_api-1.7.1.tar.gz", hash = "sha256:7ebc6cd10710f89f4cf2a2731710a98abce37ebff19427116ff2174c9236a827"},
]
[package.dependencies]
packaging = ">=24.1"
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
[package.extras]
docs = ["furo (>=2024.5.6)", "sphinx-autodoc-typehints (>=2.2.1)"]
testing = ["covdefaults (>=2.3)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=70.1)"]
[[package]]
name = "pytest"
version = "8.3.2"
@@ -1060,6 +1118,33 @@ files = [
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
[[package]]
name = "tox"
version = "4.16.0"
description = "tox is a generic virtualenv management and test command line tool"
optional = false
python-versions = ">=3.8"
files = [
{file = "tox-4.16.0-py3-none-any.whl", hash = "sha256:61e101061b977b46cf00093d4319438055290ad0009f84497a07bf2d2d7a06d0"},
{file = "tox-4.16.0.tar.gz", hash = "sha256:43499656f9949edb681c0f907f86fbfee98677af9919d8b11ae5ad77cb800748"},
]
[package.dependencies]
cachetools = ">=5.3.3"
chardet = ">=5.2"
colorama = ">=0.4.6"
filelock = ">=3.15.4"
packaging = ">=24.1"
platformdirs = ">=4.2.2"
pluggy = ">=1.5"
pyproject-api = ">=1.7.1"
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
virtualenv = ">=20.26.3"
[package.extras]
docs = ["furo (>=2024.5.6)", "sphinx (>=7.3.7)", "sphinx-argparse-cli (>=1.16)", "sphinx-autodoc-typehints (>=2.2.2)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"]
testing = ["build[virtualenv] (>=1.2.1)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=70.2)", "time-machine (>=2.14.2)", "wheel (>=0.43)"]
[[package]]
name = "typing-extensions"
version = "4.12.2"
@@ -1234,4 +1319,4 @@ test = ["pytest (>=8.1,<9.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "38fed68d89077d348221af9eb8e2d0ea6c9585bd4c5de16d6e5974664c562f73"
content-hash = "871d39c0e2481614146804d675aafa7b1b79c736ccf12a8e749655f574881670"

View File

@@ -1,8 +1,8 @@
[tool.poetry]
name = "fastanime"
version = "0.32.0"
description = "A fast and efficient anime scrapper and exploration tool"
authors = ["Benex254 <benedictx855@gmail.com>"]
version = "0.50.0"
description = "A browser anime site experience from the terminal"
authors = ["Benextempest <benextempest@gmail.com>"]
license = "UNLICENSE"
readme = "README.md"
@@ -18,6 +18,7 @@ python-dotenv = "^1.0.1"
thefuzz = "^0.22.1"
requests = "^2.32.3"
plyer = "^2.1.0"
[tool.poetry.group.dev.dependencies]
black = "^24.4.2"
isort = "^5.13.2"
@@ -26,6 +27,7 @@ ruff = "^0.4.10"
pre-commit = "^3.7.1"
autoflake = "^2.3.1"
tox = "^4.16.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

26
tox.ini Normal file
View File

@@ -0,0 +1,26 @@
[tox]
requires =
tox>=4
env_list = lint, type, py{310,311}
; [testenv]
; description = run unit tests
; deps =
; pytest>=7
; pytest-sugar
; commands =
; pytest {posargs:tests}
;
[testenv:lint]
description = run linters
skip_install = true
deps =
black==22.12
commands = black {posargs:.}
[testenv:type]
description = run type checks
deps =
mypy>=0.991
commands =
mypy {posargs:src tests}