Compare commits

...

143 Commits

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

257
README.md
View File

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

View File

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

View File

@@ -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,13 +1,12 @@
from datetime import datetime
from typing import TYPE_CHECKING
from ..libs.anilist.anilist_data_schema 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:
@@ -28,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

@@ -3,8 +3,7 @@ from queue import Queue
from threading import Thread
import yt_dlp
from ..utils import sanitize_filename
from yt_dlp.utils import sanitize_filename
logger = logging.getLogger(__name__)
@@ -28,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",
@@ -42,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,18 @@
import logging
import os
import re
from functools import lru_cache
from typing import TYPE_CHECKING
from thefuzz import fuzz
from fastanime.libs.anilist.anilist_data_schema 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)
@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
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 +36,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.50.0"
__version__ = "v2.0.0"
APP_NAME = "FastAnime"
AUTHOR = "Benex254"

View File

@@ -3,8 +3,7 @@ import signal
import click
from .. import __version__
from ..libs.anime_provider import anime_sources
from ..libs.anime_provider.allanime.constants import SERVERS_AVAILABLE
from ..libs.anime_provider import SERVERS_AVAILABLE, anime_sources
from ..Utility.data import anilist_sort_normalizer
from .commands import LazyGroup
@@ -15,6 +14,8 @@ commands = {
"config": "config.config",
"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",
@@ -52,7 +52,7 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option(
"-s",
"--server",
type=click.Choice([*SERVERS_AVAILABLE, "top"], case_sensitive=False),
type=click.Choice([*SERVERS_AVAILABLE, "top"]),
help="Server of choice",
)
@click.option(
@@ -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.IntRange(0, 3),
type=click.Choice(
[
"360",
"480",
"720",
"1080",
]
),
help="set the quality of the stream",
)
@click.option(
@@ -126,17 +138,21 @@ signal.signal(signal.SIGINT, handle_exit)
help="Rofi theme to use for the user input prompt",
type=click.Path(),
)
@click.option(
"--use-mpv-mod/--use-default-player", help="Whether to use python-mpv", type=bool
)
@click.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,
@@ -155,6 +171,8 @@ def run_cli(
rofi_theme,
rofi_theme_confirm,
rofi_theme_input,
use_mpv_mod,
sync_play,
):
from .config import Config
@@ -188,15 +206,11 @@ 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
ctx.obj.load_config()
if server:
ctx.obj.server = server
if format:
@@ -212,11 +226,21 @@ 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
):
ctx.obj.auto_select = auto_select
if (
ctx.get_parameter_source("use_mpv_mod")
== click.core.ParameterSource.COMMANDLINE
):
ctx.obj.use_mpv_mod = use_mpv_mod
if sort_by:
ctx.obj.sort_by = sort_by
if downloads_dir:

View File

@@ -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,11 +25,15 @@ 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
click.launch(APP_CACHE_DIR)

View File

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

View File

@@ -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
@@ -37,115 +41,163 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
from ...libs.fzf import fzf
from ...Utility.downloader.downloader import downloader
from ..utils.tools import exit_app
from ..utils.utils import fuzzy_inquirer
from ..utils.utils import filter_by_quality, fuzzy_inquirer
anime_provider = AnimeProvider(config.provider)
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
}
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
)
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")
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:
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
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",
)
if not streams:
print("No streams skipping")
continue
with Progress() as progress:
if highest_priority:
progress.add_task("Fetching highest priority stream", total=None)
streams = list(streams)
links = [
(link.get("priority", 0), link["link"])
for server in streams
for link in server["links"]
# ---- 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)
]
link = max(links, key=lambda x: x[0])[1]
episode_title = streams[0]["episode_title"]
elif config.server == "top":
progress.add_task("Fetching Top Server", total=None)
server = next(streams)
link = server["links"][config.quality]["link"]
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:
# TODO: Make this better but no rush whats the point of manual selection
progress.add_task("Fetching links", total=None)
streams = list(streams)
links = [
link["link"] for server in streams for link in server["links"]
]
episode_title = streams[0]["episode_title"]
if config.use_fzf:
link = fzf.run(links, "Select link", "Links")
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:
link = fuzzy_inquirer("Select link", links)
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"]
print(f"[purple]Now Downloading:[/] {search_result} Episode {episode}")
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,
)
except Exception as e:
print(e)
print("Continuing")
clear()
print("Done")
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,8 +12,16 @@ 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
@@ -20,6 +30,8 @@ def downloads(config: "Config", path: bool):
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 +39,250 @@ 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))
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))
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
@@ -26,120 +34,188 @@ def search(config: Config, anime_title: str, episode_range: str):
from ...libs.rofi import Rofi
from ..utils.mpv import run_mpv
from ..utils.tools import exit_app
from ..utils.utils import fuzzy_inquirer
from ..utils.utils import filter_by_quality, fuzzy_inquirer
anime_provider = AnimeProvider(config.provider)
# ---- 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:")
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
# ---- fetch servers ----
with Progress() as progress:
if config.server == "top":
progress.add_task("Fetching top server...", total=None)
server = next(streams)
link = server["links"][config.quality]["link"]
else:
progress.add_task("Fetching servers", total=None)
links = [link["link"] for server in streams for link in server["links"]]
if config.use_fzf:
link = fzf.run(links, "Select an link: ", header=search_result)
elif config.use_rofi:
link = Rofi.run(links, "Select an link")
# ---- 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 streams:
print("Failed to get streams")
return
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:
link = fuzzy_inquirer("Select link", links)
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.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}")
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()
run_mpv(link, search_result)
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": "0",
"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,7 +95,7 @@ class Config(object):
"rofi_theme": "",
"rofi_theme_input": "",
"rofi_theme_confirm": "",
"use_mpv_mod": "true",
"ffmpegthumnailer_seek_time": "-1",
}
)
self.configparser.add_section("stream")
@@ -59,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()
@@ -78,24 +126,28 @@ class Config(object):
self.error = self.get_error()
self.server = self.get_server()
self.format = self.get_format()
self.force_window = self.get_force_window()
self.preferred_language = self.get_preferred_language()
self.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(
{
@@ -106,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")
@@ -133,44 +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_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.getint("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")
@@ -181,19 +234,131 @@ 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}
# 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.anilist_data_schema import AnilistBaseMediaDataSchema
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...Utility import anilist_data_helper
from ...Utility.utils import remove_html_tags
from ..utils.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

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

View File

@@ -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

@@ -1,12 +1,11 @@
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:
from ...libs.anime_provider.types import EpisodeStream
# Define ANSI escape codes as constants
RESET = "\033[0m"
@@ -20,15 +19,69 @@ BG_GREEN = "\033[48;2;120;233;12;m"
GREEN = "\033[38;2;45;24;45;m"
def sizeof_fmt(num, suffix="B"):
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:
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 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:
@@ -39,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()
@@ -52,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")
@@ -20,21 +17,66 @@ if PLATFORM == "Windows":
ICON_PATH = os.path.join(ASSETS_DIR, "logo.ico")
else:
ICON_PATH = os.path.join(ASSETS_DIR, "logo.png")
PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
# ----- user configs and data -----
APP_DATA_DIR = dirs.user_config_dir
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

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

View File

@@ -122,13 +122,13 @@ mutation($mediaId:Int,$scoreRaw:Int,$repeat:Int,$progress:Int,$status:MediaListS
"""
media_list_query = """
query ($userId: Int, $status: MediaListStatus) {
query ($userId: Int, $status: MediaListStatus,$type:MediaType) {
Page {
pageInfo {
currentPage
total
}
mediaList(userId: $userId, status: $status, type: ANIME) {
mediaList(userId: $userId, status: $status, type: $type) {
mediaId
media {
@@ -173,6 +173,7 @@ query ($userId: Int, $status: MediaListStatus) {
status
description
mediaListEntry{
status
id
progress
}
@@ -223,14 +224,15 @@ $averageScore_lesser:Int,\
$startDate_greater:FuzzyDateInt,\
$startDate_lesser:FuzzyDateInt,\
$endDate_greater:FuzzyDateInt,\
$endDate_lesser:FuzzyDateInt\
$endDate_lesser:FuzzyDateInt,\
$type:MediaType\
"
# FuzzyDateInt = (yyyymmdd)
# MediaStatus = (FINISHED,RELEASING,NOT_YET_RELEASED,CANCELLED,HIATUS)
search_query = (
"""
query($query:String,%s){
Page(perPage:30,page:$page){
Page(perPage:50,page:$page){
pageInfo{
total
currentPage
@@ -255,7 +257,7 @@ query($query:String,%s){
endDate_greater:$endDate_greater,
endDate_lesser:$endDate_lesser,
sort:$sort,
type:ANIME
type:$type
)
{
id
@@ -274,6 +276,7 @@ query($query:String,%s){
}
mediaListEntry{
status
id
progress
}
@@ -316,10 +319,10 @@ query($query:String,%s){
)
trending_query = """
query{
query($type:MediaType){
Page(perPage:15){
media(sort:TRENDING_DESC,type:ANIME,genre_not_in:["hentai"]){
media(sort:TRENDING_DESC,type:$type,genre_not_in:["hentai"]){
id
idMal
title{
@@ -355,6 +358,7 @@ query{
day
}
mediaListEntry{
status
id
progress
}
@@ -376,9 +380,9 @@ query{
# mosts
most_favourite_query = """
query{
query($type:MediaType){
Page(perPage:15){
media(sort:FAVOURITES_DESC,type:ANIME,genre_not_in:["hentai"]){
media(sort:FAVOURITES_DESC,type:$type,genre_not_in:["hentai"]){
id
idMal
title{
@@ -395,6 +399,7 @@ query{
}
mediaListEntry{
status
id
progress
}
@@ -435,9 +440,9 @@ query{
"""
most_scored_query = """
query{
query($type:MediaType){
Page(perPage:15){
media(sort:SCORE_DESC,type:ANIME,genre_not_in:["hentai"]){
media(sort:SCORE_DESC,type:$type,genre_not_in:["hentai"]){
id
idMal
title{
@@ -454,6 +459,7 @@ query{
}
mediaListEntry{
status
id
progress
}
@@ -494,9 +500,9 @@ query{
"""
most_popular_query = """
query{
query($type:MediaType){
Page(perPage:15){
media(sort:POPULARITY_DESC,type:ANIME,genre_not_in:["hentai"]){
media(sort:POPULARITY_DESC,type:$type,genre_not_in:["hentai"]){
id
idMal
title{
@@ -519,6 +525,7 @@ query{
episodes
genres
mediaListEntry{
status
id
progress
}
@@ -553,9 +560,9 @@ query{
"""
most_recently_updated_query = """
query{
query($type:MediaType){
Page(perPage:15){
media(sort:UPDATED_AT_DESC,type:ANIME,averageScore_greater:50,genre_not_in:["hentai"],status:RELEASING){
media(sort:UPDATED_AT_DESC,type:$type,averageScore_greater:50,genre_not_in:["hentai"],status:RELEASING){
id
idMal
title{
@@ -571,6 +578,7 @@ query{
id
}
mediaListEntry{
status
id
progress
}
@@ -611,9 +619,9 @@ query{
"""
recommended_query = """
query {
query($type:MediaType){
Page(perPage:15) {
media( type: ANIME,genre_not_in:["hentai"]) {
media( type: $type,genre_not_in:["hentai"]) {
recommendations(sort:RATING_DESC){
nodes{
media{
@@ -629,6 +637,7 @@ query {
large
}
mediaListEntry{
status
id
progress
}
@@ -671,9 +680,9 @@ query {
"""
anime_characters_query = """
query($id:Int){
query($id:Int,$type:MediaType){
Page {
media(id:$id, type: ANIME) {
media(id:$id, type: $type) {
characters {
nodes {
name {
@@ -706,9 +715,9 @@ query($id:Int){
anime_relations_query = """
query ($id: Int) {
query ($id: Int,$type:MediaType) {
Page(perPage: 20) {
media(id: $id, sort: POPULARITY_DESC, type: ANIME,genre_not_in:["hentai"]) {
media(id: $id, sort: POPULARITY_DESC, type: $type,genre_not_in:["hentai"]) {
relations {
nodes {
id
@@ -723,6 +732,7 @@ query ($id: Int) {
large
}
mediaListEntry{
status
id
progress
}
@@ -763,9 +773,9 @@ query ($id: Int) {
"""
airing_schedule_query = """
query ($id: Int) {
query ($id: Int,$type:MediaType) {
Page {
media(id: $id, sort: POPULARITY_DESC, type: ANIME) {
media(id: $id, sort: POPULARITY_DESC, type: $type) {
airingSchedule(notYetAired:true){
nodes{
airingAt
@@ -780,7 +790,7 @@ query ($id: Int) {
"""
upcoming_anime_query = """
query ($page: Int) {
query ($page: Int,$type:MediaType) {
Page(page: $page) {
pageInfo {
total
@@ -788,7 +798,7 @@ query ($page: Int) {
currentPage
hasNextPage
}
media(type: ANIME, status: NOT_YET_RELEASED,sort:POPULARITY_DESC,genre_not_in:["hentai"]) {
media(type: $type, status: NOT_YET_RELEASED,sort:POPULARITY_DESC,genre_not_in:["hentai"]) {
id
idMal
title {
@@ -804,6 +814,7 @@ query ($page: Int) {
id
}
mediaListEntry{
status
id
progress
}
@@ -854,6 +865,7 @@ query($id:Int){
english
}
mediaListEntry{
status
id
progress
}

View File

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

View File

@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING
from requests.exceptions import Timeout
from ...anime_provider.base_provider import AnimeProvider
from ..utils import decode_hex_string
from ..utils import decode_hex_string, give_random_quality
from .constants import (
ALLANIME_API_ENDPOINT,
ALLANIME_BASE,
@@ -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(
@@ -215,7 +240,7 @@ class AllAnimeAPI(AnimeProvider):
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "Kir":
logger.debug("allanime:Found streams from wetransfer")
@@ -225,7 +250,7 @@ class AllAnimeAPI(AnimeProvider):
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "S-mp4":
logger.debug("allanime:Found streams from sharepoint")
@@ -235,7 +260,7 @@ class AllAnimeAPI(AnimeProvider):
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "Sak":
logger.debug("allanime:Found streams from dropbox")
@@ -245,7 +270,7 @@ class AllAnimeAPI(AnimeProvider):
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "Default":
logger.debug("allanime:Found streams from wixmp")
@@ -255,7 +280,7 @@ class AllAnimeAPI(AnimeProvider):
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": resp.json()["links"],
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
except Timeout:
logger.error(
@@ -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,40 +0,0 @@
from ..types import Anime, EpisodesDetail, SearchResults
from .types import AllAnimeEpisode, AllAnimeSearchResults, AllAnimeShow
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,
"results": results,
}
return normalized_search_results
def normalize_anime(anime: AllAnimeShow) -> Anime:
id: str = anime["_id"]
title: str = anime["name"]
availableEpisodesDetail: EpisodesDetail = anime["availableEpisodesDetail"]
type = anime.get("__typename")
normalized_anime: Anime = {
"id": id,
"title": title,
"availableEpisodesDetail": availableEpisodesDetail,
"type": type,
}
return normalized_anime
def normalize_episode(episode: AllAnimeEpisode):
pass

View File

@@ -1,63 +1,253 @@
import requests
import logging
import random
import re
import shutil
import subprocess
import time
from typing import TYPE_CHECKING
from .constants import ANIMEPAHE_BASE, ANIMEPAHE_ENDPOINT, REQUEST_HEADERS
from yt_dlp.utils import (
extract_attributes,
get_element_by_id,
get_element_text_and_html_by_tag,
get_elements_html_by_class,
)
from ..base_provider import AnimeProvider
from .constants import (
ANIMEPAHE_BASE,
ANIMEPAHE_ENDPOINT,
REQUEST_HEADERS,
SERVER_HEADERS,
)
if TYPE_CHECKING:
from ..types import Anime
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimeSearchResult
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
logger = logging.getLogger(__name__)
# TODO: hack this to completion
class AnimePaheApi:
def search_for_anime(self, user_query, *args):
class AnimePaheApi(AnimeProvider):
search_page: "AnimePaheSearchPage"
anime: "AnimePaheAnimePage"
def search_for_anime(self, user_query: str, *args):
try:
url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}"
headers = {**REQUEST_HEADERS}
response = requests.get(url, headers=headers)
response = self.session.get(url, headers=headers)
if not response.status_code == 200:
return
data = response.json()
data: "AnimePaheSearchPage" = response.json()
self.search_page = data
return {
"pageInfo": {"total": data["total"]},
"pageInfo": {
"total": data["total"],
"perPage": data["per_page"],
"currentPage": data["current_page"],
},
"results": [
{
"availableEpisodes": list(range(result["episodes"])),
"id": result["session"],
"title": result["title"],
"availableEpisodes": result["episodes"],
"type": result["type"],
"year": result["year"],
"score": result["score"],
"status": result["status"],
"season": result["season"],
"poster": result["poster"],
}
for result in data["data"]
],
}
except Exception as e:
print(e)
input()
logger.error(f"AnimePahe(search): {e}")
return {}
def get_anime(self, session_id: str, *args):
url = "https://animepahe.ru/api?m=release&id=&sort=episode_asc&page=1"
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page=1"
response = requests.get(url, headers=REQUEST_HEADERS)
if not response.status_code == 200:
return
data = response.json()
self.current = data
episodes = list(map(str, range(data["total"])))
return {
"id": session_id,
"title": "none",
"availableEpisodesDetail": {
"sub": episodes,
"dub": episodes,
"raw": episodes,
},
}
page = 1
try:
anime_result: "AnimeSearchResult" = [
anime
for anime in self.search_page["data"]
if anime["session"] == session_id
][0]
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,
)
if not data:
return {}
self.anime = data # pyright:ignore
episodes = list(map(str, [episode["episode"] for episode in data["data"]]))
title = ""
return {
"id": session_id,
"title": anime_result["title"],
"year": anime_result["year"],
"season": anime_result["season"],
"poster": anime_result["poster"],
"score": anime_result["score"],
"availableEpisodesDetail": {
"sub": episodes,
"dub": episodes,
"raw": episodes,
},
"episodesInfo": [
{
"title": episode["title"] or f"{title};{episode['episode']}",
"episode": episode["episode"],
"id": episode["session"],
"translation_type": episode["audio"],
"duration": episode["duration"],
"poster": episode["snapshot"],
}
for episode in data["data"]
],
}
except Exception as e:
logger.error(f"AnimePahe(anime): {e}")
return {}
def get_episode_streams(
self, anime: "Anime", episode_number: str, translation_type, *args
):
# extract episode details from memory
episode = [
episode
for episode in self.anime["data"]
if float(episode["episode"]) == float(episode_number)
]
if not episode:
logger.error(f"AnimePahe(streams): episode {episode_number} doesn't exist")
return []
episode = episode[0]
def get_episode_streams(self, anime, episode, *args):
episode_id = self.current["data"][int(episode)]["session"]
anime_id = anime["id"]
url = f"{ANIMEPAHE_BASE}play/{anime_id}{episode_id}"
response = requests.get(url, headers=REQUEST_HEADERS)
print(response.status_code)
input()
if not response.status_code == 200:
print(response.text)
return
print(response.text)
input()
# fetch the episode page
url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
response = self.session.get(url, headers=REQUEST_HEADERS)
# get the element containing links to juicy streams
c = get_element_by_id("resolutionMenu", response.text)
resolutionMenuItems = get_elements_html_by_class("dropdown-item", c)
# convert the elements containing embed links to a neat dict containing:
# data-src
# data-audio
# data-resolution
res_dicts = [extract_attributes(item) for item in resolutionMenuItems]
# get the episode title
episode_title = (
episode["title"] or f"{anime['title']}; Episode {episode['episode']}"
)
# get all links
streams = {"server": "kwik", "links": [], "episode_title": episode_title}
for res_dict in res_dicts:
# get embed url
embed_url = res_dict["data-src"]
data_audio = "dub" if res_dict["data-audio"] == "eng" else "sub"
# filter streams by translation_type
if data_audio != translation_type:
continue
if not embed_url:
logger.warn(
"AnimePahe: embed url not found please report to the developers"
)
return []
# get embed page
embed_response = self.session.get(embed_url, headers=SERVER_HEADERS)
embed = embed_response.text
# search for the encoded js
encoded_js = None
for _ in range(7):
content, html = get_element_text_and_html_by_tag("script", embed)
if not content:
embed = embed.replace(html, "")
continue
encoded_js = content
break
if not encoded_js:
logger.warn(
"AnimePahe: Encoded js not found please report to the developers"
)
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"
)
return []
result = subprocess.run(
[NODE, "-e", encoded_js],
text=True,
capture_output=True,
)
# decoded js
evaluted_js = result.stderr
if not evaluted_js:
logger.warn(
"AnimePahe: could not decode encoded js using node please report to developers"
)
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"
)
return []
# get the actual hls stream link
juicy_stream = match.group(1)
# add the link
streams["links"].append(
{
"quality": res_dict["data-resolution"],
"translation_type": data_audio,
"link": juicy_stream,
}
)
yield streams

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

73
poetry.lock generated
View File

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

View File

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

View File

@@ -45,6 +45,21 @@ def test_search_help(runner: CliRunner):
assert result.exit_code == 0
def test_cache_help(runner: CliRunner):
result = runner.invoke(run_cli, ["cache", "--help"])
assert result.exit_code == 0
def test_completions_help(runner: CliRunner):
result = runner.invoke(run_cli, ["completions", "--help"])
assert result.exit_code == 0
def test_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