Compare commits

...

88 Commits

Author SHA1 Message Date
Benex254
144bf53081 chore: bump version 2024-08-19 11:01:13 +03:00
Benex254
16dded9724 fix: inability to properly detect terminal 2024-08-19 10:51:39 +03:00
Benex254
c47b158bff fix: logging issue 2024-08-19 10:51:11 +03:00
Benex254
9a36e15d9d feat: intergrate subs to python-mpv based player 2024-08-19 10:37:04 +03:00
Benex254
d6b2bd7761 fix: ep title 2024-08-19 10:36:20 +03:00
Benex254
2346552dc4 fix: logging issue 2024-08-19 00:38:51 +03:00
Benex254
ba275055db fix: logging issue 2024-08-19 00:38:29 +03:00
Benex254
de4ddf2f3a chore: bump version 2024-08-19 00:21:48 +03:00
Benex254
9c94d824d1 fix: rearrange servers available 2024-08-19 00:21:16 +03:00
Benex254
495f3cfbf6 chore: bump version 2024-08-18 23:59:30 +03:00
Benex254
b56c9ae3dd docs: update reamde 2024-08-18 23:59:16 +03:00
Benex254
5e9ef87526 feat: improve provider api 2024-08-18 23:55:29 +03:00
Benex254
b68d6d6fe9 feat: accomodate subtitle streams 2024-08-18 23:54:59 +03:00
Benex254
5870cc6640 feat: accomodate subtitle streams 2024-08-18 23:54:36 +03:00
Benex254
7a43d58d82 fix: command order 2024-08-18 23:54:16 +03:00
Benex254
fc7efebc8d feat: accomodate subtitle streams 2024-08-18 23:53:36 +03:00
Benex254
528be74194 feat(aniwatch): init 2024-08-18 23:52:18 +03:00
Benex254
ab782acf2f chore: bump version 2024-08-18 15:47:44 +03:00
Benex254
45836d1ebc fix: handle no matches for search results 2024-08-18 15:47:29 +03:00
Benex254
dff059d8eb fix: workaround over typing issue 2024-08-18 15:32:13 +03:00
Benex254
4010cfc9c8 fix: correct update command 2024-08-18 15:29:54 +03:00
Benex254
6329730820 chore: bump version 2024-08-18 15:23:39 +03:00
Benex254
006592ae7d test: add grab command tests 2024-08-18 15:23:27 +03:00
Benex254
831dcf4e88 feat: fix python 3.10 incompatibility issue 2024-08-18 15:20:58 +03:00
Benex254
0d2cf7ed66 chore: bump version 2024-08-18 15:18:28 +03:00
Benex254
aa6dc2b98e docs: update readme 2024-08-18 15:18:12 +03:00
Benex254
2e5cde3365 feat(grab command): include more options for finer control 2024-08-18 15:09:56 +03:00
Benex254
d75a03e594 feat(animepahe): fix episode title 2024-08-18 15:09:24 +03:00
Benex254
9268c02683 docs: update readme 2024-08-18 13:49:20 +03:00
Benex254
89913036c9 chore: bump version 2024-08-18 13:49:05 +03:00
Benex254
2244026c67 feat(update command): improve update command 2024-08-18 13:05:27 +03:00
Benex254
c70564474b feat(download command): make the download never promt for user action while downloading episodes instead warn and sleep 2024-08-18 12:47:32 +03:00
Benex254
74514c9fbc feat(animepahe): fix title order 2024-08-18 12:46:46 +03:00
Benex254
077e9ab8c4 feat(grab command): include translation type in data 2024-08-18 12:37:39 +03:00
Benex254
b05f7f1640 feat(cli): add help for download and search command 2024-08-18 12:34:18 +03:00
Benex254
3382b720e3 feat(cli): add grab command 2024-08-18 12:33:51 +03:00
Benex254
f72c2d4b17 chore: bump version 2024-08-17 22:52:25 +03:00
Benex254
ff027991e0 chore: rename logfile 2024-08-17 22:52:11 +03:00
Benex254
21cdc6b015 docs: update readme 2024-08-17 22:50:04 +03:00
Benex254
29a2e3e6d1 feat(utils): include boundary in quality selector function 2024-08-17 22:46:44 +03:00
Benex254
5b3b9f740b feat(animepahe): remove use of node and implement custom logic to decode the string 2024-08-17 22:46:10 +03:00
Benex254
5bc0e52179 feat(download command): add headers functionality 2024-08-17 15:31:53 +03:00
Benex254
40f1c4fba5 chore: bump version 2024-08-17 15:25:26 +03:00
Benex254
454341eaf5 feat: enable use of http headers for providers 2024-08-17 15:17:53 +03:00
Benex254
abab2540a3 chore: update packages 2024-08-17 11:01:56 +03:00
Benex254
b2bc8cbace feat(download command): add more download options 2024-08-17 11:01:37 +03:00
Benex254
90bbf3c033 chore: bump version 2024-08-17 00:29:39 +03:00
Benex254
ac91b1770a feat(downloads command): use random episode for anime preview 2024-08-17 00:28:59 +03:00
Benex254
19d42b7924 feat(downloads command): add syncplay intergration 2024-08-16 23:37:37 +03:00
Benex254
9ec3136734 chore bump version 2024-08-16 23:02:24 +03:00
Benex254
943fca43cf docs: update readme 2024-08-16 23:02:24 +03:00
Benex254
b2e00feb94 feat(downloads command): sort by episode number 2024-08-16 23:02:24 +03:00
BeneX254
f726c8d55c Update README.md 2024-08-16 22:17:33 +03:00
Benex254
57db2e0626 chore: bump version 2024-08-16 22:09:56 +03:00
Benex254
40f66b5fde docs: update readme 2024-08-16 22:08:04 +03:00
Benex254
c87417e5e7 feat: add syncplay intergration 2024-08-16 22:03:22 +03:00
Benex254
a841dd6f66 chore: bump version 2024-08-16 20:04:57 +03:00
Benex254
d6e85bad5c docs: update readme 2024-08-16 20:04:45 +03:00
Benex254
b590ac1e91 feat(cli): improve download and search command 2024-08-16 19:49:40 +03:00
Benex254
9cfa3aeea5 feat(cli): use an option for providing anime title for search and download command 2024-08-16 19:45:00 +03:00
Benex254
18c60691ca feat(search command): improve binge power 2024-08-16 19:37:10 +03:00
Benex254
2e9fadf3b2 feat(download command): improve download command 2024-08-16 19:02:22 +03:00
Benex254
510b47b187 feat(downloads command): improve output by sorting the titles and episodes 2024-08-16 15:01:55 +03:00
Benex254
49c4d0eec0 docs: update readme 2024-08-16 14:57:15 +03:00
Benex254
8367f7bbed chore: bump version 2024-08-16 14:55:29 +03:00
Benex254
0182f674e0 feat: add status to graphql medialist query 2024-08-16 14:55:02 +03:00
Benex254
2b50fb4c97 fix(interface): improve error handling for non logged in user 2024-08-16 14:54:36 +03:00
Benex254
2602a20aa7 feat(login command): add option to erase login data 2024-08-16 14:53:57 +03:00
Benex254
13200e2d1f chore: bump version 2024-08-16 14:24:59 +03:00
Benex254
22f6e89400 fix:preferred server not reflecting in command 2024-08-16 14:24:42 +03:00
Benex254
8409fa7d43 chore: bump version 2024-08-16 13:51:57 +03:00
Benex254
c81da78190 chore: bump version 2024-08-16 13:51:36 +03:00
Benex254
e17ea4bb89 fix(interface): incorrect loading of episode during replat 2024-08-16 13:50:58 +03:00
Benex254
0087728aa8 docs: update readme 2024-08-16 13:22:35 +03:00
Benex254
9e48e02f7a feat(downloads command): improve local downloads experience 2024-08-16 13:12:10 +03:00
Benex254
1291d55ab0 feat(downloads command): add previews 2024-08-16 11:38:18 +03:00
Benex254
b5c6a1e39e feat: improve path handling on windows 2024-08-16 10:54:13 +03:00
Benex254
d6adb30802 feat(download command): remove unused option and improve help message 2024-08-16 10:47:25 +03:00
Benex254
1d08a69a85 feat(search command): improve help message 2024-08-16 10:46:37 +03:00
Benex254
1087ab3408 chore: add error checking todo 2024-08-16 10:46:05 +03:00
Benex254
51afd504df chore: update config obj 2024-08-16 10:45:40 +03:00
Benex254
75efc9d73a docs: update readme 2024-08-16 10:45:18 +03:00
Benex254
6b68086cff feat(interface): improve watch history experience 2024-08-16 10:10:47 +03:00
Benex254
3686cdfdb3 feat(completions): enhance speed of loading completion functions 2024-08-15 12:21:29 +03:00
Benex254
83c98936d1 chore: bump version 2024-08-15 11:33:22 +03:00
Benex254
0891cb279a docs: update readme 2024-08-15 11:32:21 +03:00
Benex254
95ba96f537 chore: remove plyer 2024-08-15 11:25:51 +03:00
Benex254
586790173b docs: update readme 2024-08-15 11:25:22 +03:00
44 changed files with 2156 additions and 723 deletions

232
README.md
View File

@@ -41,14 +41,15 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magi
- [Subcommands](#subcommands) - [Subcommands](#subcommands)
- [download subcommand](#download-subcommand) - [download subcommand](#download-subcommand)
- [search subcommand](#search-subcommand) - [search subcommand](#search-subcommand)
- [grab subcommand](#grab-subcommand)
- [downloads subcommand](#downloads-subcommand) - [downloads subcommand](#downloads-subcommand)
- [config subcommand](#config-subcommand) - [config subcommand](#config-subcommand)
- [cache subcommand](#cache-subcommand) - [cache subcommand](#cache-subcommand)
- [update subcommand](#update-subcommand) - [update subcommand](#update-subcommand)
- [completions subcommand](#completions-subcommand) - [completions subcommand](#completions-subcommand)
- [MPV specific commands](#mpv-specific-commands) - [MPV specific commands](#mpv-specific-commands)
- [Added keybindings](#added-keybindings) - [Key Bindings](#key-bindings)
- [Added script messages](#added-script-messages) - [Script Messages](#script-messages)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Contributing](#contributing) - [Contributing](#contributing)
- [Receiving Support](#receiving-support) - [Receiving Support](#receiving-support)
@@ -57,7 +58,7 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magi
> [!IMPORTANT] > [!IMPORTANT]
> >
> 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. > This project currently scrapes allanime, aniwatch and animepahe. The site is in the public domain and can be accessed by any one with a browser.
## Installation ## Installation
@@ -120,7 +121,7 @@ Requirements:
To build from the source, follow these steps: To build from the source, follow these steps:
1. Clone the repository: `git clone https://github.com/Benex254/FastAnime.git` 1. Clone the repository: `git clone https://github.com/Benex254/FastAnime.git --depth 1`
2. Navigate into the folder: `cd FastAnime` 2. Navigate into the folder: `cd FastAnime`
3. Then build and Install the app: 3. Then build and Install the app:
@@ -164,28 +165,25 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
**Other external dependencies that will just make your experience better:** **Other external dependencies that will just make your experience better:**
- [ffmpeg](https://www.ffmpeg.org/) is required to be in your path environment variables to properly download [hls](https://www.cloudflare.com/en-gb/learning/video/what-is-http-live-streaming/) streams.
- [fzf](https://github.com/junegunn/fzf) 🔥 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 - [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. - [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)!! - [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. - [bash](https://www.gnu.org/software/bash/) is used as the preview script language.
- [ani-skip](https://github.com/synacktraa/ani-skip) 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
- [ffmpegthumbnailer](https://github.com/dirkvdb/ffmpegthumbnailer) used for local previews of downloaded anime
- [syncplay](https://syncplay.pl/) to enable watch together.
## Usage ## Usage
The app offers both a graphical interface (under development) and a robust command-line interface. The project offers a featureful command-line interface and MPV interface through the use of python-mpv.
> [!NOTE]
>
> The GUI is mostly in hiatus; use the CLI for now.
> However, you can try it out before i decided to change my objective by checking out this [release](https://github.com/Benex254/FastAnime/tree/v0.20.0).
> But be reassured for those who aren't terminal chads, i will still complete the GUI for the fun of it
### The Commandline interface :fire: ### The Commandline interface :fire:
Designed for power users who prefer efficiency over browser-based streaming and still want the experience in their terminal. Designed for efficiency and automation. Plus has a beautiful pseudo-TUI in some of the commands.
Overview of main commands: **Overview of main commands:**
- `fastanime anilist`: Powerful command for browsing and exploring anime due to AniList integration. - `fastanime anilist`: Powerful command for browsing and exploring anime due to AniList integration.
- `fastanime download`: Download anime. - `fastanime download`: Download anime.
@@ -193,23 +191,36 @@ Overview of main commands:
- `fastanime downloads`: View downloaded anime and watch with MPV. - `fastanime downloads`: View downloaded anime and watch with MPV.
- `fastanime config`: Quickly edit configuration settings. - `fastanime config`: Quickly edit configuration settings.
- `fastanime cache`: Quickly manage the cache fastanime uses - `fastanime cache`: Quickly manage the cache fastanime uses
- `fastanime update`: Quickly update fastanime
- `fastanime grab`: print streams to stdout to use in non python application.
Configuration is directly passed into this command at run time to override your config. **Overview of options**
Available options include: Most options are directly passed into fastanime directly and are shared by multiple subcommands.
- `--server;-s <server>` set the default server to auto select Most of the options override your config file.
- `--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 This is a convention to make the dev time faster since it reduces redundancy and also makes switching of subcommands with the same options easier to the end user.
- `--translation-type;- <dub|sub` what language for anime
- `--auto-select;-a/--no-auto-select;-no-a` auto select title from provider results In general `fastanime --<option-name>`
- `--auto-next;-A;/--no-auto-next;-no-A` auto select next episode
- `-downloads-dir;-d <path>` set the folder to download anime into Available options for the fastanime include:
- `--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 - `--fzf` use fzf for the ui
- `--default` use the default ui - `--default` use the default ui
- `--preview` show a preview when using fzf - `--preview` show a preview when using fzf
- `--no-preview` dont 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 - `--icons/--no-icons` toggle the visibility of the icons
- `--skip/--no-skip` whether to skip the opening and ending theme songs. - `--skip/--no-skip` whether to skip the opening and ending theme songs.
- `--rofi` use rofi for the ui - `--rofi` use rofi for the ui
@@ -220,6 +231,30 @@ Available options include:
- `--log-file` allow logging to a file - `--log-file` allow logging to a file
- `--rich-traceback` allow rich traceback - `--rich-traceback` allow rich traceback
- `--use-mpv-mod/--use-default-player` whether to use python-mpv - `--use-mpv-mod/--use-default-player` whether to use python-mpv
- `--provider <allanime/animepahe>` anime site of choice to scrape from
- `--sync-play` or `-sp` use syncplay for streaming anime so you can watch with your friends
- `--sub_lang <en/or any other common shortform for country>` regex is used to determine the appropriate. Only works when provider is aniwatch.
Example usage of the above options
```bash
# example of syncplay intergration
fastanime --sync-play --server sharepoint search -t <anime-title>
# --- or ---
# to watch with anilist intergration
fastanime --sync-play --server sharepoint anilist
# downloading dubbed anime
fastanime --dub download <anime>
# use icons and fzf for a more elegant ui with preview
fastanime --icons --preview --fzf anilist
# use icons with default ui
fastanime --icons --default anilist
```
#### The anilist command :fire: :fire: :fire: #### The anilist command :fire: :fire: :fire:
@@ -263,7 +298,7 @@ fastanime --log anilist notifier
fastanime --log-file anilist notifier fastanime --log-file anilist notifier
``` ```
The above commands will start a loop that checks every 2 minutes if any of the anime in your watch list that are aireing has just released a new episode. The above commands will start a loop that checks every 2 minutes if any of the anime in your watch list that are airing has just released a new episode.
The notification will consist of a cover image of the anime in none windows systems. The notification will consist of a cover image of the anime in none windows systems.
@@ -281,12 +316,15 @@ end
> [!NOTE] > [!NOTE]
> To sign in just run `fastanime anilist login` and follow the instructions. > To sign in just run `fastanime anilist login` and follow the instructions.
> To view your login status `fastanime anilist login --status` > To view your login status `fastanime anilist login --status`
> To erase login data `fastanime anilist login --erase`
#### download subcommand #### download subcommand
Download anime to watch later dub or sub with this one command. 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. 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] > [!NOTE]
> >
@@ -297,29 +335,114 @@ So every step of the way has been and can be automated.
```bash ```bash
# Download all available episodes # 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 # Download specific episode range
# be sure to observe the range Syntax # be sure to observe the range Syntax
fastanime download <anime-title> -r <episodes-start>-<episodes-end> fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>'
fastanime download -t <anime-title> -r '<episodes-start>:'
fastanime download -t <anime-title> -r ':<episodes-end>'
# download specific episode
# remember python indexing starts at 0
fastanime download -t <anime-title> -r '<episode-1>:<episode>'
``` ```
#### search subcommand #### search subcommand
Powerful command mainly aimed at binging anime. Since it doesn't require interaction with the interfaces. 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:** **Syntax:**
```bash ```bash
# basic form where you will still be prompted for the episode number # basic form where you will still be prompted for the episode number
fastanime search <anime-title> # multiple titles can be specified with the -t option
fastanime search -t <anime-title> -t <anime-title>
# binge all episodes with this command # binge all episodes with this command
fastanime search <anime-title> -r - fastanime search -t <anime-title> -r ':'
# watch latest episode
fastanime search -t <anime-title> -r '-1'
# binge a specific episode range with this command # binge a specific episode range with this command
# be sure to observe the range Syntax # be sure to observe the range Syntax
fastanime search <anime-title> -r <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>'
```
#### grab subcommand
Helper command to print streams to stdout so it can be used by non-python applications.
The format of the printed out data is json and can be either an array or object depending on how many anime titles have been specified in the command-line or through a subprocess.
> [!TIP]
> For python applications just use its python api, for even greater and easier control.
> So just add fastanime as one of your dependencies.
Uses a list slicing syntax similar to that of python as the value of the `-r` option.
**Syntax:**
```bash
# --- print anime info + episode streams ---
# multiple titles can be specified with the -t option
fastanime grab -t <anime-title> -t <anime-title>
# -- or --
# print all available episodes
fastanime grab -t <anime-title> -r ':'
# print the latest episode
fastanime grab -t <anime-title> -r '-1'
# print a specific episode range
# be sure to observe the range Syntax
fastanime grab -t <anime-title> -r '<start>:<stop>'
fastanime grab -t <anime-title> -r '<start>:<stop>:<step>'
fastanime grab -t <anime-title> -r '<start>:'
fastanime grab -t <anime-title> -r ':<end>'
# --- grab options ---
# print search results only
fastanime grab -t <anime-title> -r <range> --search-results-only
# print anime info only
fastanime grab -t <anime-title> -r <range> --anime-info-only
# print episode streams only
fastanime grab -t <anime-title> -r <range> --episode-streams-only
``` ```
#### downloads subcommand #### downloads subcommand
@@ -331,9 +454,21 @@ View and stream the anime you downloaded using MPV.
```bash ```bash
fastanime downloads 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 # to get the path to the downloads folder set
fastanime downloads --path fastanime downloads --path
# useful when you want to use the value for other programs # useful when you want to use the value for other programs
``` ```
#### config subcommand #### config subcommand
@@ -410,12 +545,12 @@ fastanime completions --bash
fastanime completions --zsh fastanime completions --zsh
``` ```
## MPV specific commands ### 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. 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. 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 #### Key Bindings
`<shift>+n` fetch the next episode `<shift>+n` fetch the next episode
@@ -427,36 +562,56 @@ This is all powered with [python-mpv]() which enables writing mpv scripts with p
`<shit>+r` reload episode `<shit>+r` reload episode
### Added script messages #### Script Messages
Commands issued in the MPV console.
Examples: Examples:
```bash ```bash
# to select episode from mpv without window closing # to select episode from mpv without window closing
script-message select-episode <episode-number> script-message select-episode <episode-number>
# to select server from mpv without window closing # to select server from mpv without window closing
script-message select-server <server-name> script-message select-server <server-name>
# to select quality
script-message select-quality <1080/720/480/360>
``` ```
## Configuration ## Configuration
The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on Linux and mac or somewhere on windows; you can check by running `fastanime config --path`. The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on arch linux; for the other operating systems you can check by running `fastanime config --path`.
```ini ```ini
[stream] [stream]
continue_from_history = True # Auto continue from watch history continue_from_history = True # Auto continue from watch history
# which history to use [local/remote]
preferred_history = local
# force mpv window
# passed directly to mpv so values are same
force_window = immediate
translation_type = sub # Preferred language for anime (options: dub, sub) translation_type = sub # Preferred language for anime (options: dub, sub)
server = top # Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top, wixmp) server = top # Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top, wixmp)
auto_next = False # Auto-select next episode auto_next = False # Auto-select next episode
# Auto select the anime provider results with fuzzy find. # Auto select the anime provider results with fuzzy find.
# Note this wont always be correct.But 99% of the time will be. # Note this wont always be correct.But 99% of the time will be.
auto_select=True auto_select=True
# whether to skip the opening and ending theme songs # whether to skip the opening and ending theme songs
# note requires ani-skip to be in path # note requires ani-skip to be in path
skip=false skip=false
# the maximum delta time in minutes after which the episode should be considered as completed # the maximum delta time in minutes after which the episode should be considered as completed
# used in the continue from time stamp # used in the continue from time stamp
error=3 error=3
use_mpv_mod=False use_mpv_mod=False
# the format of downloaded anime and trailer # the format of downloaded anime and trailer
@@ -472,14 +627,19 @@ format=best[height<=1080]/bestvideo[height<=1080]+bestaudio/best # default
provider = allanime provider = allanime
preferred_language = romaji # Display language (options: english, romaji) preferred_language = romaji # Display language (options: english, romaji)
downloads_dir = <Default-videos-dir>/FastAnime # Download directory downloads_dir = <Default-videos-dir>/FastAnime # Download directory
preview=false # whether to show a preview window when using fzf or rofi 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_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 use_rofi=false # whether to use rofi for the ui
rofi_theme=<path-to-rofi-theme-file> rofi_theme=<path-to-rofi-theme-file>
rofi_theme_input=<path-to-rofi-theme-file> rofi_theme_input=<path-to-rofi-theme-file>
rofi_theme_confirm=<path-to-rofi-theme-file> rofi_theme_confirm=<path-to-rofi-theme-file>
@@ -502,7 +662,7 @@ If you wish to contribute directly, please first open an issue describing your p
## Receiving Support ## Receiving Support
For inquiries, join our [Discord Server](https://discord.gg/4NUTj5Pt). For inquiries, join our [Discord Server](https://discord.gg/C4rhMA4mmK).
<p align="center"> <p align="center">
<a href="https://discord.gg/C4rhMA4mmK"> <a href="https://discord.gg/C4rhMA4mmK">

View File

@@ -73,7 +73,7 @@ class AnimeProvider:
user_query, translation_type, nsfw, unknown user_query, translation_type, nsfw, unknown
) )
except Exception as e: except Exception as e:
logging.error(e) logger.error(e)
results = None results = None
return results return results
@@ -95,7 +95,7 @@ class AnimeProvider:
try: try:
results = anime_provider.get_anime(anime_id) results = anime_provider.get_anime(anime_id)
except Exception as e: except Exception as e:
logging.error(e) logger.error(e)
results = None results = None
return results return results
@@ -123,6 +123,6 @@ class AnimeProvider:
anime, episode, translation_type anime, episode, translation_type
) )
except Exception as e: except Exception as e:
logging.error(e) logger.error(e)
results = None results = None
return results # pyright:ignore return results # pyright:ignore

View File

@@ -35,6 +35,10 @@ class YtDLPDownloader:
download_dir: str, download_dir: str,
silent: bool, silent: bool,
vid_format: str = "best", vid_format: str = "best",
force_unknown_ext=False,
verbose=False,
headers={},
sub="",
): ):
"""Helper function that downloads anime given url and path details """Helper function that downloads anime given url and path details
@@ -50,14 +54,18 @@ class YtDLPDownloader:
episode_title = sanitize_filename(episode_title) episode_title = sanitize_filename(episode_title)
ydl_opts = { ydl_opts = {
# Specify the output path and template # Specify the output path and template
"http_headers": headers,
"outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s", "outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s",
"silent": silent, "silent": silent,
"verbose": False, "verbose": verbose,
"format": vid_format, "format": vid_format,
"compat_opts": ("allow-unsafe-ext",) if force_unknown_ext else tuple(),
} }
urls = [url]
if sub:
urls.append(sub)
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url]) ydl.download(urls)
# WARN: May remove this legacy functionality # WARN: May remove this legacy functionality
def download_file(self, url: str, title, silent=True): def download_file(self, url: str, title, silent=True):

View File

@@ -11,6 +11,13 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def sort_by_episode_number(filename: str):
import re
match = re.search(r"\d+", filename)
return int(match.group()) if match else 0
def anime_title_percentage_match( def anime_title_percentage_match(
possible_user_requested_anime_title: str, anime: "AnilistBaseMediaDataSchema" possible_user_requested_anime_title: str, anime: "AnilistBaseMediaDataSchema"
) -> float: ) -> float:

View File

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

View File

@@ -16,6 +16,7 @@ commands = {
"cache": "cache.cache", "cache": "cache.cache",
"completions": "completions.completions", "completions": "completions.completions",
"update": "update.update", "update": "update.update",
"grab": "grab.grab",
} }
@@ -68,6 +69,11 @@ signal.signal(signal.SIGINT, handle_exit)
type=bool, type=bool,
help="Continue from last episode?", 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( @click.option(
"--skip/--no-skip", "--skip/--no-skip",
type=bool, type=bool,
@@ -92,6 +98,11 @@ signal.signal(signal.SIGINT, handle_exit)
type=click.Choice(["dub", "sub"]), type=click.Choice(["dub", "sub"]),
help="Anime language[dub/sub]", help="Anime language[dub/sub]",
) )
@click.option(
"-sl",
"--sub-lang",
help="Set the preferred language for subs",
)
@click.option( @click.option(
"-A/-no-A", "-A/-no-A",
"--auto-next/--no-auto-next", "--auto-next/--no-auto-next",
@@ -136,6 +147,7 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option( @click.option(
"--use-mpv-mod/--use-default-player", help="Whether to use python-mpv", type=bool "--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 @click.pass_context
def run_cli( def run_cli(
ctx: click.Context, ctx: click.Context,
@@ -146,8 +158,10 @@ def run_cli(
server, server,
format, format,
continue_, continue_,
local_history,
skip, skip,
translation_type, translation_type,
sub_lang,
quality, quality,
auto_next, auto_next,
auto_select, auto_select,
@@ -165,6 +179,7 @@ def run_cli(
rofi_theme_confirm, rofi_theme_confirm,
rofi_theme_input, rofi_theme_input,
use_mpv_mod, use_mpv_mod,
sync_play,
): ):
from .config import Config from .config import Config
@@ -177,34 +192,42 @@ def run_cli(
FORMAT = "%(message)s" FORMAT = "%(message)s"
logging.basicConfig( logging.basicConfig(
level="NOTSET", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()] level="debug", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()]
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info("logging has been initialized") logger.info("logging has been initialized")
elif log_file: elif log_file:
import logging import logging
from ..constants import NOTIFIER_LOG_FILE_PATH from ..constants import LOG_FILE_PATH
format = "%(asctime)s%(levelname)s: %(message)s" format = "%(asctime)s%(levelname)s: %(message)s"
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG, level=logging.DEBUG,
filename=NOTIFIER_LOG_FILE_PATH, filename=LOG_FILE_PATH,
format=format, format=format,
datefmt="[%d/%m/%Y@%H:%M:%S]", datefmt="[%d/%m/%Y@%H:%M:%S]",
filemode="w", filemode="w",
) )
else:
import logging
logging.basicConfig(level=logging.CRITICAL)
if rich_traceback: if rich_traceback:
from rich.traceback import install from rich.traceback import install
install() install()
if sync_play:
ctx.obj.sync_play = sync_play
if provider: if provider:
ctx.obj.provider = provider ctx.obj.provider = provider
if server: if server:
ctx.obj.server = server ctx.obj.server = server
if format: if format:
ctx.obj.format = format ctx.obj.format = format
if sub_lang:
ctx.obj.sub_lang = sub_lang
if ctx.get_parameter_source("continue_") == click.core.ParameterSource.COMMANDLINE: if ctx.get_parameter_source("continue_") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.continue_from_history = continue_ ctx.obj.continue_from_history = continue_
if ctx.get_parameter_source("skip") == click.core.ParameterSource.COMMANDLINE: if ctx.get_parameter_source("skip") == click.core.ParameterSource.COMMANDLINE:
@@ -216,6 +239,11 @@ def run_cli(
ctx.obj.auto_next = auto_next ctx.obj.auto_next = auto_next
if ctx.get_parameter_source("icons") == click.core.ParameterSource.COMMANDLINE: if ctx.get_parameter_source("icons") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.icons = icons 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 ( if (
ctx.get_parameter_source("auto_select") ctx.get_parameter_source("auto_select")
== click.core.ParameterSource.COMMANDLINE == click.core.ParameterSource.COMMANDLINE

View File

@@ -26,7 +26,24 @@ def check_for_updates():
if request.status_code == 200: if request.status_code == 200:
release_json = request.json() release_json = request.json()
return (release_json["tag_name"] == __version__, release_json) remote_tag = list(
map(int, release_json["tag_name"].replace("v", "").split("."))
)
local_tag = list(map(int, __version__.replace("v", "").split(".")))
if (
(remote_tag[0] > local_tag[0])
or (remote_tag[1] > local_tag[1] and remote_tag[0] == local_tag[0])
or (
remote_tag[2] > local_tag[2]
and remote_tag[0] == local_tag[0]
and remote_tag[1] == local_tag[1]
)
):
is_latest = False
else:
is_latest = True
return (is_latest, release_json)
else: else:
print(request.text) print(request.text)
return (False, {}) return (False, {})

View File

@@ -8,41 +8,54 @@ if TYPE_CHECKING:
@click.command(help="Login to your anilist account") @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("--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 @click.pass_obj
def login(config: "Config", status): def login(config: "Config", status, erase):
from click import launch
from rich import print from rich import print
from rich.prompt import Confirm, Prompt from rich.prompt import Confirm, Prompt
from ....anilist import AniList
from ...utils.tools import exit_app from ...utils.tools import exit_app
if status: if status:
is_logged_in = True if config.user else False is_logged_in = True if config.user else False
message = ( 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(message)
print(config.user) print(config.user)
exit_app() exit_app()
if config.user: elif erase:
print("Already logged in :confused:") if Confirm.ask(
if not Confirm.ask("or would you like to reloggin", default=True): "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() exit_app()
# ---- new loggin ----- return
print( user["token"] = token
f"A browser session will be opened ( [link]{config.fastanime_anilist_app_login_url}[/link] )", config.update_user(user)
) print("Successfully saved credentials")
launch(config.fastanime_anilist_app_login_url, wait=True) print(user)
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() exit_app()
return
user["token"] = token
config.update_user(user)
print("Successfully saved credentials")
print(user)
exit_app()

View File

@@ -1,6 +1,6 @@
import click import click
from ...utils.completion_types import anime_titles_shell_complete from ...completion_functions import anime_titles_shell_complete
@click.command( @click.command(

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
import click import click
from ..utils.completion_types import anime_titles_shell_complete from ..completion_functions import anime_titles_shell_complete
if TYPE_CHECKING: if TYPE_CHECKING:
from ..config import Config from ..config import Config
@@ -13,23 +13,43 @@ if TYPE_CHECKING:
help="Download anime using the anime provider for a specified range", help="Download anime using the anime provider for a specified range",
short_help="Download anime", short_help="Download anime",
) )
@click.argument( @click.option(
"anime-title", required=True, shell_complete=anime_titles_shell_complete "--anime-titles",
"--anime_title",
"-t",
required=True,
shell_complete=anime_titles_shell_complete,
multiple=True,
help="Specify which anime to download",
) )
@click.option( @click.option(
"--episode-range", "--episode-range",
"-r", "-r",
help="A range of episodes to download", help="A range of episodes to download (start-end)",
) )
@click.option( @click.option(
"--highest_priority", "--force-unknown-ext",
"-h", "-f",
help="Choose stream indicated as highest priority", help="This option forces yt-dlp to download extensions its not aware of",
is_flag=True, is_flag=True,
) )
@click.option(
"--silent/--no-silent",
"-q/-V",
type=bool,
help="Download silently (during download)",
default=True,
)
@click.option("--verbose", "-v", is_flag=True, help="Download verbosely (everywhere)")
@click.pass_obj @click.pass_obj
def download(config: "Config", anime_title, episode_range, highest_priority): def download(
from click import clear config: "Config",
anime_titles: list,
episode_range,
force_unknown_ext,
silent,
verbose,
):
from rich import print from rich import print
from rich.progress import Progress from rich.progress import Progress
from thefuzz import fuzz from thefuzz import fuzz
@@ -39,129 +59,183 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
from ...libs.fzf import fzf from ...libs.fzf import fzf
from ...Utility.downloader.downloader import downloader from ...Utility.downloader.downloader import downloader
from ..utils.tools import exit_app from ..utils.tools import exit_app
from ..utils.utils import filter_by_quality, fuzzy_inquirer from ..utils.utils import (
filter_by_quality,
fuzzy_inquirer,
move_preferred_subtitle_lang_to_top,
)
anime_provider = AnimeProvider(config.provider) anime_provider = AnimeProvider(config.provider)
translation_type = config.translation_type translation_type = config.translation_type
download_dir = config.downloads_dir download_dir = config.downloads_dir
# ---- search for anime ---- print(f"[green bold]Queued:[/] {anime_titles}")
with Progress() as progress: for anime_title in anime_titles:
progress.add_task("Fetching Search Results...", total=None) print(f"[green bold]Now Downloading: [/] {anime_title}")
search_results = anime_provider.search_for_anime( # ---- search for anime ----
anime_title, translation_type=translation_type with Progress() as progress:
) progress.add_task("Fetching Search Results...", total=None)
if not search_results: search_results = anime_provider.search_for_anime(
print("Search results failed") anime_title, translation_type=translation_type
input("Enter to retry") )
download(config, anime_title, episode_range, highest_priority) if not search_results:
return print("Search results failed")
search_results = search_results["results"] input("Enter to retry")
search_results_ = { download(
search_result["title"]: search_result for search_result in search_results config, anime_title, episode_range, force_unknown_ext, silent, verbose
} )
return
search_results = search_results["results"]
if not search_results:
print("Nothing muches your search term")
exit_app(1)
search_results_ = {
search_result["title"]: search_result for search_result in search_results
}
if config.auto_select: if config.auto_select:
search_result = max( search_result = max(
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title) search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
) )
print("[cyan]Auto selecting:[/] ", search_result) print("[cyan]Auto selecting:[/] ", search_result)
else:
choices = list(search_results_.keys())
if config.use_fzf:
search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
else: else:
search_result = fuzzy_inquirer( choices = list(search_results_.keys())
choices, if config.use_fzf:
"Please Select title", search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
)
# ---- 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("-")
episodes_range = range(round(float(episodes_start)), round(float(episodes_end)))
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: else:
with Progress() as progress: search_result = fuzzy_inquirer(
progress.add_task("Fetching servers", total=None) choices,
# prompt for server selection "Please Select title",
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
else:
server = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
) )
if not stream_link:
print("Quality not found")
continue
link = stream_link["link"]
episode_title = servers[server]["episode_title"] # ---- fetch anime ----
print(f"[purple]Now Downloading:[/] {search_result} Episode {episode}") with Progress() as progress:
progress.add_task("Fetching Anime...", total=None)
downloader._download_file( anime: Anime | None = anime_provider.get_anime(
link, search_results_[search_result]["id"]
anime["title"],
episode_title,
download_dir,
True,
config.format,
) )
except Exception as e: if not anime:
print(e) print("Sth went wring anime no found")
time.sleep(1) input("Enter to continue...")
print("Continuing") download(
clear() config, anime_title, episode_range, force_unknown_ext, silent, verbose
)
return
episodes = sorted(
anime["availableEpisodesDetail"][config.translation_type], key=float
)
# where the magic happens
if episode_range:
if ":" in episode_range:
ep_range_tuple = episode_range.split(":")
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
episodes_start, episodes_end = ep_range_tuple
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
episodes_start, episodes_end, step = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end) : int(step)
]
else:
episodes_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range = episodes[int(episodes_start) :]
elif episodes_end.strip():
episodes_range = episodes[: int(episodes_end)]
else:
episodes_range = episodes
else:
episodes_range = episodes[int(episode_range) :]
print(f"[green bold]Downloading: [/] {episodes_range}")
else:
episodes_range = sorted(episodes, key=float)
# lets download em
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_name = next(streams, None)
if not server_name:
print("Sth went wrong when fetching the server")
continue
stream_link = filter_by_quality(
config.quality, server_name["links"]
)
if not stream_link:
print("[yellow bold]WARNING:[/] No streams found")
time.sleep(1)
print("Continuing...")
continue
link = stream_link["link"]
provider_headers = server_name["headers"]
episode_title = server_name["episode_title"]
subtitles = server_name["subtitles"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.server in servers_names:
server_name = config.server
else:
if config.use_fzf:
server_name = fzf.run(servers_names, "Select an link: ")
else:
server_name = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server_name]["links"]
)
if not stream_link:
print("[yellow bold]WARNING:[/] No streams found")
time.sleep(1)
print("Continuing...")
continue
link = stream_link["link"]
provider_headers = servers[server_name]["headers"]
subtitles = servers[server_name]["subtitles"]
episode_title = servers[server_name]["episode_title"]
print(f"[purple]Now Downloading:[/] {search_result} Episode {episode}")
subtitles = move_preferred_subtitle_lang_to_top(
subtitles, config.sub_lang
)
downloader._download_file(
link,
anime["title"],
episode_title,
download_dir,
silent,
config.format,
force_unknown_ext,
verbose,
headers=provider_headers,
sub=subtitles[0]["url"] if subtitles else "",
)
except Exception as e:
print(e)
time.sleep(1)
print("Continuing...")
print("Done Downloading") print("Done Downloading")
exit_app() exit_app()

View File

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

View File

@@ -0,0 +1,165 @@
from typing import TYPE_CHECKING
import click
from ..completion_functions import anime_titles_shell_complete
if TYPE_CHECKING:
from ..config import Config
@click.command(
help="Helper command to get streams for anime to use externally in a non-python application",
short_help="Print anime streams to standard out",
)
@click.option(
"--anime-titles",
"--anime_title",
"-t",
required=True,
shell_complete=anime_titles_shell_complete,
multiple=True,
help="Specify which anime to download",
)
@click.option(
"--episode-range",
"-r",
help="A range of episodes to download (start-end)",
)
@click.option(
"--search-results-only",
"-s",
help="print only the search results to stdout",
is_flag=True,
)
@click.option(
"--anime-info-only", "-i", help="print only selected anime title info", is_flag=True
)
@click.option(
"--episode-streams-only",
"-e",
help="print only selected anime episodes streams of given range",
is_flag=True,
)
@click.pass_obj
def grab(
config: "Config",
anime_titles: tuple,
episode_range,
search_results_only,
anime_info_only,
episode_streams_only,
):
import json
from logging import getLogger
from sys import exit
from thefuzz import fuzz
from ...AnimeProvider import AnimeProvider
logger = getLogger(__name__)
anime_provider = AnimeProvider(config.provider)
grabbed_animes = []
for anime_title in anime_titles:
# ---- search for anime ----
search_results = anime_provider.search_for_anime(
anime_title, translation_type=config.translation_type
)
if not search_results:
exit(1)
if search_results_only:
# grab only search results skipping all lines after this
grabbed_animes.append(search_results)
continue
search_results = search_results["results"]
if not search_results:
logger.error("no results for your search")
exit(1)
search_results_ = {
search_result["title"]: search_result for search_result in search_results
}
search_result = max(
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
)
# ---- fetch anime ----
anime = anime_provider.get_anime(search_results_[search_result]["id"])
if not anime:
exit(1)
if anime_info_only:
# grab only the anime data skipping all lines after this
grabbed_animes.append(anime)
continue
episodes = sorted(
anime["availableEpisodesDetail"][config.translation_type], key=float
)
# where the magic happens
if episode_range:
if ":" in episode_range:
ep_range_tuple = episode_range.split(":")
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
episodes_start, episodes_end = ep_range_tuple
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
episodes_start, episodes_end, step = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end) : int(step)
]
else:
episodes_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range = episodes[int(episodes_start) :]
elif episodes_end.strip():
episodes_range = episodes[: int(episodes_end)]
else:
episodes_range = episodes
else:
episodes_range = episodes[int(episode_range) :]
else:
episodes_range = sorted(episodes, key=float)
if not episode_streams_only:
grabbed_anime = dict(anime)
grabbed_anime["requested_episodes"] = episodes_range
grabbed_anime["translation_type"] = config.translation_type
grabbed_anime["episodes_streams"] = {}
else:
grabbed_anime = {}
# lets download em
for episode in episodes_range:
try:
if episode not in episodes:
continue
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
)
if not streams:
continue
episode_streams = {server["server"]: server for server in streams}
if episode_streams_only:
grabbed_anime[episode] = episode_streams
else:
grabbed_anime["episodes_streams"][ # pyright:ignore
episode
] = episode_streams
except Exception as e:
logger.error(e)
# grab the full data for single title and appen to final result or episode streams
grabbed_animes.append(grabbed_anime)
# print out the final result either {} or [] depending if more than one title os requested
if len(grabbed_animes) == 1:
print(json.dumps(grabbed_animes[0]))
else:
print(json.dumps(grabbed_animes))

View File

@@ -1,23 +1,29 @@
import click import click
from ...cli.config import Config from ...cli.config import Config
from ..utils.completion_types import anime_titles_shell_complete from ..completion_functions import anime_titles_shell_complete
@click.command( @click.command(
help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.", help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.",
short_help="Binge anime", short_help="Binge anime",
) )
@click.option(
"--anime-titles",
"--anime_title",
"-t",
required=True,
shell_complete=anime_titles_shell_complete,
multiple=True,
help="Specify which anime to download",
)
@click.option( @click.option(
"--episode-range", "--episode-range",
"-r", "-r",
help="A range of episodes to binge", help="A range of episodes to binge (start-end)",
)
@click.argument(
"anime_title", required=True, shell_complete=anime_titles_shell_complete
) )
@click.pass_obj @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 click import clear
from rich import print from rich import print
from rich.progress import Progress from rich.progress import Progress
@@ -29,155 +35,203 @@ def search(config: Config, anime_title: str, episode_range: str):
from ...libs.rofi import Rofi from ...libs.rofi import Rofi
from ..utils.mpv import run_mpv from ..utils.mpv import run_mpv
from ..utils.tools import exit_app from ..utils.tools import exit_app
from ..utils.utils import filter_by_quality, fuzzy_inquirer from ..utils.utils import (
filter_by_quality,
fuzzy_inquirer,
move_preferred_subtitle_lang_to_top,
)
anime_provider = AnimeProvider(config.provider) anime_provider = AnimeProvider(config.provider)
# ---- search for anime ---- print(f"[green bold]Streaming:[/] {anime_titles}")
with Progress() as progress: for anime_title in anime_titles:
progress.add_task("Fetching Search Results...", total=None) # ---- search for anime ----
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(
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
episode_range_ = None
episodes = anime["availableEpisodesDetail"][config.translation_type]
if episode_range:
episodes_start, episodes_end = episode_range.split("-")
if episodes_start and episodes_end:
episode_range_ = iter(
range(round(float(episodes_start)), round(float(episodes_end)) + 1)
)
else:
episode_range_ = iter(sorted(episodes, key=float))
def stream_anime():
clear()
episode = None
if episode_range_:
try:
episode = str(next(episode_range_))
print(
f"[cyan]Auto selecting:[/] {search_result} [cyan]Episode:[/] {episode}"
)
except StopIteration:
print("[green]Completed binge sequence[/]:smile:")
input("Enter to continue...")
if not episode or episode not in episodes:
if config.use_fzf:
episode = fzf.run(episodes, "Select an episode: ", header=search_result)
elif config.use_rofi:
episode = Rofi.run(episodes, "Select an episode")
else:
episode = fuzzy_inquirer(
episodes,
"Select episode",
)
# ---- fetch streams ----
with Progress() as progress: with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None) progress.add_task("Fetching Search Results...", total=None)
streams = anime_provider.get_episode_streams( search_results = anime_provider.search_for_anime(
anime, episode, config.translation_type anime_title, config.translation_type
) )
if not streams: if not search_results:
print("Failed to get streams") 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 return
try: # ---- fetch streams ----
# ---- fetch servers ---- with Progress() as progress:
if config.server == "top": progress.add_task("Fetching Episode Streams...", total=None)
with Progress() as progress: streams = anime_provider.get_episode_streams(
progress.add_task("Fetching top server...", total=None) anime, episode, config.translation_type
server = next(streams, None) )
if not server: if not streams:
print("Sth went wrong when fetching the episode") 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") input("Enter to continue")
stream_anime() stream_anime()
return return
stream_link = filter_by_quality(config.quality, server["links"]) link = stream_link["link"]
if not stream_link: subtitles = server["subtitles"]
print("Quality not found") stream_headers = server["headers"]
input("Enter to continue") episode_title = server["episode_title"]
stream_anime()
return
link = stream_link["link"]
episode_title = server["episode_title"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
elif config.use_rofi:
server = Rofi.run(servers_names, "Select an link")
else: else:
server = fuzzy_inquirer( with Progress() as progress:
servers_names, progress.add_task("Fetching servers", total=None)
"Select link", # 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"]
) )
stream_link = filter_by_quality( if not stream_link:
config.quality, servers[server]["links"] print("Quality not found")
input("Enter to continue")
stream_anime()
return
link = stream_link["link"]
stream_headers = servers[server]["headers"]
subtitles = servers[server]["subtitles"]
episode_title = servers[server]["episode_title"]
print(f"[purple]Now Playing:[/] {search_result} Episode {episode}")
subtitles = move_preferred_subtitle_lang_to_top(
subtitles, config.sub_lang
) )
if not stream_link: if config.sync_play:
print("Quality not found") from ..utils.syncplay import SyncPlayer
input("Enter to continue")
stream_anime() SyncPlayer(
return link, episode_title, headers=stream_headers, subtitles=subtitles
link = stream_link["link"] )
episode_title = servers[server]["episode_title"] else:
print(f"[purple]Now Playing:[/] {search_result} Episode {episode}") run_mpv(
link, episode_title, headers=stream_headers, subtitles=subtitles
)
except IndexError as e:
print(e)
input("Enter to continue")
stream_anime()
run_mpv(link, episode_title)
except Exception as e:
print(e)
input("Enter to continue")
stream_anime() stream_anime()
stream_anime()

View File

@@ -9,6 +9,7 @@ def update(
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from ... import __version__
from ..app_updater import check_for_updates, update_app from ..app_updater import check_for_updates, update_app
def _print_release(release_data): def _print_release(release_data):
@@ -23,15 +24,19 @@ def update(
console.print(body) console.print(body)
if check: if check:
is_update, github_release_data = check_for_updates() is_latest, github_release_data = check_for_updates()
if is_update: if not is_latest:
print( print(
"You are running an older version of fastanime please update to get the latest features" f"You are running an older version ({__version__}) of fastanime please update to get the latest features"
) )
_print_release(github_release_data) _print_release(github_release_data)
else: else:
print("You are running the latest version of fastanime") print(f"You are running the latest version ({__version__}) of fastanime")
_print_release(github_release_data) _print_release(github_release_data)
else: else:
success, github_release_data = update_app() success, github_release_data = update_app()
_print_release(github_release_data) _print_release(github_release_data)
if success:
print("Successfully updated")
else:
print("failed to update")

View File

@@ -1,10 +1,3 @@
from typing import TYPE_CHECKING
import requests
if TYPE_CHECKING:
from ...libs.anilist.types import AnilistDataSchema
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -43,13 +36,15 @@ def get_anime_titles(query: str, variables: dict = {}):
Returns: Returns:
a boolean indicating success and none or an anilist object depending on success a boolean indicating success and none or an anilist object depending on success
""" """
from requests import post
try: try:
response = requests.post( response = post(
ANILIST_ENDPOINT, ANILIST_ENDPOINT,
json={"query": query, "variables": variables}, json={"query": query, "variables": variables},
timeout=10, timeout=10,
) )
anilist_data: AnilistDataSchema = response.json() anilist_data = response.json()
# ensuring you dont get blocked # ensuring you dont get blocked
if ( if (
@@ -78,20 +73,10 @@ def get_anime_titles(query: str, variables: dict = {}):
] ]
return [*eng_titles, *romaji_titles] return [*eng_titles, *romaji_titles]
else: else:
return ["non 200 status code"] return []
except requests.exceptions.Timeout:
logger.warning(
"Timeout has been exceeded this could mean anilist is down or you have lost internet connection"
)
return ["timeout exceeded"]
except requests.exceptions.ConnectionError:
logger.warning(
"ConnectionError this could mean anilist is down or you have lost internet connection"
)
return ["connection error"]
except Exception as e: except Exception as e:
logger.error(f"Something unexpected occured {e}") logger.error(f"Something unexpected occured {e}")
return ["unexpected error"] return []
def anime_titles_shell_complete(ctx, param, incomplete): def anime_titles_shell_complete(ctx, param, incomplete):

View File

@@ -55,6 +55,7 @@ class Config(object):
user: [TODO:attribute] user: [TODO:attribute]
""" """
sync_play = False
anime_list: list anime_list: list
watch_history: dict watch_history: dict
fastanime_anilist_app_login_url = ( fastanime_anilist_app_login_url = (
@@ -78,6 +79,7 @@ class Config(object):
"translation_type": "sub", "translation_type": "sub",
"server": "top", "server": "top",
"continue_from_history": "True", "continue_from_history": "True",
"preferred_history": "local",
"use_mpv_mod": "false", "use_mpv_mod": "false",
"force_window": "immediate", "force_window": "immediate",
"preferred_language": "english", "preferred_language": "english",
@@ -93,6 +95,8 @@ class Config(object):
"rofi_theme": "", "rofi_theme": "",
"rofi_theme_input": "", "rofi_theme_input": "",
"rofi_theme_confirm": "", "rofi_theme_confirm": "",
"ffmpegthumnailer_seek_time": "-1",
"sub_lang": "eng",
} }
) )
self.configparser.add_section("stream") self.configparser.add_section("stream")
@@ -106,6 +110,7 @@ class Config(object):
# --- set config values from file or using defaults --- # --- set config values from file or using defaults ---
self.downloads_dir = self.get_downloads_dir() self.downloads_dir = self.get_downloads_dir()
self.sub_lang = self.get_sub_lang()
self.provider = self.get_provider() self.provider = self.get_provider()
self.use_fzf = self.get_use_fzf() self.use_fzf = self.get_use_fzf()
self.use_rofi = self.get_use_rofi() self.use_rofi = self.get_use_rofi()
@@ -125,12 +130,14 @@ class Config(object):
self.format = self.get_format() self.format = self.get_format()
self.force_window = self.get_force_window() self.force_window = self.get_force_window()
self.preferred_language = self.get_preferred_language() self.preferred_language = self.get_preferred_language()
self.preferred_history = self.get_preferred_history()
self.rofi_theme = self.get_rofi_theme() self.rofi_theme = self.get_rofi_theme()
Rofi.rofi_theme = self.rofi_theme Rofi.rofi_theme = self.rofi_theme
self.rofi_theme_input = self.get_rofi_theme_input() self.rofi_theme_input = self.get_rofi_theme_input()
Rofi.rofi_theme_input = self.rofi_theme_input Rofi.rofi_theme_input = self.rofi_theme_input
self.rofi_theme_confirm = self.get_rofi_theme_confirm() self.rofi_theme_confirm = self.get_rofi_theme_confirm()
Rofi.rofi_theme_confirm = self.rofi_theme_confirm Rofi.rofi_theme_confirm = self.rofi_theme_confirm
self.ffmpegthumbnailer_seek_time = self.get_ffmpegthumnailer_seek_time()
# ---- setup user data ------ # ---- setup user data ------
self.watch_history: dict = self.user_data.get("watch_history", {}) self.watch_history: dict = self.user_data.get("watch_history", {})
self.anime_list: list = self.user_data.get("animelist", []) self.anime_list: list = self.user_data.get("animelist", [])
@@ -142,7 +149,7 @@ class Config(object):
self._update_user_data() self._update_user_data()
def update_watch_history( 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( self.watch_history.update(
{ {
@@ -176,9 +183,15 @@ class Config(object):
def get_provider(self): def get_provider(self):
return self.configparser.get("general", "provider") 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): def get_preferred_language(self):
return self.configparser.get("general", "preferred_language") return self.configparser.get("general", "preferred_language")
def get_sub_lang(self):
return self.configparser.get("general", "sub_lang")
def get_downloads_dir(self): def get_downloads_dir(self):
return self.configparser.get("general", "downloads_dir") return self.configparser.get("general", "downloads_dir")
@@ -232,6 +245,9 @@ class Config(object):
def get_translation_type(self): def get_translation_type(self):
return self.configparser.get("stream", "translation_type") return self.configparser.get("stream", "translation_type")
def get_preferred_history(self):
return self.configparser.get("stream", "preferred_history")
def get_quality(self): def get_quality(self):
return self.configparser.get("stream", "quality") return self.configparser.get("stream", "quality")
@@ -255,6 +271,10 @@ class Config(object):
# Auto continue from watch history # Auto continue from watch history
continue_from_history = {self.continue_from_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) # Preferred language for anime (options: dub, sub)
translation_type = {self.translation_type} translation_type = {self.translation_type}
@@ -281,6 +301,10 @@ error = {self.error}
# adding more options to it # adding more options to it
use_mpv_mod = {self.use_mpv_mod} use_mpv_mod = {self.use_mpv_mod}
# force mpv window
# passed directly to mpv so values are same
force_window = immediate
# the format of downloaded anime and trailer # the format of downloaded anime and trailer
# based on yt-dlp format and passed directly to it # based on yt-dlp format and passed directly to it
# learn more by looking it up on their site # learn more by looking it up on their site
@@ -303,6 +327,10 @@ downloads_dir = {self.downloads_dir}
# whether to show a preview window when using fzf or rofi # whether to show a preview window when using fzf or rofi
preview = {self.preview} 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. # whether to use fzf as the interface for the anilist command and others.
use_fzf = {self.use_fzf} use_fzf = {self.use_fzf}
@@ -311,9 +339,12 @@ use_rofi = {self.use_rofi}
# rofi theme to use # rofi theme to use
rofi_theme = {self.rofi_theme} rofi_theme = {self.rofi_theme}
rofi_theme_input = {self.rofi_theme_input} rofi_theme_input = {self.rofi_theme_input}
rofi_theme_confirm = {self.rofi_theme_confirm} rofi_theme_confirm = {self.rofi_theme_confirm}
# whether to show the icons # whether to show the icons
icons = {self.icons} icons = {self.icons}

View File

@@ -21,7 +21,11 @@ from ...Utility.data import anime_normalizer
from ...Utility.utils import anime_title_percentage_match from ...Utility.utils import anime_title_percentage_match
from ..utils.mpv import run_mpv from ..utils.mpv import run_mpv
from ..utils.tools import exit_app from ..utils.tools import exit_app
from ..utils.utils import filter_by_quality, fuzzy_inquirer from ..utils.utils import (
filter_by_quality,
fuzzy_inquirer,
move_preferred_subtitle_lang_to_top,
)
from .utils import aniskip from .utils import aniskip
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -31,6 +35,7 @@ if TYPE_CHECKING:
from ..utils.tools import FastAnimeRuntimeState from ..utils.tools import FastAnimeRuntimeState
# TODO: make the error handling more sane
def calculate_time_delta(start_time, end_time): def calculate_time_delta(start_time, end_time):
"""helper function used to calculate the difference between two timestamps in seconds """helper function used to calculate the difference between two timestamps in seconds
@@ -97,8 +102,14 @@ def media_player_controls(
current_episode_number, current_episode_number,
) )
start_time = config.watch_history[str(anime_id_anilist)]["start_time"] if (
print("[green]Continuing from:[/] ", start_time) config.watch_history[str(anime_id_anilist)]["episode"]
== current_episode_number
):
start_time = config.watch_history[str(anime_id_anilist)]["start_time"]
print("[green]Continuing from:[/] ", start_time)
else:
start_time = "0"
custom_args = [] custom_args = []
if config.skip: if config.skip:
if args := aniskip( if args := aniskip(
@@ -106,27 +117,31 @@ def media_player_controls(
current_episode_number, current_episode_number,
): ):
custom_args.extend(args) custom_args.extend(args)
if config.use_mpv_mod: subtitles = move_preferred_subtitle_lang_to_top(
selected_server["subtitles"], config.sub_lang
)
if config.sync_play:
from ..utils.syncplay import SyncPlayer
stop_time, total_time = SyncPlayer(
current_episode_stream_link,
selected_server["episode_title"],
headers=selected_server["headers"],
subtitles=subtitles,
)
elif config.use_mpv_mod:
from ..utils.player import player from ..utils.player import player
mpv = player.create_player( player.create_player(
current_episode_stream_link, current_episode_stream_link,
config.anime_provider, config.anime_provider,
fastanime_runtime_state, fastanime_runtime_state,
config, config,
selected_server["episode_title"], selected_server["episode_title"],
start_time,
headers=selected_server["headers"],
subtitles=subtitles,
) )
# TODO: implement custom aniskip
if custom_args and None:
chapters_file = custom_args[0].split("=", 1)
script_opts = custom_args[1].split("=", 1)
mpv._set_property("chapters-file", chapters_file[1])
mpv._set_property("script-opts", script_opts[1])
if not start_time == "0":
mpv.start = start_time
mpv.wait_for_shutdown()
mpv.terminate()
stop_time = player.last_stop_time stop_time = player.last_stop_time
total_time = player.last_total_time total_time = player.last_total_time
else: else:
@@ -135,6 +150,8 @@ def media_player_controls(
selected_server["episode_title"], selected_server["episode_title"],
start_time=start_time, start_time=start_time,
custom_args=custom_args, custom_args=custom_args,
headers=selected_server["headers"],
subtitles=subtitles,
) )
# either update the watch history to the next episode or current depending on progress # either update the watch history to the next episode or current depending on progress
@@ -365,35 +382,18 @@ def provider_anime_episode_servers_menu(
# no need to get all servers if top just works # no need to get all servers if top just works
with Progress() as progress: with Progress() as progress:
progress.add_task("Fetching top server...", total=None) progress.add_task("Fetching top server...", total=None)
try: selected_server = next(episode_streams_generator, None)
selected_server = next(episode_streams_generator, None) if not selected_server:
if not selected_server: if config.use_rofi:
if config.use_rofi: if Rofi.confirm("Sth went wrong enter to continue"):
if Rofi.confirm("Sth went wrong enter to continue"): media_actions_menu(config, fastanime_runtime_state)
provider_anime_episode_servers_menu(
config, fastanime_runtime_state
)
else:
exit_app(1)
else:
print("Sth went wrong")
input("Enter to continue...")
provider_anime_episode_servers_menu(
config, fastanime_runtime_state
)
return
server_name = "top"
except Exception as e:
print("Failed to get streams. Reason:", e)
if not config.use_rofi:
input("Enter to coninue...")
else: else:
if not Rofi.confirm(f"!!Sth went wrong!!: {e} Enter to continue"): exit_app(1)
exit_app(1) else:
server_name = None print("Sth went wrong")
selected_server = "" input("Enter to continue...")
media_actions_menu(config, fastanime_runtime_state) media_actions_menu(config, fastanime_runtime_state)
return return
else: else:
with Progress() as progress: with Progress() as progress:
progress.add_task("Fetching servers...", total=None) progress.add_task("Fetching servers...", total=None)
@@ -402,6 +402,17 @@ def provider_anime_episode_servers_menu(
for episode_stream in episode_streams_generator for episode_stream in episode_streams_generator
} }
if not episode_streams_dict:
if config.use_rofi:
if Rofi.confirm("Sth went wrong enter to continue"):
media_actions_menu(config, fastanime_runtime_state)
else:
exit_app(1)
else:
print("Sth went wrong")
input("Enter to continue...")
media_actions_menu(config, fastanime_runtime_state)
return
# check if user server exists and is actually a valid serrver then sets it # check if user server exists and is actually a valid serrver then sets it
if config.server and config.server in episode_streams_dict.keys(): if config.server and config.server in episode_streams_dict.keys():
server_name = config.server server_name = config.server
@@ -478,7 +489,7 @@ def provider_anime_episode_servers_menu(
AniList.update_anime_list( AniList.update_anime_list(
{ {
"mediaId": anime_id_anilist, "mediaId": anime_id_anilist,
"progress": current_episode_number, "progress": int(float(current_episode_number)),
} }
) )
@@ -486,7 +497,10 @@ def provider_anime_episode_servers_menu(
start_time = config.watch_history.get(str(anime_id_anilist), {}).get( start_time = config.watch_history.get(str(anime_id_anilist), {}).get(
"start_time", "0" "start_time", "0"
) )
if start_time != "0": episode_in_history = config.watch_history.get(str(anime_id_anilist), {}).get(
"episode", ""
)
if start_time != "0" and episode_in_history == current_episode_number:
print("[green]Continuing from:[/] ", start_time) print("[green]Continuing from:[/] ", start_time)
custom_args = [] custom_args = []
if config.skip: if config.skip:
@@ -495,36 +509,47 @@ def provider_anime_episode_servers_menu(
current_episode_number, current_episode_number,
): ):
custom_args.extend(args) custom_args.extend(args)
if config.use_mpv_mod: subtitles = move_preferred_subtitle_lang_to_top(
selected_server["subtitles"], config.sub_lang
)
if config.sync_play:
from ..utils.syncplay import SyncPlayer
stop_time, total_time = SyncPlayer(
current_stream_link,
selected_server["episode_title"],
headers=selected_server["headers"],
subtitles=subtitles,
)
elif config.use_mpv_mod:
from ..utils.player import player from ..utils.player import player
mpv = player.create_player( if start_time == "0" and episode_in_history != current_episode_number:
start_time = "0"
player.create_player(
current_stream_link, current_stream_link,
anime_provider, anime_provider,
fastanime_runtime_state, fastanime_runtime_state,
config, config,
selected_server["episode_title"], selected_server["episode_title"],
start_time,
headers=selected_server["headers"],
subtitles=subtitles,
) )
# TODO: implement custom aniskip intergration
if custom_args and None:
chapters_file = custom_args[0].split("=", 1)
script_opts = custom_args[1].split("=", 1)
mpv._set_property("chapters-file", chapters_file[1])
mpv._set_property("script-opts", script_opts[1])
if not start_time == "0":
mpv.start = start_time
mpv.wait_for_shutdown()
mpv.terminate()
stop_time = player.last_stop_time stop_time = player.last_stop_time
total_time = player.last_total_time total_time = player.last_total_time
current_episode_number = fastanime_runtime_state.provider_current_episode_number current_episode_number = fastanime_runtime_state.provider_current_episode_number
else: else:
if not episode_in_history == current_episode_number:
start_time = "0"
stop_time, total_time = run_mpv( stop_time, total_time = run_mpv(
current_stream_link, current_stream_link,
selected_server["episode_title"], selected_server["episode_title"],
start_time=start_time, start_time=start_time,
custom_args=custom_args, custom_args=custom_args,
headers=selected_server["headers"],
subtitles=subtitles,
) )
print("Finished at: ", stop_time) print("Finished at: ", stop_time)
@@ -602,9 +627,19 @@ def provider_anime_episodes_menu(
user_watch_history.get(str(anime_id_anilist), {}).get("episode") user_watch_history.get(str(anime_id_anilist), {}).get("episode")
in total_episodes in total_episodes
): ):
current_episode_number = user_watch_history[str(anime_id_anilist)][ if (
"episode" config.preferred_history == "local"
] or not selected_anime_anilist["mediaListEntry"]
):
current_episode_number = user_watch_history[str(anime_id_anilist)][
"episode"
]
else:
current_episode_number = str(
(selected_anime_anilist["mediaListEntry"] or {"progress": 0}).get(
"progress"
)
)
print( print(
f"[bold cyan]Continuing from Episode:[/] [bold]{current_episode_number}[/]" f"[bold cyan]Continuing from Episode:[/] [bold]{current_episode_number}[/]"
) )
@@ -626,7 +661,7 @@ def provider_anime_episodes_menu(
current_episode_number = "" current_episode_number = ""
# prompt for episode number if not set # prompt for episode number if not set
if not current_episode_number: if not current_episode_number or current_episode_number not in total_episodes:
choices = [*total_episodes, "Back"] choices = [*total_episodes, "Back"]
if config.use_fzf: if config.use_fzf:
current_episode_number = fzf.run( current_episode_number = fzf.run(
@@ -662,7 +697,6 @@ def provider_anime_episodes_menu(
provider_anime_episode_servers_menu(config, fastanime_runtime_state) provider_anime_episode_servers_menu(config, fastanime_runtime_state)
# WARNING: Marked for deletion, the function is quite useless and function calls in python are expensive
def fetch_anime_episode(config, fastanime_runtime_state: "FastAnimeRuntimeState"): def fetch_anime_episode(config, fastanime_runtime_state: "FastAnimeRuntimeState"):
selected_anime: "SearchResult" = ( selected_anime: "SearchResult" = (
fastanime_runtime_state.provider_anime_search_result fastanime_runtime_state.provider_anime_search_result
@@ -842,6 +876,12 @@ def media_actions_menu(
config: [TODO:description] config: [TODO:description]
fastanime_runtime_state: [TODO:description] fastanime_runtime_state: [TODO:description]
""" """
if not config.user:
print("You aint logged in")
input("Enter to continue")
media_actions_menu(config, fastanime_runtime_state)
return
anime_lists = { anime_lists = {
"Watching": "CURRENT", "Watching": "CURRENT",
"Paused": "PAUSED", "Paused": "PAUSED",
@@ -886,6 +926,11 @@ def media_actions_menu(
config: [TODO:description] config: [TODO:description]
fastanime_runtime_state: [TODO:description] fastanime_runtime_state: [TODO:description]
""" """
if not config.user:
print("You aint logged in")
input("Enter to continue")
media_actions_menu(config, fastanime_runtime_state)
return
if config.use_rofi: if config.use_rofi:
score = Rofi.ask("Enter Score", is_int=True) score = Rofi.ask("Enter Score", is_int=True)
score = max(100, min(0, score)) score = max(100, min(0, score))
@@ -1076,7 +1121,9 @@ def media_actions_menu(
config: [TODO:description] config: [TODO:description]
fastanime_runtime_state: [TODO:description] fastanime_runtime_state: [TODO:description]
""" """
options = ["allanime", "animepahe"] from ...libs.anime_provider import anime_sources
options = list(anime_sources.keys())
if config.use_fzf: if config.use_fzf:
provider = fzf.run( provider = fzf.run(
options, prompt="Select Translation Type:", header="Language Options" options, prompt="Select Translation Type:", header="Language Options"
@@ -1133,7 +1180,7 @@ def media_actions_menu(
f"{'💠 ' if icons else ''}Toggle auto next episode": _toggle_auto_next, f"{'💠 ' if icons else ''}Toggle auto next episode": _toggle_auto_next,
f"{'🔘 ' if icons else ''}Toggle continue from history": _toggle_continue_from_history, f"{'🔘 ' if icons else ''}Toggle continue from history": _toggle_continue_from_history,
f"{'🔙 ' if icons else ''}Back": anilist_results_menu, f"{'🔙 ' if icons else ''}Back": anilist_results_menu,
f"{'' if icons else ''}Exit": exit_app, f"{'' if icons else ''}Exit": lambda *_: exit_app(),
} }
choices = list(options.keys()) choices = list(options.keys())
if config.use_fzf: if config.use_fzf:
@@ -1184,7 +1231,7 @@ def anilist_results_menu(
anime["status"] == "RELEASING" anime["status"] == "RELEASING"
and anime["nextAiringEpisode"] and anime["nextAiringEpisode"]
and progress > 0 and progress > 0
and anime["mediaListEntry"] and (anime["mediaListEntry"] or {}).get("status", "") == "CURRENT"
): ):
last_aired_episode = anime["nextAiringEpisode"]["episode"] - 1 last_aired_episode = anime["nextAiringEpisode"]["episode"] - 1
if last_aired_episode - progress > 0: if last_aired_episode - progress > 0:

View File

@@ -12,89 +12,11 @@ from yt_dlp.utils import clean_html
from ...constants import APP_CACHE_DIR from ...constants import APP_CACHE_DIR
from ...libs.anilist.types import AnilistBaseMediaDataSchema from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...Utility import anilist_data_helper from ...Utility import anilist_data_helper
from ..utils.scripts import fzf_preview
from ..utils.utils import get_true_fg from ..utils.utils import get_true_fg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 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
}
"""
# ---- aniskip intergration ---- # ---- aniskip intergration ----
def aniskip(mal_id: int, episode: str): def aniskip(mal_id: int, episode: str):

View File

@@ -2,6 +2,8 @@ import re
import shutil import shutil
import subprocess import subprocess
from fastanime.constants import S_PLATFORM
def stream_video(MPV, url, mpv_args, custom_args): def stream_video(MPV, url, mpv_args, custom_args):
process = subprocess.Popen( process = subprocess.Popen(
@@ -52,6 +54,8 @@ def run_mpv(
start_time: str = "0", start_time: str = "0",
ytdl_format="", ytdl_format="",
custom_args=[], custom_args=[],
headers={},
subtitles=[],
): ):
# Determine if mpv is available # Determine if mpv is available
MPV = shutil.which("mpv") MPV = shutil.which("mpv")
@@ -61,7 +65,7 @@ def run_mpv(
# Regex to check if the link is a YouTube URL # Regex to check if the link is a YouTube URL
youtube_regex = r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+" youtube_regex = r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+"
if not MPV: if not MPV and not S_PLATFORM == "win32":
# Determine if the link is a YouTube URL # Determine if the link is a YouTube URL
if re.match(youtube_regex, link): if re.match(youtube_regex, link):
# Android specific commands to launch mpv with a YouTube URL # Android specific commands to launch mpv with a YouTube URL
@@ -100,6 +104,13 @@ def run_mpv(
else: else:
# General mpv command with custom arguments # General mpv command with custom arguments
mpv_args = [] mpv_args = []
if headers:
mpv_headers = "--http-header-fields="
for header_name, header_value in headers.items():
mpv_headers += f"{header_name}:{header_value},"
mpv_args.append(mpv_headers)
for subtitle in subtitles:
mpv_args.append(f"--sub-file={subtitle['url']}")
if start_time != "0": if start_time != "0":
mpv_args.append(f"--start={start_time}") mpv_args.append(f"--start={start_time}")
if title: if title:

View File

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
import mpv import mpv
from ...anilist import AniList from ...anilist import AniList
from .utils import filter_by_quality from .utils import filter_by_quality, move_preferred_subtitle_lang_to_top
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Literal from typing import Literal
@@ -22,6 +22,7 @@ def format_time(duration_in_secs: float):
class MpvPlayer(object): class MpvPlayer(object):
anime_provider: "AnimeProvider" anime_provider: "AnimeProvider"
config: "Config" config: "Config"
subs = []
mpv_player: "mpv.MPV" mpv_player: "mpv.MPV"
last_stop_time: str = "0" last_stop_time: str = "0"
last_total_time: str = "0" last_total_time: str = "0"
@@ -70,7 +71,7 @@ class MpvPlayer(object):
elif type == "reload": elif type == "reload":
if current_episode_number not in total_episodes: if current_episode_number not in total_episodes:
self.mpv_player.show_text("Episode not available") self.mpv_player.show_text("Episode not available")
return return None, None
self.mpv_player.show_text("Replaying Episode...") self.mpv_player.show_text("Replaying Episode...")
elif type == "custom": elif type == "custom":
if not ep_no or ep_no not in total_episodes: if not ep_no or ep_no not in total_episodes:
@@ -78,7 +79,7 @@ class MpvPlayer(object):
self.mpv_player.show_text( self.mpv_player.show_text(
f"Acceptable episodes are: {total_episodes}", f"Acceptable episodes are: {total_episodes}",
) )
return return None, None
self.mpv_player.show_text(f"Fetching episode {ep_no}") self.mpv_player.show_text(f"Fetching episode {ep_no}")
current_episode_number = ep_no current_episode_number = ep_no
@@ -101,7 +102,7 @@ class MpvPlayer(object):
AniList.update_anime_list( AniList.update_anime_list(
{ {
"mediaId": anime_id_anilist, "mediaId": anime_id_anilist,
"progress": current_episode_number, "progress": int(float(current_episode_number)),
} }
) )
# get them juicy streams # get them juicy streams
@@ -113,14 +114,14 @@ class MpvPlayer(object):
) )
if not episode_streams: if not episode_streams:
self.mpv_player.show_text("No streams were found") self.mpv_player.show_text("No streams were found")
return None return None, None
# always select the first # always select the first
if server == "top": if server == "top":
selected_server = next(episode_streams, None) selected_server = next(episode_streams, None)
if not selected_server: if not selected_server:
self.mpv_player.show_text("Sth went wrong when loading the episode") self.mpv_player.show_text("Sth went wrong when loading the episode")
return return None, None
else: else:
episode_streams_dict = { episode_streams_dict = {
episode_stream["server"]: episode_stream episode_stream["server"]: episode_stream
@@ -131,16 +132,20 @@ class MpvPlayer(object):
self.mpv_player.show_text( self.mpv_player.show_text(
f"Invalid server!!; servers available are: {episode_streams_dict.keys()}", f"Invalid server!!; servers available are: {episode_streams_dict.keys()}",
) )
return None return None, None
self.current_media_title = selected_server["episode_title"] self.current_media_title = selected_server["episode_title"]
links = selected_server["links"] links = selected_server["links"]
stream_link_ = filter_by_quality(quality, links) stream_link_ = filter_by_quality(quality, links)
if not stream_link_: if not stream_link_:
self.mpv_player.show_text("Quality not found") self.mpv_player.show_text("Quality not found")
return return None, None
self.mpv_player._set_property("start", "0") self.mpv_player._set_property("start", "0")
stream_link = stream_link_["link"] stream_link = stream_link_["link"]
fastanime_runtime_state.provider_current_episode_stream_link = stream_link
self.subs = move_preferred_subtitle_lang_to_top(
selected_server["subtitles"], config.sub_lang
)
return stream_link return stream_link
def create_player( def create_player(
@@ -150,7 +155,11 @@ class MpvPlayer(object):
fastanime_runtime_state, fastanime_runtime_state,
config: "Config", config: "Config",
title, title,
start_time,
headers={},
subtitles=[],
): ):
self.subs = subtitles
self.anime_provider = anime_provider self.anime_provider = anime_provider
self.fastanime_runtime_state = fastanime_runtime_state self.fastanime_runtime_state = fastanime_runtime_state
self.config = config self.config = config
@@ -169,12 +178,6 @@ class MpvPlayer(object):
osc=True, osc=True,
ytdl=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 -- # -- events --
@mpv_player.event_callback("file-loaded") @mpv_player.event_callback("file-loaded")
@@ -183,6 +186,20 @@ class MpvPlayer(object):
self.player_fetching = False self.player_fetching = False
if isinstance(d, float): if isinstance(d, float):
self.last_total_time = format_time(d) self.last_total_time = format_time(d)
try:
if not mpv_player.core_shutdown:
if self.subs:
for i, subtitle in enumerate(self.subs):
if i == 0:
flag = "select"
else:
flag = "auto"
mpv_player.sub_add(
subtitle["url"], flag, None, subtitle["language"]
)
self.subs = []
except mpv.ShutdownError:
pass
@mpv_player.property_observer("time-pos") @mpv_player.property_observer("time-pos")
def handle_time_start_update(*args): def handle_time_start_update(*args):
@@ -211,7 +228,9 @@ class MpvPlayer(object):
def _next_episode(): def _next_episode():
url = self.get_episode("next") url = self.get_episode("next")
if url: if url:
mpv_player.loadfile(url, options=f"title={self.current_media_title}") mpv_player.loadfile(
url,
)
mpv_player.title = self.current_media_title mpv_player.title = self.current_media_title
@mpv_player.on_key_press("shift+p") @mpv_player.on_key_press("shift+p")
@@ -320,7 +339,23 @@ class MpvPlayer(object):
mpv_player.register_message_handler("select-quality", select_quality) mpv_player.register_message_handler("select-quality", select_quality)
self.mpv_player = mpv_player self.mpv_player = mpv_player
return mpv_player mpv_player.force_window = config.force_window
# mpv_player.cache = "yes"
# mpv_player.cache_pause = "no"
mpv_player.title = title
mpv_headers = ""
if headers:
for header_name, header_value in headers.items():
mpv_headers += f"{header_name}:{header_value},"
mpv_player.http_header_fields = mpv_headers
mpv_player.play(stream_link)
if not start_time == "0":
mpv_player.start = start_time
mpv_player.wait_for_shutdown()
mpv_player.terminate()
player = MpvPlayer() player = MpvPlayer()

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,44 @@
import shutil
import subprocess
from .tools import exit_app
def SyncPlayer(url: str, anime_title=None, headers={}, subtitles=[], *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
mpv_args = []
if headers:
mpv_headers = "--http-header-fields="
for header_name, header_value in headers.items():
mpv_headers += f"{header_name}:{header_value},"
mpv_args.append(mpv_headers)
for subtitle in subtitles:
mpv_args.append(f"--sub-file={subtitle['url']}")
if not anime_title:
subprocess.run(
[
SYNCPLAY_EXECUTABLE,
url,
]
)
else:
subprocess.run(
[
SYNCPLAY_EXECUTABLE,
url,
"--",
f"--force-media-title={anime_title}",
*mpv_args,
]
)
# for compatability
return "0", "0"

View File

@@ -15,25 +15,14 @@ class FastAnimeRuntimeState(dict):
def exit_app(exit_code=0, *args): def exit_app(exit_code=0, *args):
import os
import shutil
import sys import sys
from rich.console import Console
from ...constants import APP_NAME, ICON_PATH, USER_NAME from ...constants import APP_NAME, ICON_PATH, USER_NAME
def is_running_in_terminal(): console = Console()
try: if not console.is_terminal:
shutil.get_terminal_size()
return (
sys.stdin
and sys.stdin.isatty()
and sys.stdout.isatty()
and os.getenv("TERM") is not None
)
except OSError:
return False
if not is_running_in_terminal():
from plyer import notification from plyer import notification
notification.notify( notification.notify(
@@ -43,7 +32,6 @@ def exit_app(exit_code=0, *args):
title="Shutting down", title="Shutting down",
) # pyright:ignore ) # pyright:ignore
else: else:
from rich import print console.clear()
console.print("Have a good day :smile:", USER_NAME)
print("Have a good day :smile:", USER_NAME)
sys.exit(exit_code) sys.exit(exit_code)

View File

@@ -19,6 +19,25 @@ BG_GREEN = "\033[48;2;120;233;12;m"
GREEN = "\033[38;2;45;24;45;m" GREEN = "\033[38;2;45;24;45;m"
def move_preferred_subtitle_lang_to_top(sub_list, lang_str):
"""Moves the dictionary with the given ID to the front of the list.
Args:
sub_list: list of subs
lang_str: the sub lang pref
Returns:
The modified list.
"""
import re
for i, d in enumerate(sub_list):
if re.search(lang_str, d["language"], re.IGNORECASE):
sub_list.insert(0, sub_list.pop(i))
break
return sub_list
def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]", default=True): 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 """Helper function used to filter a list of EpisodeStream objects to one that has a corresponding quality
@@ -32,8 +51,8 @@ def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]", default
for stream_link in stream_links: for stream_link in stream_links:
q = float(quality) q = float(quality)
Q = float(stream_link["quality"]) Q = float(stream_link["quality"])
# some providers have inaccurate eg qualities 718 instead of 720 # some providers have inaccurate/weird/non-standard eg qualities 718 instead of 720
if Q < q + 80 and Q > q - 80: if Q <= q + 80 and Q >= q - 80:
return stream_link return stream_link
else: else:
if stream_links and default: if stream_links and default:

View File

@@ -3,20 +3,12 @@ import sys
from pathlib import Path from pathlib import Path
from platform import system from platform import system
from plyer import storagepath
from . import APP_NAME, AUTHOR, __version__ from . import APP_NAME, AUTHOR, __version__
PLATFORM = system() PLATFORM = system()
# ---- app deps ---- # ---- app deps ----
try: APP_DIR = os.path.abspath(os.path.dirname(__file__))
APP_DIR = storagepath.get_application_dir() # pyright:ignore
except Exception:
APP_DIR = None
if not APP_DIR:
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") ASSETS_DIR = os.path.join(APP_DIR, "assets")
@@ -31,18 +23,9 @@ PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
# ----- user configs and data ----- # ----- user configs and data -----
S_PLATFORM = sys.platform S_PLATFORM = sys.platform
try:
app_data_dir_base = None
video_dir_base = storagepath.get_videos_dir() # pyright:ignore
cache_dir_base = None
except Exception:
video_dir_base = None
cache_dir_base = None
app_data_dir_base = None
if S_PLATFORM == "win32": if S_PLATFORM == "win32":
# app data # app data
if not app_data_dir_base: app_data_dir_base = os.getenv("LOCALAPPDATA")
app_data_dir_base = os.getenv("LOCALAPPDATA")
if not app_data_dir_base: if not app_data_dir_base:
raise RuntimeError("Could not determine app data dir please report to devs") 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) APP_DATA_DIR = os.path.join(app_data_dir_base, AUTHOR, APP_NAME)
@@ -51,45 +34,38 @@ if S_PLATFORM == "win32":
APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache") APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache")
# videos dir # videos dir
if not video_dir_base: video_dir_base = os.path.join(Path().home(), "Videos")
video_dir_base = os.path.expanduser("~/Videos")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME) USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
elif S_PLATFORM == "darwin": elif S_PLATFORM == "darwin":
# app data # app data
if not app_data_dir_base: app_data_dir_base = os.path.expanduser("~/Library/Application Support")
app_data_dir_base = os.path.expanduser("~/Library/Application Support")
APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__) APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__)
# cache dir # cache dir
if not cache_dir_base: cache_dir_base = os.path.expanduser("~/Library/Caches")
cache_dir_base = os.path.expanduser("~/Library/Caches")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME, __version__) APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME, __version__)
# videos dir # videos dir
if not video_dir_base: video_dir_base = os.path.expanduser("~/Movies")
video_dir_base = os.path.expanduser("~/Movies")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME) USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
else: else:
# app data # app data
if not app_data_dir_base: app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "")
app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "") if not app_data_dir_base.strip():
if not app_data_dir_base.strip(): app_data_dir_base = os.path.expanduser("~/.config")
app_data_dir_base = os.path.expanduser("~/.config")
APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME) APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME)
# cache dir # cache dir
if not cache_dir_base: cache_dir_base = os.environ.get("XDG_CACHE_HOME", "")
cache_dir_base = os.environ.get("XDG_CACHE_HOME", "") if not cache_dir_base.strip():
if not cache_dir_base.strip(): cache_dir_base = os.path.expanduser("~/.cache")
cache_dir_base = os.path.expanduser("~/.cache")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME) APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME)
# videos dir # videos dir
if not video_dir_base: video_dir_base = os.environ.get("XDG_VIDEOS_DIR", "")
video_dir_base = os.environ.get("XDG_VIDEOS_DIR", "") if not video_dir_base.strip():
if not video_dir_base.strip(): video_dir_base = os.path.expanduser("~/Videos")
video_dir_base = os.path.expanduser("~/Videos")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME) USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
# ensure paths exist # ensure paths exist
@@ -100,7 +76,7 @@ Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
# useful paths # useful paths
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json") USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini") USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
NOTIFIER_LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "notifier.log") LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log")
USER_NAME = os.environ.get("USERNAME", "Anime fun") USER_NAME = os.environ.get("USERNAME", "Anime fun")

View File

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

View File

@@ -1,12 +1,10 @@
from .allanime import SERVERS_AVAILABLE as ALLANIME_SERVERS
from .animepahe import SERVERS_AVAILABLE as ANIMEPAHESERVERS
from .aniwatch import SERVERS_AVAILABLE as ANIWATCHSERVERS
anime_sources = { anime_sources = {
"allanime": "api.AllAnimeAPI", "allanime": "api.AllAnimeAPI",
"animepahe": "api.AnimePaheApi", "animepahe": "api.AnimePaheApi",
"aniwatch": "api.AniWatchApi",
} }
SERVERS_AVAILABLE = [ SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHESERVERS, *ANIWATCHSERVERS]
"sharepoint",
"dropbox",
"gogoanime",
"weTransfer",
"wixmp",
"kwik",
]

View File

@@ -0,0 +1 @@
SERVERS_AVAILABLE = ["sharepoint", "dropbox", "gogoanime", "weTransfer", "wixmp", "Yt"]

View File

@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING
from requests.exceptions import Timeout from requests.exceptions import Timeout
from ...anime_provider.base_provider import AnimeProvider from ...anime_provider.base_provider import AnimeProvider
from ..utils import decode_hex_string, give_random_quality from ..utils import give_random_quality, one_digit_symmetric_xor
from .constants import ( from .constants import (
ALLANIME_API_ENDPOINT, ALLANIME_API_ENDPOINT,
ALLANIME_BASE, ALLANIME_BASE,
@@ -205,23 +205,46 @@ class AllAnimeAPI(AnimeProvider):
# filter the working streams no need to get all since the others are mostly hsl # filter the working streams no need to get all since the others are mostly hsl
# TODO: should i just get all the servers and handle the hsl?? # TODO: should i just get all the servers and handle the hsl??
if embed.get("sourceName", "") not in ( if embed.get("sourceName", "") not in (
"Sak", # priorities based on death note
"Kir", "Sak", # 7
"S-mp4", "S-mp4", # 7.9
"Luf-mp4", "Luf-mp4", # 7.7
"Default", "Default", # 8.5
"Yt-mp4", # 7.9
"Kir", # NA
# "Vid-mp4" # 4
# "Ok", # 3.5
# "Ss-Hls", # 5.5
# "Mp4", # 4
): ):
continue continue
url = embed.get("sourceUrl") url = embed.get("sourceUrl")
#
if not url: if not url:
continue continue
if url.startswith("--"): if url.startswith("--"):
url = url[2:] url = url[2:]
url = one_digit_symmetric_xor(56, url)
if "tools.fast4speed.rsvp" in url:
yield {
"server": "Yt",
"episode_title": f'{anime["title"]}; Episode {episode_number}',
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [],
"links": [
{
"link": url,
"quality": "1080",
}
],
} # pyright:ignore
continue
# get the stream url for an episode of the defined source names # get the stream url for an episode of the defined source names
parsed_url = decode_hex_string(url) embed_url = (
embed_url = f"https://{ALLANIME_BASE}{parsed_url.replace('clock', 'clock.json')}" f"https://{ALLANIME_BASE}{url.replace('clock', 'clock.json')}"
)
resp = self.session.get( resp = self.session.get(
embed_url, embed_url,
headers={ headers={
@@ -230,12 +253,15 @@ class AllAnimeAPI(AnimeProvider):
}, },
timeout=10, timeout=10,
) )
if resp.status_code == 200: if resp.status_code == 200:
match embed["sourceName"]: match embed["sourceName"]:
case "Luf-mp4": case "Luf-mp4":
logger.debug("allanime:Found streams from gogoanime") logger.debug("allanime:Found streams from gogoanime")
yield { yield {
"server": "gogoanime", "server": "gogoanime",
"headers": {},
"subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f'{anime["title"]}' allanime_episode["notes"] or f'{anime["title"]}'
) )
@@ -246,6 +272,8 @@ class AllAnimeAPI(AnimeProvider):
logger.debug("allanime:Found streams from wetransfer") logger.debug("allanime:Found streams from wetransfer")
yield { yield {
"server": "wetransfer", "server": "wetransfer",
"headers": {},
"subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f'{anime["title"]}' allanime_episode["notes"] or f'{anime["title"]}'
) )
@@ -256,6 +284,8 @@ class AllAnimeAPI(AnimeProvider):
logger.debug("allanime:Found streams from sharepoint") logger.debug("allanime:Found streams from sharepoint")
yield { yield {
"server": "sharepoint", "server": "sharepoint",
"headers": {},
"subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f'{anime["title"]}' allanime_episode["notes"] or f'{anime["title"]}'
) )
@@ -266,6 +296,8 @@ class AllAnimeAPI(AnimeProvider):
logger.debug("allanime:Found streams from dropbox") logger.debug("allanime:Found streams from dropbox")
yield { yield {
"server": "dropbox", "server": "dropbox",
"headers": {},
"subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f'{anime["title"]}' allanime_episode["notes"] or f'{anime["title"]}'
) )
@@ -276,20 +308,23 @@ class AllAnimeAPI(AnimeProvider):
logger.debug("allanime:Found streams from wixmp") logger.debug("allanime:Found streams from wixmp")
yield { yield {
"server": "wixmp", "server": "wixmp",
"headers": {},
"subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f'{anime["title"]}' allanime_episode["notes"] or f'{anime["title"]}'
) )
+ f"; Episode {episode_number}", + f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]), "links": give_random_quality(resp.json()["links"]),
} # pyright:ignore } # pyright:ignore
except Timeout: except Timeout:
logger.error( logger.error(
"Timeout has been exceeded this could mean allanime is down or you have lost internet connection" "Timeout has been exceeded this could mean allanime is down or you have lost internet connection"
) )
return []
except Exception as e: except Exception as e:
logger.error(f"FA(Allanime): {e}") logger.error(f"FA(Allanime): {e}")
return []
except Exception as e: except Exception as e:
logger.error(f"FA(Allanime): {e}") logger.error(f"FA(Allanime): {e}")
return [] return []
@@ -301,7 +336,7 @@ if __name__ == "__main__":
import subprocess import subprocess
import sys import sys
from InquirerPy import inquirer, validator from InquirerPy import inquirer, validator # pyright:ignore
anime = input("Enter the anime name: ") anime = input("Enter the anime name: ")
translation = input("Enter the translation type: ") translation = input("Enter the translation type: ")

View File

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

View File

@@ -0,0 +1 @@
SERVERS_AVAILABLE = ["kwik"]

View File

@@ -1,15 +1,12 @@
import logging import logging
import random import random
import re import re
import shutil
import subprocess
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from yt_dlp.utils import ( from yt_dlp.utils import (
extract_attributes, extract_attributes,
get_element_by_id, get_element_by_id,
get_element_text_and_html_by_tag,
get_elements_html_by_class, get_elements_html_by_class,
) )
@@ -20,6 +17,7 @@ from .constants import (
REQUEST_HEADERS, REQUEST_HEADERS,
SERVER_HEADERS, SERVER_HEADERS,
) )
from .utils import process_animepahe_embed_page
if TYPE_CHECKING: if TYPE_CHECKING:
from ..types import Anime from ..types import Anime
@@ -27,6 +25,8 @@ if TYPE_CHECKING:
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';") JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
KWIK_RE = re.compile(r"Player\|(.+?)'")
# TODO: hack this to completion # TODO: hack this to completion
class AnimePaheApi(AnimeProvider): class AnimePaheApi(AnimeProvider):
@@ -136,7 +136,7 @@ class AnimePaheApi(AnimeProvider):
}, },
"episodesInfo": [ "episodesInfo": [
{ {
"title": episode["title"] or f"{title};{episode['episode']}", "title": f"{episode['title'] or title};{episode['episode']}",
"episode": episode["episode"], "episode": episode["episode"],
"id": episode["session"], "id": episode["session"],
"translation_type": episode["audio"], "translation_type": episode["audio"],
@@ -153,101 +153,80 @@ class AnimePaheApi(AnimeProvider):
def get_episode_streams( def get_episode_streams(
self, anime: "Anime", episode_number: str, translation_type, *args self, anime: "Anime", episode_number: str, translation_type, *args
): ):
# extract episode details from memory try:
episode = [ # extract episode details from memory
episode episode = [
for episode in self.anime["data"] episode
if float(episode["episode"]) == float(episode_number) for episode in self.anime["data"]
] if float(episode["episode"]) == float(episode_number)
]
if not episode: if not episode:
logger.error(f"AnimePahe(streams): episode {episode_number} doesn't exist") logger.error(
return [] f"AnimePahe(streams): episode {episode_number} doesn't exist"
episode = episode[0]
anime_id = anime["id"]
# 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 [] return []
# get embed page episode = episode[0]
embed_response = self.session.get(embed_url, headers=SERVER_HEADERS)
embed = embed_response.text anime_id = anime["id"]
# search for the encoded js # fetch the episode page
encoded_js = None url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
for _ in range(7): response = self.session.get(url, headers=REQUEST_HEADERS)
content, html = get_element_text_and_html_by_tag("script", embed) # get the element containing links to juicy streams
if not content: c = get_element_by_id("resolutionMenu", response.text)
embed = embed.replace(html, "") resolutionMenuItems = get_elements_html_by_class("dropdown-item", c)
# convert the elements containing embed links to a neat dict containing:
# data-src
# data-audio
# data-resolution
res_dicts = [extract_attributes(item) for item in resolutionMenuItems]
# get the episode title
episode_title = (
f"{episode['title'] or anime['title']}; Episode {episode['episode']}"
)
# get all links
streams = {
"server": "kwik",
"links": [],
"episode_title": episode_title,
"subtitles": [],
"headers": {},
}
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 continue
encoded_js = content
break if not embed_url:
if not encoded_js: logger.warn(
logger.warn( "AnimePahe: embed url not found please report to the developers"
"AnimePahe: Encoded js not found please report to the developers" )
return []
# get embed page
embed_response = self.session.get(embed_url, headers=SERVER_HEADERS)
embed_page = embed_response.text
decoded_js = process_animepahe_embed_page(embed_page)
if not decoded_js:
logger.error("Animepahe: failed to decode embed page")
return
juicy_stream = JUICY_STREAM_REGEX.search(decoded_js)
if not juicy_stream:
logger.error("Animepahe: failed to find juicy stream")
return
juicy_stream = juicy_stream.group(1)
# add the link
streams["links"].append(
{
"quality": res_dict["data-resolution"],
"translation_type": data_audio,
"link": juicy_stream,
}
) )
return [] yield streams
# execute the encoded js with node for now or maybe forever in odrder to get a more workable info except Exception as e:
NODE = shutil.which("node") logger.error(f"Animepahe: {e}")
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

@@ -0,0 +1,81 @@
# from ..utils import int2base
import re
from yt_dlp.utils import encode_base_n, get_element_text_and_html_by_tag
def animepahe_key_creator(c: int, a: int):
if c < a:
val_a = ""
else:
val_a = animepahe_key_creator(int(c / a), a)
c = c % a
if c > 35:
val_b = chr(c + 29)
else:
val_b = encode_base_n(c, 36)
return val_a + val_b
def animepahe_embed_decoder(
encoded_js_p: str,
base_a: int,
no_of_keys_c: int,
key_values_k: list,
decode_mapper_d: dict = {},
):
for i in range(no_of_keys_c):
key = animepahe_key_creator(i, base_a)
val = key_values_k[i] or key
decode_mapper_d[key] = val
return re.sub(
r"\b\w+\b", lambda match: decode_mapper_d[match.group(0)], encoded_js_p
)
PARAMETERS_REGEX = re.compile(r"eval\(function\(p,a,c,k,e,d\)\{.*\}\((.*?)\)\)$")
ENCODE_JS_REGEX = re.compile(r"'(.*?);',(\d+),(\d+),'(.*)'\.split")
def process_animepahe_embed_page(embed_page: str):
encoded_js_string = ""
embed_page_content = embed_page
for _ in range(8):
text, html = get_element_text_and_html_by_tag("script", embed_page_content)
if not text:
embed_page_content = re.sub(html, "", embed_page_content)
continue
encoded_js_string = text.strip()
break
if not encoded_js_string:
return
obsfucated_js_parameter_match = PARAMETERS_REGEX.search(encoded_js_string)
if not obsfucated_js_parameter_match:
return
parameter_string = obsfucated_js_parameter_match.group(1)
encoded_js_parameter_string = ENCODE_JS_REGEX.search(parameter_string)
if not encoded_js_parameter_string:
return
p: str = encoded_js_parameter_string.group(1)
a: int = int(encoded_js_parameter_string.group(2))
c: int = int(encoded_js_parameter_string.group(3))
k: list = encoded_js_parameter_string.group(4).split("|")
return animepahe_embed_decoder(p, a, c, k).replace("\\", "")
if __name__ == "__main__":
data = """<script>eval(function(p,a,c,k,e,d){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--){d[e(c)]=k[c]||e(c)}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('f $7={H:a(2){4 B(9.7.h(y z("(?:(?:^|.*;)\\\\s*"+d(2).h(/[\\-\\.\\+\\*]/g,"\\\\$&")+"\\\\s*\\\\=\\\\s*([^;]*).*$)|^.*$"),"$1"))||G},E:a(2,q,3,6,5,t){k(!2||/^(?:8|r\\-v|o|m|p)$/i.D(2)){4 w}f b="";k(3){F(3.J){j K:b=3===P?"; 8=O, I N Q M:u:u A":"; r-v="+3;n;j L:b="; 8="+3;n;j S:b="; 8="+3.Z();n}}9.7=d(2)+"="+d(q)+b+(5?"; m="+5:"")+(6?"; o="+6:"")+(t?"; p":"");4 x},Y:a(2,6,5){k(!2||!11.C(2)){4 w}9.7=d(2)+"=; 8=12, R 10 W l:l:l A"+(5?"; m="+5:"")+(6?"; o="+6:"");4 x},C:a(2){4(y z("(?:^|;\\\\s*)"+d(2).h(/[\\-\\.\\+\\*]/g,"\\\\$&")+"\\\\s*\\\\=")).D(9.7)},X:a(){f c=9.7.h(/((?:^|\\s*;)[^\\=]+)(?=;|$)|^\\s*|\\s*(?:\\=[^;]*)?(?:\\1|$)/g,"").T(/\\s*(?:\\=[^;]*)?;\\s*/);U(f e=0;e<c.V;e++){c[e]=B(c[e])}4 c}};',62,65,'||sKey|vEnd|return|sDomain|sPath|cookie|expires|document|function|sExpires|aKeys|encodeURIComponent|nIdx|var||replace||case|if|00|domain|break|path|secure|sValue|max||bSecure|59|age|false|true|new|RegExp|GMT|decodeURIComponent|hasItem|test|setItem|switch|null|getItem|31|constructor|Number|String|23|Dec|Fri|Infinity|9999|01|Date|split|for|length|1970|keys|removeItem|toUTCString|Jan|this|Thu'.split('|'),0,{}));eval(function(p,a,c,k,e,d){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--){d[e(c)]=k[c]||e(c)}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('h o=\'1D://1C-E.1B.1A.1z/1y/E/1x/1w/1v.1u\';h d=s.r(\'d\');h 0=B 1t(d,{\'1s\':{\'1r\':i},\'1q\':\'16:9\',\'D\':1,\'1p\':5,\'1o\':{\'1n\':\'1m\'},1l:[\'7-1k\',\'7\',\'1j\',\'1i-1h\',\'1g\',\'1f-1e\',\'1d\',\'D\',\'1c\',\'1b\',\'1a\',\'19\',\'C\',\'18\'],\'C\':{\'17\':i}});8(!A.15()){d.14=o}x{j z={13:12,11:10,Z:Y,X:i,W:i};h c=B A(z);c.V(o);c.U(d);g.c=c}0.3("T",6=>{g.S.R.Q("P")});0.O=1;k v(b,n,m){8(b.y){b.y(n,m,N)}x 8(b.w){b.w(\'3\'+n,m)}}j 4=k(l){g.M.L(l,\'*\')};v(g,\'l\',k(e){j a=e.a;8(a===\'7\')0.7();8(a===\'f\')0.f();8(a===\'u\')0.u()});0.3(\'t\',6=>{4(\'t\')});0.3(\'7\',6=>{4(\'7\')});0.3(\'f\',6=>{4(\'f\')});0.3(\'K\',6=>{4(0.q);s.r(\'.J-I\').H=G(0.q.F(2))});0.3(\'p\',6=>{4(\'p\')});',62,102,'player|||on|sendMessage||event|play|if||data|element|hls|video||pause|window|const|true|var|function|message|eventHandler|eventName|source|ended|currentTime|querySelector|document|ready|stop|bindEvent|attachEvent|else|addEventListener|config|Hls|new|fullscreen|volume|01|toFixed|String|innerHTML|timestamp|ss|timeupdate|postMessage|parent|false|speed|landscape|lock|orientation|screen|enterfullscreen|attachMedia|loadSource|lowLatencyMode|enableWorker|Infinity|backBufferLength|600|maxMaxBufferLength|180|maxBufferLength|src|isSupported||iosNative|capture|airplay|pip|settings|captions|mute|time|current|progress|forward|fast|rewind|large|controls|kwik|key|storage|seekTime|ratio|global|keyboard|Plyr|m3u8|uwu|b92a392054c041a3f9c6eecabeb0e127183f44e547828447b10bca8d77523e6f|03|stream|org|nextcdn|files|eu|https'.split('|'),0,{}))"""
a = 62
c = 102
k = "player|||on|sendMessage||event|play|if||data|element|hls|video||pause|window|const|true|var|function|message|eventHandler|eventName|source|ended|currentTime|querySelector|document|ready|stop|bindEvent|attachEvent|else|addEventListener|config|Hls|new|fullscreen|volume|toFixed|String|innerHTML|timestamp|ss|timeupdate|postMessage|parent|false|speed|landscape|lock|orientation|screen|enterfullscreen|attachMedia|loadSource|lowLatencyMode|enableWorker|Infinity|backBufferLength|600|maxMaxBufferLength||180|maxBufferLength|src|isSupported||iosNative|capture|airplay|pip|settings|captions|mute|time|current|progress|forward|fast|rewind|large|controls|kwik|key|storage|seekTime|ratio|global|keyboard|Plyr|m3u8|uwu|cda74eaebce25a12f5e548f7c220bb5dc245700b0280bdb45ff98b2fe4803d2b|06|stream|org|nextcdn|files|eu|https".split(
"|"
)
p = "h o='1D://1C-11.1B.1A.1z/1y/11/1x/1w/1v.1u';h d=s.r('d');h 0=B 1t(d,{'1s':{'1r':i},'1q':'16:9','D':1,'1p':5,'1o':{'1n':'1m'},1l:['7-1k','7','1j','1i-1h','1g','1f-1e','1d','D','1c','1b','1a','19','C','18'],'C':{'17':i}});8(!A.15()){d.14=o}x{j z={13:12,10:Z,Y:X,W:i,V:i};h c=B A(z);c.U(o);c.T(d);g.c=c}0.3(\"S\",6=>{g.R.Q.P(\"O\")});0.N=1;k v(b,n,m){8(b.y){b.y(n,m,M)}x 8(b.w){b.w('3'+n,m)}}j 4=k(l){g.L.K(l,'*')};v(g,'l',k(e){j a=e.a;8(a==='7')0.7();8(a==='f')0.f();8(a==='u')0.u()});0.3('t',6=>{4('t')});0.3('7',6=>{4('7')});0.3('f',6=>{4('f')});0.3('J',6=>{4(0.q);s.r('.I-H').G=F(0.q.E(2))});0.3('p',6=>{4('p')});"
result = animepahe_embed_decoder(
p,
a,
c,
k,
)
print(result) # Output: j player = B A();

View File

@@ -0,0 +1 @@
SERVERS_AVAILABLE = ["HD1", "HD2", "StreamSB", "StreamTape"]

View File

@@ -0,0 +1,173 @@
import logging
import re
from itertools import cycle
from yt_dlp.utils import (
extract_attributes,
get_element_html_by_class,
get_elements_html_by_class,
)
from ..base_provider import AnimeProvider
from ..common import fetch_anime_info_from_bal
from ..mini_anilist import search_for_anime_with_anilist
from ..utils import give_random_quality
from . import SERVERS_AVAILABLE
from .types import AniWatchStream
logger = logging.getLogger(__name__)
LINK_TO_STREAMS_REGEX = re.compile(r".*://(.*)/embed-(2|4|6)/e-([0-9])/(.*)\?.*")
class AniWatchApi(AnimeProvider):
def search_for_anime(self, anime_title: str, *args):
try:
return search_for_anime_with_anilist(anime_title)
except Exception as e:
logger.error(e)
def get_anime(self, anilist_id, *args):
try:
bal_results = fetch_anime_info_from_bal(anilist_id)
if not bal_results:
return
ZORO = bal_results["Sites"]["Zoro"]
aniwatch_id = list(ZORO.keys())[0]
anime_url = f"https://hianime.to/ajax/v2/episode/list/{aniwatch_id}"
response = self.session.get(anime_url, timeout=10)
if response.status_code == 200:
response_json = response.json()
aniwatch_anime_page = response_json["html"]
episodes_info_container_html = get_element_html_by_class(
"ss-list", aniwatch_anime_page
)
episodes_info_html_list = get_elements_html_by_class(
"ep-item", episodes_info_container_html
)
# keys: [ data-number: episode_number, data-id: episode_id, title: episode_title , href:episode_page_url]
episodes_info_dicts = [
extract_attributes(episode_dict)
for episode_dict in episodes_info_html_list
]
episodes = [episode["data-number"] for episode in episodes_info_dicts]
self.episodes_info = [
{
"id": episode["data-id"],
"title": (
(episode["title"] or "").replace(
f"Episode {episode['data-number']}", ""
)
or ZORO[aniwatch_id]["title"]
)
+ f"; Episode {episode['data-number']}",
"episode": episode["data-number"],
}
for episode in episodes_info_dicts
]
return {
"id": aniwatch_id,
"availableEpisodesDetail": {
"dub": episodes,
"sub": episodes,
"raw": episodes,
},
"poster": ZORO[aniwatch_id]["image"],
"title": ZORO[aniwatch_id]["title"],
"episodes_info": self.episodes_info,
}
except Exception as e:
logger.error(e)
def get_episode_streams(self, anime, episode, translation_type, *args):
try:
episode_details = [
episode_details
for episode_details in self.episodes_info
if episode_details["episode"] == episode
]
if not episode_details:
return
episode_details = episode_details[0]
episode_url = f"https://hianime.to/ajax/v2/episode/servers?episodeId={episode_details['id']}"
response = self.session.get(episode_url)
if response.status_code == 200:
response_json = response.json()
episode_page_html = response_json["html"]
servers_containers_html = get_elements_html_by_class(
"ps__-list", episode_page_html
)
if not servers_containers_html:
return
# sub servers
try:
servers_html_sub = get_elements_html_by_class(
"server-item", servers_containers_html[0]
)
except Exception:
logger.warn("AniWatch: sub not found")
servers_html_sub = None
# dub servers
try:
servers_html_dub = get_elements_html_by_class(
"server-item", servers_containers_html[1]
)
except Exception:
logger.warn("AniWatch: dub not found")
servers_html_dub = None
if translation_type == "dub":
servers_html = servers_html_dub
else:
servers_html = servers_html_sub
if not servers_html:
return
for server_name, server_html in zip(
cycle(SERVERS_AVAILABLE), servers_html
):
try:
# keys: [ data-type: translation_type, data-id: embed_id, data-server-id: server_id ]
servers_info = extract_attributes(server_html)
embed_url = f"https://hianime.to/ajax/v2/episode/sources?id={servers_info['data-id']}"
embed_response = self.session.get(embed_url)
if embed_response.status_code == 200:
embed_json = embed_response.json()
raw_link_to_streams = embed_json["link"]
match = LINK_TO_STREAMS_REGEX.match(raw_link_to_streams)
if not match:
continue
provider_domain = match.group(1)
embed_type = match.group(2)
episode_number = match.group(3)
source_id = match.group(4)
link_to_streams = f"https://{provider_domain}/embed-{embed_type}/ajax/e-{episode_number}/getSources?id={source_id}"
link_to_streams_response = self.session.get(link_to_streams)
if link_to_streams_response.status_code == 200:
juicy_streams_json: "AniWatchStream" = (
link_to_streams_response.json()
)
yield {
"headers": {},
"subtitles": [
{
"url": track["file"],
"language": track["label"],
}
for track in juicy_streams_json["tracks"]
if track["kind"] == "captions"
],
"server": server_name,
"episode_title": episode_details["title"],
"links": give_random_quality(
[
{"link": link["file"], "type": link["type"]}
for link in juicy_streams_json["sources"]
]
),
}
except Exception as e:
logger.error(e)
except Exception as e:
logger.error(e)

View File

@@ -0,0 +1,26 @@
from typing import Literal, TypedDict
class AniWatchSkipTime(TypedDict):
start: int
end: int
class AniWatchSource(TypedDict):
file: str
type: str
class AniWatchTrack(TypedDict):
file: str
label: str
kind: Literal["captions", "thumbnails", "audio"]
class AniWatchStream(TypedDict):
sources: list[AniWatchSource]
tracks: list[AniWatchTrack]
encrypted: bool
intro: AniWatchSkipTime
outro: AniWatchSkipTime
server: int

View File

@@ -0,0 +1,15 @@
import logging
from requests import get
logger = logging.getLogger(__name__)
def fetch_anime_info_from_bal(anilist_id):
try:
url = f"https://raw.githubusercontent.com/bal-mackup/mal-backup/master/anilist/anime/{anilist_id}.json"
response = get(url, timeout=11)
if response.status_code == 200:
return response.json()
except Exception as e:
logger.error(e)

View File

@@ -0,0 +1,153 @@
import logging
from typing import TYPE_CHECKING
from requests import post
from thefuzz import fuzz
if TYPE_CHECKING:
from ..anilist.types import AnilistDataSchema
logger = logging.getLogger(__name__)
ANILIST_ENDPOINT = "https://graphql.anilist.co"
"""
query($query:String){
Page(perPage:50){
pageInfo{
total
currentPage
hasNextPage
}
media(search:$query,type:ANIME){
id
idMal
title{
romaji
english
}
episodes
status
nextAiringEpisode {
timeUntilAiring
airingAt
episode
}
}
}
}
"""
def search_for_anime_with_anilist(anime_title: str):
query = """
query($query:String){
Page(perPage:50){
pageInfo{
total
currentPage
hasNextPage
}
media(search:$query,type:ANIME){
id
idMal
title{
romaji
english
}
episodes
status
nextAiringEpisode {
timeUntilAiring
airingAt
episode
}
}
}
}
"""
response = post(
ANILIST_ENDPOINT,
json={"query": query, "variables": {"query": anime_title}},
timeout=10,
)
if response.status_code == 200:
anilist_data: "AnilistDataSchema" = response.json()
return {
"pageInfo": anilist_data["data"]["Page"]["pageInfo"],
"results": [
{
"id": anime_result["id"],
"title": anime_result["title"]["romaji"]
or anime_result["title"]["english"],
"type": "anime",
"availableEpisodes": list(
range(
1,
(
anime_result["episodes"]
if not anime_result["status"] == "RELEASING"
and anime_result["episodes"]
else (
anime_result["nextAiringEpisode"]["episode"] - 1
if anime_result["nextAiringEpisode"]
else 0
)
),
)
),
}
for anime_result in anilist_data["data"]["Page"]["media"]
],
}
def get_mal_id_and_anilist_id(anime_title: str) -> "dict[str,int] | None":
"""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
"""
query = """
query($query:String){
Page(perPage:50){
pageInfo{
total
currentPage
hasNextPage
}
media(search:$query,type:ANIME){
id
idMal
title{
romaji
english
}
}
}
}
"""
try:
variables = {"query": anime_title}
response = post(
ANILIST_ENDPOINT,
json={"query": query, "variables": variables},
timeout=10,
)
anilist_data: "AnilistDataSchema" = response.json()
if response.status_code == 200:
anime = max(
anilist_data["data"]["Page"]["media"],
key=lambda anime: max(
(
fuzz.ratio(anime, str(anime["title"]["romaji"])),
fuzz.ratio(anime_title, str(anime["title"]["english"])),
)
),
)
return {"id_anilist": anime["id"], "id_mal": anime["idMal"]}
except Exception as e:
logger.error(f"Something unexpected occured {e}")

View File

@@ -39,9 +39,20 @@ class AnimeEpisodeDetails(TypedDict):
raw: list[str] raw: list[str]
class AnimeEpisode(TypedDict): #
# class AnimeEpisode(TypedDict):
# id: str
# title: str
#
class AnimeEpisodeInfo(TypedDict):
id: str id: str
title: str title: str
episode: str
poster: str | None
duration: str | None
translation_type: str | None
class Anime(TypedDict): class Anime(TypedDict):
@@ -49,7 +60,7 @@ class Anime(TypedDict):
title: str title: str
availableEpisodesDetail: AnimeEpisodeDetails availableEpisodesDetail: AnimeEpisodeDetails
type: str | None type: str | None
episodesInfo: list[AnimeEpisode] | None episodesInfo: list[AnimeEpisodeInfo] | None
poster: str poster: str
year: str year: str
@@ -60,12 +71,19 @@ class EpisodeStream(TypedDict):
hls: bool | None hls: bool | None
mp4: bool | None mp4: bool | None
priority: int | None priority: int | None
headers: dict | None
quality: Literal["360", "720", "1080", "unknown"] quality: Literal["360", "720", "1080", "unknown"]
translation_type: Literal["dub", "sub"] translation_type: Literal["dub", "sub"]
class Subtitle(TypedDict):
url: str
language: str
class Server(TypedDict): class Server(TypedDict):
headers: dict
subtitles: list[Subtitle]
audio: list
server: str server: str
episode_title: str episode_title: str
links: list[EpisodeStream] links: list[EpisodeStream]

View File

@@ -35,15 +35,23 @@ hex_to_char = {
} }
def give_random_quality(links: list[dict]): def give_random_quality(links):
qualities = cycle(["1080", "720", "480", "360"]) qualities = cycle(["1080", "720", "480", "360"])
return [ return [
{"link": link["link"], "quality": quality} {**episode_stream, "quality": quality}
for link, quality in zip(links, qualities) for episode_stream, quality in zip(links, qualities)
] ]
def one_digit_symmetric_xor(password: int, target: str):
def genexp():
for segment in bytearray.fromhex(target):
yield segment ^ password
return bytes(genexp()).decode("utf-8")
def decode_hex_string(hex_string): def decode_hex_string(hex_string):
"""some of the sources encrypt the urls into hex codes this function decrypts the urls """some of the sources encrypt the urls into hex codes this function decrypts the urls

12
poetry.lock generated
View File

@@ -845,13 +845,13 @@ testing = ["covdefaults (>=2.3)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytes
[[package]] [[package]]
name = "pyright" name = "pyright"
version = "1.1.375" version = "1.1.376"
description = "Command line wrapper for pyright" description = "Command line wrapper for pyright"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "pyright-1.1.375-py3-none-any.whl", hash = "sha256:4c5e27eddeaee8b41cc3120736a1dda6ae120edf8523bb2446b6073a52f286e3"}, {file = "pyright-1.1.376-py3-none-any.whl", hash = "sha256:0f2473b12c15c46b3207f0eec224c3cea2bdc07cd45dd4a037687cbbca0fbeff"},
{file = "pyright-1.1.375.tar.gz", hash = "sha256:7765557b0d6782b2fadabff455da2014476404c9e9214f49977a4e49dec19a0f"}, {file = "pyright-1.1.376.tar.gz", hash = "sha256:bffd63b197cd0810395bb3245c06b01f95a85ddf6bfa0e5644ed69c841e954dd"},
] ]
[package.dependencies] [package.dependencies]
@@ -1157,13 +1157,13 @@ files = [
[[package]] [[package]]
name = "tox" name = "tox"
version = "4.17.1" version = "4.18.0"
description = "tox is a generic virtualenv management and test command line tool" description = "tox is a generic virtualenv management and test command line tool"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "tox-4.17.1-py3-none-any.whl", hash = "sha256:2974597c0353577126ab014f52d1a399fb761049e165ff34427f84e8cfe6c990"}, {file = "tox-4.18.0-py3-none-any.whl", hash = "sha256:0a457400cf70615dc0627eb70d293e80cd95d8ce174bb40ac011011f0c03a249"},
{file = "tox-4.17.1.tar.gz", hash = "sha256:2c41565a571e34480bd401d668a4899806169a4633e972ac296c54406d2ded8a"}, {file = "tox-4.18.0.tar.gz", hash = "sha256:5dfa1cab9f146becd6e351333a82f9e0ade374451630ba65ee54584624c27b58"},
] ]
[package.dependencies] [package.dependencies]

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "fastanime" name = "fastanime"
version = "1.6.2.dev1" version = "2.3.2"
description = "A browser anime site experience from the terminal" description = "A browser anime site experience from the terminal"
authors = ["Benextempest <benextempest@gmail.com>"] authors = ["Benextempest <benextempest@gmail.com>"]
license = "UNLICENSE" license = "UNLICENSE"

View File

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