Compare commits

...

98 Commits

Author SHA1 Message Date
Benex254
9ec3136734 chore bump version 2024-08-16 23:02:24 +03:00
Benex254
943fca43cf docs: update readme 2024-08-16 23:02:24 +03:00
Benex254
b2e00feb94 feat(downloads command): sort by episode number 2024-08-16 23:02:24 +03:00
BeneX254
f726c8d55c Update README.md 2024-08-16 22:17:33 +03:00
Benex254
57db2e0626 chore: bump version 2024-08-16 22:09:56 +03:00
Benex254
40f66b5fde docs: update readme 2024-08-16 22:08:04 +03:00
Benex254
c87417e5e7 feat: add syncplay intergration 2024-08-16 22:03:22 +03:00
Benex254
a841dd6f66 chore: bump version 2024-08-16 20:04:57 +03:00
Benex254
d6e85bad5c docs: update readme 2024-08-16 20:04:45 +03:00
Benex254
b590ac1e91 feat(cli): improve download and search command 2024-08-16 19:49:40 +03:00
Benex254
9cfa3aeea5 feat(cli): use an option for providing anime title for search and download command 2024-08-16 19:45:00 +03:00
Benex254
18c60691ca feat(search command): improve binge power 2024-08-16 19:37:10 +03:00
Benex254
2e9fadf3b2 feat(download command): improve download command 2024-08-16 19:02:22 +03:00
Benex254
510b47b187 feat(downloads command): improve output by sorting the titles and episodes 2024-08-16 15:01:55 +03:00
Benex254
49c4d0eec0 docs: update readme 2024-08-16 14:57:15 +03:00
Benex254
8367f7bbed chore: bump version 2024-08-16 14:55:29 +03:00
Benex254
0182f674e0 feat: add status to graphql medialist query 2024-08-16 14:55:02 +03:00
Benex254
2b50fb4c97 fix(interface): improve error handling for non logged in user 2024-08-16 14:54:36 +03:00
Benex254
2602a20aa7 feat(login command): add option to erase login data 2024-08-16 14:53:57 +03:00
Benex254
13200e2d1f chore: bump version 2024-08-16 14:24:59 +03:00
Benex254
22f6e89400 fix:preferred server not reflecting in command 2024-08-16 14:24:42 +03:00
Benex254
8409fa7d43 chore: bump version 2024-08-16 13:51:57 +03:00
Benex254
c81da78190 chore: bump version 2024-08-16 13:51:36 +03:00
Benex254
e17ea4bb89 fix(interface): incorrect loading of episode during replat 2024-08-16 13:50:58 +03:00
Benex254
0087728aa8 docs: update readme 2024-08-16 13:22:35 +03:00
Benex254
9e48e02f7a feat(downloads command): improve local downloads experience 2024-08-16 13:12:10 +03:00
Benex254
1291d55ab0 feat(downloads command): add previews 2024-08-16 11:38:18 +03:00
Benex254
b5c6a1e39e feat: improve path handling on windows 2024-08-16 10:54:13 +03:00
Benex254
d6adb30802 feat(download command): remove unused option and improve help message 2024-08-16 10:47:25 +03:00
Benex254
1d08a69a85 feat(search command): improve help message 2024-08-16 10:46:37 +03:00
Benex254
1087ab3408 chore: add error checking todo 2024-08-16 10:46:05 +03:00
Benex254
51afd504df chore: update config obj 2024-08-16 10:45:40 +03:00
Benex254
75efc9d73a docs: update readme 2024-08-16 10:45:18 +03:00
Benex254
6b68086cff feat(interface): improve watch history experience 2024-08-16 10:10:47 +03:00
Benex254
3686cdfdb3 feat(completions): enhance speed of loading completion functions 2024-08-15 12:21:29 +03:00
Benex254
83c98936d1 chore: bump version 2024-08-15 11:33:22 +03:00
Benex254
0891cb279a docs: update readme 2024-08-15 11:32:21 +03:00
Benex254
95ba96f537 chore: remove plyer 2024-08-15 11:25:51 +03:00
Benex254
586790173b docs: update readme 2024-08-15 11:25:22 +03:00
Benex254
1d19449ab7 chore: bump version 2024-08-15 10:52:59 +03:00
Benex254
e1f73334ef feat(cli): remove unknown as possible quality 2024-08-15 10:50:32 +03:00
Benex254
4faac017b5 feat(utils): add 480 as possible quality 2024-08-15 10:49:29 +03:00
Benex254
bfbd2a57a0 feat(cli): add 480 as a possible quality 2024-08-15 10:48:39 +03:00
Benex254
9519472f83 feat(utils): pretty colors when defaulting to quality 2024-08-15 10:48:15 +03:00
Benex254
5c0c119cbc feat(download command): use actual episodes if downloading all 2024-08-15 10:47:35 +03:00
Benex254
87eb257a10 chore: use plyer.sttoragepath if possible 2024-08-15 10:29:59 +03:00
Benex254
4a08076c3b feat: use actual episodes list than inference 2024-08-15 00:20:06 +03:00
Benex254
0d239e6793 chore: bump version 2024-08-14 22:43:05 +03:00
Benex254
0a0d47ae88 chore: raise search results for anilist 2024-08-14 22:42:35 +03:00
Benex254
2ba07d47b3 feat(cli): add anime title completions 2024-08-14 22:32:47 +03:00
Benex254
f1b520fe3c chore: bump version 2024-08-14 21:22:40 +03:00
Benex254
8cfcc26468 feat(animepahe): use true episodes 2024-08-14 21:20:49 +03:00
Benex254
cd51edf0b8 feat(interface): better post error response 2024-08-14 21:10:32 +03:00
Benex254
6eb28cfa3d feat(mpv): show feedback on toggle translation type 2024-08-14 20:43:26 +03:00
Benex254
542d39fa6a chore: bump version 2024-08-14 20:40:45 +03:00
Benex254
e5e328148f fix: failed quality selection 2024-08-14 20:39:58 +03:00
Benex254
cea1a67d64 chore: bump version 2024-08-14 20:07:35 +03:00
Benex254
97c6dc7968 feat(animepahe): make it random 2024-08-14 20:07:35 +03:00
Benex254
d97072e298 feat(anime_pahe): load all pages 2024-08-14 20:07:35 +03:00
Benex254
7cd246478e feat: improve error handling when fetching servers 2024-08-14 20:07:35 +03:00
BenedictX
8afe1df3a9 Update README.md 2024-08-13 20:57:16 +03:00
Benex254
452c2a3569 chore: bump version 2024-08-13 20:25:29 +03:00
Benex254
f738069794 docs: fix doc on search command 2024-08-13 20:22:51 +03:00
Benex254
d178eb976e fix(mpv): not starting from begining of episode 2024-08-13 20:22:27 +03:00
Benex254
d58dae6d6b fix(mpv): watch history not updating to correct position 2024-08-13 20:21:48 +03:00
Benex254
136cf841e1 feat: drop pyshortcuts and python-dotenv as a dependency 2024-08-13 19:58:14 +03:00
Benex254
748d321f36 feat: remove platformdirs as dep 2024-08-13 19:01:57 +03:00
Benex254
3e71239981 Revert "feat(mpv): update episode progress timestamp"
This reverts commit 571ab488f8.
2024-08-13 01:36:06 +03:00
Benex254
571ab488f8 feat(mpv): update episode progress timestamp 2024-08-13 01:05:21 +03:00
Benex254
62878311c6 chore: bump version 2024-08-12 00:50:39 +03:00
Benex254
432e9374cb chore: update deps 2024-08-12 00:49:10 +03:00
Benex254
a4974fbba7 test: add test for update command 2024-08-12 00:48:55 +03:00
Benex254
b935e80928 docs: update readme 2024-08-12 00:45:29 +03:00
Benex254
8999d88d23 feat(cli): add help to completions command 2024-08-12 00:45:10 +03:00
benex
9608bace07 format: reformat to pep8 2024-08-11 20:53:02 +03:00
benex
09b88df49a fix: issues with anime pahe provider 2024-08-11 20:34:26 +03:00
benex
ccaacc9948 fix(allanime): invalid key 2024-08-11 20:10:13 +03:00
Benex254
96d88b0f47 feat(allanime): remove useless module 2024-08-11 22:06:26 +03:00
Benex254
6939471e48 feat(cli): add update app command 2024-08-11 21:57:11 +03:00
Benex254
b1a2307c4d feat(updater): update it 2024-08-11 21:21:26 +03:00
Benex254
1ead2fb176 chore(cli): get rid of depracted function 2024-08-11 21:09:18 +03:00
Benex254
51e3ca004e feat(cli): clean tools 2024-08-11 21:06:07 +03:00
Benex254
3814db460f feat(cli): default print_image function to use icat 2024-08-11 21:00:38 +03:00
Benex254
4102685cbc refactor: downloader 2024-08-11 20:54:32 +03:00
Benex254
c80a8235e1 feat: use clean_html yt-dlp function over custom remove_html function 2024-08-11 20:46:54 +03:00
Benex254
622427b748 feat(cli): add config --view command 2024-08-11 20:38:00 +03:00
Benex254
e36413cdef refactor: move user_data_helper functionality to config class 2024-08-11 20:15:45 +03:00
Benex254
7e9a510706 feat(anilist_data_helper): only import types if type checking 2024-08-11 19:49:15 +03:00
Benex254
9185a08102 fix(downloads): typing issue 2024-08-11 17:55:02 +03:00
Benex254
4f754a5129 chore: bump version 2024-08-11 17:51:21 +03:00
Benex254
cec7aaebdb refactor: media player controls 2024-08-11 17:50:09 +03:00
Benex254
bfd580ec79 refactor: provider_anime_episode_servers_menu 2024-08-11 17:19:10 +03:00
Benex254
aabd356c0b refactor: provider anime episodes menu 2024-08-11 16:32:02 +03:00
Benex254
f929e83a62 refactor: anime provider search results menu 2024-08-11 15:56:00 +03:00
Benex254
74cacded6e feat(interface): add episodes and toggle continue from history to the anilist media actions menu 2024-08-11 15:33:56 +03:00
Benex254
82b6b849cf refactor: anilist_media_actions_menu 2024-08-11 15:24:01 +03:00
Benex254
5dbc3a16f7 refactor: a whole lot of it 2024-08-11 14:58:49 +03:00
Benex254
912535d166 docs: document anilist menu 2024-08-11 13:35:28 +03:00
52 changed files with 2634 additions and 1269 deletions

148
README.md
View File

@@ -44,6 +44,7 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magi
- [downloads subcommand](#downloads-subcommand)
- [config subcommand](#config-subcommand)
- [cache subcommand](#cache-subcommand)
- [update subcommand](#update-subcommand)
- [completions subcommand](#completions-subcommand)
- [MPV specific commands](#mpv-specific-commands)
- [Added keybindings](#added-keybindings)
@@ -163,7 +164,8 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
**Other external dependencies that will just make your experience better:**
- [fzf](https://github.com/junegunn/fzf) :fire: which is used as a better alternative to the ui.
- [fzf](https://github.com/junegunn/fzf) 🔥 which is used as a better alternative to the ui.
- [rofi](https://github.com/davatorium/rofi) 🔥 which is used as another alternative ui + the the desktop entry ui
- [chafa](https://github.com/hpjansson/chafa) currently the best cross platform and cross terminal image viewer for the terminal.
- [icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) an image viewer that only works in [kitty terminal](https://sw.kovidgoyal.net/kitty/), which is currently the best terminal in my opinion, and by far the best image renderer for the terminal thanks to kitty's terminal graphics protocol. Its terminal graphics is so op that you can [run a browser on it](https://github.com/chase/awrit?tab=readme-ov-file)!!
- [bash](https://www.gnu.org/software/bash/) is used as the preview script language.
@@ -194,20 +196,23 @@ Overview of main commands:
Configuration is directly passed into this command at run time to override your config.
Available options include:
Available options for the fastanime command include:
- `--server;-s <server>` set the default server to auto select
- `--continue;-c/--no-continue;-no-c` whether to continue from the last episode you were watching
- `--quality;-q <0|1|2|3>` the link to choose from server
- `--translation-type;- <dub|sub` what language for anime
- `--auto-select;-a/--no-auto-select;-no-a` auto select title from provider results
- `--auto-next;-A;/--no-auto-next;-no-A` auto select next episode
- `-downloads-dir;-d <path>` set the folder to download anime into
- `--server <server>` or `-s <server>` set the default server to auto select
- `--continue/--no-continue` or `-c/-no-c` whether to continue from the last episode you were watching
- `--local-history/--remote-history` whether to use remote or local history defaults to local
- `--quality <1080/720/480/360>` or `-q <1080/720/480/360>` the link to choose from server
- `--translation-type <dub/sub>` or `-t <dub/sub>` what language for anime
- `--dub` dubbed anime
- `--sub` subbed anime
- `--auto-select/--no-auto-select` or `-a/-no-a` auto select title from provider results
- `--auto-next/--no-auto-next` or `-A/-no-A` auto select next episode
- `-downloads-dir <path>` or `-d <path>` set the folder to download anime into
- `--fzf` use fzf for the ui
- `--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>` or `-f <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
@@ -218,6 +223,29 @@ Available options include:
- `--log-file` allow logging to a file
- `--rich-traceback` allow rich traceback
- `--use-mpv-mod/--use-default-player` whether to use python-mpv
- `--provider <allanime>` anime site of choice to scrape from
- `--sync-play` or `-sp` use syncplay for streaming anime so you can watch with your friends
Example usage of the above options
```bash
# example of syncplay intergration
fastanime --sync-play --server sharepoint search -t <anime-title>
# --- or ---
# to watch with anilist intergration
fastanime --sync-play --server sharepoint anilist
# downloading dubbed anime
fastanime --dub download <anime>
# use icons and fzf for a more elegant ui with preview
fastanime --icons --preview --fzf anilist
# use icons with default ui
fastanime --icons --default anilist
```
#### The anilist command :fire: :fire: :fire:
@@ -279,12 +307,14 @@ end
> [!NOTE]
> To sign in just run `fastanime anilist login` and follow the instructions.
> To view your login status `fastanime anilist login --status`
> To erase login data `fastanime anilist login --erase`
#### download subcommand
Download anime to watch later dub or sub with this one command.
Its optimized for scripting due to fuzzy matching.
Its optimized for scripting due to fuzzy matching; basically you don't have to manually select search results.
So every step of the way has been and can be automated.
Uses a list slicing syntax similar to that of python as the value for the `-r` option.
> [!NOTE]
>
@@ -295,29 +325,57 @@ So every step of the way has been and can be automated.
```bash
# Download all available episodes
fastanime download <anime-title>
# multiple titles can be specified with -t option
fastanime download -t <anime-title> -t <anime-title>
# -- or --
fastanime download -t <anime-title> -t <anime-title> -r ':'
# download latest episode for the two anime titles
# the number can be any no of latest episodes but a minus sign
# must be present
fastanime download -t <anime-title> -t <anime-title> -r '-1'
# latest 5
fastanime download -t <anime-title> -t <anime-title> -r '-5'
# Download specific episode range
# be sure to observe the range Syntax
fastanime download <anime-title> -r <episodes-start>-<episodes-end>
fastanime download <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
fastanime download <anime-title> -r '<episodes-start>:<episodes-end>'
fastanime download <anime-title> -r '<episodes-start>:'
fastanime download <anime-title> -r ':<episodes-end>'
```
#### search subcommand
Powerful command mainly aimed at binging anime. Since it doesn't require interaction with the interfaces.
Uses a list slicing syntax similar to that of python as the value of the `-r` option.
**Syntax:**
```bash
# basic form where you will still be promted for the episode number
fastanime search <anime-title>
# basic form where you will still be prompted for the episode number
# multiple titles can be specified with the -t option
fastanime search -t <anime-title> -t <anime-title>
# binge all episodes with this command
fastanime search <anime-title> -
fastanime search -t <anime-title> -r ':'
# watch latest episode
fastanime search -t <anime-title> -r '-1'
# binge a specific episode range with this command
# be sure to observe the range Syntax
fastanime search <anime-title> <episodes-start>-<episodes-end>
fastanime search -t <anime-title> -r '<start>:<stop>'
fastanime search -t <anime-title> -r '<start>:<stop>:<step>'
fastanime search -t <anime-title> -r '<start>:'
fastanime search -t <anime-title> -r ':<end>'
```
#### downloads subcommand
@@ -329,9 +387,21 @@ View and stream the anime you downloaded using MPV.
```bash
fastanime downloads
# view individual episodes
fastanime downloads --view-episodes
# --- or ---
fastanime downloads -v
# to set seek time when using ffmpegthumbnailer for local previews
# -1 means random and is the default
fastanime downloads --time-to-seek <intRange(-1,100)>
# --- or ---
fastanime downloads -t <intRange(-1,100)>
# to get the path to the downloads folder set
fastanime downloads --path
# useful when you want to use the value for other programs
```
#### config subcommand
@@ -348,6 +418,9 @@ fastanime config --path
# add a desktop entry
fastanime config --desktop-entry
# view current contents of your configuration or can be used to get an example config
fastanime config --view
```
> [!Note]
@@ -374,6 +447,20 @@ fastanime cache --size
fastanime cache
```
#### update subcommand
Easily update fastanime to latest
**Syntax:**
```bash
# update fastanime to latest
fastanime update
# check for latest release
fastanime update --check
```
#### completions subcommand
Helper command to setup shell completions
@@ -415,29 +502,47 @@ Examples:
```bash
# to select episode from mpv without window closing
script-message select-episode <episode-number>
# to select server from mpv without window closing
script-message select-server <server-name>
# to select quality
script-message select-quality <1080/720/480/360>
```
## Configuration
## configuration
The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on Linux and mac or somewhere on windows; you can check by running `fastanime config --path`.
The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on arch linux; for the other operating systems you can check by running `fastanime config --path`.
```ini
[stream]
continue_from_history = True # Auto continue from watch history
# which history to use [local/remote]
preferred_history = local
# force mpv window
# passed directly to mpv so values are same
force_window = immediate
translation_type = sub # Preferred language for anime (options: dub, sub)
server = top # Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top, wixmp)
auto_next = False # Auto-select next episode
# 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
use_mpv_mod=False
# the format of downloaded anime and trailer
@@ -453,14 +558,19 @@ format=best[height<=1080]/bestvideo[height<=1080]+bestaudio/best # default
provider = allanime
preferred_language = romaji # Display language (options: english, romaji)
downloads_dir = <Default-videos-dir>/FastAnime # Download directory
preview=false # whether to show a preview window when using fzf or rofi
use_fzf=False # whether to use fzf as the interface for the anilist command and others.
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>

View File

@@ -0,0 +1,4 @@
"""This package exist as away to expose functions and classes that my be useful to a developer using the fastanime library
[TODO:description]
"""

View File

@@ -1,10 +1,12 @@
from datetime import datetime
from typing import TYPE_CHECKING
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
if TYPE_CHECKING:
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
# TODO: Add formating options for the final date
def format_anilist_date_object(anilist_date_object: AnilistDateObject):
def format_anilist_date_object(anilist_date_object: "AnilistDateObject"):
if anilist_date_object:
return f"{anilist_date_object['day']}/{anilist_date_object['month']}/{anilist_date_object['year']}"
else:
@@ -25,7 +27,7 @@ def format_list_data_with_comma(data: list | None):
return "None"
def extract_next_airing_episode(airing_episode: AnilistMediaNextAiringEpisode):
def extract_next_airing_episode(airing_episode: "AnilistMediaNextAiringEpisode"):
if airing_episode:
return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"
else:

View File

@@ -27,9 +27,27 @@ class YtDLPDownloader:
# Function to download the file
# TODO: untpack the title to its actual values episode_title and anime_title
def _download_file(self, url: str, download_dir, title, silent, vid_format="best"):
anime_title = sanitize_filename(title[0])
episode_title = sanitize_filename(title[1])
def _download_file(
self,
url: str,
anime_title: str,
episode_title: str,
download_dir: str,
silent: bool,
vid_format: str = "best",
):
"""Helper function that downloads anime given url and path details
Args:
url: [TODO:description]
anime_title: [TODO:description]
episode_title: [TODO:description]
download_dir: [TODO:description]
silent: [TODO:description]
vid_format: [TODO:description]
"""
anime_title = sanitize_filename(anime_title)
episode_title = sanitize_filename(episode_title)
ydl_opts = {
# Specify the output path and template
"outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s",
@@ -41,7 +59,15 @@ class YtDLPDownloader:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
# WARN: May remove this legacy functionality
def download_file(self, url: str, title, silent=True):
"""A helper that just does things in the background
Args:
title ([TODO:parameter]): [TODO:description]
silent ([TODO:parameter]): [TODO:description]
url: [TODO:description]
"""
self.downloads_queue.put((self._download_file, (url, title, silent)))

View File

@@ -1,40 +0,0 @@
import json
import logging
import os
from ..constants import USER_DATA_PATH
logger = logging.getLogger(__name__)
# TODO: merger this functionality with the config object
class UserData:
user_data = {"watch_history": {}, "animelist": [], "user": {}}
def __init__(self):
try:
if os.path.isfile(USER_DATA_PATH):
with open(USER_DATA_PATH, "r") as f:
user_data = json.load(f)
self.user_data.update(user_data)
except Exception as e:
logger.error(e)
def update_watch_history(self, watch_history: dict):
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()
def _update_user_data(self):
with open(USER_DATA_PATH, "w") as f:
json.dump(self.user_data, f)
user_data_helper = UserData()

View File

@@ -1,79 +1,25 @@
import logging
import os
import re
from functools import lru_cache
from typing import TYPE_CHECKING
from thefuzz import fuzz
from fastanime.libs.anilist.types import AnilistBaseMediaDataSchema
from .data import anime_normalizer
if TYPE_CHECKING:
from ..libs.anilist.types import AnilistBaseMediaDataSchema
logger = logging.getLogger(__name__)
@lru_cache()
def remove_html_tags(text: str):
clean = re.compile("<.*?>")
return re.sub(clean, "", text)
def sort_by_episode_number(filename: str):
import re
@lru_cache()
def sanitize_filename(filename: str):
"""
Sanitize a string to be safe for use as a file name.
:param filename: The original filename string.
:return: A sanitized filename string.
"""
# List of characters not allowed in filenames on various operating systems
invalid_chars = r'[<>:"/\\|?*\0]'
reserved_names = {
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
}
# Replace invalid characters with an underscore
sanitized = re.sub(invalid_chars, " ", filename)
# Remove leading and trailing whitespace
sanitized = sanitized.strip()
# Check for reserved filenames
name, ext = os.path.splitext(sanitized)
if name.upper() in reserved_names:
name += "_file"
sanitized = name + ext
# Ensure the filename is not empty
if not sanitized:
sanitized = "default_filename"
return sanitized
match = re.search(r"\d+", filename)
return int(match.group()) if match else 0
def anime_title_percentage_match(
possible_user_requested_anime_title: str, anime: AnilistBaseMediaDataSchema
possible_user_requested_anime_title: str, anime: "AnilistBaseMediaDataSchema"
) -> float:
"""Returns the percentage match between the possible title and user title
@@ -97,10 +43,3 @@ def anime_title_percentage_match(
)
logger.info(f"{locals()}")
return percentage_ratio
if __name__ == "__main__":
# Example usage
unsafe_filename = "CON:example?file*name.txt"
safe_filename = sanitize_filename(unsafe_filename)
print(safe_filename) # Output: 'CON_example_file_name.txt'

View File

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

View File

@@ -15,6 +15,7 @@ commands = {
"downloads": "downloads.downloads",
"cache": "cache.cache",
"completions": "completions.completions",
"update": "update.update",
}
@@ -42,7 +43,6 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option("--log", help="Allow logging to stdout", is_flag=True)
@click.option("--log-file", help="Allow logging to a file", is_flag=True)
@click.option("--rich-traceback", help="Use rich to output tracebacks", is_flag=True)
@click.option("--update", help="Update fastanime to the latest version", is_flag=True)
@click.option(
"-p",
"--provider",
@@ -68,6 +68,11 @@ signal.signal(signal.SIGINT, handle_exit)
type=bool,
help="Continue from last episode?",
)
@click.option(
"--local-history/--remote-history",
type=bool,
help="Whether to continue from local history or remote history",
)
@click.option(
"--skip/--no-skip",
type=bool,
@@ -76,7 +81,14 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option(
"-q",
"--quality",
type=click.Choice(["360", "720", "1080", "unknown"]),
type=click.Choice(
[
"360",
"480",
"720",
"1080",
]
),
help="set the quality of the stream",
)
@click.option(
@@ -129,17 +141,18 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option(
"--use-mpv-mod/--use-default-player", help="Whether to use python-mpv", type=bool
)
@click.option("--sync-play", "-sp", help="Use sync play", is_flag=True)
@click.pass_context
def run_cli(
ctx: click.Context,
log,
log_file,
rich_traceback,
update,
provider,
server,
format,
continue_,
local_history,
skip,
translation_type,
quality,
@@ -159,6 +172,7 @@ def run_cli(
rofi_theme_confirm,
rofi_theme_input,
use_mpv_mod,
sync_play,
):
from .config import Config
@@ -192,12 +206,9 @@ def run_cli(
from rich.traceback import install
install()
if update and None:
from .app_updater import update_app
update_app()
return
if sync_play:
ctx.obj.sync_play = sync_play
if provider:
ctx.obj.provider = provider
if server:
@@ -215,6 +226,11 @@ def run_cli(
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("local_history")
== click.core.ParameterSource.COMMANDLINE
):
ctx.obj.preferred_history = "local" if local_history else "remote"
if (
ctx.get_parameter_source("auto_select")
== click.core.ParameterSource.COMMANDLINE

View File

@@ -2,8 +2,8 @@ import pathlib
import re
import shlex
import shutil
import subprocess
import sys
from subprocess import PIPE, Popen
import requests
from rich import print
@@ -34,71 +34,72 @@ def check_for_updates():
def is_git_repo(author, repository):
# Check if the current directory contains a .git folder
if not pathlib.Path("./.git").exists():
git_dir = pathlib.Path(".git")
if not git_dir.exists() or not git_dir.is_dir():
return False
repository_qualname = f"{author}/{repository}"
# Read the .git/config file to find the remote repository URL
config_path = pathlib.Path("./.git/config")
# Check if the config file exists
config_path = git_dir / "config"
if not config_path.exists():
return False
print("here")
with open(config_path, "r") as git_config:
git_config_content = git_config.read()
# Use regex to find the repository URL in the config file
repo_name_pattern = r"\[remote \"origin\"\]\s+url = .*\/([^/]+\/[^/]+)\.git"
match = re.search(repo_name_pattern, git_config_content)
print(match)
if match is None:
try:
# Read the .git/config file to find the remote repository URL
with config_path.open("r") as git_config:
git_config_content = git_config.read()
except (FileNotFoundError, PermissionError):
return False
# Extract the repository name and compare with the expected repository_qualname
config_repo_name = match.group(1)
return config_repo_name == repository_qualname
# Use regex to find the repository URL in the config file
repo_name_pattern = r"url\s*=\s*.+/([^/]+/[^/]+)\.git"
match = re.search(repo_name_pattern, git_config_content)
# Return True if match found and repository name matches
return bool(match) and match.group(1) == f"{author}/{repository}"
def update_app():
is_latest, release_json = check_for_updates()
if is_latest:
print("[green]App is up to date[/]")
return
return False, release_json
tag_name = release_json["tag_name"]
print("[cyan]Updating app to version %s[/]" % tag_name)
if is_git_repo(AUTHOR, APP_NAME):
executable = shutil.which("git")
GIT_EXECUTABLE = shutil.which("git")
args = [
executable,
GIT_EXECUTABLE,
"pull",
]
print(f"Pulling latest changes from the repository via git: {shlex.join(args)}")
if not executable:
return print("[red]Cannot find git.[/]")
if not GIT_EXECUTABLE:
print("[red]Cannot find git please install it.[/]")
return False, release_json
process = Popen(
process = subprocess.run(
args,
stdout=PIPE,
stderr=PIPE,
)
process.communicate()
else:
executable = sys.executable
if PIPX_EXECUTABLE := shutil.which("pipx"):
process = subprocess.run([PIPX_EXECUTABLE, "upgrade", APP_NAME])
else:
PYTHON_EXECUTABLE = sys.executable
args = [
executable,
"-m",
"pip",
"install",
APP_NAME,
"--user",
"--no-warn-script-location",
]
process = Popen(args)
process.communicate()
args = [
PYTHON_EXECUTABLE,
"-m",
"pip",
"install",
APP_NAME,
"--user",
"--no-warn-script-location",
]
process = subprocess.run(args)
if process.returncode == 0:
return True, release_json
else:
return False, release_json

View File

@@ -1,6 +1,6 @@
import click
from ...utils.tools import QueryDict
from ...utils.tools import FastAnimeRuntimeState
from .__lazyloader__ import LazyGroup
commands = {
@@ -36,7 +36,9 @@ def anilist(ctx: click.Context):
from ....anilist import AniList
from ....AnimeProvider import AnimeProvider
from ...interfaces.anilist_interfaces import anilist as anilist_interface
from ...interfaces.anilist_interfaces import (
fastanime_main_menu as anilist_interface,
)
if TYPE_CHECKING:
from ...config import Config
@@ -45,5 +47,5 @@ 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)
fastanime_runtime_state = FastAnimeRuntimeState()
anilist_interface(ctx.obj, fastanime_runtime_state)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def completed(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def completed(config: "Config"):
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)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def dropped(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def dropped(config: "Config"):
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)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -8,11 +8,11 @@ import click
@click.pass_obj
def favourites(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_favourite()
if anime_data[0]:
anilist_config = QueryDict()
anilist_config.data = anime_data[1]
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -8,41 +8,54 @@ if TYPE_CHECKING:
@click.command(help="Login to your anilist account")
@click.option("--status", "-s", help="Whether you are logged in or not", is_flag=True)
@click.option("--erase", "-e", help="Erase your login details", is_flag=True)
@click.pass_obj
def login(config: "Config", status):
from click import launch
def login(config: "Config", status, erase):
from rich import print
from rich.prompt import Confirm, Prompt
from ....anilist import AniList
from ...utils.tools import exit_app
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"
"You are logged in :smile:" 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):
elif erase:
if Confirm.ask(
"Are you sure you want to erase your login status", default=False
):
config.update_user({})
print("Success")
exit_app(0)
else:
exit_app(1)
else:
from click import launch
from ....anilist import AniList
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] )",
)
launch(config.fastanime_anilist_app_login_url, wait=True)
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()
# ---- new loggin -----
print(
f"A browser session will be opened ( [link]{config.fastanime_anilist_app_login_url}[/link] )",
)
launch(config.fastanime_anilist_app_login_url, wait=True)
print("Please paste the token provided here")
token = Prompt.ask("Enter token")
user = AniList.login_user(token)
if not user:
print("Sth went wrong", user)
return
user["token"] = token
config.update_user(user)
print("Successfully saved credentials")
print(user)
exit_app()
return
user["token"] = token
config.update_user(user)
print("Successfully saved credentials")
print(user)
exit_app()

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def paused(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def paused(config: "Config"):
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 = FastAnimeRuntimeState()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)
anilist_interfaces.anilist_results_menu(config, anilist_config)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def planning(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def planning(config: "Config"):
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)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -7,11 +7,11 @@ import click
@click.pass_obj
def popular(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_popular()
if anime_data[0]:
anilist_config = QueryDict()
anilist_config.data = anime_data[1]
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -10,8 +10,8 @@ def random_anime(config):
import random
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
random_anime = range(1, 15000)
@@ -20,8 +20,8 @@ def random_anime(config):
anime_data = AniList.search(id_in=list(random_anime))
if anime_data[0]:
anilist_config = QueryDict()
anilist_config.data = anime_data[1]
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
else:
print(anime_data[1])

View File

@@ -8,11 +8,11 @@ import click
@click.pass_obj
def recent(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_recently_updated()
if anime_data[0]:
anilist_config = QueryDict()
anilist_config.data = anime_data[1]
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def rewatching(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def rewatching(config: "Config"):
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)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -7,11 +7,11 @@ import click
@click.pass_obj
def scores(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_scored()
if anime_data[0]:
anilist_config = QueryDict()
anilist_config.data = anime_data[1]
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -1,21 +1,21 @@
import click
from ...completion_functions import anime_titles_shell_complete
@click.command(
help="Search for anime using anilists api and get top ~50 results",
short_help="Search for anime",
)
@click.argument(
"title",
)
@click.argument("title", shell_complete=anime_titles_shell_complete)
@click.pass_obj
def search(config, title):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
success, search_results = AniList.search(title)
if success:
anilist_config = QueryDict()
anilist_config.data = search_results
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = search_results
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -8,11 +8,11 @@ import click
@click.pass_obj
def trending(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
success, data = AniList.get_trending()
if success:
anilist_config = QueryDict()
anilist_config.data = data
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = data
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -7,11 +7,11 @@ import click
@click.pass_obj
def upcoming(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
success, data = AniList.get_upcoming_anime()
if success:
anilist_config = QueryDict()
anilist_config.data = data
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = data
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def watching(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def watching(config: "Config"):
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)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -25,14 +25,14 @@ def cache(clean, path, size):
elif size:
import os
from ..utils.utils import sizeof_fmt
from ..utils.utils import format_bytes_to_human
total_size = 0
for dirpath, dirnames, filenames in os.walk(APP_CACHE_DIR):
for f in filenames:
fp = os.path.join(dirpath, f)
total_size += os.path.getsize(fp)
print("Total Size: ", sizeof_fmt(total_size))
print("Total Size: ", format_bytes_to_human(total_size))
else:
import click

View File

@@ -2,9 +2,9 @@ import click
@click.command(help="Helper command to get shell completions")
@click.option("--fish", is_flag=True)
@click.option("--zsh", is_flag=True)
@click.option("--bash", is_flag=True)
@click.option("--fish", is_flag=True, help="print fish completions")
@click.option("--zsh", is_flag=True, help="print zsh completions")
@click.option("--bash", is_flag=True, help="print bash completions")
def completions(fish, zsh, bash):
if not fish or not zsh or not bash:
import os

View File

@@ -1,47 +1,92 @@
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from ..config import Config
@click.command(
help="Opens up your fastanime config in your preferred editor",
short_help="Edit your config",
)
@click.option("--path", "-p", help="Print the config location and exit", is_flag=True)
@click.option(
"--view", "-v", help="View the current contents of your config", is_flag=True
)
@click.option(
"--desktop-entry",
"-d",
help="Configure the desktop entry of fastanime",
is_flag=True,
)
# @click.pass_obj
def config(path, desktop_entry):
pass
@click.pass_obj
def config(config: "Config", path, view, desktop_entry):
import sys
from pyshortcuts import make_shortcut
from rich import print
from ...constants import APP_NAME, ICON_PATH, USER_CONFIG_PATH
from ... import __version__
from ...constants import APP_NAME, ICON_PATH, S_PLATFORM, USER_CONFIG_PATH
if path:
print(USER_CONFIG_PATH)
elif view:
print(config)
elif desktop_entry:
import os
import shutil
from pathlib import Path
from textwrap import dedent
from rich import print
from rich.prompt import Confirm
from ..utils.tools import exit_app
FASTANIME_EXECUTABLE = shutil.which("fastanime")
if FASTANIME_EXECUTABLE:
cmds = f"{FASTANIME_EXECUTABLE} --rofi anilist"
else:
cmds = "_ -m fastanime --rofi anilist"
shortcut = make_shortcut(
name=APP_NAME,
description="Watch Anime from the terminal",
icon=ICON_PATH,
script=cmds,
terminal=False,
)
if shortcut:
print("Success", shortcut)
cmds = f"{sys.executable} -m fastanime --rofi anilist"
# TODO: Get funs of the other platforms to complete this lol
if S_PLATFORM == "win32":
print(
"Not implemented; the author thinks its not straight forward so welcomes lovers of windows to try and implement it themselves or to switch to a proper os like arch linux or pray the author gets bored 😜"
)
elif S_PLATFORM == "darwin":
print(
"Not implemented; the author thinks its not straight forward so welcomes lovers of mac to try and implement it themselves or to switch to a proper os like arch linux or pray the author gets bored 😜"
)
else:
print("Failed")
desktop_entry = dedent(
f"""
[Desktop Entry]
Name={APP_NAME}
Type=Application
version={__version__}
Path={Path().home()}
Comment=Watch anime from your terminal
Terminal=false
Icon={ICON_PATH}
Exec={cmds}
Categories=Entertainment
"""
)
base = os.path.expanduser("~/.local/share/applications")
desktop_entry_path = os.path.join(base, f"{APP_NAME}.desktop")
if os.path.exists(desktop_entry_path):
if not Confirm.ask(
f"The file already exists {desktop_entry_path}; or would you like to rewrite it",
default=False,
):
exit_app(1)
with open(desktop_entry_path, "w") as f:
f.write(desktop_entry)
with open(desktop_entry_path) as f:
print(f"Successfully wrote \n{f.read()}")
exit_app(0)
else:
import click

View File

@@ -1,7 +1,10 @@
import time
from typing import TYPE_CHECKING
import click
from ..completion_functions import anime_titles_shell_complete
if TYPE_CHECKING:
from ..config import Config
@@ -10,24 +13,25 @@ if TYPE_CHECKING:
help="Download anime using the anime provider for a specified range",
short_help="Download anime",
)
@click.argument(
"anime-title",
@click.option(
"--anime-titles",
"--anime_title",
"-t",
required=True,
shell_complete=anime_titles_shell_complete,
multiple=True,
)
@click.option(
"--episode-range",
"-r",
help="A range of episodes to download",
)
@click.option(
"--highest_priority",
"-h",
help="Choose stream indicated as highest priority",
is_flag=True,
help="A range of episodes to download (start-end)",
)
@click.pass_obj
def download(config: "Config", anime_title, episode_range, highest_priority):
from click import clear
def download(
config: "Config",
anime_titles: list,
episode_range,
):
from rich import print
from rich.progress import Progress
from thefuzz import fuzz
@@ -44,109 +48,156 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
translation_type = config.translation_type
download_dir = config.downloads_dir
# ---- search for anime ----
with Progress() as progress:
progress.add_task("Fetching Search Results...", total=None)
search_results = anime_provider.search_for_anime(
anime_title, translation_type=translation_type
)
if not search_results:
print("Search results failed")
input("Enter to retry")
download(config, anime_title, episode_range, highest_priority)
return
search_results = search_results["results"]
search_results_ = {
search_result["title"]: search_result for search_result in search_results
}
if config.auto_select:
search_result = max(
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
)
print("[cyan]Auto selecting:[/] ", search_result)
else:
choices = list(search_results_.keys())
if config.use_fzf:
search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
else:
search_result = fuzzy_inquirer("Please Select title", choices)
# ---- fetch anime ----
with Progress() as progress:
progress.add_task("Fetching Anime...", total=None)
anime: Anime | None = anime_provider.get_anime(
search_results_[search_result]["id"]
)
if not anime:
print("Sth went wring anime no found")
input("Enter to continue...")
download(config, anime_title, episode_range, highest_priority)
return
episodes = anime["availableEpisodesDetail"][config.translation_type]
if episode_range:
episodes_start, episodes_end = episode_range.split("-")
else:
episodes_start, episodes_end = 0, len(episodes)
for episode in range(round(float(episodes_start)), round(float(episodes_end))):
try:
episode = str(episode)
if episode not in episodes:
print(f"[cyan]Warning[/]: Episode {episode} not found, skipping")
continue
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
)
if not streams:
print("No streams skipping")
continue
# ---- fetch servers ----
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams)
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
input("Enter to continue")
continue
link = stream_link["link"]
episode_title = server["episode_title"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
else:
server = fuzzy_inquirer("Select link", servers_names)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
)
if not stream_link:
print("Quality not found")
continue
link = stream_link["link"]
episode_title = servers[server]["episode_title"]
print(f"[purple]Now Downloading:[/] {search_result} Episode {episode}")
downloader._download_file(
link,
download_dir,
(anime["title"], episode_title),
True,
config.format,
print(f"[green bold]Queued:[/] {anime_titles}")
for anime_title in anime_titles:
print(f"[green bold]Now Downloading: [/] {anime_title}")
# ---- search for anime ----
with Progress() as progress:
progress.add_task("Fetching Search Results...", total=None)
search_results = anime_provider.search_for_anime(
anime_title, translation_type=translation_type
)
except Exception as e:
print(e)
print("Continuing")
clear()
print("Done")
if not search_results:
print("Search results failed")
input("Enter to retry")
download(
config,
anime_title,
episode_range,
)
return
search_results = search_results["results"]
search_results_ = {
search_result["title"]: search_result for search_result in search_results
}
if config.auto_select:
search_result = max(
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
)
print("[cyan]Auto selecting:[/] ", search_result)
else:
choices = list(search_results_.keys())
if config.use_fzf:
search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
else:
search_result = fuzzy_inquirer(
choices,
"Please Select title",
)
# ---- fetch anime ----
with Progress() as progress:
progress.add_task("Fetching Anime...", total=None)
anime: Anime | None = anime_provider.get_anime(
search_results_[search_result]["id"]
)
if not anime:
print("Sth went wring anime no found")
input("Enter to continue...")
download(
config,
anime_title,
episode_range,
)
return
episodes = sorted(
anime["availableEpisodesDetail"][config.translation_type], key=float
)
if episode_range:
if ":" in episode_range:
ep_range_tuple = episode_range.split(":")
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
episodes_start, episodes_end = ep_range_tuple
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
episodes_start, episodes_end, step = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end) : int(step)
]
else:
episodes_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range = episodes[int(episodes_start) :]
elif episodes_end.strip():
episodes_range = episodes[: int(episodes_end)]
else:
episodes_range = episodes
else:
episodes_range = episodes[int(episode_range) :]
print(f"[green bold]Downloading: [/] {episodes_range}")
else:
episodes_range = sorted(episodes, key=float)
for episode in episodes_range:
try:
episode = str(episode)
if episode not in episodes:
print(f"[cyan]Warning[/]: Episode {episode} not found, skipping")
continue
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
)
if not streams:
print("No streams skipping")
continue
# ---- fetch servers ----
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams, None)
if not server:
print("Sth went wrong when fetching the server")
continue
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
input("Enter to continue")
continue
link = stream_link["link"]
episode_title = server["episode_title"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.server in servers_names:
server = config.server
else:
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
else:
server = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
)
if not stream_link:
print("Quality not found")
continue
link = stream_link["link"]
episode_title = servers[server]["episode_title"]
print(f"[purple]Now Downloading:[/] {search_result} Episode {episode}")
downloader._download_file(
link,
anime["title"],
episode_title,
download_dir,
True,
config.format,
)
except Exception as e:
print(e)
time.sleep(1)
print("Continuing")
print("Done Downloading")
exit_app()

View File

@@ -1,7 +1,9 @@
import logging
from typing import TYPE_CHECKING
import click
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ..config import Config
@@ -10,16 +12,27 @@ if TYPE_CHECKING:
help="View and watch your downloads using mpv", short_help="Watch downloads"
)
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
@click.option("--view-episodes", "-v", help="View individual episodes", is_flag=True)
@click.option(
"--ffmpegthumbnailer-seek-time",
"--time-to-seek",
"-t",
type=click.IntRange(-1, 100),
help="ffmpegthumbnailer seek time [0-100]",
)
@click.pass_obj
def downloads(config: "Config", path: bool):
def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_seek_time):
import os
from ...cli.utils.mpv import run_mpv
from ...libs.fzf import fzf
from ...libs.rofi import Rofi
from ...Utility.utils import sort_by_episode_number
from ..utils.tools import exit_app
from ..utils.utils import fuzzy_inquirer
if not ffmpegthumbnailer_seek_time:
ffmpegthumbnailer_seek_time = config.ffmpegthumbnailer_seek_time
USER_VIDEOS_DIR = config.downloads_dir
if path:
print(USER_VIDEOS_DIR)
@@ -27,21 +40,258 @@ def downloads(config: "Config", path: bool):
if not os.path.exists(USER_VIDEOS_DIR):
print("Downloads directory specified does not exist")
return
playlists = os.listdir(USER_VIDEOS_DIR)
playlists.append("Exit")
anime_downloads = sorted(
os.listdir(USER_VIDEOS_DIR),
)
anime_downloads.append("Exit")
def stream():
if config.use_fzf:
playlist_name = fzf.run(playlists, "Enter Playlist Name", "Downloads")
elif config.use_rofi:
playlist_name = Rofi.run(playlists, "Enter Playlist Name")
def create_thumbnails(video_path, anime_title, downloads_thumbnail_cache_dir):
import os
import shutil
import subprocess
FFMPEG_THUMBNAILER = shutil.which("ffmpegthumbnailer")
if not FFMPEG_THUMBNAILER:
return
out = os.path.join(downloads_thumbnail_cache_dir, anime_title)
if ffmpegthumbnailer_seek_time == -1:
import random
seektime = str(random.randrange(0, 100))
else:
playlist_name = fuzzy_inquirer("Enter Playlist Name: ", playlists)
seektime = str(ffmpegthumbnailer_seek_time)
_ = subprocess.run(
[
FFMPEG_THUMBNAILER,
"-i",
video_path,
"-o",
out,
"-s",
"0",
"-t",
seektime,
],
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
)
def get_previews_anime(workers=None, bg=True):
import concurrent.futures
import shutil
from pathlib import Path
if not shutil.which("ffmpegthumbnailer"):
print("ffmpegthumbnailer not found")
logger.error("ffmpegthumbnailer not found")
return
from ...constants import APP_CACHE_DIR
from ..utils.scripts import fzf_preview
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
def _worker():
# use concurrency to download the images as fast as possible
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs
future_to_url = {}
for anime_title in anime_downloads:
anime_path = os.path.join(USER_VIDEOS_DIR, anime_title)
if not os.path.isdir(anime_path):
continue
playlist = sorted(
os.listdir(anime_path),
)
if playlist:
# actual link to download image from
video_path = os.path.join(anime_path, playlist[0])
future_to_url[
executor.submit(
create_thumbnails,
video_path,
anime_title,
downloads_thumbnail_cache_dir,
)
] = anime_title
# execute the jobs
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
future.result()
except Exception as e:
logger.error("%r generated an exception: %s" % (url, e))
if bg:
from threading import Thread
worker = Thread(target=_worker)
worker.daemon = True
worker.start()
else:
_worker()
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then
if ! fzf-preview %s/{} 2>/dev/null; then
echo Loading...
fi
else echo Loading...
fi
""" % (
fzf_preview,
downloads_thumbnail_cache_dir,
downloads_thumbnail_cache_dir,
)
return preview
def get_previews_episodes(anime_playlist_path, workers=None, bg=True):
import shutil
from pathlib import Path
from ...constants import APP_CACHE_DIR
from ..utils.scripts import fzf_preview
if not shutil.which("ffmpegthumbnailer"):
print("ffmpegthumbnailer not found")
logger.error("ffmpegthumbnailer not found")
return
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
def _worker():
import concurrent.futures
# use concurrency to download the images as fast as possible
# anime_playlist_path = os.path.join(USER_VIDEOS_DIR, anime_playlist_path)
if not os.path.isdir(anime_playlist_path):
return
anime_episodes = sorted(
os.listdir(anime_playlist_path), key=sort_by_episode_number
)
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs
future_to_url = {}
for episode_title in anime_episodes:
episode_path = os.path.join(anime_playlist_path, episode_title)
# actual link to download image from
future_to_url[
executor.submit(
create_thumbnails,
episode_path,
episode_title,
downloads_thumbnail_cache_dir,
)
] = episode_title
# execute the jobs
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
future.result()
except Exception as e:
logger.error("%r generated an exception: %s" % (url, e))
if bg:
from threading import Thread
worker = Thread(target=_worker)
worker.daemon = True
worker.start()
else:
_worker()
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then
if ! fzf-preview %s/{} 2>/dev/null; then
echo Loading...
fi
else echo Loading...
fi
""" % (
fzf_preview,
downloads_thumbnail_cache_dir,
downloads_thumbnail_cache_dir,
)
return preview
def stream_episode(
anime_playlist_path,
):
if view_episodes:
if not os.path.isdir(anime_playlist_path):
print(anime_playlist_path, "is not dir")
exit_app(1)
return
episodes = sorted(
os.listdir(anime_playlist_path), key=sort_by_episode_number
)
downloaded_episodes = [*episodes, "Back"]
if config.use_fzf:
if not config.preview:
episode_title = fzf.run(
downloaded_episodes,
"Enter Episode ",
)
else:
preview = get_previews_episodes(anime_playlist_path)
episode_title = fzf.run(
downloaded_episodes,
"Enter Episode ",
preview=preview,
)
elif config.use_rofi:
episode_title = Rofi.run(downloaded_episodes, "Enter Episode")
else:
episode_title = fuzzy_inquirer(
downloaded_episodes,
"Enter Playlist Name: ",
)
if episode_title == "Back":
stream_anime()
return
episode_path = os.path.join(anime_playlist_path, episode_title)
run_mpv(episode_path)
stream_episode(anime_playlist_path)
def stream_anime():
if config.use_fzf:
if not config.preview:
playlist_name = fzf.run(
anime_downloads,
"Enter Playlist Name",
)
else:
preview = get_previews_anime()
playlist_name = fzf.run(
anime_downloads,
"Enter Playlist Name",
preview=preview,
)
elif config.use_rofi:
playlist_name = Rofi.run(anime_downloads, "Enter Playlist Name")
else:
playlist_name = fuzzy_inquirer(
anime_downloads,
"Enter Playlist Name: ",
)
if playlist_name == "Exit":
exit_app()
return
playlist = os.path.join(USER_VIDEOS_DIR, playlist_name)
run_mpv(playlist)
stream()
if view_episodes:
stream_episode(
playlist,
)
else:
run_mpv(playlist)
stream_anime()
stream()
stream_anime()

View File

@@ -1,20 +1,28 @@
import click
from ...cli.config import Config
from ..completion_functions import anime_titles_shell_complete
@click.command(
help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.",
short_help="Binge anime",
)
@click.option(
"--anime-titles",
"--anime_title",
"-t",
required=True,
shell_complete=anime_titles_shell_complete,
multiple=True,
)
@click.option(
"--episode-range",
"-r",
help="A range of episodes to binge",
help="A range of episodes to binge (start-end)",
)
@click.argument("anime_title", required=True, type=str)
@click.pass_obj
def search(config: Config, anime_title: str, episode_range: str):
def search(config: Config, anime_titles: str, episode_range: str):
from click import clear
from rich import print
from rich.progress import Progress
@@ -30,140 +38,184 @@ def search(config: Config, anime_title: str, episode_range: str):
anime_provider = AnimeProvider(config.provider)
# ---- search for anime ----
with Progress() as progress:
progress.add_task("Fetching Search Results...", total=None)
search_results = anime_provider.search_for_anime(
anime_title, config.translation_type
)
if not search_results:
print("Search results not found")
input("Enter to retry")
search(config, anime_title, episode_range)
return
search_results = search_results["results"]
if not search_results:
print("Anime not found :cry:")
exit_app()
search_results_ = {
search_result["title"]: search_result for search_result in search_results
}
if config.auto_select:
search_result = max(
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
)
print("[cyan]Auto Selecting:[/] ", search_result)
else:
choices = list(search_results_.keys())
if config.use_fzf:
search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
elif config.use_rofi:
search_result = Rofi.run(choices, "Please Select Title")
else:
search_result = fuzzy_inquirer(
"Please Select Title",
choices,
)
# ---- fetch selected anime ----
with Progress() as progress:
progress.add_task("Fetching Anime...", total=None)
anime: Anime | None = anime_provider.get_anime(
search_results_[search_result]["id"]
)
if not anime:
print("Sth went wring anime no found")
input("Enter to continue...")
search(config, anime_title, episode_range)
return
episode_range_ = None
episodes = anime["availableEpisodesDetail"][config.translation_type]
if episode_range:
episodes_start, episodes_end = episode_range.split("-")
if episodes_start and episodes_end:
episode_range_ = iter(
range(round(float(episodes_start)), round(float(episodes_end)) + 1)
)
else:
episode_range_ = iter(sorted(episodes, key=float))
def stream_anime():
clear()
episode = None
if episode_range_:
try:
episode = str(next(episode_range_))
print(
f"[cyan]Auto selecting:[/] {search_result} [cyan]Episode:[/] {episode}"
)
except StopIteration:
print("[green]Completed binge sequence[/]:smile:")
input("Enter to continue...")
if not episode or episode not in episodes:
if config.use_fzf:
episode = fzf.run(episodes, "Select an episode: ", header=search_result)
elif config.use_rofi:
episode = Rofi.run(episodes, "Select an episode")
else:
episode = fuzzy_inquirer("Select episode", episodes)
# ---- fetch streams ----
print(f"[green bold]Streaming:[/] {anime_titles}")
for anime_title in anime_titles:
# ---- search for anime ----
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
progress.add_task("Fetching Search Results...", total=None)
search_results = anime_provider.search_for_anime(
anime_title, config.translation_type
)
if not streams:
print("Failed to get streams")
if not search_results:
print("Search results not found")
input("Enter to retry")
search(config, anime_title, episode_range)
return
search_results = search_results["results"]
if not search_results:
print("Anime not found :cry:")
exit_app()
search_results_ = {
search_result["title"]: search_result for search_result in search_results
}
if config.auto_select:
search_result = max(
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
)
print("[cyan]Auto Selecting:[/] ", search_result)
else:
choices = list(search_results_.keys())
if config.use_fzf:
search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
elif config.use_rofi:
search_result = Rofi.run(choices, "Please Select Title")
else:
search_result = fuzzy_inquirer(
choices,
"Please Select Title",
)
# ---- fetch selected anime ----
with Progress() as progress:
progress.add_task("Fetching Anime...", total=None)
anime: Anime | None = anime_provider.get_anime(
search_results_[search_result]["id"]
)
if not anime:
print("Sth went wring anime no found")
input("Enter to continue...")
search(config, anime_title, episode_range)
return
episodes_range = []
episodes: list[str] = sorted(
anime["availableEpisodesDetail"][config.translation_type], key=float
)
if episode_range:
if ":" in episode_range:
ep_range_tuple = episode_range.split(":")
if len(ep_range_tuple) == 3 and all(ep_range_tuple):
episodes_start, episodes_end, step = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end) : int(step)
]
elif len(ep_range_tuple) == 2 and all(ep_range_tuple):
episodes_start, episodes_end = ep_range_tuple
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
else:
episodes_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range = episodes[int(episodes_start) :]
elif episodes_end.strip():
episodes_range = episodes[: int(episodes_end)]
else:
episodes_range = episodes
else:
episodes_range = episodes[int(episode_range) :]
episodes_range = iter(episodes_range)
def stream_anime():
clear()
episode = None
if episodes_range:
try:
episode = next(episodes_range) # pyright:ignore
print(
f"[cyan]Auto selecting:[/] {search_result} [cyan]Episode:[/] {episode}"
)
except StopIteration:
print("[green]Completed binge sequence[/]:smile:")
return
if not episode or episode not in episodes:
choices = [*episodes, "end"]
if config.use_fzf:
episode = fzf.run(
choices, "Select an episode: ", header=search_result
)
elif config.use_rofi:
episode = Rofi.run(choices, "Select an episode")
else:
episode = fuzzy_inquirer(
choices,
"Select episode",
)
if episode == "end":
return
try:
# ---- fetch servers ----
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams)
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
return
link = stream_link["link"]
episode_title = server["episode_title"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
elif config.use_rofi:
server = Rofi.run(servers_names, "Select an link")
else:
server = fuzzy_inquirer("Select link", servers_names)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
# ---- fetch streams ----
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
)
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
if not streams:
print("Failed to get streams")
return
link = stream_link["link"]
episode_title = servers[server]["episode_title"]
print(f"[purple]Now Playing:[/] {search_result} Episode {episode}")
run_mpv(link, episode_title)
except Exception as e:
print(e)
input("Enter to continue")
try:
# ---- fetch servers ----
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams, None)
if not server:
print("Sth went wrong when fetching the episode")
input("Enter to continue")
stream_anime()
return
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
return
link = stream_link["link"]
episode_title = server["episode_title"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.server in servers_names:
server = config.server
else:
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
elif config.use_rofi:
server = Rofi.run(servers_names, "Select an link")
else:
server = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
)
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
return
link = stream_link["link"]
episode_title = servers[server]["episode_title"]
print(f"[purple]Now Playing:[/] {search_result} Episode {episode}")
if config.sync_play:
from ..utils.syncplay import SyncPlayer
SyncPlayer(link, episode_title)
else:
run_mpv(link, episode_title)
except Exception as e:
print(e)
input("Enter to continue")
stream_anime()
stream_anime()
stream_anime()

View File

@@ -0,0 +1,37 @@
import click
@click.command(help="Helper command to update fastanime to latest")
@click.option("--check", "-c", help="Check for the latest release", is_flag=True)
def update(
check,
):
from rich.console import Console
from rich.markdown import Markdown
from ..app_updater import check_for_updates, update_app
def _print_release(release_data):
console = Console()
body = Markdown(release_data["body"])
tag = github_release_data["tag_name"]
tag_title = release_data["name"]
github_page_url = release_data["html_url"]
console.print(f"Release Page: {github_page_url}")
console.print(f"Tag: {tag}")
console.print(f"Title: {tag_title}")
console.print(body)
if check:
is_update, github_release_data = check_for_updates()
if is_update:
print(
"You are running an older version of fastanime please update to get the latest features"
)
_print_release(github_release_data)
else:
print("You are running the latest version of fastanime")
_print_release(github_release_data)
else:
success, github_release_data = update_app()
_print_release(github_release_data)

View File

@@ -0,0 +1,83 @@
import logging
logger = logging.getLogger(__name__)
ANILIST_ENDPOINT = "https://graphql.anilist.co"
anime_title_query = """
query($query:String){
Page(perPage:50){
pageInfo{
total
currentPage
hasNextPage
}
media(search:$query,type:ANIME){
id
idMal
title{
romaji
english
}
}
}
}
"""
def get_anime_titles(query: str, variables: dict = {}):
"""the abstraction over all none authenticated requests and that returns data of a similar type
Args:
query: the anilist query
variables: the anilist api variables
Returns:
a boolean indicating success and none or an anilist object depending on success
"""
from requests import post
try:
response = post(
ANILIST_ENDPOINT,
json={"query": query, "variables": variables},
timeout=10,
)
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:
eng_titles = [
anime["title"]["english"]
for anime in anilist_data["data"]["Page"]["media"]
if anime["title"]["english"]
]
romaji_titles = [
anime["title"]["romaji"]
for anime in anilist_data["data"]["Page"]["media"]
if anime["title"]["romaji"]
]
return [*eng_titles, *romaji_titles]
else:
return []
except Exception as e:
logger.error(f"Something unexpected occured {e}")
return []
def anime_titles_shell_complete(ctx, param, incomplete):
return [name for name in get_anime_titles(anime_title_query, {"query": incomplete})]

View File

@@ -1,39 +1,87 @@
import json
import logging
import os
from configparser import ConfigParser
from typing import TYPE_CHECKING
from rich import print
from ..constants import USER_CONFIG_PATH, USER_VIDEOS_DIR
from ..constants import USER_CONFIG_PATH, USER_DATA_PATH, USER_VIDEOS_DIR
from ..libs.rofi import Rofi
from ..Utility.user_data_helper import user_data_helper
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ..AnimeProvider import AnimeProvider
class Config(object):
"""class that handles and manages configuration and user data throughout the clis lifespan
Attributes:
anime_list: [TODO:attribute]
watch_history: [TODO:attribute]
fastanime_anilist_app_login_url: [TODO:attribute]
anime_provider: [TODO:attribute]
user_data: [TODO:attribute]
configparser: [TODO:attribute]
downloads_dir: [TODO:attribute]
provider: [TODO:attribute]
use_fzf: [TODO:attribute]
use_rofi: [TODO:attribute]
skip: [TODO:attribute]
icons: [TODO:attribute]
preview: [TODO:attribute]
translation_type: [TODO:attribute]
sort_by: [TODO:attribute]
continue_from_history: [TODO:attribute]
auto_next: [TODO:attribute]
auto_select: [TODO:attribute]
use_mpv_mod: [TODO:attribute]
quality: [TODO:attribute]
notification_duration: [TODO:attribute]
error: [TODO:attribute]
server: [TODO:attribute]
format: [TODO:attribute]
force_window: [TODO:attribute]
preferred_language: [TODO:attribute]
rofi_theme: [TODO:attribute]
rofi_theme: [TODO:attribute]
rofi_theme_input: [TODO:attribute]
rofi_theme_input: [TODO:attribute]
rofi_theme_confirm: [TODO:attribute]
rofi_theme_confirm: [TODO:attribute]
watch_history: [TODO:attribute]
anime_list: [TODO:attribute]
user: [TODO:attribute]
"""
sync_play = False
anime_list: list
watch_history: dict
fastanime_anilist_app_login_url = (
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
)
anime_provider: "AnimeProvider"
user_data = {"watch_history": {}, "animelist": [], "user": {}}
def __init__(self) -> None:
self.initialize_user_data()
self.load_config()
def load_config(self):
self.configparser = ConfigParser(
{
"server": "top",
"continue_from_history": "True",
"quality": "1080",
"auto_next": "False",
"auto_select": "True",
"sort_by": "search match",
"downloads_dir": USER_VIDEOS_DIR,
"translation_type": "sub",
"server": "top",
"continue_from_history": "True",
"preferred_history": "local",
"use_mpv_mod": "false",
"force_window": "immediate",
"preferred_language": "english",
"use_fzf": "False",
"preview": "False",
@@ -47,8 +95,7 @@ class Config(object):
"rofi_theme": "",
"rofi_theme_input": "",
"rofi_theme_confirm": "",
"use_mpv_mod": "false",
"force_window": "immediate",
"ffmpegthumnailer_seek_time": "-1",
}
)
self.configparser.add_section("stream")
@@ -60,7 +107,7 @@ class Config(object):
self.configparser.read(USER_CONFIG_PATH)
# --- set defaults ---
# --- set config values from file or using defaults ---
self.downloads_dir = self.get_downloads_dir()
self.provider = self.get_provider()
self.use_fzf = self.get_use_fzf()
@@ -81,23 +128,26 @@ class Config(object):
self.format = self.get_format()
self.force_window = self.get_force_window()
self.preferred_language = self.get_preferred_language()
self.preferred_history = self.get_preferred_history()
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
self.ffmpegthumbnailer_seek_time = self.get_ffmpegthumnailer_seek_time()
# ---- 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.watch_history: dict = self.user_data.get("watch_history", {})
self.anime_list: list = self.user_data.get("animelist", [])
self.user: dict = self.user_data.get("user", {})
def update_user(self, user):
self.user = user
user_data_helper.update_user_info(user)
self.user_data["user"] = user
self._update_user_data()
def update_watch_history(
self, anime_id: int, episode: str | None, start_time="0", total_time="0"
self, anime_id: int, episode: str, start_time="0", total_time="0"
):
self.watch_history.update(
{
@@ -108,24 +158,51 @@ class Config(object):
}
}
)
user_data_helper.update_watch_history(self.watch_history)
self.user_data["watch_history"] = self.watch_history
self._update_user_data()
def update_anime_list(self, anime_id: int, remove=False):
if remove:
try:
self.anime_list.remove(anime_id)
print("Succesfully removed :cry:")
except Exception:
print(anime_id, "Nothing to remove :confused:")
else:
self.anime_list.append(anime_id)
user_data_helper.update_animelist(self.anime_list)
print("Succesfully added :smile:")
input("Enter to continue...")
def initialize_user_data(self):
try:
if os.path.isfile(USER_DATA_PATH):
with open(USER_DATA_PATH, "r") as f:
user_data = json.load(f)
self.user_data.update(user_data)
except Exception as e:
logger.error(e)
def _update_user_data(self):
"""method that updates the actual user data file"""
with open(USER_DATA_PATH, "w") as f:
json.dump(self.user_data, f)
# getters for user configuration
# --- general section ---
def get_provider(self):
return self.configparser.get("general", "provider")
def get_ffmpegthumnailer_seek_time(self):
return self.configparser.getint("general", "ffmpegthumnailer_seek_time")
def get_preferred_language(self):
return self.configparser.get("general", "preferred_language")
def get_downloads_dir(self):
return self.configparser.get("general", "downloads_dir")
def get_icons(self):
return self.configparser.getboolean("general", "icons")
def get_preview(self):
return self.configparser.getboolean("general", "preview")
def get_use_fzf(self):
return self.configparser.getboolean("general", "use_fzf")
# rofi conifiguration
def get_use_rofi(self):
return self.configparser.getboolean("general", "use_rofi")
def get_rofi_theme(self):
return self.configparser.get("general", "rofi_theme")
@@ -135,47 +212,18 @@ class Config(object):
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")
# --- stream section ---
def get_skip(self):
return self.configparser.getboolean("stream", "skip")
def get_force_window(self):
return self.configparser.get("stream", "force_window")
def get_icons(self):
return self.configparser.getboolean("general", "icons")
def get_preview(self):
return self.configparser.getboolean("general", "preview")
def get_preferred_language(self):
return self.configparser.get("general", "preferred_language")
def get_sort_by(self):
return self.configparser.get("anilist", "sort_by")
def get_continue_from_history(self):
return self.configparser.getboolean("stream", "continue_from_history")
def get_translation_type(self):
return self.configparser.get("stream", "translation_type")
def get_auto_next(self):
return self.configparser.getboolean("stream", "auto_next")
def get_auto_select(self):
return self.configparser.getboolean("stream", "auto_select")
def get_quality(self):
return self.configparser.get("stream", "quality")
def get_continue_from_history(self):
return self.configparser.getboolean("stream", "continue_from_history")
def get_use_mpv_mod(self):
return self.configparser.getboolean("stream", "use_mpv_mod")
@@ -186,19 +234,135 @@ class Config(object):
def get_error(self):
return self.configparser.getint("stream", "error")
def get_force_window(self):
return self.configparser.get("stream", "force_window")
def get_translation_type(self):
return self.configparser.get("stream", "translation_type")
def get_preferred_history(self):
return self.configparser.get("stream", "preferred_history")
def get_quality(self):
return self.configparser.get("stream", "quality")
def get_server(self):
return self.configparser.get("stream", "server")
def get_format(self):
return self.configparser.get("stream", "format")
def get_sort_by(self):
return self.configparser.get("anilist", "sort_by")
def update_config(self, section: str, key: str, value: str):
self.configparser.set(section, key, value)
with open(USER_CONFIG_PATH, "w") as config:
self.configparser.write(config)
def __repr__(self):
return f"Config(server:{self.get_server()},quality:{self.get_quality()},auto_next:{self.get_auto_next()},continue_from_history:{self.get_continue_from_history()},sort_by:{self.get_sort_by()},downloads_dir:{self.get_downloads_dir()})"
current_config_state = f"""
[stream]
# Auto continue from watch history
continue_from_history = {self.continue_from_history}
# which hostory to use [local/remote]
preferred_history = {self.preferred_history}
# Preferred language for anime (options: dub, sub)
translation_type = {self.translation_type}
# Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top, wixmp)
server = {self.server}
# Auto-select next episode
auto_next = {self.auto_next}
# Auto select the anime provider results with fuzzy find.
# Note this wont always be correct.But 99% of the time will be.
auto_select = {self.auto_select}
# whether to skip the opening and ending theme songs
# NOTE: requires ani-skip to be in path
skip = {self.skip}
# the maximum delta time in minutes after which the episode should be considered as completed
# used in the continue from time stamp
error = {self.error}
# whether to use python-mpv
# to enable superior control over the player
# adding more options to it
use_mpv_mod = {self.use_mpv_mod}
# force mpv window
# passed directly to mpv so values are same
force_window = immediate
# the format of downloaded anime and trailer
# based on yt-dlp format and passed directly to it
# learn more by looking it up on their site
# only works for downloaded anime if server=gogoanime
# since its the only one that offers different formats
# the others tend not to
format = {self.format}
[general]
# can be [allanime,animepahe]
provider = {self.provider}
# Display language (options: english, romaji)
preferred_language = {self.preferred_language}
# Download directory
downloads_dir = {self.downloads_dir}
# whether to show a preview window when using fzf or rofi
preview = {self.preview}
# the time to seek when using ffmpegthumbnailer [-1 to 100]
# -1 means random and is the default
ffmpegthumbnailer_seek_time = {self.ffmpegthumbnailer_seek_time}
# whether to use fzf as the interface for the anilist command and others.
use_fzf = {self.use_fzf}
# whether to use rofi for the ui
use_rofi = {self.use_rofi}
# rofi theme to use
rofi_theme = {self.rofi_theme}
rofi_theme_input = {self.rofi_theme_input}
rofi_theme_confirm = {self.rofi_theme_confirm}
# whether to show the icons
icons = {self.icons}
# the duration in minutes a notification will stay in the screen
# used by notifier command
notification_duration = {self.notification_duration}
"""
return current_config_state
def __str__(self):
return self.__repr__()
# WARNING: depracated and will probably be removed
def update_anime_list(self, anime_id: int, remove=False):
if remove:
try:
self.anime_list.remove(anime_id)
print("Succesfully removed :cry:")
except Exception:
print(anime_id, "Nothing to remove :confused:")
else:
self.anime_list.append(anime_id)
self.user_data["animelist"] = list(set(self.anime_list))
self._update_user_data()
print("Succesfully added :smile:")
input("Enter to continue...")

File diff suppressed because it is too large Load Diff

View File

@@ -7,95 +7,28 @@ import textwrap
from threading import Thread
import requests
from yt_dlp.utils import clean_html
from ...constants import APP_CACHE_DIR
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...Utility import anilist_data_helper
from ...Utility.utils import remove_html_tags
from ..utils.scripts import fzf_preview
from ..utils.utils import get_true_fg
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.
#
# Dependencies:
# - https://github.com/sharkdp/bat
# - https://github.com/hpjansson/chafa
# - https://iterm2.com/utilities/imgcat
fzf-preview(){
if [[ $# -ne 1 ]]; then
>&2 echo "usage: $0 FILENAME"
exit 1
fi
file=${1/#\~\//$HOME/}
type=$(file --dereference --mime -- "$file")
if [[ ! $type =~ image/ ]]; then
if [[ $type =~ =binary ]]; then
file "$1"
exit
fi
# Sometimes bat is installed as batcat.
if command -v batcat > /dev/null; then
batname="batcat"
elif command -v bat > /dev/null; then
batname="bat"
else
cat "$1"
exit
fi
${batname} --style="${BAT_STYLE:-numbers}" --color=always --pager=never -- "$file"
exit
fi
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [[ $dim = x ]]; then
dim=$(stty size < /dev/tty | awk '{print $2 "x" $1}')
elif ! [[ $KITTY_WINDOW_ID ]] && (( FZF_PREVIEW_TOP + FZF_PREVIEW_LINES == $(stty size < /dev/tty | awk '{print $1}') )); then
# Avoid scrolling issue when the Sixel image touches the bottom of the screen
# * https://github.com/junegunn/fzf/issues/2544
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
fi
# 1. Use kitty icat on kitty terminal
if [[ $KITTY_WINDOW_ID ]]; then
# 1. 'memory' is the fastest option but if you want the image to be scrollable,
# you have to use 'stream'.
#
# 2. The last line of the output is the ANSI reset code without newline.
# This confuses fzf and makes it render scroll offset indicator.
# So we remove the last line and append the reset code to its previous line.
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed '$d' | sed $'$s/$/\e[m/'
# 2. Use chafa with Sixel output
elif command -v chafa > /dev/null; then
chafa -f sixel -s "$dim" "$file"
# Add a new line character so that fzf can display multiple images in the preview window
echo
# 3. If chafa is not found but imgcat is available, use it on iTerm2
elif command -v imgcat > /dev/null; then
# NOTE: We should use https://iterm2.com/utilities/it2check to check if the
# user is running iTerm2. But for the sake of simplicity, we just assume
# that's the case here.
imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file"
# 4. Cannot find any suitable method to preview the image
else
file "$file"
fi
}
"""
# ---- aniskip intergration ----
def aniskip(mal_id, episode):
def aniskip(mal_id: int, episode: str):
"""helper function to be used for setting and getting skip data
Args:
mal_id: mal id of the anime
episode: episode number
Returns:
mpv chapter options
"""
ANISKIP = shutil.which("ani-skip")
if not ANISKIP:
print("Aniskip not found, please install and try again")
@@ -111,37 +44,61 @@ def aniskip(mal_id, episode):
# ---- prevew stuff ----
# import tempfile
# NOTE: May change this to a temp dir but there were issues so later
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)
IMAGES_CACHE_DIR = os.path.join(WORKING_DIR, "images")
if not os.path.exists(IMAGES_CACHE_DIR):
os.mkdir(IMAGES_CACHE_DIR)
ANIME_INFO_CACHE_DIR = os.path.join(WORKING_DIR, "info")
if not os.path.exists(ANIME_INFO_CACHE_DIR):
os.mkdir(ANIME_INFO_CACHE_DIR)
def save_image_from_url(url: str, file_name: str):
"""Helper function that downloads an image to the FastAnime images cache dir given its url and filename
Args:
url: image url to download
file_name: filename to use
"""
image = requests.get(url)
with open(f"{IMAGES_DIR}/{file_name}", "wb") as f:
with open(f"{IMAGES_CACHE_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:
"""Helper function that writes text (anime details and info) to a file given its filename
Args:
info: the information anilist has on the anime
file_name: the filename to use
"""
with open(f"{ANIME_INFO_CACHE_DIR}/{file_name}", "w") as f:
f.write(info)
def write_search_results(
search_results: list[AnilistBaseMediaDataSchema],
titles,
workers=None,
anilist_results: list[AnilistBaseMediaDataSchema],
titles: list[str],
workers: int | None = None,
):
H_COLOR = 215, 0, 95
S_COLOR = 208, 208, 208
S_WIDTH = 45
"""A helper function used by and run in a background thread by get_fzf_preview function inorder to get the actual preview data to be displayed by fzf
Args:
anilist_results: the anilist results from an anilist action
titles: sanitized anime titles
workers:number of threads to use defaults to as many as possible
"""
# NOTE: Will probably make this a configuraable option
HEADER_COLOR = 215, 0, 95
SEPARATOR_COLOR = 208, 208, 208
SEPARATOR_WIDTH = 45
# use concurency to download and write as fast as possible
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
future_to_task = {}
for anime, title in zip(search_results, titles):
for anime, title in zip(anilist_results, titles):
# actual image url
image_url = anime["coverImage"]["large"]
future_to_task[executor.submit(save_image_from_url, image_url, title)] = (
image_url
@@ -149,24 +106,24 @@ def write_search_results(
# handle the text data
template = f"""
{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)}
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
{get_true_fg('Title(jp):',*HEADER_COLOR)} {anime['title']['romaji']}
{get_true_fg('Title(eng):',*HEADER_COLOR)} {anime['title']['english']}
{get_true_fg('Popularity:',*HEADER_COLOR)} {anime['popularity']}
{get_true_fg('Favourites:',*HEADER_COLOR)} {anime['favourites']}
{get_true_fg('Status:',*HEADER_COLOR)} {anime['status']}
{get_true_fg('Episodes:',*HEADER_COLOR)} {anime['episodes']}
{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres'])}
{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode'])}
{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate'])}
{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate'])}
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
{get_true_fg('Description:',*HEADER_COLOR)}
"""
template = textwrap.dedent(template)
template = f"""
{template}
{textwrap.fill(remove_html_tags(
{textwrap.fill(clean_html(
str(anime['description'])), width=45)}
"""
future_to_task[executor.submit(save_info_from_str, template, title)] = title
@@ -181,11 +138,22 @@ def write_search_results(
# get rofi icons
def get_icons(search_results: list[AnilistBaseMediaDataSchema], titles, workers=None):
def get_rofi_icons(
anilist_results: list[AnilistBaseMediaDataSchema], titles, workers=None
):
"""A helper function to make sure that the images are downloaded so they can be used as icons
Args:
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
anilist_results: the anilist results from an anilist action
"""
# use concurrency to download the images as fast as possible
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs
future_to_url = {}
for anime, title in zip(search_results, titles):
for anime, title in zip(anilist_results, titles):
# actual link to download image from
image_url = anime["coverImage"]["large"]
future_to_url[executor.submit(save_image_from_url, image_url, title)] = (
image_url
@@ -196,19 +164,32 @@ def get_icons(search_results: list[AnilistBaseMediaDataSchema], titles, workers=
url = future_to_url[future]
try:
future.result()
except Exception as exc:
logger.error("%r generated an exception: %s" % (url, exc))
except Exception as e:
logger.error("%r generated an exception: %s" % (url, e))
def get_preview(search_results: list[AnilistBaseMediaDataSchema], titles, wait=False):
def get_fzf_preview(
anilist_results: list[AnilistBaseMediaDataSchema], titles, wait=False
):
"""A helper function that constructs data to be used for the fzf preview
Args:
titles (list[str]): The sanitized titles to use, NOTE: its important that they are sanitized since thay will be used as filenames
wait (bool): whether to block the ui as we wait for preview defaults to false
anilist_results: the anilist results got from an anilist action
Returns:
THe fzf preview script to use
"""
# ensure images and info exists
background_worker = Thread(
target=write_search_results, args=(search_results, titles)
target=write_search_results, args=(anilist_results, titles)
)
background_worker.daemon = True
background_worker.start()
os.environ["SHELL"] = shutil.which("bash") or "sh"
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then fzf-preview %s/{}
@@ -219,12 +200,11 @@ def get_preview(search_results: list[AnilistBaseMediaDataSchema], titles, wait=F
fi
""" % (
fzf_preview,
IMAGES_DIR,
IMAGES_DIR,
INFO_DIR,
INFO_DIR,
IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
)
# preview.replace("\n", ";")
if wait:
background_worker.join()
return preview

View File

@@ -36,13 +36,17 @@ class MpvPlayer(object):
ep_no=None,
server="top",
):
anilist_config = self.anilist_config
fastanime_runtime_state = self.fastanime_runtime_state
config = self.config
episode_number: str = anilist_config.episode_number
current_episode_number: str = (
fastanime_runtime_state.provider_current_episode_number
)
quality = config.quality
episodes: list = sorted(anilist_config.episodes, key=float)
anime_id: int = anilist_config.anime_id
anime = anilist_config.anime
total_episodes: list = sorted(
fastanime_runtime_state.provider_available_episodes, key=float
)
anime_id_anilist: int = fastanime_runtime_state.selected_anime_id_anilist
provider_anime = fastanime_runtime_state.provider_anime
translation_type = config.translation_type
anime_provider = config.anime_provider
self.last_stop_time: str = "0"
@@ -53,51 +57,59 @@ class MpvPlayer(object):
# next or prev
if type == "next":
self.mpv_player.show_text("Fetching next episode...")
next_episode = episodes.index(episode_number) + 1
if next_episode >= len(episodes):
next_episode = len(episodes) - 1
anilist_config.episode_number = episodes[next_episode]
episode_number = anilist_config.episode_number
config.update_watch_history(anime_id, str(episode_number))
next_episode = total_episodes.index(current_episode_number) + 1
if next_episode >= len(total_episodes):
next_episode = len(total_episodes) - 1
fastanime_runtime_state.provider_current_episode_number = total_episodes[
next_episode
]
current_episode_number = (
fastanime_runtime_state.provider_current_episode_number
)
config.update_watch_history(anime_id_anilist, str(current_episode_number))
elif type == "reload":
if episode_number not in episodes:
if current_episode_number not in total_episodes:
self.mpv_player.show_text("Episode not available")
return
self.mpv_player.show_text("Replaying Episode...")
elif type == "custom":
if not ep_no or ep_no not in episodes:
if not ep_no or ep_no not in total_episodes:
self.mpv_player.show_text("Episode number not specified or invalid")
self.mpv_player.show_text(
f"Acceptable episodes are: {episodes}",
f"Acceptable episodes are: {total_episodes}",
)
return
self.mpv_player.show_text(f"Fetching episode {ep_no}")
episode_number = ep_no
config.update_watch_history(anime_id, str(ep_no))
anilist_config.episode_number = str(ep_no)
current_episode_number = ep_no
config.update_watch_history(anime_id_anilist, str(ep_no))
fastanime_runtime_state.provider_current_episode_number = str(ep_no)
else:
self.mpv_player.show_text("Fetching previous episode...")
prev_episode = episodes.index(episode_number) - 1
prev_episode = total_episodes.index(current_episode_number) - 1
if prev_episode <= 0:
prev_episode = 0
anilist_config.episode_number = episodes[prev_episode]
episode_number = anilist_config.episode_number
config.update_watch_history(anime_id, str(episode_number))
fastanime_runtime_state.provider_current_episode_number = total_episodes[
prev_episode
]
current_episode_number = (
fastanime_runtime_state.provider_current_episode_number
)
config.update_watch_history(anime_id_anilist, str(current_episode_number))
# update episode progress
if config.user and episode_number:
if config.user and current_episode_number:
AniList.update_anime_list(
{
"mediaId": anime_id,
"progress": episode_number,
"mediaId": anime_id_anilist,
"progress": int(float(current_episode_number)),
}
)
# get them juicy streams
episode_streams = anime_provider.get_episode_streams(
anime,
episode_number,
provider_anime,
current_episode_number,
translation_type,
anilist_config.selected_anime_anilist,
fastanime_runtime_state.selected_anime_anilist,
)
if not episode_streams:
self.mpv_player.show_text("No streams were found")
@@ -105,7 +117,10 @@ class MpvPlayer(object):
# always select the first
if server == "top":
selected_server = next(episode_streams)
selected_server = next(episode_streams, None)
if not selected_server:
self.mpv_player.show_text("Sth went wrong when loading the episode")
return
else:
episode_streams_dict = {
episode_stream["server"]: episode_stream
@@ -124,19 +139,21 @@ class MpvPlayer(object):
if not stream_link_:
self.mpv_player.show_text("Quality not found")
return
self.mpv_player._set_property("start", "0")
stream_link = stream_link_["link"]
fastanime_runtime_state.provider_current_episode_stream_link = stream_link
return stream_link
def create_player(
self,
stream_link,
anime_provider: "AnimeProvider",
anilist_config,
fastanime_runtime_state,
config: "Config",
title,
):
self.anime_provider = anime_provider
self.anilist_config = anilist_config
self.fastanime_runtime_state = fastanime_runtime_state
self.config = config
self.last_stop_time: str = "0"
self.last_total_time: str = "0"
@@ -218,14 +235,17 @@ class MpvPlayer(object):
@mpv_player.on_key_press("shift+t")
def _toggle_translation_type():
translation_type = "sub" if config.translation_type == "dub" else "dub"
mpv_player.show_text("Changing translation type...")
anime = anime_provider.get_anime(
anilist_config._anime["id"],
anilist_config.selected_anime_anilist,
fastanime_runtime_state.provider_anime_search_result["id"],
fastanime_runtime_state.selected_anime_anilist,
)
if not anime:
mpv_player.show_text("Failed to update translation type")
return
anilist_config.episodes = anime["availableEpisodesDetail"][translation_type]
fastanime_runtime_state.provider_available_episodes = anime[
"availableEpisodesDetail"
][translation_type]
config.translation_type = translation_type
if config.translation_type == "dub":
@@ -276,7 +296,7 @@ class MpvPlayer(object):
return
q = ["360", "720", "1080"]
quality = quality_raw.decode()
links: list = anilist_config.current_stream_links
links: list = fastanime_runtime_state.provider_server_episode_streams
q = [link["quality"] for link in links]
if quality in q:
config.quality = quality

View File

@@ -5,20 +5,23 @@ import requests
def print_img(url: str):
executable = shutil.which("chafa")
curl = shutil.which("curl")
# curl -sL "$1" | chafa /dev/stdin
"""helper funtion to print an image given its url
if executable is None or curl is None:
print("chafa or curl not found")
return
Args:
url: [TODO:description]
"""
if EXECUTABLE := shutil.which("icat"):
subprocess.run([EXECUTABLE, url])
else:
EXECUTABLE = shutil.which("chafa")
res = requests.get(url)
if res.status_code != 200:
print("Error fetching image")
return
img_bytes = res.content
if not img_bytes:
print("No image found")
img_bytes = subprocess.check_output([curl, "-sL", url])
subprocess.run([executable, url, "--size=15x15"], input=img_bytes)
if EXECUTABLE is None:
print("chafanot found")
return
res = requests.get(url)
if res.status_code != 200:
print("Error fetching image")
return
img_bytes = res.content
subprocess.run([EXECUTABLE, url, "--size=15x15"], input=img_bytes)

View File

@@ -0,0 +1,78 @@
# this script was written by the fzf devs as an example on how to preview images
# its only here for convinience
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.
#
# Dependencies:
# - https://github.com/sharkdp/bat
# - https://github.com/hpjansson/chafa
# - https://iterm2.com/utilities/imgcat
fzf-preview(){
if [[ $# -ne 1 ]]; then
>&2 echo "usage: $0 FILENAME"
exit 1
fi
file=${1/#\~\//$HOME/}
type=$(file --dereference --mime -- "$file")
if [[ ! $type =~ image/ ]]; then
if [[ $type =~ =binary ]]; then
file "$1"
exit
fi
# Sometimes bat is installed as batcat.
if command -v batcat > /dev/null; then
batname="batcat"
elif command -v bat > /dev/null; then
batname="bat"
else
cat "$1"
exit
fi
${batname} --style="${BAT_STYLE:-numbers}" --color=always --pager=never -- "$file"
exit
fi
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [[ $dim = x ]]; then
dim=$(stty size < /dev/tty | awk '{print $2 "x" $1}')
elif ! [[ $KITTY_WINDOW_ID ]] && (( FZF_PREVIEW_TOP + FZF_PREVIEW_LINES == $(stty size < /dev/tty | awk '{print $1}') )); then
# Avoid scrolling issue when the Sixel image touches the bottom of the screen
# * https://github.com/junegunn/fzf/issues/2544
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
fi
# 1. Use kitty icat on kitty terminal
if [[ $KITTY_WINDOW_ID ]]; then
# 1. 'memory' is the fastest option but if you want the image to be scrollable,
# you have to use 'stream'.
#
# 2. The last line of the output is the ANSI reset code without newline.
# This confuses fzf and makes it render scroll offset indicator.
# So we remove the last line and append the reset code to its previous line.
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed '$d' | sed $'$s/$/\e[m/'
# 2. Use chafa with Sixel output
elif command -v chafa > /dev/null; then
chafa -f sixel -s "$dim" "$file"
# Add a new line character so that fzf can display multiple images in the preview window
echo
# 3. If chafa is not found but imgcat is available, use it on iTerm2
elif command -v imgcat > /dev/null; then
# NOTE: We should use https://iterm2.com/utilities/it2check to check if the
# user is running iTerm2. But for the sake of simplicity, we just assume
# that's the case here.
imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file"
# 4. Cannot find any suitable method to preview the image
else
file "$file"
fi
}
"""

View File

@@ -0,0 +1,21 @@
import shutil
import subprocess
from .tools import exit_app
def SyncPlayer(url: str, anime_title, *args):
# TODO: handle m3u8 multi quality streams
#
# check for SyncPlay
SYNCPLAY_EXECUTABLE = shutil.which("syncplay")
if not SYNCPLAY_EXECUTABLE:
print("Syncplay not found")
exit_app(1)
return "0", "0"
# start SyncPlayer
subprocess.run(
[SYNCPLAY_EXECUTABLE, url, "--", f"--force-media-title={anime_title}"]
)
# for compatability
return "0", "0"

View File

@@ -1,5 +1,6 @@
class QueryDict(dict):
"""dot.notation access to dictionary attributes"""
# TODO: add typing
class FastAnimeRuntimeState(dict):
"""A class that manages fastanime runtime during anilist command runtime"""
def __getattr__(self, attr):
try:
@@ -13,7 +14,7 @@ class QueryDict(dict):
self.__setitem__(attr, value)
def exit_app(*args):
def exit_app(exit_code=0, *args):
import os
import shutil
import sys
@@ -24,7 +25,8 @@ def exit_app(*args):
try:
shutil.get_terminal_size()
return (
sys.stdin.isatty()
sys.stdin
and sys.stdin.isatty()
and sys.stdout.isatty()
and os.getenv("TERM") is not None
)
@@ -44,17 +46,4 @@ def exit_app(*args):
from rich import print
print("Have a good day :smile:", USER_NAME)
sys.exit(0)
def get_formatted_str(string: str, style):
from rich.text import Text
# Create a Text object with desired style
text = Text(string, style="bold red")
# Convert the Text object to an ANSI string
ansi_output = text.__rich_console__(None, None) # pyright:ignore
# Join the ANSI strings to form the final output
"".join(segment.text for segment in ansi_output)
sys.exit(exit_code)

View File

@@ -2,9 +2,6 @@ import logging
from typing import TYPE_CHECKING
from InquirerPy import inquirer
from thefuzz import fuzz
from ...Utility.data import anime_normalizer
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
@@ -22,21 +19,69 @@ BG_GREEN = "\033[48;2;120;233;12;m"
GREEN = "\033[38;2;45;24;45;m"
def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]"):
def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]", default=True):
"""Helper function used to filter a list of EpisodeStream objects to one that has a corresponding quality
Args:
quality: the quality to use
stream_links: a list of EpisodeStream objects
Returns:
an EpisodeStream object or None incase the quality was not found
"""
for stream_link in stream_links:
if stream_link["quality"] == quality:
q = float(quality)
Q = float(stream_link["quality"])
# some providers have inaccurate eg qualities 718 instead of 720
if Q < q + 80 and Q > q - 80:
return stream_link
else:
if stream_links and default:
from rich import print
try:
print("[yellow bold]WARNING Qualities were:[/] ", stream_links)
print(
"[cyan bold]Using default of quality:[/] ",
stream_links[0]["quality"],
)
return stream_links[0]
except Exception as e:
print(e)
return
def sizeof_fmt(num, suffix="B"):
def format_bytes_to_human(num_of_bytes: float, suffix: str = "B"):
"""Helper function usedd to format bytes to human
Args:
num_of_bytes: the number of bytes to format
suffix: the suffix to use
Returns:
formated bytes
"""
for unit in ("", "K", "M", "G", "T", "P", "E", "Z"):
if abs(num) < 1024.0:
return f"{num:3.1f}{unit}{suffix}"
num /= 1024.0
return f"{num:.1f}Yi{suffix}"
if abs(num_of_bytes) < 1024.0:
return f"{num_of_bytes:3.1f}{unit}{suffix}"
num_of_bytes /= 1024.0
return f"{num_of_bytes:.1f}Yi{suffix}"
def get_true_fg(string: str, r: int, g: int, b: int, bold=True) -> str:
def get_true_fg(string: str, r: int, g: int, b: int, bold: bool = True) -> str:
"""Custom helper function that enables colored text in the terminal
Args:
bold: whether to bolden the text
string: string to color
r: red
g: green
b: blue
Returns:
colored string
"""
# NOTE: Currently only supports terminals that support true color
if bold:
return f"{BOLD}\033[38;2;{r};{g};{b};m{string}{RESET}"
else:
@@ -47,7 +92,17 @@ def get_true_bg(string, r: int, g: int, b: int) -> str:
return f"\033[48;2;{r};{g};{b};m{string}{RESET}"
def fuzzy_inquirer(prompt: str, choices, **kwargs):
def fuzzy_inquirer(choices: list, prompt: str, **kwargs):
"""helper function that enables easier interaction with InquirerPy lib
Args:
choices: the choices to prompt
prompt: the prompt string to use
**kwargs: other options to pass to fuzzy_inquirer
Returns:
a choice
"""
from click import clear
clear()
@@ -60,29 +115,3 @@ def fuzzy_inquirer(prompt: str, choices, **kwargs):
**kwargs,
).execute()
return action
def anime_title_percentage_match(
possible_user_requested_anime_title: str, title: tuple
) -> float:
"""Returns the percentage match between the possible title and user title
Args:
possible_user_requested_anime_title (str): an Animdl search result title
title (str): the anime title the user wants
Returns:
int: the percentage match
"""
if normalized_anime_title := anime_normalizer.get(
possible_user_requested_anime_title
):
possible_user_requested_anime_title = normalized_anime_title
for key, value in locals().items():
logger.info(f"{key}: {value}")
# compares both the romaji and english names and gets highest Score
percentage_ratio = max(
fuzz.ratio(title[0].lower(), possible_user_requested_anime_title.lower()),
fuzz.ratio(title[1].lower(), possible_user_requested_anime_title.lower()),
)
return percentage_ratio

View File

@@ -1,17 +1,14 @@
import os
import sys
from pathlib import Path
from platform import system
from platformdirs import PlatformDirs
from . import APP_NAME, AUTHOR
from . import APP_NAME, AUTHOR, __version__
PLATFORM = system()
dirs = PlatformDirs(appname=APP_NAME, appauthor=AUTHOR, ensure_exists=True)
# ---- app deps ----
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")
@@ -24,19 +21,62 @@ PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
# ----- user configs and data -----
APP_DATA_DIR = dirs.user_config_dir
if not APP_DATA_DIR:
APP_DATA_DIR = dirs.user_data_dir
S_PLATFORM = sys.platform
if S_PLATFORM == "win32":
# app data
app_data_dir_base = os.getenv("LOCALAPPDATA")
if not app_data_dir_base:
raise RuntimeError("Could not determine app data dir please report to devs")
APP_DATA_DIR = os.path.join(app_data_dir_base, AUTHOR, APP_NAME)
# cache dir
APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache")
# videos dir
video_dir_base = os.path.join(Path().home(), "Videos")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
elif S_PLATFORM == "darwin":
# app data
app_data_dir_base = os.path.expanduser("~/Library/Application Support")
APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__)
# cache dir
cache_dir_base = os.path.expanduser("~/Library/Caches")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME, __version__)
# videos dir
video_dir_base = os.path.expanduser("~/Movies")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
else:
# app data
app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "")
if not app_data_dir_base.strip():
app_data_dir_base = os.path.expanduser("~/.config")
APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME)
# cache dir
cache_dir_base = os.environ.get("XDG_CACHE_HOME", "")
if not cache_dir_base.strip():
cache_dir_base = os.path.expanduser("~/.cache")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME)
# videos dir
video_dir_base = os.environ.get("XDG_VIDEOS_DIR", "")
if not video_dir_base.strip():
video_dir_base = os.path.expanduser("~/Videos")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
# ensure paths exist
Path(APP_DATA_DIR).mkdir(parents=True, exist_ok=True)
Path(APP_CACHE_DIR).mkdir(parents=True, exist_ok=True)
Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
# useful paths
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
NOTIFIER_LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "notifier.log")
# cache dir
APP_CACHE_DIR = dirs.user_cache_dir
# video dir
USER_VIDEOS_DIR = os.path.join(dirs.user_videos_dir, APP_NAME)
USER_NAME = os.environ.get("USERNAME", "Anime fun")

View File

@@ -173,6 +173,7 @@ query ($userId: Int, $status: MediaListStatus,$type:MediaType) {
status
description
mediaListEntry{
status
id
progress
}
@@ -231,7 +232,7 @@ $type:MediaType\
search_query = (
"""
query($query:String,%s){
Page(perPage:30,page:$page){
Page(perPage:50,page:$page){
pageInfo{
total
currentPage
@@ -275,6 +276,7 @@ query($query:String,%s){
}
mediaListEntry{
status
id
progress
}
@@ -356,6 +358,7 @@ query($type:MediaType){
day
}
mediaListEntry{
status
id
progress
}
@@ -396,6 +399,7 @@ query($type:MediaType){
}
mediaListEntry{
status
id
progress
}
@@ -455,6 +459,7 @@ query($type:MediaType){
}
mediaListEntry{
status
id
progress
}
@@ -520,6 +525,7 @@ query($type:MediaType){
episodes
genres
mediaListEntry{
status
id
progress
}
@@ -572,6 +578,7 @@ query($type:MediaType){
id
}
mediaListEntry{
status
id
progress
}
@@ -630,6 +637,7 @@ query($type:MediaType){
large
}
mediaListEntry{
status
id
progress
}
@@ -724,6 +732,7 @@ query ($id: Int,$type:MediaType) {
large
}
mediaListEntry{
status
id
progress
}
@@ -805,6 +814,7 @@ query ($page: Int,$type:MediaType) {
id
}
mediaListEntry{
status
id
progress
}
@@ -855,6 +865,7 @@ query($id:Int){
english
}
mediaListEntry{
status
id
progress
}

View File

@@ -18,7 +18,6 @@ from .constants import (
USER_AGENT,
)
from .gql_queries import ALLANIME_EPISODES_GQL, ALLANIME_SEARCH_GQL, ALLANIME_SHOW_GQL
from .normalizer import normalize_anime, normalize_search_results
if TYPE_CHECKING:
from typing import Iterator
@@ -106,7 +105,23 @@ class AllAnimeAPI(AnimeProvider):
}
try:
search_results = self._fetch_gql(ALLANIME_SEARCH_GQL, variables)
return normalize_search_results(search_results) # pyright:ignore
page_info = search_results["shows"]["pageInfo"]
results = []
for result in search_results["shows"]["edges"]:
normalized_result = {
"id": result["_id"],
"title": result["name"],
"type": result["__typename"],
"availableEpisodes": result["availableEpisodes"],
}
results.append(normalized_result)
normalized_search_results = {
"pageInfo": page_info,
"results": results,
}
return normalized_search_results
except Exception as e:
logger.error(f"FA(AllAnime): {e}")
return {}
@@ -123,9 +138,19 @@ class AllAnimeAPI(AnimeProvider):
variables = {"showId": allanime_show_id}
try:
anime = self._fetch_gql(ALLANIME_SHOW_GQL, variables)
return normalize_anime(anime["show"])
id: str = anime["show"]["_id"]
title: str = anime["show"]["name"]
availableEpisodesDetail = anime["show"]["availableEpisodesDetail"]
type = anime.get("__typename")
normalized_anime = {
"id": id,
"title": title,
"availableEpisodesDetail": availableEpisodesDetail,
"type": type,
}
return normalized_anime
except Exception as e:
logger.error(f"FA(AllAnime): {e}")
logger.error(f"AllAnime(get_anime): {e}")
return None
def _get_anime_episode(
@@ -323,7 +348,9 @@ if __name__ == "__main__":
print("Sth went wrong")
break
episode_streams_ = anime_provider.get_episode_streams(
anime_data, episode, translation.strip()
anime_data, # pyright: ignore
episode,
translation.strip(),
)
if episode_streams_ is None:
raise Exception("Episode not found")

View File

@@ -1,44 +0,0 @@
from ..types import Anime, AnimeEpisodeDetails, SearchResults
from .types import AllAnimeEpisode, AllAnimeSearchResults, AllAnimeShow
# TODO: scrap this module and do the transformations directly from the provider class
def normalize_search_results(search_results: AllAnimeSearchResults) -> SearchResults:
page_info = search_results["shows"]["pageInfo"]
results = []
for result in search_results["shows"]["edges"]:
normalized_result = {
"id": result["_id"],
"title": result["name"],
"type": result["__typename"],
"availableEpisodes": result["availableEpisodes"],
}
results.append(normalized_result)
normalized_search_results: SearchResults = {
"pageInfo": page_info, # pyright:ignore
"results": results,
}
return normalized_search_results
def normalize_anime(anime: AllAnimeShow) -> Anime:
id: str = anime["_id"]
title: str = anime["name"]
availableEpisodesDetail: AnimeEpisodeDetails = anime[
"availableEpisodesDetail"
] # pyright:ignore
type = anime.get("__typename")
normalized_anime: Anime = { # pyright:ignore
"id": id,
"title": title,
"availableEpisodesDetail": availableEpisodesDetail,
"type": type,
}
return normalized_anime
def normalize_episode(episode: AllAnimeEpisode):
pass

View File

@@ -1,7 +1,9 @@
import logging
import random
import re
import shutil
import subprocess
import time
from typing import TYPE_CHECKING
from yt_dlp.utils import (
@@ -68,21 +70,57 @@ class AnimePaheApi(AnimeProvider):
return {}
def get_anime(self, session_id: str, *args):
page = 1
try:
anime_result: "AnimeSearchResult" = [
anime
for anime in self.search_page["data"]
if anime["session"] == session_id
][0]
url = (
f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page=1"
data: "AnimePaheAnimePage" = {} # pyright:ignore
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
def _pages_loader(
url,
page,
):
response = self.session.get(url, headers=REQUEST_HEADERS)
if response.status_code == 200:
if not data:
data.update(response.json())
else:
if ep_data := response.json().get("data"):
data["data"].extend(ep_data)
if response.json()["next_page_url"]:
# TODO: Refine this
time.sleep(
random.choice(
[
0.25,
0.1,
0.5,
0.75,
1,
]
)
)
page += 1
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
_pages_loader(
url,
page,
)
_pages_loader(
url,
page,
)
response = self.session.get(url, headers=REQUEST_HEADERS)
if not response.status_code == 200:
if not data:
return {}
data: "AnimePaheAnimePage" = response.json()
self.anime = data
episodes = list(map(str, range(data["total"])))
self.anime = data # pyright:ignore
episodes = list(map(str, [episode["episode"] for episode in data["data"]]))
title = ""
return {
"id": session_id,
@@ -121,9 +159,10 @@ class AnimePaheApi(AnimeProvider):
for episode in self.anime["data"]
if float(episode["episode"]) == float(episode_number)
]
if not episode:
logger.error(f"AnimePahe(streams): episode {episode_number} doesn't exist")
raise Exception("Episode not found")
return []
episode = episode[0]
anime_id = anime["id"]
@@ -157,7 +196,7 @@ class AnimePaheApi(AnimeProvider):
logger.warn(
"AnimePahe: embed url not found please report to the developers"
)
raise Exception("Episode not found")
return []
# get embed page
embed_response = self.session.get(embed_url, headers=SERVER_HEADERS)
embed = embed_response.text
@@ -174,14 +213,14 @@ class AnimePaheApi(AnimeProvider):
logger.warn(
"AnimePahe: Encoded js not found please report to the developers"
)
raise Exception("Episode not found")
return []
# execute the encoded js with node for now or maybe forever in odrder to get a more workable info
NODE = shutil.which("node")
if not NODE:
logger.warn(
"AnimePahe: animepahe currently requires node js to extract them juicy streams"
)
raise Exception("Episode not found")
return []
result = subprocess.run(
[NODE, "-e", encoded_js],
text=True,
@@ -193,14 +232,14 @@ class AnimePaheApi(AnimeProvider):
logger.warn(
"AnimePahe: could not decode encoded js using node please report to developers"
)
raise Exception("Episode not found")
return []
# get that juicy stream
match = JUICY_STREAM_REGEX.search(evaluted_js)
if not match:
logger.warn(
"AnimePahe: could not find the juicy stream please report to developers"
)
raise Exception("Episode not found")
return []
# get the actual hls stream link
juicy_stream = match.group(1)
# add the link

View File

@@ -67,5 +67,5 @@ class EpisodeStream(TypedDict):
class Server(TypedDict):
server: str
episode_title: str | None
episode_title: str
links: list[EpisodeStream]

View File

@@ -36,7 +36,7 @@ hex_to_char = {
def give_random_quality(links: list[dict]):
qualities = cycle(["1080", "720", "360"])
qualities = cycle(["1080", "720", "480", "360"])
return [
{"link": link["link"], "quality": quality}

59
poetry.lock generated
View File

@@ -861,20 +861,6 @@ nodeenv = ">=1.6.0"
all = ["twine (>=3.4.1)"]
dev = ["twine (>=3.4.1)"]
[[package]]
name = "pyshortcuts"
version = "1.9.0"
description = "Create desktop and Start Menu shortcuts for python scripts"
optional = false
python-versions = ">=3.8"
files = [
{file = "pyshortcuts-1.9.0-py3-none-any.whl", hash = "sha256:54d12ed8cd29bf83ac15153ce882a77072f2032b5f979474c519a2bac5af849d"},
{file = "pyshortcuts-1.9.0.tar.gz", hash = "sha256:016e89111337f74ce1ba3f4b79b295a643bc70b3e63ce4600247aa4bafa06877"},
]
[package.dependencies]
pywin32 = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "pytest"
version = "8.3.2"
@@ -897,43 +883,6 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "python-dotenv"
version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.8"
files = [
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
{file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "pywin32"
version = "306"
description = "Python for Window Extensions"
optional = false
python-versions = "*"
files = [
{file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"},
{file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"},
{file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"},
{file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"},
{file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"},
{file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"},
{file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"},
{file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"},
{file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"},
{file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"},
{file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"},
{file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"},
{file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"},
{file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"},
]
[[package]]
name = "pyyaml"
version = "6.0.2"
@@ -1208,13 +1157,13 @@ files = [
[[package]]
name = "tox"
version = "4.17.0"
version = "4.17.1"
description = "tox is a generic virtualenv management and test command line tool"
optional = false
python-versions = ">=3.8"
files = [
{file = "tox-4.17.0-py3-none-any.whl", hash = "sha256:82ef41e7e54182e2143daf0b2920d9030c2e1c4291e12091ebad66860c7be7a4"},
{file = "tox-4.17.0.tar.gz", hash = "sha256:b1e2e1dfbfdc174d9be95ae78ec2c4d2cf4800d4c15571deddb197a2c90d2de6"},
{file = "tox-4.17.1-py3-none-any.whl", hash = "sha256:2974597c0353577126ab014f52d1a399fb761049e165ff34427f84e8cfe6c990"},
{file = "tox-4.17.1.tar.gz", hash = "sha256:2c41565a571e34480bd401d668a4899806169a4633e972ac296c54406d2ded8a"},
]
[package.dependencies]
@@ -1407,4 +1356,4 @@ test = ["pytest (>=8.1,<9.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "83ec7de7d9466dcd1fadef4b21eec2a879cc9a7d526992ed280b6af53b49d9f1"
content-hash = "7d20e2d0c0c3c8f3a48d9160a2b4a11a5f353d23bb5d7a06ec527fe08e425b91"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "fastanime"
version = "0.62.0.dev1"
version = "2.0.1"
description = "A browser anime site experience from the terminal"
authors = ["Benextempest <benextempest@gmail.com>"]
license = "UNLICENSE"
@@ -12,12 +12,9 @@ yt-dlp = "^2024.5.27"
rich = "^13.7.1"
click = "^8.1.7"
inquirerpy = "^0.3.4"
platformdirs = "^4.2.2"
python-dotenv = "^1.0.1"
thefuzz = "^0.22.1"
requests = "^2.32.3"
plyer = "^2.1.0"
pyshortcuts = "^1.9.0"
mpv = "^1.0.7"
[tool.poetry.group.dev.dependencies]

View File

@@ -55,6 +55,11 @@ def test_completions_help(runner: CliRunner):
assert result.exit_code == 0
def test_update_help(runner: CliRunner):
result = runner.invoke(run_cli, ["update", "--help"])
assert result.exit_code == 0
def test_anilist_help(runner: CliRunner):
result = runner.invoke(run_cli, ["anilist", "--help"])
assert result.exit_code == 0