Compare commits

...

374 Commits

Author SHA1 Message Date
benexl
677f4690fa feat(anilist): make perPage configurable 2024-12-07 21:49:37 +03:00
Benexl
a79b59f727 Merge pull request #38 from she11sh0cked/master
fix(nix): add pypresence to flake.nix
2024-12-03 15:54:12 +03:00
she11sh0cked
5641c245e7 fix(nix): add pypresence to flake.nix 2024-12-03 13:49:52 +01:00
Benexl
058fc285cd Merge pull request #36 from gand0rf/master
Added pypresence discord function
2024-12-03 12:47:46 +03:00
Benexl
71cfe667c9 Merge branch 'master' into master 2024-12-03 12:39:56 +03:00
benex
d9692201aa fix: type errors 2024-12-03 12:38:29 +03:00
benex
1fd4087b41 fix: type errors 2024-12-03 12:32:08 +03:00
benex
787eb0c9ca refactor: conform all provider functions 2024-12-03 12:29:47 +03:00
benex
acd937f8ab fix(allanime): argument parsing 2024-12-03 12:07:53 +03:00
Gand0rf
52af68d13f minor chagne to discord.py 2024-12-02 20:26:49 -05:00
Gand0rf
1ff3074fad updated discord.py 2024-12-02 20:11:40 -05:00
Gand0rf
debaa2ffa6 Moved where threading switch is created to remove error 2024-12-02 19:37:49 -05:00
Gand0rf
5b6ccbe748 Fixing erros per github python test 2024-12-02 19:32:07 -05:00
Gand0rf
d6ca923951 Delete line from config.py 2024-12-02 19:25:50 -05:00
Gand0rf
0e9bf7f2de added pypresence with uv command 2024-12-02 19:16:25 -05:00
Gand0rf
ccad2435b0 Added pypresence discord function 2024-12-02 19:08:51 -05:00
benex
30fa9851dd feat(animepahe): refactor API calls to use query parameters and improve stream retrieval logic 2024-12-03 00:25:18 +03:00
benex
000bae9bb7 refactor(animepahe): update import path for process_animepahe_embed_page 2024-12-02 23:57:18 +03:00
benex
8c2bb71e08 feat(animepahe): enhance search functionality and improve error handling in episode retrieval 2024-12-02 23:54:35 +03:00
benex
57393b085a feat(animepahe): refactor episode stream retrieval and enhance error handling 2024-12-02 23:46:28 +03:00
benex
5f721847d7 feat(allanime): improve error handling for stream source retrieval 2024-12-02 23:19:18 +03:00
benex
383cb62ede style: format files 2024-12-02 22:26:03 +03:00
benex
434ac947dd chore: add pre-commit as dev dep 2024-12-02 21:06:48 +03:00
benex
d0fb39cede style: format files with ruff 2024-12-02 18:01:20 +03:00
benex
f98ae77587 feat(allanime): enhance stream source handling with new cases and regex for MP4 server (mp4-upload: success) 2024-12-02 16:27:58 +03:00
benex
33e1b0fb6f refactor(allanime): introduce default constants for search parameters and improve code readability 2024-12-02 12:19:03 +03:00
benex
7134702eb9 feat(allanime): add command-line interface for anime search and streaming 2024-12-02 12:08:38 +03:00
benex
cac7586a86 refactor(constants): update API_ENDPOINT to use f-string for improved readability 2024-12-02 12:08:37 +03:00
benex
0b9da27def refactor(allanime): enhance error handling in API response processing 2024-12-02 12:08:37 +03:00
benex
ddbb4ca451 refactor(anime_provider): simplify URL processing in AllAnime class 2024-12-02 12:08:37 +03:00
benex
757393aa36 refactor(allanime): update constants and improve naming for clarity 2024-12-02 12:08:37 +03:00
benex
eb54d5e995 chore: add VSCode settings for Python auto import completions 2024-12-02 12:08:37 +03:00
benex
0d95a38321 refactor(anime_provider): replace PROVIDER attribute with class name for improved clarity 2024-12-02 12:08:37 +03:00
benex
8d2734db74 refactor(allanime): streamline API methods and improve naming conventions 2024-12-02 12:08:37 +03:00
benex
b3abcb958b refactor: simplify debug_provider decorator and remove redundant provider name usage 2024-12-02 12:08:37 +03:00
benex
0667749e4c refactor: rename API classes for consistency and clarity 2024-12-02 12:08:37 +03:00
Benexl
57e73e6799 Update README.md 2024-11-28 20:00:45 +03:00
Benexl
7d890b9719 Update README.md 2024-11-28 19:51:28 +03:00
benex
8cbbcf458d chore: update lock files 2024-11-28 09:16:32 +03:00
benex
67bc25a527 chore: bump version (v2.8.4) 2024-11-28 09:16:23 +03:00
benex
e668f9326a feat(anilist): add support for relations and recommendations 2024-11-25 14:16:07 +03:00
benex
a02db6471f fix(hianim) always use fresh requests in extractors 2024-11-25 12:53:16 +03:00
Benexl
08b1f0c90c Merge pull request #31 from iMithrellas/feature-menu-order
feat: add  menu order feature
2024-11-24 22:56:31 +03:00
Benexl
3ec8dbee8c Merge branch 'master' into feature-menu-order 2024-11-24 22:55:19 +03:00
iMithrellas
473c11faca Added 'menu_order' into the default object and default config. 2024-11-24 20:45:23 +01:00
benex
320e3799d3 chore: update username 2024-11-24 15:05:33 +03:00
benex
a0f28ddf6d feat(cli): don't check for update if running notifier 2024-11-24 14:55:38 +03:00
benex
9512c3530a feat: check for updates after every 12hrs 2024-11-24 14:43:43 +03:00
iMithrellas
72602a0ec1 Removed an accidentaly added import by my LSP. 2024-11-24 12:02:23 +01:00
iMithrellas
4daf6a2b07 Merge branch 'Benexl:master' into feature-menu-order 2024-11-24 11:49:24 +01:00
benex
8b37927f6a fix(anilist-download): prefer romaji title over english when searching provider 2024-11-24 13:44:02 +03:00
benex
9d6f785a7f fix(anilist-interface): check if both total and stop time are defined 2024-11-24 13:26:57 +03:00
benex
897c34d98c chore(hianime): include source to solution 2024-11-24 13:26:28 +03:00
benex
28c75215bd chore: update flake.nix 2024-11-24 13:26:08 +03:00
benex
8697b27fe0 Merge branch 'hianime'
Recovers HD2 server of hianime
2024-11-24 13:14:22 +03:00
benex
b6e05c877b feat(hianime): finish HD2 server recovery 2024-11-24 13:10:52 +03:00
benex
d8c3ba6181 feat(hianime): finish megacloud extractor 2024-11-24 13:10:08 +03:00
benex
8b5c917038 Merge branch 'master' into hianime 2024-11-24 12:44:02 +03:00
Benexl
856f62c245 Update README.md 2024-11-24 11:48:30 +03:00
Vlastimil Urban
02dfc9d71c feat: configurable main_menu 2024-11-24 02:17:30 +01:00
benex
cef0bae528 feat(anilist-interface): finish next and previous page implementation 2024-11-23 17:44:53 +03:00
benex
4867720ad2 feat: implement experimental next and previous page. 2024-11-23 17:44:53 +03:00
benex
8d85e30150 feat(runtime): add current-page and current-data-loader to runtime 2024-11-23 17:44:53 +03:00
benex
eb99b7e6ba feat(anilist_api): also make the page configurable 2024-11-23 17:44:53 +03:00
Benexl
089c049f26 Merge pull request #28 from Type-Delta/psflag-fix
Fix(downloader): corrupted Parametric Stereo (PS) flag in downloaded .m3u8 videos
2024-11-23 14:15:30 +03:00
benex
a33e47d205 fix(config): use separate var for the config file val 2024-11-23 14:08:48 +03:00
benex
25dc35eaaf feat: print update message to stderr + disable auto check for updates (needs better implementation) 2024-11-23 14:08:22 +03:00
Type-Delta
525586e955 Merge branch 'Benexl:master' into psflag-fix 2024-11-23 17:11:58 +07:00
Type-Delta
5129219e23 fix: added --force-ffmpeg & --hls-use-mpegts options to properly handle some m3u8 streams 2024-11-23 17:10:31 +07:00
benex
7cd97c78b1 feat(config): make the max_cache_lifetime configurable 2024-11-22 23:21:58 +03:00
benex
27b4422ef3 feat(requests_cacher): make more reliable by ordering the results by created_at 2024-11-22 23:21:32 +03:00
benex
1c367c8aa1 feat(anilist-interface): add resume flag to auto continue from the most recent anime 2024-11-22 22:28:12 +03:00
Benex
7b6cc48b90 Update README.md 2024-11-22 09:56:26 +03:00
Benex
812d0110a7 Update README.md 2024-11-22 08:41:04 +03:00
Benex
60b05bf0ac Merge pull request #29 from Pixelizer09/master
feat: Update data.py
2024-11-22 08:22:12 +03:00
benex
d830cca3bc feat: init resurrection hianime 2024-11-22 08:14:45 +03:00
Pixelizer09
209e93b6d9 Merge branch 'master' into master 2024-11-22 11:28:44 +08:00
Pixelizer09
b10d9dc39a Update data.py 2024-11-22 11:27:29 +08:00
Benex
fe8cda094c Update README.md 2024-11-21 18:55:55 +03:00
Benex
33c06eab0a Update README.md 2024-11-21 18:07:13 +03:00
benex
f3f4be7410 chore: update lock files 2024-11-21 16:52:30 +03:00
benex
3915ef0fb6 chore: bump version (v2.8.3) 2024-11-21 16:52:23 +03:00
benex
20d26166dd docs: update readme 2024-11-21 16:50:07 +03:00
benex
ddca724bd8 chore: update deps 2024-11-21 16:40:21 +03:00
benex
b86c1a0479 feat: add fastanime anilist download beta 2024-11-21 16:40:09 +03:00
benex
1fa7830ddf Merge branch 'master' into fastanime-anilist-download 2024-11-21 15:35:36 +03:00
Benex
59abafbe16 Update README.md 2024-11-21 13:16:56 +03:00
Benex
b6eebb9736 Update README.md 2024-11-21 10:43:19 +03:00
benex
7797053102 chore: update lock files 2024-11-21 10:30:06 +03:00
benex
d763445f72 chore: bump version (v2.8.2) 2024-11-21 10:30:03 +03:00
benex
7bc6b14b5f docs: update config file docs 2024-11-21 10:29:39 +03:00
benex
f70d2ac8af feat(make_release): update to include nix files 2024-11-21 10:29:39 +03:00
benex
defdfc5a47 refactor: update todo 2024-11-21 10:29:39 +03:00
benex
e67eeda492 feat(updater): add ability to update on nix 2024-11-21 10:29:39 +03:00
benex
a17588d02c fix(interfaces): episode previews 2024-11-21 10:29:39 +03:00
Benex
67b59305c4 Update README.md 2024-11-21 07:43:53 +03:00
Pixelizer09
61db9aeea6 Update data.py 2024-11-21 10:57:02 +08:00
Benex
4f0768a060 Merge pull request #24 from gand0rf/chafa_call_update
fix(anilist_interface):  Updated chafa call
2024-11-19 22:14:29 +03:00
Gand0rf
21704cbbea Updated chafa call 2024-11-19 14:00:06 -05:00
Benex
886bc4d011 Update README.md 2024-11-19 19:23:51 +03:00
Benex
e3437e066a Update README.md 2024-11-19 19:18:49 +03:00
Benex
8f2795843a Update README.md 2024-11-19 19:03:53 +03:00
Benex
c6290592e8 Merge pull request #23 from she11sh0cked/master
fix(cli): prevent update check during completions
2024-11-19 19:01:57 +03:00
she11sh0cked
050ba740b8 fix(cli): prevent update check during completions 2024-11-19 15:24:55 +01:00
benex
0b1a27b223 chore: bump version (v2.8.1) 2024-11-19 14:08:42 +03:00
benex
bafd04b788 chore: update lock file 2024-11-19 14:08:08 +03:00
benex
fb5f51eea5 feat(config): add customization options 2024-11-19 14:08:08 +03:00
benex
799e1f0681 fix(updater): handle no internet 2024-11-19 14:08:08 +03:00
benex
53a2d953f8 feat: enable customization of the preview window 2024-11-19 14:08:08 +03:00
Benex
9ce5bc3c76 Update README.md 2024-11-19 11:38:33 +03:00
benex
dc58fc8536 chore: bump version (v2.8.0) 2024-11-19 10:06:57 +03:00
benex
1d5c3016fc chore: update lock file 2024-11-19 10:06:38 +03:00
benex
8737aea746 fix(player): set character encoding for compatibility with windows 2024-11-19 10:06:38 +03:00
Benedict Xavier
bd03866f5e Update README.md 2024-11-19 08:40:09 +03:00
Benedict Xavier
81690a8015 Update README.md 2024-11-19 08:37:26 +03:00
Benedict Xavier
933112a52b Update README.md 2024-11-19 08:34:23 +03:00
Benedict Xavier
eb513dfe0e Update README.md 2024-11-19 08:23:45 +03:00
Benedict Xavier
3928b77506 Update README.md 2024-11-19 08:17:44 +03:00
Benedict Xavier
95cb2bd78c Update README.md 2024-11-19 08:03:33 +03:00
Benedict Xavier
4fa1c45eb2 Update DISCLAIMER.md 2024-11-19 08:02:41 +03:00
Benedict Xavier
b9051bc792 Create DISCLAIMER.md 2024-11-19 08:01:23 +03:00
Benedict Xavier
a590024f1c Merge pull request #15 from piradata/patch-1
Update README.md typo
2024-11-19 07:42:10 +03:00
Benedict Xavier
2f51936679 Merge pull request #16 from AtticusHelvig/uvREADME
Update README.md for uv Instructions
2024-11-19 07:37:26 +03:00
Atticus Helvig
327c50d290 Update README.md 2024-11-18 20:32:38 -07:00
Piradata
031dfbb9b5 Update README.md typo 2024-11-19 00:15:47 -03:00
benex
050365302a chore: bump flake.nix 2024-11-18 20:10:21 +03:00
benex
0f248b1119 chore: update flake.nix to include external deps and all python deps 2024-11-18 20:04:57 +03:00
benex
871d5cf758 chore: add result to .gitignore 2024-11-18 20:04:22 +03:00
benex
320376d2e8 chore: bump version (v2.7.9) 2024-11-18 18:19:21 +03:00
benex
02e7fdff6f chore: update lock file 2024-11-18 18:17:03 +03:00
benex
2c5c28f295 refactor(config): remove useless functions 2024-11-18 18:17:03 +03:00
benex
2d3509ccc1 chore: bump version (v2.7.8) 2024-11-18 17:10:26 +03:00
benex
30babf2d69 feat(cli): alert the user that the cli is checking for updates 2024-11-18 17:10:10 +03:00
benex
cfbbabf898 chore: update uv.lock 2024-11-18 17:10:10 +03:00
benex
5ac6c45fdf fix(allanime_provider): give all the servers the referer header 2024-11-18 17:10:10 +03:00
Benedict Xavier
a14645b563 Update README.md 2024-11-18 15:17:32 +03:00
Benedict Xavier
90dbc26c46 Merge pull request #9 from KaylorBen/master
fix: for nix flake
2024-11-18 13:31:31 +03:00
Benjamin Kaylor
54cc830c35 sed flake preBuild 2024-11-18 03:23:32 -07:00
Benjamin Kaylor
4928ff5b74 undo pyproject.toml edit 2024-11-18 03:20:55 -07:00
Benjamin Kaylor
bb481fe21a fix for nix flake 2024-11-17 16:21:56 -07:00
benex
0d27b8f652 chore: bump version (v2.7.7) 2024-11-17 20:08:37 +03:00
benex
bdd3aae399 chore: update lockfile 2024-11-17 20:08:08 +03:00
benex
af94cd7eb5 fix: recent anime 2024-11-17 20:07:46 +03:00
benex
54044f9527 chore: bump version (v2.7.6) 2024-11-17 18:46:42 +03:00
benex
1e5c039ece chore: update flake.nix 2024-11-17 18:45:32 +03:00
benex
15555759dc feat: ask user if they want to update on new release 2024-11-17 18:45:17 +03:00
benex
0ed51e05cc chore: fix error is in flake.nix 2024-11-17 17:23:41 +03:00
benex
634ef6febf chore: update lock file 2024-11-17 14:28:02 +03:00
benex
bda4b2dbe1 chore: add flake.nix 2024-11-17 14:27:41 +03:00
benex
f015305e7c chore: add shell.nix 2024-11-17 14:27:32 +03:00
benex
d32b7e917f chore: bump version (v2.7.5) 2024-11-16 22:47:31 +03:00
benex
3b35e80199 chore: update lock file 2024-11-16 22:47:19 +03:00
benex
c65a1a2815 feat: by default check for updates when any command is ran 2024-11-16 22:47:04 +03:00
benex
0b3615c9f5 feat: add default rofi themes 2024-11-16 22:46:19 +03:00
benex
3ac4e1ac71 chore: bump version (v2.7.4) 2024-11-16 22:10:15 +03:00
benex
d62f580d7a docs: update readme 2024-11-16 22:09:41 +03:00
benex
02e35b66cb fix: implement another way to get timestamps from mpv due to issues on nixos 2024-11-16 22:09:41 +03:00
benex
7b11e0a301 feat: add rofi-theme-preview option 2024-11-16 22:09:41 +03:00
benex
aa8b91aed3 chore: remove unnecessary yt-dlp extras 2024-11-16 22:09:41 +03:00
benex
fe0fa97576 fix(player): workaround weird problem with mpv in nixos 2024-11-16 22:09:41 +03:00
Benedict Xavier
92059cd5ed Update README.md 2024-11-16 12:01:55 +03:00
benex
ed3064e3b1 feat(anime_provider): add yugen provider 2024-11-13 20:53:18 +03:00
Benex254
441d1e5e6c chore: bump version (v2.7.3) 2024-11-11 12:57:50 +03:00
Benex254
653b2cf4eb chore: add pyinstaller as dev dep 2024-11-11 12:57:29 +03:00
Benex254
8d4b71e0c8 feat: add entry point for pyinstaller executable 2024-11-11 12:57:29 +03:00
Benex254
29cc6cad09 build: pyinstaller spec 2024-11-11 12:57:28 +03:00
Benex254
8119eef263 docs(readme): update table of contents 2024-11-11 12:57:28 +03:00
Benex254
912c8674cf refactor(player): remove unnecessary orint statement 2024-11-11 12:57:28 +03:00
Benex254
6b3ca236dd fix: auto next episode 2024-11-11 12:57:28 +03:00
benex
f1c352d4ff chore: bump version (v2.7.2) 2024-11-10 12:29:46 +03:00
benex
714533d845 refactor(cli): update config options and set fastanime config environs 2024-11-10 12:06:44 +03:00
benex
56dd25df8d refactor(cli): update config options
This commit updates the configuration options in the CLI module. Specifically, it modifies the "image_previews" option to be platform-dependent, setting it to "True" for non-Windows platforms and "False" for Windows. Additionally, it sets the "normalize_titles" option to "True". These changes improve the behavior and user experience of the CLI.
2024-11-10 12:06:17 +03:00
benex
8248dc53df fix: text preview not showing on windows 2024-11-10 12:05:32 +03:00
Benex254
1a8a187de6 docs: update readme 2024-11-09 00:27:49 +03:00
Benex254
bc86be8c93 chore: bump version 2024-11-09 00:11:20 +03:00
Benex254
75026d4fc5 feat(api): add watch endpoint 2024-11-09 00:11:20 +03:00
Benedict Xavier
f8a5ccb8d2 Update README.md 2024-11-08 22:33:16 +03:00
Benex254
719d1bd187 chore: update deps 2024-11-08 16:26:43 +03:00
Benex254
0dd83463c6 docs: update readme 2024-10-20 11:28:39 +03:00
Benex254
966301bce8 feat: register fastanime anilist download(s) 2024-10-20 10:39:53 +03:00
Benex254
d776880306 feat: init fastanime anilist download(s) 2024-10-20 10:14:03 +03:00
Benex254
1ee50e8a55 chore: bump version (v2.6.9) 2024-10-20 10:06:02 +03:00
Benex254
ae95c5ea3d docs: update readme 2024-10-20 10:04:58 +03:00
Benex254
d64ad5e11d fix: move quality to stream section in config 2024-10-20 10:03:49 +03:00
Benex254
d1a47c6d44 chore: bump version (v2.6.8) 2024-10-18 22:59:18 +03:00
Benex254
51a834a62f chore: update deps 2024-10-18 22:53:39 +03:00
Benex254
3a030bf6f7 feat: add ability to update fastanime uv installations 2024-10-18 22:53:26 +03:00
Benex254
eb6a6fc82c chore: use uv in fa script 2024-10-18 22:46:44 +03:00
Benex254
437ccd94e4 ci: update to use uv 2024-10-18 22:37:14 +03:00
Benex254
d65868cc30 chore: update workflows to work with uv 2024-10-18 21:50:20 +03:00
Benex254
8678aa6544 Merge branch 'master' into uv 2024-10-18 20:26:55 +03:00
Benex254
00e5141152 chore: bump version (v2.6.7) 2024-10-12 01:08:14 +03:00
Benex254
90e757dfe1 feat: init switch to uv 2024-10-11 11:57:29 +03:00
Benex254
8b471b08e8 chore: init switch to uv 2024-10-11 10:52:18 +03:00
Benex254
158bc5710f docs: update readme 2024-10-11 10:49:53 +03:00
Benex254
a0b946a13d feat: add recent menu 2024-10-11 10:22:23 +03:00
Benex254
b547b75f03 feat: add environment variable that force updating of the cache db 2024-10-11 09:34:40 +03:00
Benex254
58c7427a47 feat(cli:serve): use the full executable path to python 2024-10-06 01:25:22 +03:00
Benex254
6220b9c55d chore: bump version (v2.6.6) 2024-10-06 01:15:15 +03:00
Benex254
6b9b5c131c fix(cli): use str instead of ints in serve 2024-10-06 01:15:05 +03:00
Benex254
212f2af39c chore: bump version (v2.6.5) 2024-10-06 01:05:28 +03:00
Benex254
f7b2b4e0c9 feat: add serve command 2024-10-06 01:04:20 +03:00
Benedict Xavier
a747529279 Update README.md 2024-10-05 19:37:19 +03:00
Benex254
1dfdcc27ce chore: bump version (v2.6.4) 2024-10-05 12:33:32 +03:00
Benex254
3c03289453 fix: add git push to make_release 2024-10-05 12:33:23 +03:00
Benex254
06fd446a72 chore: bump version (v2.6.3) 2024-10-05 12:29:29 +03:00
Benex254
172d912d8b chore(release): improve the make release script to also stage changes after bumping version 2024-10-05 12:29:15 +03:00
Benex254
2396018607 feat: make script to automate releases 2024-10-05 12:19:03 +03:00
Benex254
a9be9779c5 feat(fa): improve fa script 2024-10-05 12:14:45 +03:00
Benex254
2f76b26a99 feat(fzf): add some bindings 2024-10-05 11:54:22 +03:00
Benex254
2fe5edf810 feat(cli): make all threads daemon threads 2024-10-05 11:47:52 +03:00
Benex254
d67ee6a779 feat(downloader): add progress hook option to be passed to yt-dlp 2024-10-05 11:47:30 +03:00
Benex254
e06ec5dbd4 feat(cli): make the image previews optional 2024-10-05 11:31:13 +03:00
Benex254
c1b24ba2aa feat(cli): save images with .png extenstion to enable easier viewing by external apps 2024-10-05 11:05:07 +03:00
Benex254
59e9cf9fd0 feat: improve previews 2024-10-05 10:12:14 +03:00
Benex254
58761f5b96 chore: bump version 2024-10-04 19:44:54 +03:00
Benex254
ac959da229 feat: renable bg downloading function 2024-10-04 19:42:53 +03:00
benex
bacc8c48ec fix: image previews not showing up on windows 2024-10-04 11:03:54 +03:00
Benex254
905a159428 chore: add a mapping for re:zero s3 in normalizer 2024-10-03 15:09:51 +03:00
Benex254
20f734cab2 feat: also compare synonymns 2024-10-03 15:09:14 +03:00
Benex254
7c2c644aef chore: bump version 2024-10-03 14:18:22 +03:00
Benex254
0efc92081a feat: use .get in normlizer 2024-10-03 14:18:05 +03:00
Benex254
fafeee2367 chore: bump version 2024-10-03 12:48:41 +03:00
Benex254
e03063cd76 feat: let configuration of providers be managed by AnimeProvider wrapper 2024-10-03 12:34:34 +03:00
Benex254
93b38b055f docs: update readme 2024-10-03 12:33:52 +03:00
Benex254
045635fb55 feat: update config.py 2024-10-03 12:33:40 +03:00
Benex254
de7f773e9e feat: make the threads non-daemon 2024-10-03 11:47:27 +03:00
Benex254
ef6a465bd2 fix: typing issue 2024-10-02 22:19:02 +03:00
Benex254
0c623af8a4 chore: update poetry lockfile 2024-10-02 21:57:17 +03:00
Benex254
0589f83998 chore: bump version 2024-10-02 21:56:50 +03:00
Benex254
e17608afd5 feat: add provider store 2024-10-02 21:33:41 +03:00
Benex254
b915654685 feat: make the requests cache allow multiple connections by switching to wal 2024-10-02 16:49:36 +03:00
Benex254
2ce9bf6c47 feat: use a more meaningful name for the request caching files 2024-09-30 13:20:14 +03:00
Benex254
3c22232432 feat: add option to delete the db file 2024-09-30 13:19:33 +03:00
Benex254
3474e9520c tests: pass custom env 2024-09-29 22:09:57 +03:00
Benex254
e9bacf4f9c fix: extra atexit callbacks 2024-09-29 21:31:58 +03:00
Benex254
ef422ed6fd fix: inability to reload provider dynamically when using cached sessions 2024-09-29 21:19:15 +03:00
Benex254
d0f5366908 feat: allow access of fastanime config from environment variables 2024-09-29 21:00:41 +03:00
Benex254
3557205feb chore: cleanup requests cacher 2024-09-29 20:41:55 +03:00
Benex254
ba4c41d888 feat: implement usage of the requests cacher 2024-09-29 20:40:14 +03:00
Benex254
1427a3193c feat: implement requests cacher for fastanime 2024-09-29 20:39:27 +03:00
Benex254
b5cee20e56 fix: episodes range generated by mini anilist 2024-09-28 10:31:33 +03:00
Benex254
be7f464073 chore: update deps 2024-09-24 15:56:09 +03:00
Benex254
c7f8f168f5 docs: update readme 2024-09-24 15:55:59 +03:00
Benex254
ba59fbdcb0 chore: bump version 2024-09-24 15:55:47 +03:00
Benex254
9f54fa4998 feat: handle abscence of webtorrent-cli 2024-09-24 15:55:24 +03:00
Benex254
3c9688b32c feat: add nyaa as provider 2024-09-24 15:45:34 +03:00
Benex254
1f046447bb chore: update all instances of aniwatch to hianime 2024-09-24 10:02:49 +03:00
Benex254
87e3a275bb chore: bump version 2024-09-23 11:29:22 +03:00
Benex254
037b5c36a4 fix: normalize unknown_video to mp4 2024-09-23 11:04:31 +03:00
Benex254
7d8b60fb14 fix: change aniwatch to hianime in data.py 2024-09-23 11:04:06 +03:00
Benex254
0ad16fee53 fix: typing issue in player 2024-09-22 22:34:07 +03:00
Benex254
249243aeb4 chore: use --all-extras flag in poetry install 2024-09-22 22:31:35 +03:00
Benex254
c208dc3579 chore: bump version 2024-09-22 22:27:04 +03:00
Benex254
ea93f2ba23 chore: make some dependencies optional 2024-09-22 22:26:37 +03:00
Benex254
d910a0bb6a chore: update depenedencies 2024-09-22 22:25:52 +03:00
Benex254
550fcfeddc feat: make plyer an optional dependency 2024-09-22 22:13:12 +03:00
Benex254
c6910e5a1c feat: improve prompt text 2024-09-22 22:13:12 +03:00
Benex254
8555edb521 feat: dont pass obj to providers 2024-09-22 22:13:12 +03:00
Benex254
139193ce29 chore: remove aniwave as a provider; you shall forever live in our hearts 2024-09-22 22:13:12 +03:00
Benex254
1a87375ccd feat: add debug mode for providers 2024-09-22 22:13:12 +03:00
BeneX254
83cbef40f6 Update README.md 2024-09-21 18:06:09 +03:00
Benex254
85b4fc75a1 docs: update the readme 2024-09-20 17:58:29 +03:00
Benex254
f2e2da378f feat: improved medi list tracking 2024-09-20 17:58:06 +03:00
Benex254
7c34bc9120 feat: restrict some genres in mini_anilist 2024-09-19 19:12:10 +03:00
Benex254
6f153f2acb feat: immprove help messages for all cli commands 2024-09-19 19:11:15 +03:00
Benex254
8171083978 chore: update deps 2024-09-18 20:10:35 +03:00
Benex254
db5b9a59b4 fix: fastanime update not working with pip installs 2024-09-18 20:09:34 +03:00
Benex254
6fa656ba11 chore: bump version 2024-09-18 19:59:54 +03:00
Benex254
de0682c1bb fix: invalid cmd 2024-09-18 19:59:40 +03:00
Benex254
a6a32d8de4 chore: bump version 2024-09-18 19:44:10 +03:00
Benex254
bb14b269de feat: add --player option 2024-09-18 19:42:47 +03:00
Benex254
14331d8bc2 feat: workaround image previews on android 2024-09-18 19:42:47 +03:00
BeneX254
1729464844 Update README.md 2024-09-17 21:44:35 +03:00
benex
5fb9747285 fix: default ui not persisting when using config --update 2024-09-15 14:59:01 +03:00
benex
394228d391 chore: bump version 2024-09-15 14:42:54 +03:00
benex
5d3c0cc6ec fix: unicode error on windows when writing the config file 2024-09-15 14:38:50 +03:00
BeneX254
3ef7c5248c Update README.md 2024-09-15 13:46:50 +03:00
Benex254
8bebc401fd fix: rename use_mpv_mod to use_python_mpv in config 2024-09-15 13:40:20 +03:00
Benex254
215b28457b docs: update readme 2024-09-15 13:39:58 +03:00
Benex254
dfd2bfc857 docs: update readme 2024-09-15 13:29:32 +03:00
Benex254
f991292e94 chore: bump version 2024-09-15 13:29:20 +03:00
Benex254
d837457f80 feat: improve config file docs 2024-09-15 13:22:14 +03:00
Benex254
343bdba31b feat: add the --update option to the config command which causes all config options passed to fastanime to be persisted to your config file 2024-09-15 13:22:14 +03:00
benex
1c1c2457e8 feat: improve the preview with a workaround 2024-09-15 10:05:20 +03:00
benex
b083bfb074 fix: previews not working on windows 2024-09-15 09:36:15 +03:00
benex
ea1abcb2ae feat: dont use roaming folder for the config file 2024-09-15 08:54:02 +03:00
benex
001030ba2b fix: unicode error when running fzf on wndows 2024-09-15 08:53:22 +03:00
BeneX254
eda8984781 Update README.md 2024-09-13 21:57:15 +03:00
Benex254
d8dc6f0a34 chore: bump version 2024-09-10 19:15:43 +03:00
Benex254
2d711a7a7f docs: update readme 2024-09-10 19:15:25 +03:00
Benex254
30ca25626a feat: add --titles option to downloads 2024-09-10 19:11:52 +03:00
Benex254
b1f5a558c8 feat: improve animepahe utils 2024-09-10 19:11:19 +03:00
Benex254
8062c8dc83 feat: stat command ?? 2024-08-23 20:51:53 +03:00
Benex254
cb7eed46bc docs: update readme 2024-08-23 17:44:23 +03:00
Benex254
4626eca89e feat: improvements on media list intergration 2024-08-23 17:44:10 +03:00
Benex254
0d549c5915 docs: update readme 2024-08-23 17:18:49 +03:00
Benex254
33c518ed4c chore: cleanup codebase 2024-08-23 17:18:36 +03:00
Benex254
8e155dcc74 chore: bump version 2024-08-23 16:05:45 +03:00
Benex254
7743b0423e chore: clean up codebase 2024-08-23 16:05:26 +03:00
Benex254
6346ea7343 docs: update readme 2024-08-23 11:40:08 +03:00
Benex254
32de01047f chore:bump version 2024-08-23 11:39:57 +03:00
Benex254
35c7f81afb fix: no chapter title 2024-08-23 11:39:45 +03:00
Benex254
2dbbb1c4df feat: add experimental manga support 2024-08-23 11:19:25 +03:00
Benex254
6a6efa9d56 chore: bump version 2024-08-22 20:39:44 +03:00
Benex254
e510dc3a11 docs: update readme 2024-08-22 20:39:15 +03:00
Benex254
9639fd8c05 feat: improve normalizing of titles 2024-08-22 20:35:43 +03:00
Benex254
add35ce682 chore: bump version 2024-08-22 19:09:31 +03:00
Benex254
6bcc77ea44 fix: incorrect episode regex 2024-08-22 19:09:00 +03:00
Benex254
1a72f88be3 docs: updaate readme 2024-08-22 18:31:23 +03:00
Benex254
1a9f1120b8 chore: bump version 2024-08-22 18:31:11 +03:00
Benex254
c2fc807688 feat: episode preview 2024-08-22 18:25:41 +03:00
Benex254
2b0ade093c feat: normalize anime titles 2024-08-22 17:32:53 +03:00
BeneX254
a26193706e Update README.md 2024-08-22 13:37:18 +03:00
BeneX254
ff3c57ef9b Update README.md 2024-08-22 13:31:53 +03:00
BeneX254
3b987bd07a Update README.md 2024-08-22 12:43:58 +03:00
BeneX254
e8474c0428 Update README.md 2024-08-22 12:37:43 +03:00
BeneX254
c78a759aa1 Update README.md 2024-08-22 00:38:46 +03:00
Benex254
d1aad70c48 feat: add awesome completions to search command 2024-08-21 23:49:39 +03:00
Benex254
62b36f3e58 fix: workaround over typing issue 2024-08-21 23:20:45 +03:00
Benex254
c5b905fb0d chore: update deps 2024-08-21 23:18:12 +03:00
Benex254
7d3dc671ed fix: workaround typing issue 2024-08-21 23:07:01 +03:00
Benex254
0ec3c7a5bb docs: update docs 2024-08-21 22:53:30 +03:00
Benex254
8e0619863a feat: search command 2024-08-21 22:53:18 +03:00
Benex254
e8a05ec4b8 feat: add dump json to anilist commands 2024-08-21 20:48:01 +03:00
Benex254
34e8b2abd1 feat: update download command 2024-08-21 19:45:57 +03:00
Benex254
161b6eb961 chore: bump version 2024-08-21 19:41:35 +03:00
Benex254
dd2090f85d docs: update 2024-08-21 19:41:01 +03:00
Benex254
8b1595a5da feat:update 2024-08-21 19:40:45 +03:00
Benex254
77ffa27ed8 chore: bump version 2024-08-21 17:37:09 +03:00
Benex254
15f79b65c9 feat: aniwave?? 2024-08-21 17:18:30 +03:00
Benex254
33c3af0241 chore: remove print and input statements 2024-08-21 16:00:52 +03:00
Benex254
9badde62fb feat: improve providers 2024-08-21 15:58:01 +03:00
Benex254
4e401dca40 fix: logging issue 2024-08-21 14:53:30 +03:00
Benex254
25422b1b7d feat: improve aniwatch provider api 2024-08-21 14:52:56 +03:00
Benex254
e8463f13b4 chore: reconfigure pyright 2024-08-21 11:42:48 +03:00
Benex254
556f42e41f fix: clean option of download command 2024-08-21 11:41:55 +03:00
Benex254
b99a4f7efc chore: bump version 2024-08-19 23:44:05 +03:00
Benex254
f6f45cf322 docs: update readme 2024-08-19 23:43:50 +03:00
Benex254
ae6db1847a feat: improve download functionality 2024-08-19 23:43:34 +03:00
Benex254
20d04ea07b feat(utils): add m3u8 quality selector 2024-08-19 17:27:52 +03:00
Benex254
8f3834453c chore: bump version 2024-08-19 15:28:04 +03:00
Benex254
7ad8b8a0e3 fix: return values 2024-08-19 15:25:36 +03:00
Benex254
80b41f06da feat:add new ui command 2024-08-19 15:25:05 +03:00
Benex254
e79321ed50 chore: bump version 2024-08-19 13:05:03 +03:00
Benex254
f7b5898dfa fix: some stuff 2024-08-19 13:04:30 +03:00
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
123 changed files with 11254 additions and 3828 deletions

View File

@@ -8,31 +8,24 @@ jobs:
debug_build:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Python
- name: "Set up Python"
uses: actions/setup-python@v5
- name: Install poetry
uses: abatilo/actions-poetry@v2
- name: Setup a local virtual environment (if no poetry.toml file)
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v3
name: Define a cache for the virtual environment based on the dependencies lock file
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies
run: poetry install
- name: build app
run: poetry build
enable-cache: true
- name: Build fastanime
run: uv build
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: fastanime_debug_build
path: |
dist
!dist/*.whl
# - name: Run the automated tests (for example)
# run: poetry run pytest -v

View File

@@ -27,11 +27,13 @@ jobs:
with:
python-version: "3.10"
- name: Build release distributions
run: |
# NOTE: put your own distribution build steps here.
python -m pip install build
python -m build
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Build fastanime
run: uv build
- name: Upload distributions
uses: actions/upload-artifact@v4

View File

@@ -6,37 +6,35 @@ on:
pull_request:
branches:
- master
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11"] # List the Python versions you want to test
steps:
- uses: actions/checkout@v4
- name: Install Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install poetry
uses: abatilo/actions-poetry@v2
- name: Setup a local virtual environment (if no poetry.toml file)
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v3
name: Define a cache for the virtual environment based on the dependencies lock file
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies
run: poetry install
- name: run linter, formatters and sort imports
run: |
poetry run black .
poetry run ruff check --output-format=github . --fix
poetry run isort . --profile black
- name: run type checking
run: poetry run pyright
- name: run tests
run: poetry run pytest
enable-cache: true
- name: Install the project
run: uv sync --all-extras --dev
- name: Run linter and formater
run: uv run ruff check --output-format=github
- name: Run type checking
run: uv run pyright
- name: Run tests
run: uv run pytest tests

1
.gitignore vendored
View File

@@ -176,3 +176,4 @@ app/View/SearchScreen/.search_screen.py.un~
app/View/SearchScreen/search_screen.py~
app/user_data.json
.buildozer
result

View File

@@ -1,5 +1,5 @@
default_language_version:
python: python3.10
python: python3.12
repos:
- repo: https://github.com/pycqa/isort
@@ -7,7 +7,7 @@ repos:
hooks:
- id: isort
name: isort (python)
args: ["--profile", "black"] # Ensure compatibility with Black
args: ["--profile", "black"]
- repo: https://github.com/PyCQA/autoflake
rev: v2.2.1
@@ -19,17 +19,15 @@ repos:
"--remove-unused-variables",
"--remove-all-unused-imports",
]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.4.10
hooks:
# Run the linter.
- id: ruff
args: [--fix]
# - repo: https://github.com/astral-sh/ruff-pre-commit
# rev: v0.4.10
# hooks:
# - id: ruff
# args: [--fix]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.4.2
hooks:
- id: black
name: black
language_version: python3.10 # to ensure compatibilty
#language_version: python3.10

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"python.analysis.autoImportCompletions": true
}

40
DISCLAIMER.md Normal file
View File

@@ -0,0 +1,40 @@
<h1 align="center">Disclaimer</h1>
<div align="center">
<h2>This project: fastanime</h2>
<br>
The core aim of this project is to co-relate automation and efficiency to extract what is provided to a user on the internet. All content available through the project is hosted by external non-affiliated sources.
<br>
<b>All content served through this project is publicly accessible. If your site is listed in this project, the code is pretty much public. Take necessary measures to counter the exploits used to extract content in your site.</b>
Think of this project as your normal browser, but a bit more straight-forward and specific. While an average browser makes hundreds of requests to get everything from a site, this project goes on to only make requests associated with getting the content served by the sites.
<b>
This project is to be used at the user's own risk, based on their government and laws.
This project has no control on the content it is serving, using copyrighted content from the providers is not going to be accounted for by the developer. It is the user's own risk.
</b>
<br>
<h2>DMCA and Copyright Infrigements</h3>
<br>
<b>
A browser is a tool, and the maliciousness of the tool is directly based on the user.
</b>
This project uses client-side content access mechanisms. Hence, the copyright infrigements or DMCA in this project's regards are to be forwarded to the associated site by the associated notifier of any such claims. This is one of the main reasons the sites are listed in this project.
<b>Do not harass the developer. Any personal information about the developer is intentionally not made public. Exploiting such information without consent in regards to this topic will lead to legal actions by the developer themselves.</b>

View File

@@ -1,10 +1,7 @@
FROM ubuntu
RUN apt-get update
RUN apt-get -y install python3
RUN apt-get update
RUN apt-get -y install pipx
RUN pipx ensurepath
FROM python:3.12-slim-bookworm
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY . /fastanime
ENV PATH=/root/.local/bin:$PATH
WORKDIR /fastanime
RUN pipx install .
RUN uv tool install .
CMD ["bash"]

440
README.md
View File

@@ -1,12 +1,54 @@
# FastAnime
<p align="center">
<h1 align="center">FastAnime</h1>
</p>
<p align="center">
<sup>
Browse anime from the terminal
</sup>
</p>
<div align="center">
![PyPI - Downloads](https://img.shields.io/pypi/dm/fastanime) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/FastAnime/FastAnime/test.yml?label=Tests)
![Discord](https://img.shields.io/discord/1250887070906323096?label=Discord)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/FastAnime/FastAnime)
![GitHub deployments](https://img.shields.io/github/deployments/FastAnime/fastanime/pypi?label=PyPi%20Publish)
![PyPI - License](https://img.shields.io/pypi/l/fastanime)
![Static Badge](https://img.shields.io/badge/lines%20of%20code-13k%2B-green)
</div>
Welcome to **FastAnime**, anime site experience from the terminal.
<p align="center">
<a href="https://discord.gg/HBEmAwvbHV">
<img src="https://invidget.switchblade.xyz/C4rhMA4mmK">
</a>
</p>
**fzf mode**
![fastanime](https://github.com/user-attachments/assets/9ab09f26-e4a8-4b70-a315-7def998cec63)
[fa_fzf_demo.webm](https://github.com/user-attachments/assets/b1fecf25-e358-4e8b-a144-bcb7947210cf)
<details>
<summary>
<b>My Rice</b>
</summary>
**other modes:**
**Anilist results menu:**
![image](https://github.com/user-attachments/assets/240023a7-7e4e-47dd-80ff-017d65081ee1)
**Episodes menu preview:**
![image](https://github.com/user-attachments/assets/580f86ef-326f-4ab3-9bd8-c1cb312fbfa6)
**Without preview images enabled:**
![image](https://github.com/user-attachments/assets/e1248a85-438f-4758-ae34-b0e0b224addd)
**Desktop notifications + episodes menu without image preview:**
![image](https://github.com/user-attachments/assets/b7802ef1-ca0d-45f5-a13a-e39c96a5d499)
</details>
<details>
<summary><b>fzf mode</b></summary>
[fastanime-fzf.webm](https://github.com/user-attachments/assets/90875a57-198b-4c78-98d5-10a459001edd)
</details>
<details>
<summary><b>rofi mode</b></summary>
@@ -22,13 +64,12 @@ Welcome to **FastAnime**, anime site experience from the terminal.
</details>
Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magic-tape](https://gitlab.com/christosangel/magic-tape/-/tree/main?ref_type=heads) and [ani-cli](https://github.com/pystardust/ani-cli).
<!--toc:start-->
- [FastAnime](#fastanime)
- [**FastAnime**](#fastanime)
- [Installation](#installation)
- [Installation using your favourite package manager](#installation-using-your-favourite-package-manager)
- [Using uv](#using-uv)
- [Using pipx](#using-pipx)
- [Using pip](#using-pip)
- [Installing the bleeding edge version](#installing-the-bleeding-edge-version)
@@ -47,34 +88,75 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magi
- [cache subcommand](#cache-subcommand)
- [update subcommand](#update-subcommand)
- [completions subcommand](#completions-subcommand)
- [fastanime serve](#fastanime-serve)
- [MPV specific commands](#mpv-specific-commands)
- [Key Bindings](#key-bindings)
- [Script Messages](#script-messages)
- [styling the default interface](#styling-the-default-interface)
- [Configuration](#configuration)
- [The python api](#the-python-api)
- [Contributing](#contributing)
- [Receiving Support](#receiving-support)
- [Supporting the Project](#supporting-the-project)
<!--toc:end-->
> [!IMPORTANT]
>
> This project currently scrapes allanime and animepahe. The site is in the public domain and can be accessed by any one with a browser.
## Installation
![Windows](https://img.shields.io/badge/-Windows_x64-blue.svg?style=for-the-badge&logo=windows)
![Linux/BSD](https://img.shields.io/badge/-Linux/BSD-red.svg?style=for-the-badge&logo=linux)
![Arch Linux](https://img.shields.io/badge/-Arch_Linux-black.svg?style=for-the-badge&logo=archlinux)
![MacOS](https://img.shields.io/badge/-MacOS-lightblue.svg?style=for-the-badge&logo=apple)
![Android](https://img.shields.io/badge/-Android-green.svg?style=for-the-badge&logo=android)
The app can run wherever python can run. So all you need to have is python installed on your device.
On android you can use [termux](https://github.com/termux/termux-app).
If you have any difficulty consult for help on the [discord channel](https://discord.gg/HRjySFjQ)
If you have any difficulty consult for help on the [discord channel](https://discord.gg/HBEmAwvbHV)
### Installation on nixos
![Static Badge](https://img.shields.io/badge/NixOs-black?style=flat&logo=nixos)
```bash
nix profile install github:Benexl/fastanime
```
### Installation using your favourite package manager
Currently the app is only published on [pypi](https://pypi.org/project/fastanime/).
With the following extras available:
- standard -which installs all dependencies
- api - which installs dependencies required to use `fastanime serve`
- mpv - which installs python mpv
- notifications - which installs plyer required for desktop notifications
>[!IMPORTANT]
> Due to unfortunate series of events the latest version will no longer, for now, be published on pypi
>
> So use the direct link to the git repo
> to get the latest
>
> so you don't miss out on features
>
> For example: `uv tool install https://github.com/Benexl/FastAnime.git[standard]` when using uv
#### Using uv
Recommended method of installation is using [uv](https://docs.astral.sh/uv/).
```bash
# generally:
uv tool install "fastanime[standard]"
# or stripped down installations:
uv tool install fastanime
uv tool install "fastanime[api]"
uv tool install "fastanime[mpv]"
uv tool install "fastanime[notifications]"
```
#### Using pipx
Preferred method of installation since [Pipx](https://github.com/pypa/pipx) creates an isolated environment for each app it installs.
```bash
pipx install fastanime
@@ -97,7 +179,7 @@ pip install 'fastanime==<latest-pre-release-tag>.dev1'
### Installing the bleeding edge version
To install the latest build which are created on every push by GitHub actions, download the [fastanime_debug_build](https://github.com/Benex254/FastAnime/actions) of your choosing from the GitHub actions page.
To install the latest build which are created on every push by GitHub actions, download the [fastanime_debug_build](https://github.com/FastAnime/FastAnime/actions) of your choosing from the GitHub actions page.
Then:
```bash
@@ -118,24 +200,17 @@ Requirements:
- [git](https://git-scm.com/)
- [python 3.10 and above](https://www.python.org/)
- [poetry](https://python-poetry.org/docs/#installation)
- [uv](https://astral.sh/blog/uv)
To build from the source, follow these steps:
1. Clone the repository: `git clone https://github.com/Benex254/FastAnime.git --depth 1`
1. Clone the repository: `git clone https://github.com/Benexl/FastAnime.git --depth 1`
2. Navigate into the folder: `cd FastAnime`
3. Then build and Install the app:
```bash
# Normal Installation
poetry build
cd dist
pip install fastanime<version>.whl
# Editable installation (easiest for updates)
# just do a git pull in the Project dir
# the latter will require rebuilding the app
pip install -e .
# build and install fastanime with uv
uv tool install .
```
4. Enjoy! Verify installation with:
@@ -146,12 +221,13 @@ fastanime --version
> [!Tip]
>
> Download the completions from [here](https://github.com/Benex254/FastAnime/tree/master/completions) for your shell.
> Download the completions from [here](https://github.com/FastAnime/FastAnime/tree/master/completions) for your shell.
> To add completions:
>
> - Fish Users: `cp $FASTANIME_PATH/completions/fastanime.fish ~/.config/fish/completions/`
> - Bash Users: Add `source $FASTANIME_PATH/completions/fastanime.bash` to your `.bashrc`
> - Zsh Users: Add `source $FASTANIME_PATH/completions/fastanime.zsh` to your `.zshrc`
> or using the built in command `fastanime completions`
### External Dependencies
@@ -163,26 +239,31 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
> player because we believe nothing beats **MPV** and it provides
> everything you could ever need with a small footprint.
> But if you have a reason feel free to encourage as to do so.
> However, on android this is not the case so vlc is also supported
**Other external dependencies that will just make your experience better:**
- [webtorrent-cli](https://github.com/webtorrent/webtorrent-cli) used when the provider is nyaa
- [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.
- [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 desktop entry ui
- [chafa](https://github.com/hpjansson/chafa) currently the best cross platform and cross terminal image viewer for the terminal.
- [icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) an image viewer that only works in [kitty terminal](https://sw.kovidgoyal.net/kitty/), which is currently the best terminal in my opinion, and by far the best image renderer for the terminal thanks to kitty's terminal graphics protocol. Its terminal graphics is so op that you can [run a browser on it](https://github.com/chase/awrit?tab=readme-ov-file)!!
- [bash](https://www.gnu.org/software/bash/) is used as the preview script language.
- [ani-skip](https://github.com/synacktraa/ani-skip) 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.
- [feh](https://github.com/derf/feh) used in manga mode
## Usage
The project offers a featureful command-line interface and MPV interface through the use of python-mpv.
The project also offers subs in different languages thanks to hianime provider.
### The Commandline interface :fire:
Designed for efficiency and automation. Plus has a beautiful pseudo-TUI in some of the commands.
If you are stuck anywhere just use `--help` before the command you would like to get help on
**Overview of main commands:**
@@ -221,7 +302,7 @@ Available options for the fastanime include:
- `--default` use the default ui
- `--preview` show a preview when using fzf
- `--no-preview` dont show a preview when using fzf
- `--format <yt-dlp format string>` or `-f <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](https://github.com/yt-dlp/yt-dlp#format-selection). Works when `--server gogoanime` or on providers that provide multi quality streams eg hianime
- `--icons/--no-icons` toggle the visibility of the icons
- `--skip/--no-skip` whether to skip the opening and ending theme songs.
- `--rofi` use rofi for the ui
@@ -232,8 +313,11 @@ Available options for the fastanime include:
- `--log-file` allow logging to a file
- `--rich-traceback` allow rich traceback
- `--use-mpv-mod/--use-default-player` whether to use python-mpv
- `--provider <allanime/animepahe>` anime site of choice to scrape from
- `--provider <allanime/animepahe/hianime/nyaa>` 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 hianime.
- `--normalize-titles/--no-normalize-titles` whether to normalize provider titles
- `--manga` toggle experimental manga mode
Example usage of the above options
@@ -247,18 +331,21 @@ fastanime --sync-play --server sharepoint search -t <anime-title>
fastanime --sync-play --server sharepoint anilist
# downloading dubbed anime
fastanime --dub download <anime>
fastanime --dub download -t <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
# viewing manga
fastanime --manga search -t <manga-title>
```
#### The anilist command :fire: :fire: :fire:
Stream, browse, and discover anime efficiently from the terminal using the [AniList API](https://github.com/AniList/ApiV2-GraphQL-Docs).
Uses the [AniList API](https://github.com/AniList/ApiV2-GraphQL-Docs) to create a terminal anilist client which is then intergrated with the scraping capabilities of the project.
##### Running without any subcommand
@@ -267,6 +354,7 @@ Run `fastanime anilist` to access the main interface.
##### Subcommands
The subcommands are mainly their as convenience. Since all the features already exist in the main interface.
Most of the subcommands share the common option `--dump-json` or `-d` which will print only the json data and suppress the ui.
- `fastanime anilist trending`: Top 15 trending anime.
- `fastanime anilist recent`: Top 15 recently updated anime.
@@ -276,6 +364,76 @@ The subcommands are mainly their as convenience. Since all the features already
- `fastanime anilist favourites`: Top 15 favorite anime.
- `fastanime anilist random`: get random anime
**FastAnime Anilist Search subcommand** 🔥 🔥 🔥
It is by far one of the most powerful commands.
It offers the following options:
- `--sort <MediaSort>` or `-s <MediaSort>`
- `--title <anime-title>` or `-t <anime-title>`
- `--tags <tag>` or `-T <tag>` can be specified multiple times for different tags to filter by.
- `--year <year>` or `-y <year>`
- `--status <MediaStatus>` or `-S <MediaStatus>` can be specified multiple times
- `--media-format <MediaFormat>` or `-f <MediaFormat>`
- `--season <MediaSeason>`
- `--genres <genre>` or `-g <genre>` can be specified multiple times.
- `--on-list/--not-on-list`
Example:
```bash
# get anime with the tag of isekai
fastanime anilist search -T isekai
# get anime of 2024 and sort by popularity
# that has already finished airing or is releasing
# and is not in your anime lists
fastanime anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
# get anime of 2024 season WINTER
fastanime anilist search -y 2024 --season WINTER
# get anime genre action and tag isekai,magic
fastanime anilist search -g Action -T Isekai -T Magic
# get anime of 2024 thats finished airing
fastanime anilist search -y 2024 -S FINISHED
# get the most favourite anime movies
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
```
For more details visit the anilist docs or just get the completions which will improve the experience.
Like seriously **[get the completions](https://github.com/FastAnime/FastAnime#completions-subcommand)** and the experience will be a 💯 💯 better.
**Fastanime anilist download:**
Supports all the options for search except its used for downloading.
it also supports all options for `fastanime download`
Example:
```bash
# get anime with the tag of isekai
fastanime anilist download -T isekai
# get anime of 2024 and sort by popularity
# that has already finished airing or is releasing
# and is not in your anime lists
fastanime anilist download -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
# get anime of 2024 season WINTER
fastanime anilist download -y 2024 --season WINTER
# get anime genre action and tag isekai,magic
fastanime anilist download -g Action -T Isekai -T Magic
# get anime of 2024 thats finished airing
fastanime anilist download -y 2024 -S FINISHED
# get the most favourite anime movies
fastanime anilist download -f MOVIE -s FAVOURITES_DESC
```
The following are commands you can only run if you are signed in to your AniList account:
- `fastanime anilist watching`
@@ -285,7 +443,7 @@ The following are commands you can only run if you are signed in to your AniList
- `fastanime anilist paused`
- `fastanime anilist completed`
Plus: `fastanime anilist notifier` :fire:
Plus: `fastanime anilist notifier` 🔥
```bash
# basic form
@@ -350,13 +508,36 @@ fastanime download -t <anime-title> -t <anime-title> -r '-5'
# Download specific episode range
# be sure to observe the range Syntax
fastanime download <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
fastanime download <anime-title> -r '<episodes-start>:<episodes-end>'
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>'
# merge subtitles with ffmpeg to mkv format; hianime tends to give subs as separate files
# and dont prompt for anything
# eg existing file in destination instead remove
# and clean
# ie remove original files (sub file and vid file)
# only keep merged files
fastanime download -t <anime-title> --merge --clean --no-prompt
# EOF is used since -t always expects a title
# you can supply anime titles from file or -t at the same time
#
# from stdin
echo -e "<anime-title>\n<anime-title>\n<anime-title>" | fastanime download -t "EOF" -r <range> -f -
# from file
fastanime download -t "EOF" -r <range> -f <file-path>
fastanime download <anime-title> -r '<episodes-start>:'
fastanime download <anime-title> -r ':<episodes-end>'
```
#### search subcommand
@@ -404,7 +585,8 @@ Uses a list slicing syntax similar to that of python as the value of the `-r` op
**Syntax:**
```bash
# print all available episodes
# --- print anime info + episode streams ---
# multiple titles can be specified with the -t option
fastanime grab -t <anime-title> -t <anime-title>
@@ -425,6 +607,18 @@ 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
@@ -447,6 +641,10 @@ fastanime downloads --time-to-seek <intRange(-1,100)>
# --- or ---
fastanime downloads -t <intRange(-1,100)>
# to watch a specific title
# be sure to get the completions for the best experience
fastanime downloads --title <title>
# to get the path to the downloads folder set
fastanime downloads --path
# useful when you want to use the value for other programs
@@ -474,7 +672,7 @@ fastanime config --view
> [!Note]
>
> If it opens [vim](https://www.vim.org/download.php) you can exit by typing `:q` .
> If it opens [vim](https://www.vim.org/download.php) you can exit by typing `:q` 😉.
#### cache subcommand
@@ -527,6 +725,19 @@ fastanime completions --bash
fastanime completions --zsh
```
#### fastanime serve
Helper command that starts a rest server.
This requires you to install fastanime with the api extra or standard extra.
```bash
# default options
fastanime serve
# specify host and port
fastanime serve --host <host> --port <port>
```
### 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.
@@ -561,119 +772,120 @@ script-message select-server <server-name>
script-message select-quality <1080/720/480/360>
```
## styling the default interface
The default interface uses inquirerPy which is customizable. Read here to findout more <https://inquirerpy.readthedocs.io/en/latest/pages/env.html>
## Configuration
The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on arch linux; for the other operating systems you can check by running `fastanime config --path`.
> [!TIP]
> You can now use the option `--update` to update your config file from the command-line
> For Example:
> `fastanime --icons --fzf --preview config --update`
> the above will set icons to true, use_fzf to true and preview to true in your config file
By default if a config file does not exist it will be auto created with comments to explain each and every option.
The default config:
```ini
[stream]
continue_from_history = True # Auto continue from watch history
# which history to use [local/remote]
preferred_history = local
# force mpv window
# passed directly to mpv so values are same
force_window = immediate
translation_type = sub # Preferred language for anime (options: dub, sub)
server = top # Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top, wixmp)
auto_next = False # Auto-select next episode
# Auto select the anime provider results with fuzzy find.
# Note this wont always be correct.But 99% of the time will be.
auto_select=True
# whether to skip the opening and ending theme songs
# note requires ani-skip to be in path
skip=false
# the maximum delta time in minutes after which the episode should be considered as completed
# used in the continue from time stamp
error=3
use_mpv_mod=False
# the format of downloaded anime and trailer
# based on yt-dlp format and passed directly to it
# learn more by looking it up on their site
# only works for downloaded anime if server=gogoanime
# since its the only one that offers different formats
# the others tend not to
format=best[height<=1080]/bestvideo[height<=1080]+bestaudio/best # default
[general]
# can be [allanime,animepahe]
icons = False
quality = 1080
normalize_titles = True
provider = allanime
preferred_language = romaji # Display language (options: english, romaji)
preferred_language = english
downloads_dir = <Default-videos-dir>/FastAnime # Download directory
downloads_dir = ~/Videos/FastAnime
preview=false # whether to show a preview window when using fzf or rofi
preview = False
use_fzf=False # whether to use fzf as the interface for the anilist command and others.
ffmpegthumbnailer_seek_time = -1
use_rofi=false # whether to use rofi for the ui
use_fzf = False
rofi_theme=<path-to-rofi-theme-file>
use_rofi = False
rofi_theme_input=<path-to-rofi-theme-file>
rofi_theme =
rofi_theme_confirm=<path-to-rofi-theme-file>
rofi_theme_input =
rofi_theme_confirm =
notification_duration = 2
sub_lang = eng
default_media_list_tracking = None
force_forward_tracking = True
cache_requests = True
use_persistent_provider_store = False
recent = 50
# whether to show the icons
icons=false
[stream]
continue_from_history = True
# the duration in minutes a notification will stay in the screen
# used by notifier command
notification_duration=2
preferred_history = local
[anilist]
# Not implemented yet
```
translation_type = sub
## The python api
server = top
The project offers a python api that can be used in other python programs.
auto_next = False
```python
from fastanime.AnimeProvider import AnimeProvider
auto_select = True
# all output is typed, so will be easy to work with
skip = False
# providers include [allanime, animepahe]
provider = AnimeProvider(provider="allanime")
episode_complete_at = 80
# to search for anime
provider.search_for_anime()
use_python_mpv = False
# to get anime info
provider.get_anime()
force_window = immediate
# to get streams of an episode
provider.get_episode_streams()
format = best[height<=1080]/bestvideo[height<=1080]+bestaudio/best
player = mpv
```
## Contributing
We welcome your issues and feature requests. However, due to time constraints, we currently do not plan to add another provider.
We welcome your issues and feature requests. However, due to time constraints, I currently do not plan to add another provider.
But if you are willing to add one yourself pr's are welcome.
If you wish to contribute directly, please first open an issue describing your proposed changes so it can be discussed or if you are in a rush for the feature to be merged just open a pr.
If you find an anime title that does not correspond with a provider or is just weird just [edit the data file](https://github.com/Benexl/FastAnime/blob/master/fastanime/Utility/data.py) and open a pr, i will ignore issues 😝.
## Receiving Support
For inquiries, join our [Discord Server](https://discord.gg/C4rhMA4mmK).
For inquiries, join our [Discord Server](https://discord.gg/HBEmAwvbHV).
<p align="center">
<a href="https://discord.gg/C4rhMA4mmK">
<a href="https://discord.gg/HBEmAwvbHV">
<img src="https://invidget.switchblade.xyz/C4rhMA4mmK">
</a>
</p>
## Supporting the Project
Show your support by starring our GitHub repository or [buying us a coffee](https://ko-fi.com/benex254).
More pr's less issues 🙃
Those who contribute at least five times will be able to make changes to the repo without my review.
Show your support by starring the GitHub repository or [buying me a coffee](https://ko-fi.com/benexl).
## Disclaimer
> [!IMPORTANT]
>
> This project currently scrapes allanime, hianime, nyaa, yugen and animepahe.
> The developer(s) of this application does not have any affiliation with the content providers available, and this application hosts zero content.
> [DISCLAIMER](https://github.com/Benexl/FastAnime/blob/master/DISCLAIMER.md)

5
fa
View File

@@ -1,4 +1,3 @@
#!/usr/bin/env sh
# exec "${PYTHON:-python3}" -Werror -Xdev -m "$(dirname "$(realpath "$0")")/fastanime" "$@"
cd "$(dirname "$(realpath "$0")")" || exit 1
exec python -m fastanime "$@"
CLI_DIR="$(dirname "$(realpath "$0")")"
exec uv run --directory "$CLI_DIR/../" fastanime "$@"

View File

@@ -1,10 +1,8 @@
"""An abstraction over all providers offering added features with a simple and well typed api
[TODO:description]
"""
"""An abstraction over all providers offering added features with a simple and well typed api"""
import importlib
import logging
import os
from typing import TYPE_CHECKING
from .libs.anime_provider import anime_sources
@@ -12,13 +10,12 @@ from .libs.anime_provider import anime_sources
if TYPE_CHECKING:
from typing import Iterator
from .libs.anilist.types import AnilistBaseMediaDataSchema
from .libs.anime_provider.types import Anime, SearchResults, Server
logger = logging.getLogger(__name__)
# TODO: improve performance of this class and add cool features like auto retry
# TODO: add cool features like auto retry
class AnimeProvider:
"""Class that manages all anime sources adding some extra functionality to them.
Attributes:
@@ -33,27 +30,39 @@ class AnimeProvider:
PROVIDERS = list(anime_sources.keys())
provider = PROVIDERS[0]
def __init__(self, provider, dynamic=False, retries=0) -> None:
def __init__(
self,
provider,
cache_requests=os.environ.get("FASTANIME_CACHE_REQUESTS", "false"),
use_persistent_provider_store=os.environ.get(
"FASTANIME_USE_PERSISTENT_PROVIDER_STORE", "false"
),
dynamic=False,
retries=0,
) -> None:
self.provider = provider
self.dynamic = dynamic
self.retries = retries
self.lazyload_provider()
self.cache_requests = cache_requests
self.use_persistent_provider_store = use_persistent_provider_store
self.lazyload_provider(self.provider)
def lazyload_provider(self):
def lazyload_provider(self, provider):
"""updates the current provider being used"""
_, anime_provider_cls_name = anime_sources[self.provider].split(".", 1)
package = f"fastanime.libs.anime_provider.{self.provider}"
try:
self.anime_provider.session.kill_connection_to_db()
except Exception:
pass
_, anime_provider_cls_name = anime_sources[provider].split(".", 1)
package = f"fastanime.libs.anime_provider.{provider}"
provider_api = importlib.import_module(".api", package)
anime_provider = getattr(provider_api, anime_provider_cls_name)
self.anime_provider = anime_provider()
self.anime_provider = anime_provider(
self.cache_requests, self.use_persistent_provider_store
)
def search_for_anime(
self,
user_query,
translation_type,
anilist_obj: "AnilistBaseMediaDataSchema | None" = None,
nsfw=True,
unknown=True,
self, search_keywords, translation_type, **kwargs
) -> "SearchResults | None":
"""core abstraction over all providers search functionality
@@ -68,19 +77,16 @@ class AnimeProvider:
[TODO:return]
"""
anime_provider = self.anime_provider
try:
results = anime_provider.search_for_anime(
user_query, translation_type, nsfw, unknown
)
except Exception as e:
logging.error(e)
results = None
results = anime_provider.search_for_anime(
search_keywords, translation_type, **kwargs
)
return results
def get_anime(
self,
anime_id: str,
anilist_obj: "AnilistBaseMediaDataSchema | None" = None,
**kwargs,
) -> "Anime | None":
"""core abstraction over getting info of an anime from all providers
@@ -92,19 +98,16 @@ class AnimeProvider:
[TODO:return]
"""
anime_provider = self.anime_provider
try:
results = anime_provider.get_anime(anime_id)
except Exception as e:
logging.error(e)
results = None
results = anime_provider.get_anime(anime_id, **kwargs)
return results
def get_episode_streams(
self,
anime,
anime_id,
episode: str,
translation_type: str,
anilist_obj: "AnilistBaseMediaDataSchema|None" = None,
**kwargs,
) -> "Iterator[Server] | None":
"""core abstractions for getting juicy streams from all providers
@@ -118,11 +121,7 @@ class AnimeProvider:
[TODO:return]
"""
anime_provider = self.anime_provider
try:
results = anime_provider.get_episode_streams(
anime, episode, translation_type
)
except Exception as e:
logging.error(e)
results = None
return results # pyright:ignore
results = anime_provider.get_episode_streams(
anime_id, episode, translation_type, **kwargs
)
return results

105
fastanime/MangaProvider.py Normal file
View File

@@ -0,0 +1,105 @@
"""An abstraction over all providers offering added features with a simple and well typed api
[TODO:description]
"""
import importlib
import logging
from typing import TYPE_CHECKING
from .libs.manga_provider import manga_sources
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class MangaProvider:
"""Class that manages all anime sources adding some extra functionality to them.
Attributes:
PROVIDERS: [TODO:attribute]
provider: [TODO:attribute]
provider: [TODO:attribute]
dynamic: [TODO:attribute]
retries: [TODO:attribute]
manga_provider: [TODO:attribute]
"""
PROVIDERS = list(manga_sources.keys())
provider = PROVIDERS[0]
def __init__(self, provider="mangadex", dynamic=False, retries=0) -> None:
self.provider = provider
self.dynamic = dynamic
self.retries = retries
self.lazyload_provider(self.provider)
def lazyload_provider(self, provider):
"""updates the current provider being used"""
_, anime_provider_cls_name = manga_sources[provider].split(".", 1)
package = f"fastanime.libs.manga_provider.{provider}"
provider_api = importlib.import_module(".api", package)
manga_provider = getattr(provider_api, anime_provider_cls_name)
self.manga_provider = manga_provider()
def search_for_manga(
self,
user_query,
nsfw=True,
unknown=True,
):
"""core abstraction over all providers search functionality
Args:
user_query ([TODO:parameter]): [TODO:description]
translation_type ([TODO:parameter]): [TODO:description]
nsfw ([TODO:parameter]): [TODO:description]
manga_provider ([TODO:parameter]): [TODO:description]
anilist_obj: [TODO:description]
Returns:
[TODO:return]
"""
manga_provider = self.manga_provider
try:
results = manga_provider.search_for_manga(user_query, nsfw, unknown)
except Exception as e:
logger.error(e)
results = None
return results
def get_manga(
self,
anime_id: str,
):
"""core abstraction over getting info of an anime from all providers
Args:
anime_id: [TODO:description]
anilist_obj: [TODO:description]
Returns:
[TODO:return]
"""
manga_provider = self.manga_provider
try:
results = manga_provider.get_manga(anime_id)
except Exception as e:
logger.error(e)
results = None
return results
def get_chapter_thumbnails(
self,
manga_id: str,
chapter: str,
):
manga_provider = self.manga_provider
try:
results = manga_provider.get_chapter_thumbnails(manga_id, chapter)
except Exception as e:
logger.error(e)
results = None
return results # pyright:ignore

View File

@@ -1,13 +1,16 @@
import re
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
COMMA_REGEX = re.compile(r"([0-9]{3})(?=\d)")
# TODO: Add formating options for the final date
def format_anilist_date_object(anilist_date_object: "AnilistDateObject"):
if anilist_date_object:
if anilist_date_object and anilist_date_object["day"]:
return f"{anilist_date_object['day']}/{anilist_date_object['month']}/{anilist_date_object['year']}"
else:
return "Unknown"
@@ -27,6 +30,12 @@ def format_list_data_with_comma(data: list | None):
return "None"
def format_number_with_commas(number: int | None):
if not number:
return "0"
return COMMA_REGEX.sub(lambda match: f"{match.group(1)},", str(number)[::-1])[::-1]
def extract_next_airing_episode(airing_episode: "AnilistMediaNextAiringEpisode"):
if airing_episode:
return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"

View File

@@ -3,12 +3,31 @@ Just contains some useful data used across the codebase
"""
# useful incases where the anilist title is too different from the provider title
anime_normalizer = {
"1P": "one piece",
"Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica",
"Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka",
'Hazurewaku no "Joutai Ijou Skill" de Saikyou ni Natta Ore ga Subete wo Juurin suru made': "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made",
anime_normalizer_raw = {
"allanime": {
"1P": "one piece",
"Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica",
"Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka",
'Hazurewaku no "Joutai Ijou Skill" de Saikyou ni Natta Ore ga Subete wo Juurin suru made': "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made",
"Re:Zero kara Hajimeru Isekai Seikatsu Season 3": "Re:Zero kara Hajimeru Isekai Seikatsu 3rd Season",
},
"hianime": {"My Star": "Oshi no Ko"},
"animepahe": {
"Azumanga Daiou The Animation": "Azumanga Daioh",
"Mairimashita! Iruma-kun 2nd Season": "Mairimashita! Iruma-kun 2",
"Mairimashita! Iruma-kun 3rd Season": "Mairimashita! Iruma-kun 3",
},
"nyaa": {},
"yugen": {},
}
anilist_sort_normalizer = {"search match": "SEARCH_MATCH"}
def get_anime_normalizer():
"""Used because there are different providers"""
import os
current_provider = os.environ.get("FASTANIME_PROVIDER", "allanime")
return anime_normalizer_raw[current_provider]
anime_normalizer = get_anime_normalizer()

View File

@@ -0,0 +1,6 @@
from yt_dlp import YoutubeDL
# TODO: create a class that makes yt-dlp's YoutubeDL fit in more with fastanime
class YtDlp(YoutubeDL):
pass

View File

@@ -1,8 +1,14 @@
import logging
import os
import shutil
import subprocess
import tempfile
from queue import Queue
from threading import Thread
import yt_dlp
from rich import print
from rich.prompt import Confirm
from yt_dlp.utils import sanitize_filename
logger = logging.getLogger(__name__)
@@ -10,6 +16,7 @@ logger = logging.getLogger(__name__)
class YtDLPDownloader:
downloads_queue = Queue()
_thread = None
def _worker(self):
while True:
@@ -20,13 +27,6 @@ class YtDLPDownloader:
logger.error(f"Something went wrong {e}")
self.downloads_queue.task_done()
def __init__(self):
self._thread = Thread(target=self._worker)
self._thread.daemon = True
self._thread.start()
# Function to download the file
# TODO: untpack the title to its actual values episode_title and anime_title
def _download_file(
self,
url: str,
@@ -34,10 +34,17 @@ class YtDLPDownloader:
episode_title: str,
download_dir: str,
silent: bool,
progress_hooks=[],
vid_format: str = "best",
force_unknown_ext=False,
verbose=False,
headers={},
sub="",
merge=False,
clean=False,
prompt=True,
force_ffmpeg=False,
hls_use_mpegts=False,
):
"""Helper function that downloads anime given url and path details
@@ -51,6 +58,25 @@ class YtDLPDownloader:
"""
anime_title = sanitize_filename(anime_title)
episode_title = sanitize_filename(episode_title)
if url.endswith(".torrent"):
WEBTORRENT_CLI = shutil.which("webtorrent")
if not WEBTORRENT_CLI:
import time
print(
"webtorrent cli is not installed which is required for downloading and streaming from nyaa\nplease install it or use another provider"
)
time.sleep(120)
return
cmd = [
WEBTORRENT_CLI,
"download",
url,
"--out",
os.path.join(download_dir, anime_title, episode_title),
]
subprocess.run(cmd)
return
ydl_opts = {
# Specify the output path and template
"http_headers": headers,
@@ -59,13 +85,118 @@ class YtDLPDownloader:
"verbose": verbose,
"format": vid_format,
"compat_opts": ("allow-unsafe-ext",) if force_unknown_ext else tuple(),
"progress_hooks": progress_hooks,
}
urls = [url]
if sub:
urls.append(sub)
vid_path = ""
sub_path = ""
for i, url in enumerate(urls):
options = ydl_opts
if i == 0:
if force_ffmpeg:
options = options | {
"external_downloader": {"default": "ffmpeg"},
"external_downloader_args": {
"ffmpeg_i1": ["-v", "error", "-stats"],
},
}
if hls_use_mpegts:
options = options | {
"hls_use_mpegts": hls_use_mpegts,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
with yt_dlp.YoutubeDL(options) as ydl:
info = ydl.extract_info(url, download=True)
if not info:
continue
if i == 0:
vid_path: str = info["requested_downloads"][0]["filepath"]
if vid_path.endswith(".unknown_video"):
print("Normalizing path...")
_vid_path = vid_path.replace(".unknown_video", ".mp4")
shutil.move(vid_path, _vid_path)
vid_path = _vid_path
print("successfully normalized path")
# WARN: May remove this legacy functionality
def download_file(self, url: str, title, silent=True):
else:
sub_path = info["requested_downloads"][0]["filepath"]
if sub_path and vid_path and merge:
self.merge_subtitles(vid_path, sub_path, clean, prompt)
def merge_subtitles(self, video_path, sub_path, clean, prompt):
# Extract the directory and filename
video_dir = os.path.dirname(video_path)
video_name = os.path.basename(video_path)
video_name, _ = os.path.splitext(video_name)
video_name += ".mkv"
FFMPEG_EXECUTABLE = shutil.which("ffmpeg")
if not FFMPEG_EXECUTABLE:
print("[yellow bold]WARNING: [/]FFmpeg not found")
return
# Create a temporary directory
with tempfile.TemporaryDirectory() as temp_dir:
# Temporary output path in the temporary directory
temp_output_path = os.path.join(temp_dir, video_name)
# FFmpeg command to merge subtitles
command = [
FFMPEG_EXECUTABLE,
"-hide_banner",
"-i",
video_path,
"-i",
sub_path,
"-c",
"copy",
"-map",
"0",
"-map",
"1",
temp_output_path,
]
# Run the command
try:
subprocess.run(command, check=True)
# Move the file back to the original directory with the original name
final_output_path = os.path.join(video_dir, video_name)
if os.path.exists(final_output_path):
if not prompt or Confirm.ask(
f"File exists({final_output_path}) would you like to overwrite it",
default=True,
):
# move file to dest
os.remove(final_output_path)
shutil.move(temp_output_path, final_output_path)
else:
shutil.move(temp_output_path, final_output_path)
# clean up
if clean:
print("[cyan]Cleaning original files...[/]")
os.remove(video_path)
os.remove(sub_path)
print(
f"[green bold]Subtitles merged successfully.[/] Output file: {final_output_path}"
)
except subprocess.CalledProcessError as e:
print(f"[red bold]Error[/] during merging subtitles: {e}")
except Exception as e:
print(f"[red bold]An error[/] occurred: {e}")
def download_file(
self,
url: str,
anime_title: str,
episode_title: str,
download_dir: str,
silent: bool = True,
**kwargs,
):
"""A helper that just does things in the background
Args:
@@ -73,7 +204,17 @@ class YtDLPDownloader:
silent ([TODO:parameter]): [TODO:description]
url: [TODO:description]
"""
self.downloads_queue.put((self._download_file, (url, title, silent)))
if not self._thread:
self._thread = Thread(target=self._worker)
self._thread.daemon = True
self._thread.start()
self.downloads_queue.put(
(
self._download_file,
(url, anime_title, episode_title, download_dir, silent),
)
)
downloader = YtDLPDownloader()

View File

@@ -30,14 +30,17 @@ def anime_title_percentage_match(
Returns:
int: the percentage match
"""
if normalized_anime_title := anime_normalizer.get(
possible_user_requested_anime_title
):
possible_user_requested_anime_title = normalized_anime_title
possible_user_requested_anime_title = anime_normalizer.get(
possible_user_requested_anime_title, possible_user_requested_anime_title
)
# compares both the romaji and english names and gets highest Score
title_a = str(anime["title"]["romaji"])
title_b = str(anime["title"]["english"])
percentage_ratio = max(
*[
fuzz.ratio(title.lower(), possible_user_requested_anime_title.lower())
for title in anime["synonyms"]
],
fuzz.ratio(title_a.lower(), possible_user_requested_anime_title.lower()),
fuzz.ratio(title_b.lower(), possible_user_requested_anime_title.lower()),
)

View File

@@ -2,14 +2,14 @@ import sys
if sys.version_info < (3, 10):
raise ImportError(
"You are using an unsupported version of Python. Only Python versions 3.8 and above are supported by yt-dlp"
"You are using an unsupported version of Python. Only Python versions 3.10 and above are supported by FastAnime"
) # noqa: F541
__version__ = "v2.2.3"
__version__ = "v2.8.4"
APP_NAME = "FastAnime"
AUTHOR = "Benex254"
AUTHOR = "Benexl"
GIT_REPO = "github.com"
REPO = f"{GIT_REPO}/{AUTHOR}/{APP_NAME}"

93
fastanime/api/__init__.py Normal file
View File

@@ -0,0 +1,93 @@
from typing import Literal
from fastapi import FastAPI
from requests import post
from thefuzz import fuzz
from ..AnimeProvider import AnimeProvider
from ..Utility.data import anime_normalizer
app = FastAPI()
anime_provider = AnimeProvider("allanime", "true", "true")
ANILIST_ENDPOINT = "https://graphql.anilist.co"
@app.get("/search")
def search_for_anime(title: str, translation_type: Literal["dub", "sub"] = "sub"):
return anime_provider.search_for_anime(title, translation_type)
@app.get("/anime/{anime_id}")
def get_anime(anime_id: str):
return anime_provider.get_anime(anime_id)
@app.get("/anime/{anime_id}/watch")
def get_episode_streams(
anime_id: str, episode: str, translation_type: Literal["sub", "dub"]
):
return anime_provider.get_episode_streams(anime_id, episode, translation_type)
def get_anime_by_anilist_id(anilist_id: int):
query = f"""
query {{
Media(id: {anilist_id}) {{
id
title {{
romaji
english
native
}}
synonyms
episodes
duration
}}
}}
"""
response = post(ANILIST_ENDPOINT, json={"query": query}).json()
return response["data"]["Media"]
@app.get("/watch/{anilist_id}")
def get_episode_streams_by_anilist_id(
anilist_id: int, episode: str, translation_type: Literal["sub", "dub"]
):
anime = get_anime_by_anilist_id(anilist_id)
if not anime:
return
if search_results := anime_provider.search_for_anime(
str(anime["title"]["romaji"] or anime["title"]["english"]), translation_type
):
if not search_results["results"]:
return
def match_title(possible_user_requested_anime_title):
possible_user_requested_anime_title = anime_normalizer.get(
possible_user_requested_anime_title, possible_user_requested_anime_title
)
title_a = str(anime["title"]["romaji"])
title_b = str(anime["title"]["english"])
percentage_ratio = max(
*[
fuzz.ratio(
title.lower(), possible_user_requested_anime_title.lower()
)
for title in anime["synonyms"]
],
fuzz.ratio(
title_a.lower(), possible_user_requested_anime_title.lower()
),
fuzz.ratio(
title_b.lower(), possible_user_requested_anime_title.lower()
),
)
return percentage_ratio
provider_anime = max(
search_results["results"], key=lambda x: match_title(x["title"])
)
anime_provider.get_anime(provider_anime["id"])
return anime_provider.get_episode_streams(
provider_anime["id"], episode, translation_type
)

View File

@@ -0,0 +1,84 @@
// https://github.com/Wraient/curd/blob/main/rofi/selectanime.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
configuration {
font: "Sans 12";
line-margin: 10;
display-drun: "";
}
* {
background: #000000; /* Black background for everything */
background-alt: #000000; /* Ensures no alternation */
foreground: #CCCCCC;
selected: #3584E4;
active: #2E7D32;
urgent: #C62828;
}
window {
fullscreen: false;
background-color: rgba(0, 0, 0, 1); /* Solid black background */
}
mainbox {
padding: 50px 100px;
background-color: rgba(0, 0, 0, 1); /* Ensures black background fills entire main area */
children: [inputbar, listview];
spacing: 20px;
}
inputbar {
background-color: #333333; /* Dark gray background for input bar */
padding: 8px;
border-radius: 8px;
children: [prompt, entry];
}
prompt {
enabled: true;
padding: 8px;
background-color: @selected;
text-color: #000000;
border-radius: 4px;
}
entry {
padding: 8px;
background-color: #444444; /* Slightly lighter gray for visibility */
text-color: #FFFFFF; /* White text to make typing visible */
placeholder: "Search...";
placeholder-color: rgba(255, 255, 255, 0.5);
border-radius: 6px;
}
listview {
layout: vertical;
spacing: 8px;
lines: 10;
background-color: @background; /* Consistent black background for list items */
}
element {
padding: 12px;
border-radius: 4px;
background-color: @background; /* Uniform color for each list item */
text-color: @foreground;
}
element normal.normal {
background-color: @background; /* Ensures no alternating color */
}
element selected.normal {
background-color: @selected;
text-color: #FFFFFF;
}
element-text {
background-color: transparent;
text-color: inherit;
vertical-align: 0.5;
}

View File

@@ -0,0 +1,55 @@
// https://github.com/Wraient/curd/blob/main/rofi/userinput.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
configuration {
font: "Sans 12";
}
* {
background-color: rgba(0, 0, 0, 0.7);
text-color: #FFFFFF;
}
window {
fullscreen: true;
transparency: "real";
background-color: @background-color;
}
mainbox {
children: [ message, listview, inputbar ];
padding: 40% 30%;
}
message {
border: 0;
padding: 10px;
margin: 0 0 20px 0;
font: "Sans Bold 24"; /* Increased font size and made it bold */
}
inputbar {
children: [ prompt, entry ];
background-color: rgba(255, 255, 255, 0.1);
padding: 8px;
border-radius: 4px;
}
prompt {
padding: 8px;
}
entry {
padding: 8px;
}
listview {
lines: 0;
}
/* Style for the message text specifically */
textbox {
horizontal-align: 0.5; /* Center the text */
font: "Sans Bold 24"; /* Match message font */
}

View File

@@ -0,0 +1,55 @@
// https://github.com/Wraient/curd/blob/main/rofi/userinput.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
configuration {
font: "Sans 12";
}
* {
background-color: rgba(0, 0, 0, 0.7);
text-color: #FFFFFF;
}
window {
fullscreen: true;
transparency: "real";
background-color: @background-color;
}
mainbox {
children: [ message, listview, inputbar ];
padding: 40% 30%;
}
message {
border: 0;
padding: 10px;
margin: 0 0 20px 0;
font: "Sans Bold 24"; /* Increased font size and made it bold */
}
inputbar {
children: [ prompt, entry ];
background-color: rgba(255, 255, 255, 0.1);
padding: 8px;
border-radius: 4px;
}
prompt {
padding: 8px;
}
entry {
padding: 8px;
}
listview {
lines: 0;
}
/* Style for the message text specifically */
textbox {
horizontal-align: 0.5; /* Center the text */
font: "Sans Bold 24"; /* Match message font */
}

View File

@@ -0,0 +1,122 @@
// Based on https://github.com/Wraient/curd/blob/main/rofi/selectanimepreview.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
// Colours
* {
background-color: transparent;
background: #1D2330;
background-transparent: #1D2330A0;
text-color: #BBBBBB;
text-color-selected: #FFFFFF;
primary: #BB77BB;
important: #BF616A;
}
configuration {
font: "Roboto 17";
show-icons: true;
}
window {
fullscreen: true;
height: 100%;
width: 100%;
transparency: "real";
background-color: @background-transparent;
border: 0px;
border-color: @primary;
}
mainbox {
children: [prompt, inputbar-box, listview];
padding: 0px;
}
prompt {
width: 100%;
margin: 10px 0px 0px 30px;
text-color: @important;
font: "Roboto Bold 27";
}
listview {
layout: vertical;
padding: 60px;
dynamic: true;
columns: 7;
spacing: 20px;
horizontal-align: center; /* Center the list items */
}
inputbar-box {
children: [dummy, inputbar, dummy];
orientation: horizontal;
expand: false;
}
inputbar {
children: [textbox-prompt, entry];
margin: 0px;
background-color: @primary;
border: 4px;
border-color: @primary;
border-radius: 8px;
}
textbox-prompt {
text-color: @background;
horizontal-align: 0.5;
vertical-align: 0.5;
expand: false;
}
entry {
expand: false;
padding: 8px;
margin: -6px;
horizontal-align: 0;
width: 300;
background-color: @background;
border: 6px;
border-color: @primary;
border-radius: 8px;
cursor: text;
}
element {
children: [dummy, element-box, dummy];
padding: 5px;
orientation: vertical;
border: 0px;
border-radius: 16px;
background-color: transparent; /* Default background */
}
element selected {
background-color: @primary; /* Solid color for selected item */
}
element-box {
children: [element-icon, element-text];
orientation: vertical;
expand: false;
cursor: pointer;
}
element-icon {
padding: 10px;
cursor: inherit;
size: 33%;
margin: 10px;
}
element-text {
horizontal-align: 0.5;
cursor: inherit;
text-color: @text-color;
}
element-text selected {
text-color: @text-color-selected;
}

View File

@@ -4,7 +4,6 @@ import click
from .. import __version__
from ..libs.anime_provider import SERVERS_AVAILABLE, anime_sources
from ..Utility.data import anilist_sort_normalizer
from .commands import LazyGroup
commands = {
@@ -17,6 +16,7 @@ commands = {
"completions": "completions.completions",
"update": "update.update",
"grab": "grab.grab",
"serve": "serve.serve",
}
@@ -39,8 +39,32 @@ signal.signal(signal.SIGINT, handle_exit)
cls=LazyGroup,
help="A command line application for streaming anime that provides a complete and featureful interface",
short_help="Stream Anime",
epilog="""
\b
\b\bExamples:
# example of syncplay intergration
fastanime --sync-play --server sharepoint search -t <anime-title>
\b
# --- or ---
\b
# to watch with anilist intergration
fastanime --sync-play --server sharepoint anilist
\b
# downloading dubbed anime
fastanime --dub download -t <anime>
\b
# use icons and fzf for a more elegant ui with preview
fastanime --icons --preview --fzf anilist
\b
# use icons with default ui
fastanime --icons --default anilist
\b
# viewing manga
fastanime --manga search -t <manga-title>
""",
)
@click.version_option(__version__, "--version")
@click.option("--manga", "-m", help="Enable manga mode", is_flag=True)
@click.option("--log", help="Allow logging to stdout", is_flag=True)
@click.option("--log-file", help="Allow logging to a file", is_flag=True)
@click.option("--rich-traceback", help="Use rich to output tracebacks", is_flag=True)
@@ -98,6 +122,11 @@ signal.signal(signal.SIGINT, handle_exit)
type=click.Choice(["dub", "sub"]),
help="Anime language[dub/sub]",
)
@click.option(
"-sl",
"--sub-lang",
help="Set the preferred language for subs",
)
@click.option(
"-A/-no-A",
"--auto-next/--no-auto-next",
@@ -111,9 +140,9 @@ signal.signal(signal.SIGINT, handle_exit)
help="Auto select anime title?",
)
@click.option(
"-S",
"--sort-by",
type=click.Choice(anilist_sort_normalizer.keys()), # pyright: ignore
"--normalize-titles/--no-normalize-titles",
type=bool,
help="whether to normalize anime and episode titls given by providers",
)
@click.option("-d", "--downloads-dir", type=click.Path(), help="Downloads location")
@click.option("--fzf", is_flag=True, help="Use fzf for the ui")
@@ -129,6 +158,9 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option("--sub", help="Set the translation type to sub", is_flag=True)
@click.option("--rofi", help="Use rofi for the ui", is_flag=True)
@click.option("--rofi-theme", help="Rofi theme to use", type=click.Path())
@click.option(
"--rofi-theme-preview", help="Rofi theme to use for previews", type=click.Path()
)
@click.option(
"--rofi-theme-confirm",
help="Rofi theme to use for the confirm prompt",
@@ -140,12 +172,22 @@ signal.signal(signal.SIGINT, handle_exit)
type=click.Path(),
)
@click.option(
"--use-mpv-mod/--use-default-player", help="Whether to use python-mpv", type=bool
"--use-python-mpv/--use-default-player", help="Whether to use python-mpv", type=bool
)
@click.option("--sync-play", "-sp", help="Use sync play", is_flag=True)
@click.option(
"--player",
"-P",
help="the player to use when streaming",
type=click.Choice(["mpv", "vlc"]),
)
@click.option(
"--fresh-requests", is_flag=True, help="Force the requests cache to be updated"
)
@click.pass_context
def run_cli(
ctx: click.Context,
manga,
log,
log_file,
rich_traceback,
@@ -156,10 +198,11 @@ def run_cli(
local_history,
skip,
translation_type,
sub_lang,
quality,
auto_next,
auto_select,
sort_by,
normalize_titles,
downloads_dir,
fzf,
default,
@@ -170,14 +213,72 @@ def run_cli(
sub,
rofi,
rofi_theme,
rofi_theme_preview,
rofi_theme_confirm,
rofi_theme_input,
use_mpv_mod,
use_python_mpv,
sync_play,
player,
fresh_requests,
):
import os
import sys
from .config import Config
ctx.obj = Config()
if (
ctx.obj.check_for_updates
and ctx.invoked_subcommand != "completions"
and "notifier" not in sys.argv
):
import time
last_update = ctx.obj.user_data["meta"]["last_updated"]
now = time.time()
# checks after every 12 hours
if (now - last_update) > 43200:
ctx.obj.user_data["meta"]["last_updated"] = now
ctx.obj._update_user_data()
from .app_updater import check_for_updates
print("Checking for updates...", file=sys.stderr)
print("So you can enjoy the latest features and bug fixes", file=sys.stderr)
print(
"You can disable this by setting check_for_updates to False in the config",
file=sys.stderr,
)
is_latest, github_release_data = check_for_updates()
if not is_latest:
from rich.console import Console
from rich.markdown import Markdown
from rich.prompt import Confirm
from .app_updater import update_app
def _print_release(release_data):
console = Console()
body = Markdown(release_data["body"])
tag = github_release_data["tag_name"]
tag_title = release_data["name"]
github_page_url = release_data["html_url"]
console.print(f"Release Page: {github_page_url}")
console.print(f"Tag: {tag}")
console.print(f"Title: {tag_title}")
console.print(body)
if Confirm.ask(
"A new version of fastanime is available, would you like to update?"
):
_, release_json = update_app()
print("Successfully updated")
_print_release(release_json)
exit(0)
else:
print("You are using the latest version of fastanime", file=sys.stderr)
ctx.obj.manga = manga
if log:
import logging
@@ -186,7 +287,7 @@ def run_cli(
FORMAT = "%(message)s"
logging.basicConfig(
level="NOTSET", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()]
level=logging.DEBUG, format=FORMAT, datefmt="[%X]", handlers=[RichHandler()]
)
logger = logging.getLogger(__name__)
logger.info("logging has been initialized")
@@ -203,11 +304,17 @@ def run_cli(
datefmt="[%d/%m/%Y@%H:%M:%S]",
filemode="w",
)
else:
import logging
logging.basicConfig(level=logging.CRITICAL)
if rich_traceback:
from rich.traceback import install
install()
if fresh_requests:
os.environ["FASTANIME_FRESH_REQUESTS"] = "1"
if sync_play:
ctx.obj.sync_play = sync_play
if provider:
@@ -216,10 +323,19 @@ def run_cli(
ctx.obj.server = server
if format:
ctx.obj.format = format
if sub_lang:
ctx.obj.sub_lang = sub_lang
if ctx.get_parameter_source("continue_") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.continue_from_history = continue_
if ctx.get_parameter_source("player") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.player = player
if ctx.get_parameter_source("skip") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.skip = skip
if (
ctx.get_parameter_source("normalize_titles")
== click.core.ParameterSource.COMMANDLINE
):
ctx.obj.normalize_titles = normalize_titles
if quality:
ctx.obj.quality = quality
@@ -238,20 +354,19 @@ def run_cli(
):
ctx.obj.auto_select = auto_select
if (
ctx.get_parameter_source("use_mpv_mod")
ctx.get_parameter_source("use_python_mpv")
== click.core.ParameterSource.COMMANDLINE
):
ctx.obj.use_mpv_mod = use_mpv_mod
if sort_by:
ctx.obj.sort_by = sort_by
ctx.obj.use_python_mpv = use_python_mpv
if downloads_dir:
ctx.obj.downloads_dir = downloads_dir
if translation_type:
ctx.obj.translation_type = translation_type
if fzf:
ctx.obj.use_fzf = True
if default:
ctx.obj.use_fzf = False
ctx.obj.use_rofi = False
if fzf:
ctx.obj.use_fzf = True
if preview:
ctx.obj.preview = True
if no_preview:
@@ -266,6 +381,10 @@ def run_cli(
if rofi:
from ..libs.rofi import Rofi
if rofi_theme_preview:
ctx.obj.rofi_theme_preview = rofi_theme_preview
Rofi.rofi_theme_preview = rofi_theme_preview
if rofi_theme:
ctx.obj.rofi_theme = rofi_theme
Rofi.rofi_theme = rofi_theme
@@ -277,3 +396,4 @@ def run_cli(
if rofi_theme_confirm:
ctx.obj.rofi_theme_confirm = rofi_theme_confirm
Rofi.rofi_theme_confirm = rofi_theme_confirm
ctx.obj.set_fastanime_config_environs()

View File

@@ -1,3 +1,4 @@
import os
import pathlib
import re
import shlex
@@ -15,14 +16,18 @@ API_URL = f"https://api.{GIT_REPO}/repos/{AUTHOR}/{APP_NAME}/releases/latest"
def check_for_updates():
USER_AGENT = f"{APP_NAME} user"
request = requests.get(
API_URL,
headers={
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28",
"Accept": "application/vnd.github+json",
},
)
try:
request = requests.get(
API_URL,
headers={
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28",
"Accept": "application/vnd.github+json",
},
)
except Exception:
print("You are not connected to the internet")
return True, {}
if request.status_code == 200:
release_json = request.json()
@@ -39,14 +44,15 @@ def check_for_updates():
and remote_tag[1] == local_tag[1]
)
):
is_update = True
is_latest = False
else:
is_update = False
is_latest = True
return (is_update, release_json)
return (is_latest, release_json)
else:
print("Failed to check for updates")
print(request.text)
return (False, {})
return (True, {})
def is_git_repo(author, repository):
@@ -75,15 +81,22 @@ def is_git_repo(author, repository):
return bool(match) and match.group(1) == f"{author}/{repository}"
def update_app():
def update_app(force=False):
is_latest, release_json = check_for_updates()
if is_latest:
if is_latest and not force:
print("[green]App is up to date[/]")
return False, release_json
tag_name = release_json["tag_name"]
print("[cyan]Updating app to version %s[/]" % tag_name)
if is_git_repo(AUTHOR, APP_NAME):
if os.path.exists("/nix/store") and os.path.exists("/run/current-system"):
NIX = shutil.which("nix")
if not NIX:
print("[red]Cannot find nix, it looks like your system is broken.[/]")
return False, release_json
process = subprocess.run([NIX, "profile", "upgrade", APP_NAME.lower()])
elif is_git_repo(AUTHOR, APP_NAME):
GIT_EXECUTABLE = shutil.which("git")
args = [
GIT_EXECUTABLE,
@@ -101,8 +114,10 @@ def update_app():
)
else:
if PIPX_EXECUTABLE := shutil.which("pipx"):
process = subprocess.run([PIPX_EXECUTABLE, "upgrade", APP_NAME])
if UV := shutil.which("uv"):
process = subprocess.run([UV, "tool", "upgrade", APP_NAME])
elif PIPX := shutil.which("pipx"):
process = subprocess.run([PIPX, "upgrade", APP_NAME])
else:
PYTHON_EXECUTABLE = sys.executable
@@ -112,6 +127,7 @@ def update_app():
"pip",
"install",
APP_NAME,
"-U",
"--user",
"--no-warn-script-location",
]

View File

@@ -20,6 +20,9 @@ commands = {
"completed": "completed.completed",
"planning": "planning.planning",
"notifier": "notifier.notifier",
"stats": "stats.stats",
"download": "download.download",
"downloads": "downloads.downloads",
}
@@ -29,16 +32,61 @@ commands = {
invoke_without_command=True,
help="A beautiful interface that gives you access to a commplete streaming experience",
short_help="Access all streaming options",
epilog="""
\b
\b\bExamples:
# ---- search ----
\b
# get anime with the tag of isekai
fastanime anilist search -T isekai
\b
# get anime of 2024 and sort by popularity
# that has already finished airing or is releasing
# and is not in your anime lists
fastanime anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
\b
# get anime of 2024 season WINTER
fastanime anilist search -y 2024 --season WINTER
\b
# get anime genre action and tag isekai,magic
fastanime anilist search -g Action -T Isekai -T Magic
\b
# get anime of 2024 thats finished airing
fastanime anilist search -y 2024 -S FINISHED
\b
# get the most favourite anime movies
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
\b
# ---- login ----
\b
# To sign in just run
fastanime anilist login
\b
# To view your login status
fastanime anilist login --status
\b
# To erase login data
fastanime anilist login --erase
\b
# ---- notifier ----
\b
# basic form
fastanime anilist notifier
\b
# with logging to stdout
fastanime --log anilist notifier
\b
# with logging to a file. stored in the same place as your config
fastanime --log-file anilist notifier
""",
)
@click.option("--resume", is_flag=True, help="Resume from the last session")
@click.pass_context
def anilist(ctx: click.Context):
def anilist(ctx: click.Context, resume: bool):
from typing import TYPE_CHECKING
from ....anilist import AniList
from ....AnimeProvider import AnimeProvider
from ...interfaces.anilist_interfaces import (
fastanime_main_menu as anilist_interface,
)
if TYPE_CHECKING:
from ...config import Config
@@ -48,4 +96,33 @@ def anilist(ctx: click.Context):
AniList.update_login_info(user, user["token"])
if ctx.invoked_subcommand is None:
fastanime_runtime_state = FastAnimeRuntimeState()
anilist_interface(ctx.obj, fastanime_runtime_state)
if resume:
from ...interfaces.anilist_interfaces import (
anime_provider_search_results_menu,
)
if not config.user_data["recent_anime"]:
click.echo("No recent anime found", err=True, color=True)
return
fastanime_runtime_state.anilist_results_data = {
"data": {"Page": {"media": config.user_data["recent_anime"]}}
}
fastanime_runtime_state.selected_anime_anilist = config.user_data[
"recent_anime"
][0]
fastanime_runtime_state.selected_anime_id_anilist = config.user_data[
"recent_anime"
][0]["id"]
fastanime_runtime_state.selected_anime_title_anilist = (
config.user_data["recent_anime"][0]["title"]["romaji"]
or config.user_data["recent_anime"][0]["title"]["english"]
)
anime_provider_search_results_menu(config, fastanime_runtime_state)
else:
from ...interfaces.anilist_interfaces import (
fastanime_main_menu as anilist_interface,
)
anilist_interface(ctx.obj, fastanime_runtime_state)

View File

@@ -7,16 +7,23 @@ if TYPE_CHECKING:
@click.command(help="View anime you completed")
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def completed(config: "Config"):
def completed(config: "Config", dump_json):
from sys import exit
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState, exit_app
from ...utils.tools import FastAnimeRuntimeState
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
exit(1)
anime_list = AniList.get_anime_list("COMPLETED")
if not anime_list or not anime_list[1]:
return
@@ -27,6 +34,20 @@ def completed(config: "Config"):
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(anime_list))
else:
from ...interfaces import anilist_interfaces
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = (
lambda config, **kwargs: anilist_interfaces._handle_animelist(
config, fastanime_runtime_state, "Completed", **kwargs
)
)
fastanime_runtime_state.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -0,0 +1,476 @@
sorts_available = [
"ID",
"ID_DESC",
"TITLE_ROMAJI",
"TITLE_ROMAJI_DESC",
"TITLE_ENGLISH",
"TITLE_ENGLISH_DESC",
"TITLE_NATIVE",
"TITLE_NATIVE_DESC",
"TYPE",
"TYPE_DESC",
"FORMAT",
"FORMAT_DESC",
"START_DATE",
"START_DATE_DESC",
"END_DATE",
"END_DATE_DESC",
"SCORE",
"SCORE_DESC",
"POPULARITY",
"POPULARITY_DESC",
"TRENDING",
"TRENDING_DESC",
"EPISODES",
"EPISODES_DESC",
"DURATION",
"DURATION_DESC",
"STATUS",
"STATUS_DESC",
"CHAPTERS",
"CHAPTERS_DESC",
"VOLUMES",
"VOLUMES_DESC",
"UPDATED_AT",
"UPDATED_AT_DESC",
"SEARCH_MATCH",
"FAVOURITES",
"FAVOURITES_DESC",
]
media_statuses_available = [
"FINISHED",
"RELEASING",
"NOT_YET_RELEASED",
"CANCELLED",
"HIATUS",
]
seasons_available = ["WINTER", "SPRING", "SUMMER", "FALL"]
genres_available = [
"Action",
"Adventure",
"Comedy",
"Drama",
"Ecchi",
"Fantasy",
"Horror",
"Mahou Shoujo",
"Mecha",
"Music",
"Mystery",
"Psychological",
"Romance",
"Sci-Fi",
"Slice of Life",
"Sports",
"Supernatural",
"Thriller",
"Hentai",
]
media_formats_available = [
"TV",
"TV_SHORT",
"MOVIE",
"SPECIAL",
"OVA",
"MUSIC",
"NOVEL",
"ONE_SHOT",
]
years_available = [
"1900",
"1910",
"1920",
"1930",
"1940",
"1950",
"1960",
"1970",
"1980",
"1990",
"2000",
"2004",
"2005",
"2006",
"2007",
"2008",
"2009",
"2010",
"2011",
"2012",
"2013",
"2014",
"2015",
"2016",
"2017",
"2018",
"2019",
"2020",
"2021",
"2022",
"2023",
"2024",
]
tags_available = {
"Cast": ["Polyamorous"],
"Cast Main Cast": [
"Anti-Hero",
"Elderly Protagonist",
"Ensemble Cast",
"Estranged Family",
"Female Protagonist",
"Male Protagonist",
"Primarily Adult Cast",
"Primarily Animal Cast",
"Primarily Child Cast",
"Primarily Female Cast",
"Primarily Male Cast",
"Primarily Teen Cast",
],
"Cast Traits": [
"Age Regression",
"Agender",
"Aliens",
"Amnesia",
"Angels",
"Anthropomorphism",
"Aromantic",
"Arranged Marriage",
"Artificial Intelligence",
"Asexual",
"Butler",
"Centaur",
"Chimera",
"Chuunibyou",
"Clone",
"Cosplay",
"Cowboys",
"Crossdressing",
"Cyborg",
"Delinquents",
"Demons",
"Detective",
"Dinosaurs",
"Disability",
"Dissociative Identities",
"Dragons",
"Dullahan",
"Elf",
"Fairy",
"Femboy",
"Ghost",
"Goblin",
"Gods",
"Gyaru",
"Hikikomori",
"Homeless",
"Idol",
"Kemonomimi",
"Kuudere",
"Maids",
"Mermaid",
"Monster Boy",
"Monster Girl",
"Nekomimi",
"Ninja",
"Nudity",
"Nun",
"Office Lady",
"Oiran",
"Ojou-sama",
"Orphan",
"Pirates",
"Robots",
"Samurai",
"Shrine Maiden",
"Skeleton",
"Succubus",
"Tanned Skin",
"Teacher",
"Tomboy",
"Transgender",
"Tsundere",
"Twins",
"Vampire",
"Veterinarian",
"Vikings",
"Villainess",
"VTuber",
"Werewolf",
"Witch",
"Yandere",
"Zombie",
],
"Demographic": ["Josei", "Kids", "Seinen", "Shoujo", "Shounen"],
"Setting": ["Matriarchy"],
"Setting Scene": [
"Bar",
"Boarding School",
"Circus",
"Coastal",
"College",
"Desert",
"Dungeon",
"Foreign",
"Inn",
"Konbini",
"Natural Disaster",
"Office",
"Outdoor",
"Prison",
"Restaurant",
"Rural",
"School",
"School Club",
"Snowscape",
"Urban",
"Work",
],
"Setting Time": [
"Achronological Order",
"Anachronism",
"Ancient China",
"Dystopian",
"Historical",
"Time Skip",
],
"Setting Universe": [
"Afterlife",
"Alternate Universe",
"Augmented Reality",
"Omegaverse",
"Post-Apocalyptic",
"Space",
"Urban Fantasy",
"Virtual World",
],
"Technical": [
"4-koma",
"Achromatic",
"Advertisement",
"Anthology",
"CGI",
"Episodic",
"Flash",
"Full CGI",
"Full Color",
"No Dialogue",
"Non-fiction",
"POV",
"Puppetry",
"Rotoscoping",
"Stop Motion",
],
"Theme Action": [
"Archery",
"Battle Royale",
"Espionage",
"Fugitive",
"Guns",
"Martial Arts",
"Spearplay",
"Swordplay",
],
"Theme Arts": [
"Acting",
"Calligraphy",
"Classic Literature",
"Drawing",
"Fashion",
"Food",
"Makeup",
"Photography",
"Rakugo",
"Writing",
],
"Theme Arts-Music": [
"Band",
"Classical Music",
"Dancing",
"Hip-hop Music",
"Jazz Music",
"Metal Music",
"Musical Theater",
"Rock Music",
],
"Theme Comedy": ["Parody", "Satire", "Slapstick", "Surreal Comedy"],
"Theme Drama": [
"Bullying",
"Class Struggle",
"Coming of Age",
"Conspiracy",
"Eco-Horror",
"Fake Relationship",
"Kingdom Management",
"Rehabilitation",
"Revenge",
"Suicide",
"Tragedy",
],
"Theme Fantasy": [
"Alchemy",
"Body Swapping",
"Cultivation",
"Fairy Tale",
"Henshin",
"Isekai",
"Kaiju",
"Magic",
"Mythology",
"Necromancy",
"Shapeshifting",
"Steampunk",
"Super Power",
"Superhero",
"Wuxia",
"Youkai",
],
"Theme Game": ["Board Game", "E-Sports", "Video Games"],
"Theme Game-Card & Board Game": [
"Card Battle",
"Go",
"Karuta",
"Mahjong",
"Poker",
"Shogi",
],
"Theme Game-Sport": [
"Acrobatics",
"Airsoft",
"American Football",
"Athletics",
"Badminton",
"Baseball",
"Basketball",
"Bowling",
"Boxing",
"Cheerleading",
"Cycling",
"Fencing",
"Fishing",
"Fitness",
"Football",
"Golf",
"Handball",
"Ice Skating",
"Judo",
"Lacrosse",
"Parkour",
"Rugby",
"Scuba Diving",
"Skateboarding",
"Sumo",
"Surfing",
"Swimming",
"Table Tennis",
"Tennis",
"Volleyball",
"Wrestling",
],
"Theme Other": [
"Adoption",
"Animals",
"Astronomy",
"Autobiographical",
"Biographical",
"Body Horror",
"Cannibalism",
"Chibi",
"Cosmic Horror",
"Crime",
"Crossover",
"Death Game",
"Denpa",
"Drugs",
"Economics",
"Educational",
"Environmental",
"Ero Guro",
"Filmmaking",
"Found Family",
"Gambling",
"Gender Bending",
"Gore",
"Language Barrier",
"LGBTQ+ Themes",
"Lost Civilization",
"Marriage",
"Medicine",
"Memory Manipulation",
"Meta",
"Mountaineering",
"Noir",
"Otaku Culture",
"Pandemic",
"Philosophy",
"Politics",
"Proxy Battle",
"Psychosexual",
"Reincarnation",
"Religion",
"Royal Affairs",
"Slavery",
"Software Development",
"Survival",
"Terrorism",
"Torture",
"Travel",
"War",
],
"Theme Other-Organisations": [
"Assassins",
"Criminal Organization",
"Cult",
"Firefighters",
"Gangs",
"Mafia",
"Military",
"Police",
"Triads",
"Yakuza",
],
"Theme Other-Vehicle": [
"Aviation",
"Cars",
"Mopeds",
"Motorcycles",
"Ships",
"Tanks",
"Trains",
],
"Theme Romance": [
"Age Gap",
"Bisexual",
"Boys' Love",
"Female Harem",
"Heterosexual",
"Love Triangle",
"Male Harem",
"Matchmaking",
"Mixed Gender Harem",
"Teens' Love",
"Unrequited Love",
"Yuri",
],
"Theme Sci Fi": [
"Cyberpunk",
"Space Opera",
"Time Loop",
"Time Manipulation",
"Tokusatsu",
],
"Theme Sci Fi-Mecha": ["Real Robot", "Super Robot"],
"Theme Slice of Life": [
"Agriculture",
"Cute Boys Doing Cute Things",
"Cute Girls Doing Cute Things",
"Family Life",
"Horticulture",
"Iyashikei",
"Parenthood",
],
}
tags_available_list = []
for tag_category, tags_in_category in tags_available.items():
tags_available_list.extend(tags_in_category)

View File

@@ -0,0 +1,394 @@
import click
from ...completion_functions import anime_titles_shell_complete
from .data import (
genres_available,
media_formats_available,
media_statuses_available,
seasons_available,
sorts_available,
tags_available_list,
years_available,
)
@click.command(
help="download anime using anilists api to get the titles",
short_help="download anime with anilist intergration",
)
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
@click.option(
"--season",
help="The season the media was released",
type=click.Choice(seasons_available),
)
@click.option(
"--status",
"-S",
help="The media status of the anime",
multiple=True,
type=click.Choice(media_statuses_available),
)
@click.option(
"--sort",
"-s",
help="What to sort the search results on",
type=click.Choice(sorts_available),
)
@click.option(
"--genres",
"-g",
multiple=True,
help="the genres to filter by",
type=click.Choice(genres_available),
)
@click.option(
"--tags",
"-T",
multiple=True,
help="the tags to filter by",
type=click.Choice(tags_available_list),
)
@click.option(
"--media-format",
"-f",
multiple=True,
help="Media format",
type=click.Choice(media_formats_available),
)
@click.option(
"--year",
"-y",
type=click.Choice(years_available),
help="the year the media was released",
)
@click.option(
"--on-list/--not-on-list",
"-L/-no-L",
help="Whether the anime should be in your list or not",
type=bool,
)
@click.option(
"--episode-range",
"-r",
help="A range of episodes to download (start-end)",
)
@click.option(
"--force-unknown-ext",
"-F",
help="This option forces yt-dlp to download extensions its not aware of",
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.option(
"--merge", "-m", is_flag=True, help="Merge the subfile with video using ffmpeg"
)
@click.option(
"--clean",
"-c",
is_flag=True,
help="After merging delete the original files",
)
@click.option(
"--wait-time",
"-w",
type=int,
help="The amount of time to wait after downloading is complete before the screen is completely cleared",
default=60,
)
@click.option(
"--prompt/--no-prompt",
help="Whether to prompt for anything instead just do the best thing",
default=True,
)
@click.option(
"--force-ffmpeg",
is_flag=True,
help="Force the use of FFmpeg for downloading (supports large variety of streams but slower)",
)
@click.option(
"--hls-use-mpegts",
is_flag=True,
help="Use mpegts for hls streams (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
)
@click.option(
"--max-results", "-M", type=int, help="The maximum number of results to show"
)
@click.pass_obj
def download(
config,
title,
season,
status,
sort,
genres,
tags,
media_format,
year,
on_list,
episode_range,
force_unknown_ext,
silent,
verbose,
merge,
clean,
wait_time,
prompt,
force_ffmpeg,
hls_use_mpegts,
max_results,
):
from rich import print
from ....anilist import AniList
force_ffmpeg |= hls_use_mpegts
success, anilist_search_results = AniList.search(
query=title,
sort=sort,
status_in=list(status),
genre_in=list(genres),
season=season,
tag_in=list(tags),
seasonYear=year,
format_in=list(media_format),
on_list=on_list,
max_results=max_results,
)
if success:
import time
from rich.progress import Progress
from thefuzz import fuzz
from ....AnimeProvider import AnimeProvider
from ....libs.anime_provider.types import Anime
from ....libs.fzf import fzf
from ....Utility.data import anime_normalizer
from ....Utility.downloader.downloader import downloader
from ...utils.tools import exit_app
from ...utils.utils import (
filter_by_quality,
fuzzy_inquirer,
move_preferred_subtitle_lang_to_top,
)
anime_provider = AnimeProvider(config.provider)
anilist_anime_info = None
translation_type = config.translation_type
download_dir = config.downloads_dir
anime_titles = [
(anime["title"]["romaji"] or anime["title"]["english"])
for anime in anilist_search_results["data"]["Page"]["media"]
]
print(f"[green bold]Queued:[/] {anime_titles}")
for i, anime_title in enumerate(anime_titles):
print(f"[green bold]Now Downloading: [/] {anime_title}")
# ---- search for anime ----
with Progress() as progress:
progress.add_task("Fetching Search Results...", total=None)
search_results = anime_provider.search_for_anime(
anime_title, translation_type=translation_type
)
if not search_results:
print(
"No search results found from provider for {}".format(anime_title)
)
continue
search_results = search_results["results"]
if not search_results:
print("Nothing muches your search term")
continue
search_results_ = {
search_result["title"]: search_result
for search_result in search_results
}
if config.auto_select:
selected_anime_title = max(
search_results_.keys(),
key=lambda title: fuzz.ratio(
anime_normalizer.get(title, title), anime_title
),
)
print("[cyan]Auto selecting:[/] ", selected_anime_title)
else:
choices = list(search_results_.keys())
if config.use_fzf:
selected_anime_title = fzf.run(
choices, "Please Select title", "FastAnime"
)
else:
selected_anime_title = fuzzy_inquirer(
choices,
"Please Select title",
)
# ---- fetch anime ----
with Progress() as progress:
progress.add_task("Fetching Anime...", total=None)
anime: Anime | None = anime_provider.get_anime(
search_results_[selected_anime_title]["id"]
)
if not anime:
print("Failed to fetch anime {}".format(selected_anime_title))
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) :]
print(f"[green bold]Downloading: [/] {episodes_range}")
else:
episodes_range = sorted(episodes, key=float)
if config.normalize_titles:
anilist_anime_info = anilist_search_results["data"]["Page"]["media"][i]
# 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["id"], 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"]
if anilist_anime_info:
selected_anime_title = (
anilist_anime_info["title"][config.preferred_language]
or anilist_anime_info["title"]["romaji"]
or anilist_anime_info["title"]["english"]
)
import re
for episode_detail in anilist_anime_info["streamingEpisodes"]:
if re.match(
f".*Episode {episode} .*", episode_detail["title"]
):
episode_title = episode_detail["title"]
break
print(f"[purple]Now Downloading:[/] {episode_title}")
subtitles = move_preferred_subtitle_lang_to_top(
subtitles, config.sub_lang
)
downloader._download_file(
link,
selected_anime_title,
episode_title,
download_dir,
silent,
vid_format=config.format,
force_unknown_ext=force_unknown_ext,
verbose=verbose,
headers=provider_headers,
sub=subtitles[0]["url"] if subtitles else "",
merge=merge,
clean=clean,
prompt=prompt,
force_ffmpeg=force_ffmpeg,
hls_use_mpegts=hls_use_mpegts,
)
except Exception as e:
print(e)
time.sleep(1)
print("Continuing...")
print("Done Downloading")
time.sleep(wait_time)
exit_app()
else:
from sys import exit
print("Failed to search for anime", anilist_search_results)
exit(1)

View File

@@ -0,0 +1,358 @@
import logging
from typing import TYPE_CHECKING
import click
from ...completion_functions import downloaded_anime_titles
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ..config import Config
@click.command(
help="View and watch your downloads using mpv",
short_help="Watch downloads",
epilog="""
\b
\b\bExamples:
fastanime downloads
\b
# view individual episodes
fastanime downloads --view-episodes
# --- or ---
fastanime downloads -v
\b
# 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)>
\b
# to watch a specific title
# be sure to get the completions for the best experience
fastanime downloads --title <title>
\b
# to get the path to the downloads folder set
fastanime downloads --path
# useful when you want to use the value for other programs
""",
)
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
@click.option(
"--title",
"-T",
shell_complete=downloaded_anime_titles,
help="watch a specific title",
)
@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",
)
@click.pass_obj
def downloads(
config: "Config", path: bool, title, view_episodes, ffmpegthumbnailer_seek_time
):
import os
from ....cli.utils.mpv import run_mpv
from ....libs.fzf import fzf
from ....libs.rofi import Rofi
from ....Utility.utils import sort_by_episode_number
from ...utils.tools import exit_app
from ...utils.utils import fuzzy_inquirer
if not ffmpegthumbnailer_seek_time:
ffmpegthumbnailer_seek_time = config.ffmpegthumbnailer_seek_time
USER_VIDEOS_DIR = config.downloads_dir
if path:
print(USER_VIDEOS_DIR)
return
if not os.path.exists(USER_VIDEOS_DIR):
print("Downloads directory specified does not exist")
return
anime_downloads = sorted(
os.listdir(USER_VIDEOS_DIR),
)
anime_downloads.append("Exit")
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,
player=config.player,
)
stream_episode(anime_playlist_path)
def stream_anime(title=None):
if title:
from thefuzz import fuzz
playlist_name = max(anime_downloads, key=lambda t: fuzz.ratio(title, t))
elif config.use_fzf:
if not config.preview:
playlist_name = fzf.run(
anime_downloads,
"Enter Playlist Name",
)
else:
preview = get_previews_anime()
playlist_name = fzf.run(
anime_downloads,
"Enter Playlist Name",
preview=preview,
)
elif config.use_rofi:
playlist_name = Rofi.run(anime_downloads, "Enter Playlist Name")
else:
playlist_name = fuzzy_inquirer(
anime_downloads,
"Enter Playlist Name",
)
if playlist_name == "Exit":
exit_app()
return
playlist = os.path.join(USER_VIDEOS_DIR, playlist_name)
if view_episodes:
stream_episode(
playlist,
)
else:
if config.sync_play:
from ...utils.syncplay import SyncPlayer
SyncPlayer(playlist)
else:
run_mpv(
playlist,
player=config.player,
)
stream_anime()
stream_anime(title)

View File

@@ -7,26 +7,47 @@ if TYPE_CHECKING:
@click.command(help="View anime you dropped")
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def dropped(config: "Config"):
def dropped(config: "Config", dump_json):
from sys import exit
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
exit(1)
anime_list = AniList.get_anime_list("DROPPED")
if not anime_list:
return
exit(1)
if not anime_list[0] or not anime_list[1]:
return
exit(1)
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(anime_list[1]))
else:
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = (
lambda config, **kwargs: anilist_interfaces._handle_animelist(
config, fastanime_runtime_state, "Dropped", **kwargs
)
)
fastanime_runtime_state.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -5,14 +5,33 @@ import click
help="Fetch the top 15 most favourited anime from anilist",
short_help="View most favourited anime",
)
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def favourites(config):
def favourites(config, dump_json):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_favourite()
if anime_data[0]:
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(anime_data[1]))
else:
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = AniList.get_most_favourite
fastanime_runtime_state.anilist_results_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
else:
from sys import exit
exit(1)

View File

@@ -11,11 +11,11 @@ if TYPE_CHECKING:
@click.option("--erase", "-e", help="Erase your login details", is_flag=True)
@click.pass_obj
def login(config: "Config", status, erase):
from sys import exit
from rich import print
from rich.prompt import Confirm, Prompt
from ...utils.tools import exit_app
if status:
is_logged_in = True if config.user else False
message = (
@@ -23,16 +23,16 @@ def login(config: "Config", status, erase):
)
print(message)
print(config.user)
exit_app()
exit(0)
elif erase:
if Confirm.ask(
"Are you sure you want to erase your login status", default=False
):
config.update_user({})
print("Success")
exit_app(0)
exit(0)
else:
exit_app(1)
exit(1)
else:
from click import launch
@@ -41,7 +41,7 @@ def login(config: "Config", status, erase):
if config.user:
print("Already logged in :confused:")
if not Confirm.ask("or would you like to reloggin", default=True):
exit_app()
exit(0)
# ---- new loggin -----
print(
f"A browser session will be opened ( [link]{config.fastanime_anilist_app_login_url}[/link] )",
@@ -52,10 +52,10 @@ def login(config: "Config", status, erase):
user = AniList.login_user(token)
if not user:
print("Sth went wrong", user)
exit_app()
exit(1)
return
user["token"] = token
config.update_user(user)
print("Successfully saved credentials")
print(user)
exit_app()
exit(0)

View File

@@ -13,9 +13,15 @@ def notifier(config: "Config"):
import logging
import os
import time
from sys import exit
import requests
from plyer import notification
try:
from plyer import notification
except ImportError:
print("Please install plyer to use this command")
exit(1)
from ....anilist import AniList
from ....constants import APP_CACHE_DIR, APP_DATA_DIR, APP_NAME, ICON_PATH, PLATFORM
@@ -30,7 +36,7 @@ def notifier(config: "Config"):
if not config.user:
print("Not Authenticated")
print("Run the following to get started: fastanime anilist loggin")
return
exit(1)
run = True
# WARNING: Mess around with this value at your own risk
timeout = 2 # time is in minutes

View File

@@ -7,26 +7,46 @@ if TYPE_CHECKING:
@click.command(help="View anime you paused on watching")
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def paused(config: "Config"):
def paused(config: "Config", dump_json):
from sys import exit
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
exit(1)
anime_list = AniList.get_anime_list("PAUSED")
if not anime_list:
return
exit(1)
if not anime_list[0] or not anime_list[1]:
return
exit(1)
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = FastAnimeRuntimeState()
anilist_config.data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, anilist_config)
if dump_json:
import json
print(json.dumps(anime_list[1]))
else:
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = (
lambda config, **kwargs: anilist_interfaces._handle_animelist(
config, fastanime_runtime_state, "Paused", **kwargs
)
)
fastanime_runtime_state.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -7,26 +7,47 @@ if TYPE_CHECKING:
@click.command(help="View anime you are planning on watching")
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def planning(config: "Config"):
def planning(config: "Config", dump_json):
from sys import exit
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
exit(1)
anime_list = AniList.get_anime_list("PLANNING")
if not anime_list:
return
exit(1)
if not anime_list[0] or not anime_list[1]:
return
exit(1)
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(anime_list[1]))
else:
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = (
lambda config, **kwargs: anilist_interfaces._handle_animelist(
config, fastanime_runtime_state, "Planned", **kwargs
)
)
fastanime_runtime_state.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -4,14 +4,33 @@ import click
@click.command(
help="Fetch the top 15 most popular anime", short_help="View most popular anime"
)
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def popular(config):
def popular(config, dump_json):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_popular()
if anime_data[0]:
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(anime_data[1]))
else:
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = AniList.get_most_popular
fastanime_runtime_state.anilist_results_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
else:
from sys import exit
exit(1)

View File

@@ -5,23 +5,35 @@ import click
help="Get random anime from anilist based on a range of anilist anime ids that are seected at random",
short_help="View random anime",
)
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def random_anime(config):
def random_anime(config, dump_json):
import random
from ....anilist import AniList
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
random_anime = range(1, 15000)
random_anime = range(1, 100000)
random_anime = random.sample(random_anime, k=50)
anime_data = AniList.search(id_in=list(random_anime))
if anime_data[0]:
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(anime_data[1]))
else:
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_results_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
else:
print(anime_data[1])
exit(1)

View File

@@ -5,14 +5,35 @@ import click
help="Fetch the 15 most recently updated anime from anilist that are currently releasing",
short_help="View recently updated anime",
)
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def recent(config):
def recent(config, dump_json):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_recently_updated()
if anime_data[0]:
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(anime_data[1]))
else:
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = (
AniList.get_most_recently_updated
)
fastanime_runtime_state.anilist_results_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
else:
from sys import exit
exit(1)

View File

@@ -7,26 +7,47 @@ if TYPE_CHECKING:
@click.command(help="View anime you are rewatching")
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def rewatching(config: "Config"):
def rewatching(config: "Config", dump_json):
from sys import exit
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
exit(1)
anime_list = AniList.get_anime_list("REPEATING")
if not anime_list:
return
exit(1)
if not anime_list[0] or not anime_list[1]:
return
exit(1)
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(anime_list[1]))
else:
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = (
lambda config, **kwargs: anilist_interfaces._handle_animelist(
config, fastanime_runtime_state, "Rewatching", **kwargs
)
)
fastanime_runtime_state.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -4,14 +4,33 @@ import click
@click.command(
help="Fetch the 15 most scored anime", short_help="View most scored anime"
)
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def scores(config):
def scores(config, dump_json):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_scored()
if anime_data[0]:
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(anime_data[1]))
else:
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = AniList.get_most_scored
fastanime_runtime_state.anilist_results_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
else:
from sys import exit
exit(1)

View File

@@ -1,21 +1,135 @@
import click
from ...completion_functions import anime_titles_shell_complete
from .data import (
genres_available,
media_formats_available,
media_statuses_available,
seasons_available,
sorts_available,
tags_available_list,
years_available,
)
@click.command(
help="Search for anime using anilists api and get top ~50 results",
short_help="Search for anime",
)
@click.argument("title", shell_complete=anime_titles_shell_complete)
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.option(
"--season",
help="The season the media was released",
type=click.Choice(seasons_available),
)
@click.option(
"--status",
"-S",
help="The media status of the anime",
multiple=True,
type=click.Choice(media_statuses_available),
)
@click.option(
"--sort",
"-s",
help="What to sort the search results on",
type=click.Choice(sorts_available),
)
@click.option(
"--genres",
"-g",
multiple=True,
help="the genres to filter by",
type=click.Choice(genres_available),
)
@click.option(
"--tags",
"-T",
multiple=True,
help="the tags to filter by",
type=click.Choice(tags_available_list),
)
@click.option(
"--media-format",
"-f",
multiple=True,
help="Media format",
type=click.Choice(media_formats_available),
)
@click.option(
"--year",
"-y",
type=click.Choice(years_available),
help="the year the media was released",
)
@click.option(
"--on-list/--not-on-list",
"-L/-no-L",
help="Whether the anime should be in your list or not",
type=bool,
)
@click.pass_obj
def search(config, title):
def search(
config,
title,
dump_json,
season,
status,
sort,
genres,
tags,
media_format,
year,
on_list,
):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
success, search_results = AniList.search(title)
success, search_results = AniList.search(
query=title,
sort=sort,
status_in=list(status),
genre_in=list(genres),
season=season,
tag_in=list(tags),
seasonYear=year,
format_in=list(media_format),
on_list=on_list,
)
if success:
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = search_results
anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(search_results))
else:
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = (
lambda page=1, **kwargs: AniList.search(
query=title,
sort=sort,
status_in=list(status),
genre_in=list(genres),
season=season,
tag_in=list(tags),
seasonYear=year,
format_in=list(media_format),
on_list=on_list,
page=page,
)
)
fastanime_runtime_state.anilist_results_data = search_results
anilist_results_menu(config, fastanime_runtime_state)
else:
from sys import exit
exit(1)

View File

@@ -0,0 +1,63 @@
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from ...config import Config
@click.command(help="Print out your anilist stats")
@click.pass_obj
def stats(
config: "Config",
):
import shutil
import subprocess
from sys import exit
from rich.console import Console
console = Console()
from rich.markdown import Markdown
from rich.panel import Panel
from ....anilist import AniList
user_data = AniList.get_user_info()
if not user_data[0] or not user_data[1]:
print("Failed to get user info")
print(user_data[1])
exit(1)
KITTEN_EXECUTABLE = shutil.which("kitten")
if not KITTEN_EXECUTABLE:
print("Kitten not found")
exit(1)
image_url = user_data[1]["data"]["User"]["avatar"]["medium"]
user_name = user_data[1]["data"]["User"]["name"]
about = user_data[1]["data"]["User"]["about"] or ""
console.clear()
image_x = int(console.size.width * 0.1)
image_y = int(console.size.height * 0.1)
img_w = console.size.width // 3
img_h = console.size.height // 3
image_process = subprocess.run(
[
KITTEN_EXECUTABLE,
"icat",
"--clear",
"--place",
f"{img_w}x{img_h}@{image_x}x{image_y}",
image_url,
],
)
if not image_process.returncode == 0:
print("failed to get image from icat")
exit(1)
console.print(
Panel(
Markdown(about),
title=user_name,
)
)

View File

@@ -5,14 +5,33 @@ import click
help="Fetch the top 15 anime that are currently trending",
short_help="Trending anime 🔥🔥🔥",
)
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def trending(config):
def trending(config, dump_json):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
success, data = AniList.get_trending()
if success:
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = data
anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(data))
else:
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = AniList.get_trending
fastanime_runtime_state.anilist_results_data = data
anilist_results_menu(config, fastanime_runtime_state)
else:
from sys import exit
exit(1)

View File

@@ -4,14 +4,33 @@ import click
@click.command(
help="Fetch the 15 most anticipited anime", short_help="View upcoming anime"
)
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def upcoming(config):
def upcoming(config, dump_json):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
success, data = AniList.get_upcoming_anime()
if success:
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = data
anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(data))
else:
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = AniList.get_upcoming_anime
fastanime_runtime_state.anilist_results_data = data
anilist_results_menu(config, fastanime_runtime_state)
else:
from sys import exit
exit(1)

View File

@@ -7,26 +7,47 @@ if TYPE_CHECKING:
@click.command(help="View anime you are watching")
@click.option(
"--dump-json",
"-d",
is_flag=True,
help="Only print out the results dont open anilist menu",
)
@click.pass_obj
def watching(config: "Config"):
def watching(config: "Config", dump_json):
from sys import exit
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit_app()
exit(1)
anime_list = AniList.get_anime_list("CURRENT")
if not anime_list:
return
exit(1)
if not anime_list[0] or not anime_list[1]:
return
exit(1)
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
if dump_json:
import json
print(json.dumps(anime_list[1]))
else:
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.current_page = 1
fastanime_runtime_state.current_data_loader = (
lambda config, **kwargs: anilist_interfaces._handle_animelist(
config, fastanime_runtime_state, "Watching", **kwargs
)
)
fastanime_runtime_state.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -1,7 +1,24 @@
import click
@click.command(help="Helper command to manage cache")
@click.command(
help="Helper command to manage cache",
epilog="""
\b
\b\bExamples:
# delete everything in the cache dir
fastanime cache --clean
\b
# print the path to the cache dir and exit
fastanime cache --path
\b
# print the current size of the cache dir and exit
fastanime cache --size
\b
# open the cache dir and exit
fastanime cache
""",
)
@click.option("--clean", help="Clean the cache dir", is_flag=True)
@click.option("--path", help="The path to the cache dir", is_flag=True)
@click.option("--size", help="The size of the cache dir", is_flag=True)

View File

@@ -1,7 +1,24 @@
import click
@click.command(help="Helper command to get shell completions")
@click.command(
help="Helper command to get shell completions",
epilog="""
\b
\b\bExamples:
# try to detect your shell and print completions
fastanime completions
\b
# print fish completions
fastanime completions --fish
\b
# print bash completions
fastanime completions --bash
\b
# print zsh completions
fastanime completions --zsh
""",
)
@click.option("--fish", is_flag=True, help="print fish completions")
@click.option("--zsh", is_flag=True, help="print zsh completions")
@click.option("--bash", is_flag=True, help="print bash completions")

View File

@@ -7,8 +7,27 @@ if TYPE_CHECKING:
@click.command(
help="Opens up your fastanime config in your preferred editor",
help="Manage your config with ease",
short_help="Edit your config",
epilog="""
\b
\b\bExamples:
# Edit your config in your default editor
# NB: If it opens vim or vi exit with `:q`
fastanime config
\b
# get the path of the config file
fastanime config --path
\b
# print desktop entry info
fastanime config --desktop-entry
\b
# update your config without opening an editor
fastanime --icons --fzf --preview config --update
\b
# view the current contents of your config
fastanime config --view
""",
)
@click.option("--path", "-p", help="Print the config location and exit", is_flag=True)
@click.option(
@@ -20,8 +39,14 @@ if TYPE_CHECKING:
help="Configure the desktop entry of fastanime",
is_flag=True,
)
@click.option(
"--update",
"-u",
help="Persist all the config options passed to fastanime to your config file",
is_flag=True,
)
@click.pass_obj
def config(config: "Config", path, view, desktop_entry):
def config(user_config: "Config", path, view, desktop_entry, update):
import sys
from rich import print
@@ -32,7 +57,7 @@ def config(config: "Config", path, view, desktop_entry):
if path:
print(USER_CONFIG_PATH)
elif view:
print(config)
print(user_config)
elif desktop_entry:
import os
import shutil
@@ -87,7 +112,9 @@ def config(config: "Config", path, view, desktop_entry):
with open(desktop_entry_path) as f:
print(f"Successfully wrote \n{f.read()}")
exit_app(0)
elif update:
with open(USER_CONFIG_PATH, "w", encoding="utf-8") as file:
file.write(user_config.__str__())
print("update successfull")
else:
import click
click.edit(filename=USER_CONFIG_PATH)

View File

@@ -1,4 +1,3 @@
import time
from typing import TYPE_CHECKING
import click
@@ -12,6 +11,53 @@ if TYPE_CHECKING:
@click.command(
help="Download anime using the anime provider for a specified range",
short_help="Download anime",
epilog="""
\b
\b\bExamples:
# Download all available episodes
# 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 ':'
\b
# 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'
\b
# latest 5
fastanime download -t <anime-title> -t <anime-title> -r '-5'
\b
# Download specific episode range
# be sure to observe the range Syntax
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
\b
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>'
\b
fastanime download -t <anime-title> -r '<episodes-start>:'
\b
fastanime download -t <anime-title> -r ':<episodes-end>'
\b
# download specific episode
# remember python indexing starts at 0
fastanime download -t <anime-title> -r '<episode-1>:<episode>'
\b
# merge subtitles with ffmpeg to mkv format; hianime tends to give subs as separate files
# and dont prompt for anything
# eg existing file in destination instead remove
# and clean
# ie remove original files (sub file and vid file)
# only keep merged files
fastanime download -t <anime-title> --merge --clean --no-prompt
\b
# EOF is used since -t always expects a title
# you can supply anime titles from file or -t at the same time
# from stdin
echo -e "<anime-title>\\n<anime-title>\\n<anime-title>" | fastanime download -t "EOF" -r <range> -f -
\b
# from file
fastanime download -t "EOF" -r <range> -f <file-path>
""",
)
@click.option(
"--anime-titles",
@@ -28,8 +74,14 @@ if TYPE_CHECKING:
help="A range of episodes to download (start-end)",
)
@click.option(
"--force-unknown-ext",
"--file",
"-f",
type=click.File(),
help="A file to read from all anime to download",
)
@click.option(
"--force-unknown-ext",
"-F",
help="This option forces yt-dlp to download extensions its not aware of",
is_flag=True,
)
@@ -41,15 +93,55 @@ if TYPE_CHECKING:
default=True,
)
@click.option("--verbose", "-v", is_flag=True, help="Download verbosely (everywhere)")
@click.option(
"--merge", "-m", is_flag=True, help="Merge the subfile with video using ffmpeg"
)
@click.option(
"--clean",
"-c",
is_flag=True,
help="After merging delete the original files",
)
@click.option(
"--wait-time",
"-w",
type=int,
help="The amount of time to wait after downloading is complete before the screen is completely cleared",
default=60,
)
@click.option(
"--prompt/--no-prompt",
help="Whether to prompt for anything instead just do the best thing",
default=True,
)
@click.option(
"--force-ffmpeg",
is_flag=True,
help="Force the use of FFmpeg for downloading (supports large variety of streams but slower)",
)
@click.option(
"--hls-use-mpegts",
is_flag=True,
help="Use mpegts for hls streams (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
)
@click.pass_obj
def download(
config: "Config",
anime_titles: list,
anime_titles: tuple,
episode_range,
file,
force_unknown_ext,
silent,
verbose,
merge,
clean,
wait_time,
prompt,
force_ffmpeg,
hls_use_mpegts,
):
import time
from rich import print
from rich.progress import Progress
from thefuzz import fuzz
@@ -57,17 +149,34 @@ def download(
from ...AnimeProvider import AnimeProvider
from ...libs.anime_provider.types import Anime
from ...libs.fzf import fzf
from ...Utility.data import anime_normalizer
from ...Utility.downloader.downloader import downloader
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,
)
force_ffmpeg |= hls_use_mpegts
anime_provider = AnimeProvider(config.provider)
anilist_anime_info = None
translation_type = config.translation_type
download_dir = config.downloads_dir
if file:
contents = file.read()
anime_titles_from_file = tuple(
[title for title in contents.split("\n") if title]
)
file.close()
anime_titles = (*anime_titles_from_file, *anime_titles)
print(f"[green bold]Queued:[/] {anime_titles}")
for anime_title in anime_titles:
if anime_title == "EOF":
break
print(f"[green bold]Now Downloading: [/] {anime_title}")
# ---- search for anime ----
with Progress() as progress:
@@ -79,25 +188,45 @@ def download(
print("Search results failed")
input("Enter to retry")
download(
config, anime_title, episode_range, force_unknown_ext, silent, verbose
config,
anime_title,
episode_range,
file,
force_unknown_ext,
silent,
verbose,
merge,
clean,
wait_time,
prompt,
force_ffmpeg,
hls_use_mpegts,
)
return
search_results = search_results["results"]
if not search_results:
print("Nothing muches your search term")
continue
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)
selected_anime_title = max(
search_results_.keys(),
key=lambda title: fuzz.ratio(
anime_normalizer.get(title, title), anime_title
),
)
print("[cyan]Auto selecting:[/] ", search_result)
print("[cyan]Auto selecting:[/] ", selected_anime_title)
else:
choices = list(search_results_.keys())
if config.use_fzf:
search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
selected_anime_title = fzf.run(
choices, "Please Select title", "FastAnime"
)
else:
search_result = fuzzy_inquirer(
selected_anime_title = fuzzy_inquirer(
choices,
"Please Select title",
)
@@ -106,13 +235,25 @@ def download(
with Progress() as progress:
progress.add_task("Fetching Anime...", total=None)
anime: Anime | None = anime_provider.get_anime(
search_results_[search_result]["id"]
search_results_[selected_anime_title]["id"]
)
if not anime:
print("Sth went wring anime no found")
input("Enter to continue...")
download(
config, anime_title, episode_range, force_unknown_ext, silent, verbose
config,
anime_title,
episode_range,
file,
force_unknown_ext,
silent,
verbose,
merge,
clean,
wait_time,
prompt,
force_ffmpeg,
hls_use_mpegts,
)
return
@@ -146,6 +287,11 @@ def download(
else:
episodes_range = sorted(episodes, key=float)
if config.normalize_titles:
from ...libs.common.mini_anilist import get_basic_anime_info_by_title
anilist_anime_info = get_basic_anime_info_by_title(anime["title"])
# lets download em
for episode in episodes_range:
try:
@@ -156,7 +302,7 @@ def download(
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
anime["id"], episode, config.translation_type
)
if not streams:
print("No streams skipping")
@@ -165,37 +311,12 @@ def download(
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams, None)
if not server:
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["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["headers"]
episode_title = server["episode_title"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.server in servers_names:
server = config.server
else:
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
else:
server = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
config.quality, server_name["links"]
)
if not stream_link:
print("[yellow bold]WARNING:[/] No streams found")
@@ -203,25 +324,76 @@ def download(
print("Continuing...")
continue
link = stream_link["link"]
provider_headers = servers[server]["headers"]
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"]
episode_title = servers[server]["episode_title"]
print(f"[purple]Now Downloading:[/] {search_result} Episode {episode}")
subtitles = servers[server_name]["subtitles"]
episode_title = servers[server_name]["episode_title"]
if anilist_anime_info:
selected_anime_title = (
anilist_anime_info["title"][config.preferred_language]
or anilist_anime_info["title"]["romaji"]
or anilist_anime_info["title"]["english"]
)
import re
for episode_detail in anilist_anime_info["episodes"]:
if re.match(f"Episode {episode} ", episode_detail["title"]):
episode_title = episode_detail["title"]
break
print(f"[purple]Now Downloading:[/] {episode_title}")
subtitles = move_preferred_subtitle_lang_to_top(
subtitles, config.sub_lang
)
downloader._download_file(
link,
anime["title"],
selected_anime_title,
episode_title,
download_dir,
silent,
config.format,
force_unknown_ext,
verbose,
vid_format=config.format,
force_unknown_ext=force_unknown_ext,
verbose=verbose,
headers=provider_headers,
sub=subtitles[0]["url"] if subtitles else "",
merge=merge,
clean=clean,
prompt=prompt,
force_ffmpeg=force_ffmpeg,
hls_use_mpegts=hls_use_mpegts,
)
except Exception as e:
print(e)
time.sleep(1)
print("Continuing...")
print("Done Downloading")
time.sleep(wait_time)
exit_app()

View File

@@ -3,25 +3,60 @@ from typing import TYPE_CHECKING
import click
from ..completion_functions import downloaded_anime_titles
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ..config import Config
@click.command(
help="View and watch your downloads using mpv", short_help="Watch downloads"
help="View and watch your downloads using mpv",
short_help="Watch downloads",
epilog="""
\b
\b\bExamples:
fastanime downloads
\b
# view individual episodes
fastanime downloads --view-episodes
# --- or ---
fastanime downloads -v
\b
# 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)>
\b
# to watch a specific title
# be sure to get the completions for the best experience
fastanime downloads --title <title>
\b
# to get the path to the downloads folder set
fastanime downloads --path
# useful when you want to use the value for other programs
""",
)
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
@click.option(
"--title",
"-T",
shell_complete=downloaded_anime_titles,
help="watch a specific title",
)
@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]",
help="ffmpegthumbnailer seek time",
)
@click.pass_obj
def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_seek_time):
def downloads(
config: "Config", path: bool, title, view_episodes, ffmpegthumbnailer_seek_time
):
import os
from ...cli.utils.mpv import run_mpv
@@ -239,6 +274,7 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
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(
@@ -257,7 +293,7 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
else:
episode_title = fuzzy_inquirer(
downloaded_episodes,
"Enter Playlist Name: ",
"Enter Playlist Name",
)
if episode_title == "Back":
stream_anime()
@@ -268,11 +304,18 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
SyncPlayer(episode_path)
else:
run_mpv(episode_path)
run_mpv(
episode_path,
player=config.player,
)
stream_episode(anime_playlist_path)
def stream_anime():
if config.use_fzf:
def stream_anime(title=None):
if title:
from thefuzz import fuzz
playlist_name = max(anime_downloads, key=lambda t: fuzz.ratio(title, t))
elif config.use_fzf:
if not config.preview:
playlist_name = fzf.run(
anime_downloads,
@@ -290,7 +333,7 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
else:
playlist_name = fuzzy_inquirer(
anime_downloads,
"Enter Playlist Name: ",
"Enter Playlist Name",
)
if playlist_name == "Exit":
exit_app()
@@ -306,7 +349,10 @@ def downloads(config: "Config", path: bool, view_episodes, ffmpegthumbnailer_see
SyncPlayer(playlist)
else:
run_mpv(playlist)
run_mpv(
playlist,
player=config.player,
)
stream_anime()
stream_anime()
stream_anime(title)

View File

@@ -11,6 +11,41 @@ if TYPE_CHECKING:
@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",
epilog="""
\b
\b\bExamples:
# --- print anime info + episode streams ---
\b
# 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 ':'
\b
# print the latest episode
fastanime grab -t <anime-title> -r '-1'
\b
# print a specific episode range
# be sure to observe the range Syntax
fastanime grab -t <anime-title> -r '<start>:<stop>'
\b
fastanime grab -t <anime-title> -r '<start>:<stop>:<step>'
\b
fastanime grab -t <anime-title> -r '<start>:'
\b
fastanime grab -t <anime-title> -r ':<end>'
\b
# --- grab options ---
\b
# print search results only
fastanime grab -t <anime-title> -r <range> --search-results-only
\b
# print anime info only
fastanime grab -t <anime-title> -r <range> --anime-info-only
\b
# print episode streams only
fastanime grab -t <anime-title> -r <range> --episode-streams-only
""",
)
@click.option(
"--anime-titles",
@@ -26,11 +61,29 @@ if TYPE_CHECKING:
"-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
@@ -38,83 +91,148 @@ def grab(
from thefuzz import fuzz
from ...AnimeProvider import AnimeProvider
logger = getLogger(__name__)
if config.manga:
manga_title = anime_titles[0]
from ...MangaProvider import MangaProvider
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:
manga_provider = MangaProvider()
search_data = manga_provider.search_for_manga(manga_title)
if not search_data:
exit(1)
if search_results_only:
print(json.dumps(search_data))
exit(0)
search_results = search_data["results"]
if not search_results:
logger.error("no results for your search")
exit(1)
search_results = search_results["results"]
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)
search_result_anime_title = max(
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_titles[0])
)
manga_info = manga_provider.get_manga(
search_results_[search_result_anime_title]["id"]
)
if not manga_info:
return
if anime_info_only:
print(json.dumps(manga_info))
exit(0)
# ---- fetch anime ----
anime = anime_provider.get_anime(search_results_[search_result]["id"])
if not anime:
chapter_info = manga_provider.get_chapter_thumbnails(
manga_info["id"], str(episode_range)
)
if not chapter_info:
exit(1)
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)]
print(json.dumps(chapter_info))
else:
from ...AnimeProvider import AnimeProvider
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_anime_title = max(
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
)
# ---- fetch anime ----
anime = anime_provider.get_anime(
search_results_[search_result_anime_title]["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_range = episodes
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 = episodes[int(episode_range) :]
episodes_range = sorted(episodes, key=float)
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 = {}
grabbed_anime = dict(anime)
grabbed_anime["requested_episodes"] = episodes_range
grabbed_anime["translation_type"] = config.translation_type
grabbed_anime["episodes_streams"] = {}
# lets download em
for episode in episodes_range:
try:
# lets download em
for episode in episodes_range:
if episode not in episodes:
continue
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
anime["id"], episode, config.translation_type
)
if not streams:
continue
grabbed_anime["episodes_streams"][episode] = {
server["server"]: server for server in streams
}
episode_streams = {server["server"]: server for server in streams}
except Exception as e:
logger.error(e)
grabbed_animes.append(grabbed_anime)
if episode_streams_only:
grabbed_anime[episode] = episode_streams
else:
grabbed_anime["episodes_streams"][ # pyright:ignore
episode
] = episode_streams
# 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:

View File

@@ -1,12 +1,39 @@
from typing import TYPE_CHECKING
import click
from ...cli.config import Config
from ..completion_functions import anime_titles_shell_complete
if TYPE_CHECKING:
from ...cli.config import Config
@click.command(
help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.",
short_help="Binge anime",
epilog="""
\b
\b\bExamples:
# basic form where you will still be prompted for the episode number
# multiple titles can be specified with the -t option
fastanime search -t <anime-title> -t <anime-title>
\b
# binge all episodes with this command
fastanime search -t <anime-title> -r ':'
\b
# watch latest episode
fastanime search -t <anime-title> -r '-1'
\b
# binge a specific episode range with this command
# be sure to observe the range Syntax
fastanime search -t <anime-title> -r '<start>:<stop>'
\b
fastanime search -t <anime-title> -r '<start>:<stop>:<step>'
\b
fastanime search -t <anime-title> -r '<start>:'
\b
fastanime search -t <anime-title> -r ':<end>'
""",
)
@click.option(
"--anime-titles",
@@ -23,202 +50,336 @@ from ..completion_functions import anime_titles_shell_complete
help="A range of episodes to binge (start-end)",
)
@click.pass_obj
def search(config: Config, anime_titles: str, episode_range: str):
def search(config: "Config", anime_titles: str, episode_range: str):
from click import clear
from rich import print
from rich.progress import Progress
from thefuzz import fuzz
from ...AnimeProvider import AnimeProvider
from ...libs.anime_provider.types import Anime
from ...libs.fzf import fzf
from ...libs.rofi import Rofi
from ..utils.mpv import run_mpv
from ..utils.tools import exit_app
from ..utils.utils import filter_by_quality, fuzzy_inquirer
from ..utils.utils import fuzzy_inquirer
anime_provider = AnimeProvider(config.provider)
if config.manga:
from InquirerPy.prompts.number import NumberPrompt
from yt_dlp.utils import sanitize_filename
from ...MangaProvider import MangaProvider
from ..utils.feh import feh_manga_viewer
manga_title = anime_titles[0]
manga_provider = MangaProvider()
search_data = manga_provider.search_for_manga(manga_title)
if not search_data:
print("No search results")
exit(1)
search_results = search_data["results"]
print(f"[green bold]Streaming:[/] {anime_titles}")
for anime_title in anime_titles:
# ---- search for anime ----
with Progress() as progress:
progress.add_task("Fetching Search Results...", total=None)
search_results = anime_provider.search_for_anime(
anime_title, config.translation_type
)
if not search_results:
print("Search results not found")
input("Enter to retry")
search(config, anime_title, episode_range)
return
search_results = search_results["results"]
if not search_results:
print("Anime not found :cry:")
exit_app()
search_results_ = {
search_result["title"]: search_result for search_result in search_results
sanitize_filename(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)
search_result_manga_title = max(
search_results_.keys(),
key=lambda title: fuzz.ratio(title, manga_title),
)
print("[cyan]Auto Selecting:[/] ", search_result)
print("[cyan]Auto Selecting:[/] ", search_result_manga_title)
else:
choices = list(search_results_.keys())
preview = None
if config.preview:
from ..interfaces.utils import get_fzf_manga_preview
preview = get_fzf_manga_preview(search_results)
if config.use_fzf:
search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
search_result_manga_title = fzf.run(
choices, "Please Select title", preview=preview
)
elif config.use_rofi:
search_result = Rofi.run(choices, "Please Select Title")
search_result_manga_title = Rofi.run(choices, "Please Select Title")
else:
search_result = fuzzy_inquirer(
search_result_manga_title = 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"]
anilist_id = search_results_[search_result_manga_title]["id"]
manga_info = manga_provider.get_manga(anilist_id)
if not manga_info:
print("No manga info")
exit(1)
anilist_helper = None
if config.user:
from ...anilist import AniList
AniList.login_user(config.user["token"])
anilist_helper = AniList
def _manga_viewer():
chapter_number = NumberPrompt("Select a chapter number").execute()
chapter_info = manga_provider.get_chapter_thumbnails(
manga_info["id"], str(chapter_number)
)
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)
]
if not chapter_info:
print("No chapter info")
input("Enter to retry...")
_manga_viewer()
return
print(
f"[purple bold]Now Reading: [/] {search_result_manga_title} [cyan bold]Chapter:[/] {chapter_info['title']}"
)
feh_manga_viewer(chapter_info["thumbnails"], str(chapter_info["title"]))
if anilist_helper:
anilist_helper.update_anime_list(
{"mediaId": anilist_id, "progress": chapter_number}
)
_manga_viewer()
_manga_viewer()
else:
from ...AnimeProvider import AnimeProvider
from ...libs.anime_provider.types import Anime
from ...Utility.data import anime_normalizer
from ..utils.mpv import run_mpv
from ..utils.utils import filter_by_quality, move_preferred_subtitle_lang_to_top
anime_provider = AnimeProvider(config.provider)
anilist_anime_info = None
print(f"[green bold]Streaming:[/] {anime_titles}")
for anime_title in anime_titles:
# ---- search for anime ----
with Progress() as progress:
progress.add_task("Fetching Search Results...", total=None)
search_results = anime_provider.search_for_anime(
anime_title, config.translation_type
)
if not search_results:
print("Search results not found")
input("Enter to retry")
search(config, anime_title, episode_range)
return
search_results = search_results["results"]
if not search_results:
print("Anime not found :cry:")
exit_app()
search_results_ = {
search_result["title"]: search_result
for search_result in search_results
}
if config.auto_select:
search_result_manga_title = max(
search_results_.keys(),
key=lambda title: fuzz.ratio(
anime_normalizer.get(title, title), anime_title
),
)
print("[cyan]Auto Selecting:[/] ", search_result_manga_title)
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"]
choices = list(search_results_.keys())
if config.use_fzf:
episode = fzf.run(
choices, "Select an episode: ", header=search_result
search_result_manga_title = fzf.run(
choices, "Please Select title", "FastAnime"
)
elif config.use_rofi:
episode = Rofi.run(choices, "Select an episode")
search_result_manga_title = Rofi.run(choices, "Please Select Title")
else:
episode = fuzzy_inquirer(
search_result_manga_title = fuzzy_inquirer(
choices,
"Select episode",
"Please Select Title",
)
if episode == "end":
return
# ---- fetch streams ----
# ---- fetch selected anime ----
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
progress.add_task("Fetching Anime...", total=None)
anime: Anime | None = anime_provider.get_anime(
search_results_[search_result_manga_title]["id"]
)
if not streams:
print("Failed to get streams")
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)
if config.normalize_titles:
from ...libs.common.mini_anilist import get_basic_anime_info_by_title
anilist_anime_info = get_basic_anime_info_by_title(anime["title"])
def stream_anime(anime: "Anime"):
clear()
episode = None
if episodes_range:
try:
episode = next(episodes_range) # pyright:ignore
print(
f"[cyan]Auto selecting:[/] {search_result_manga_title} [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_manga_title,
)
elif config.use_rofi:
episode = Rofi.run(choices, "Select an episode")
else:
episode = fuzzy_inquirer(
choices,
"Select episode",
)
if episode == "end":
return
try:
# ---- fetch servers ----
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams, None)
if not server:
print("Sth went wrong when fetching the episode")
input("Enter to continue")
stream_anime()
return
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
return
link = stream_link["link"]
stream_headers = server["headers"]
episode_title = server["episode_title"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.server in servers_names:
server = config.server
else:
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
elif config.use_rofi:
server = Rofi.run(servers_names, "Select an link")
else:
server = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
# ---- fetch streams ----
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime["id"], episode, config.translation_type
)
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
if not streams:
print("Failed to get streams")
return
link = stream_link["link"]
stream_headers = servers[server]["headers"]
episode_title = servers[server]["episode_title"]
print(f"[purple]Now Playing:[/] {search_result} Episode {episode}")
if config.sync_play:
from ..utils.syncplay import SyncPlayer
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(anime)
return
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime(anime)
return
link = stream_link["link"]
subtitles = server["subtitles"]
stream_headers = server["headers"]
episode_title = server["episode_title"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.server in servers_names:
server = config.server
else:
if config.use_fzf:
server = fzf.run(servers_names, "Select an link")
elif config.use_rofi:
server = Rofi.run(servers_names, "Select an link")
else:
server = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
)
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime(anime)
return
link = stream_link["link"]
stream_headers = servers[server]["headers"]
subtitles = servers[server]["subtitles"]
episode_title = servers[server]["episode_title"]
SyncPlayer(link, episode_title, headers=stream_headers)
else:
run_mpv(link, episode_title, headers=stream_headers)
except IndexError as e:
print(e)
input("Enter to continue")
stream_anime()
selected_anime_title = search_result_manga_title
if anilist_anime_info:
selected_anime_title = (
anilist_anime_info["title"][config.preferred_language]
or anilist_anime_info["title"]["romaji"]
or anilist_anime_info["title"]["english"]
)
import re
stream_anime()
for episode_detail in anilist_anime_info["episodes"]:
if re.match(f"Episode {episode} ", episode_detail["title"]):
episode_title = episode_detail["title"]
break
print(
f"[purple]Now Playing:[/] {selected_anime_title} Episode {episode}"
)
subtitles = move_preferred_subtitle_lang_to_top(
subtitles, config.sub_lang
)
if config.sync_play:
from ..utils.syncplay import SyncPlayer
SyncPlayer(
link,
episode_title,
headers=stream_headers,
subtitles=subtitles,
)
else:
run_mpv(
link,
episode_title,
headers=stream_headers,
subtitles=subtitles,
player=config.player,
)
except IndexError as e:
print(e)
input("Enter to continue")
stream_anime(anime)
stream_anime(anime)

View File

@@ -0,0 +1,31 @@
import click
@click.command(
help="Command that automates the starting of the builtin fastanime server",
epilog="""
\b
\b\bExamples:
# default
fastanime serve
# specify host and port
fastanime serve --host 127.0.0.1 --port 8080
""",
)
@click.option("--host", "-H", help="Specify the host to run the server on")
@click.option("--port", "-p", help="Specify the port to run the server on")
def serve(host, port):
import os
import sys
from ...constants import APP_DIR
args = [sys.executable, "-m", "fastapi", "run"]
if host:
args.extend(["--host", host])
if port:
args.extend(["--port", port])
args.append(os.path.join(APP_DIR, "api"))
os.execv(sys.executable, args)

View File

@@ -1,11 +1,24 @@
import click
@click.command(help="Helper command to update fastanime to latest")
@click.command(
help="Helper command to update fastanime to latest",
epilog="""
\b
\b\bExamples:
# update fastanime to latest
fastanime update
\b
# check for latest release
fastanime update --check
# Force an update regardless of the current version
fastanime update --force
""",
)
@click.option("--check", "-c", help="Check for the latest release", is_flag=True)
def update(
check,
):
@click.option("--force", "-c", help="Force update", is_flag=True)
def update(check, force):
from rich.console import Console
from rich.markdown import Markdown
@@ -24,8 +37,8 @@ def update(
console.print(body)
if check:
is_update, github_release_data = check_for_updates()
if is_update:
is_latest, github_release_data = check_for_updates()
if not is_latest:
print(
f"You are running an older version ({__version__}) of fastanime please update to get the latest features"
)
@@ -34,5 +47,9 @@ def update(
print(f"You are running the latest version ({__version__}) of fastanime")
_print_release(github_release_data)
else:
success, github_release_data = update_app()
success, github_release_data = update_app(force)
_print_release(github_release_data)
if success:
print("Successfully updated")
else:
print("failed to update")

View File

@@ -6,22 +6,20 @@ ANILIST_ENDPOINT = "https://graphql.anilist.co"
anime_title_query = """
query($query:String){
Page(perPage:50){
pageInfo{
total
currentPage
hasNextPage
}
media(search:$query,type:ANIME){
id
idMal
title{
romaji
english
}
}
query ($query: String) {
Page(perPage: 50) {
pageInfo {
total
}
media(search: $query, type: ANIME) {
id
idMal
title {
romaji
english
}
}
}
}
"""
@@ -46,20 +44,6 @@ def get_anime_titles(query: str, variables: dict = {}):
)
anilist_data = response.json()
# ensuring you dont get blocked
if (
int(response.headers.get("X-RateLimit-Remaining", 0)) < 30
and not response.status_code == 500
):
print("Warning you are exceeding the allowed number of calls per minute")
logger.warning(
"You are exceeding the allowed number of calls per minute for the AniList api enforcing timeout"
)
print("Forced timeout will now be initiated")
import time
print("sleeping...")
time.sleep(1 * 60)
if response.status_code == 200:
eng_titles = [
anime["title"]["english"]
@@ -79,5 +63,33 @@ def get_anime_titles(query: str, variables: dict = {}):
return []
def downloaded_anime_titles(ctx, param, incomplete):
import os
from ..constants import USER_VIDEOS_DIR
try:
titles = [
title
for title in os.listdir(USER_VIDEOS_DIR)
if title.lower().startswith(incomplete.lower()) or not incomplete
]
return titles
except Exception:
return []
def anime_titles_shell_complete(ctx, param, incomplete):
return [name for name in get_anime_titles(anime_title_query, {"query": incomplete})]
incomplete = incomplete.strip()
if not incomplete:
incomplete = None
variables = {}
else:
variables = {"query": incomplete}
return get_anime_titles(anime_title_query, variables)
if __name__ == "__main__":
t = input("Enter title")
results = get_anime_titles(anime_title_query, {"query": t})
print(results)

View File

@@ -4,9 +4,15 @@ import os
from configparser import ConfigParser
from typing import TYPE_CHECKING
from rich import print
from ..constants import USER_CONFIG_PATH, USER_DATA_PATH, USER_VIDEOS_DIR
from ..constants import (
ASSETS_DIR,
S_PLATFORM,
USER_CONFIG_PATH,
USER_DATA_PATH,
USER_VIDEOS_DIR,
USER_WATCH_HISTORY_PATH,
)
from ..libs.fzf import FZF_DEFAULT_OPTS, HEADER
from ..libs.rofi import Rofi
logger = logging.getLogger(__name__)
@@ -15,153 +21,225 @@ if TYPE_CHECKING:
class Config(object):
"""class that handles and manages configuration and user data throughout the clis lifespan
Attributes:
anime_list: [TODO:attribute]
watch_history: [TODO:attribute]
fastanime_anilist_app_login_url: [TODO:attribute]
anime_provider: [TODO:attribute]
user_data: [TODO:attribute]
configparser: [TODO:attribute]
downloads_dir: [TODO:attribute]
provider: [TODO:attribute]
use_fzf: [TODO:attribute]
use_rofi: [TODO:attribute]
skip: [TODO:attribute]
icons: [TODO:attribute]
preview: [TODO:attribute]
translation_type: [TODO:attribute]
sort_by: [TODO:attribute]
continue_from_history: [TODO:attribute]
auto_next: [TODO:attribute]
auto_select: [TODO:attribute]
use_mpv_mod: [TODO:attribute]
quality: [TODO:attribute]
notification_duration: [TODO:attribute]
error: [TODO:attribute]
server: [TODO:attribute]
format: [TODO:attribute]
force_window: [TODO:attribute]
preferred_language: [TODO:attribute]
rofi_theme: [TODO:attribute]
rofi_theme: [TODO:attribute]
rofi_theme_input: [TODO:attribute]
rofi_theme_input: [TODO:attribute]
rofi_theme_confirm: [TODO:attribute]
rofi_theme_confirm: [TODO:attribute]
watch_history: [TODO:attribute]
anime_list: [TODO:attribute]
user: [TODO:attribute]
"""
manga = False
sync_play = False
anime_list: list
watch_history: dict
watch_history: dict = {}
fastanime_anilist_app_login_url = (
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
)
anime_provider: "AnimeProvider"
user_data = {"watch_history": {}, "animelist": [], "user": {}}
user_data = {
"recent_anime": [],
"animelist": [],
"user": {},
"meta": {"last_updated": 0},
}
default_config = {
"auto_next": "False",
"menu_order": "",
"auto_select": "True",
"cache_requests": "true",
"check_for_updates": "True",
"continue_from_history": "True",
"default_media_list_tracking": "None",
"downloads_dir": USER_VIDEOS_DIR,
"disable_mpv_popen": "True",
"discord": "False",
"episode_complete_at": "80",
"ffmpegthumbnailer_seek_time": "-1",
"force_forward_tracking": "true",
"force_window": "immediate",
"fzf_opts": FZF_DEFAULT_OPTS,
"header_color": "95,135,175",
"header_ascii_art": HEADER,
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
"icons": "false",
"image_previews": "True" if S_PLATFORM != "win32" else "False",
"normalize_titles": "True",
"notification_duration": "2",
"max_cache_lifetime": "03:00:00",
"per_page": "15",
"player": "mpv",
"preferred_history": "local",
"preferred_language": "english",
"preview": "False",
"preview_header_color": "215,0,95",
"preview_separator_color": "208,208,208",
"provider": "allanime",
"quality": "1080",
"recent": "50",
"rofi_theme": os.path.join(ASSETS_DIR, "rofi_theme.rasi"),
"rofi_theme_preview": os.path.join(ASSETS_DIR, "rofi_theme_preview.rasi"),
"rofi_theme_confirm": os.path.join(ASSETS_DIR, "rofi_theme_confirm.rasi"),
"rofi_theme_input": os.path.join(ASSETS_DIR, "rofi_theme_input.rasi"),
"server": "top",
"skip": "false",
"sort_by": "search match",
"sub_lang": "eng",
"translation_type": "sub",
"use_fzf": "False",
"use_persistent_provider_store": "false",
"use_python_mpv": "false",
"use_rofi": "false",
}
def __init__(self) -> None:
self.initialize_user_data()
self.initialize_user_data_and_watch_history_recent_anime()
self.load_config()
def load_config(self):
self.configparser = ConfigParser(
{
"quality": "1080",
"auto_next": "False",
"auto_select": "True",
"sort_by": "search match",
"downloads_dir": USER_VIDEOS_DIR,
"translation_type": "sub",
"server": "top",
"continue_from_history": "True",
"preferred_history": "local",
"use_mpv_mod": "false",
"force_window": "immediate",
"preferred_language": "english",
"use_fzf": "False",
"preview": "False",
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
"provider": "allanime",
"error": "3",
"icons": "false",
"notification_duration": "2",
"skip": "false",
"use_rofi": "false",
"rofi_theme": "",
"rofi_theme_input": "",
"rofi_theme_confirm": "",
"ffmpegthumnailer_seek_time": "-1",
}
)
self.configparser = ConfigParser(self.default_config)
self.configparser.add_section("stream")
self.configparser.add_section("general")
self.configparser.add_section("anilist")
if not os.path.exists(USER_CONFIG_PATH):
with open(USER_CONFIG_PATH, "w") as config:
self.configparser.write(config)
self.configparser.read(USER_CONFIG_PATH)
# --- set config values from file or using defaults ---
self.downloads_dir = self.get_downloads_dir()
self.provider = self.get_provider()
self.use_fzf = self.get_use_fzf()
self.use_rofi = self.get_use_rofi()
self.skip = self.get_skip()
self.icons = self.get_icons()
self.preview = self.get_preview()
self.translation_type = self.get_translation_type()
self.sort_by = self.get_sort_by()
self.continue_from_history = self.get_continue_from_history()
self.auto_next = self.get_auto_next()
self.auto_select = self.get_auto_select()
self.use_mpv_mod = self.get_use_mpv_mod()
self.quality = self.get_quality()
self.notification_duration = self.get_notification_duration()
self.error = self.get_error()
self.server = self.get_server()
self.format = self.get_format()
self.force_window = self.get_force_window()
self.preferred_language = self.get_preferred_language()
self.preferred_history = self.get_preferred_history()
self.rofi_theme = self.get_rofi_theme()
if os.path.exists(USER_CONFIG_PATH):
self.configparser.read(USER_CONFIG_PATH, encoding="utf-8")
# get the configuration
self.auto_next = self.configparser.getboolean("stream", "auto_next")
self.auto_select = self.configparser.getboolean("stream", "auto_select")
self.cache_requests = self.configparser.getboolean("general", "cache_requests")
self.check_for_updates = self.configparser.getboolean(
"general", "check_for_updates"
)
self.continue_from_history = self.configparser.getboolean(
"stream", "continue_from_history"
)
self.default_media_list_tracking = self.configparser.get(
"general", "default_media_list_tracking"
)
self.disable_mpv_popen = self.configparser.getboolean(
"stream", "disable_mpv_popen"
)
self.discord = self.configparser.getboolean("general", "discord")
self.downloads_dir = self.configparser.get("general", "downloads_dir")
self.episode_complete_at = self.configparser.getint(
"stream", "episode_complete_at"
)
self.ffmpegthumbnailer_seek_time = self.configparser.getint(
"general", "ffmpegthumbnailer_seek_time"
)
self.force_forward_tracking = self.configparser.getboolean(
"general", "force_forward_tracking"
)
self.force_window = self.configparser.get("stream", "force_window")
self.format = self.configparser.get("stream", "format")
self.fzf_opts = self.configparser.get("general", "fzf_opts")
self.header_color = self.configparser.get("general", "header_color")
self.header_ascii_art = self.configparser.get("general", "header_ascii_art")
self.icons = self.configparser.getboolean("general", "icons")
self.image_previews = self.configparser.getboolean("general", "image_previews")
self.normalize_titles = self.configparser.getboolean(
"general", "normalize_titles"
)
self.notification_duration = self.configparser.getint(
"general", "notification_duration"
)
self._max_cache_lifetime = self.configparser.get(
"general", "max_cache_lifetime"
)
max_cache_lifetime = list(map(int, self._max_cache_lifetime.split(":")))
self.max_cache_lifetime = (
max_cache_lifetime[0] * 86400
+ max_cache_lifetime[1] * 3600
+ max_cache_lifetime[2] * 60
)
self.per_page = self.configparser.get("anilist", "per_page")
self.player = self.configparser.get("stream", "player")
self.preferred_history = self.configparser.get("stream", "preferred_history")
self.preferred_language = self.configparser.get("general", "preferred_language")
self.preview = self.configparser.getboolean("general", "preview")
self.preview_separator_color = self.configparser.get(
"general", "preview_separator_color"
)
self.preview_header_color = self.configparser.get(
"general", "preview_header_color"
)
self.provider = self.configparser.get("general", "provider")
self.quality = self.configparser.get("stream", "quality")
self.recent = self.configparser.getint("general", "recent")
self.rofi_theme_confirm = self.configparser.get("general", "rofi_theme_confirm")
self.rofi_theme_input = self.configparser.get("general", "rofi_theme_input")
self.rofi_theme = self.configparser.get("general", "rofi_theme")
self.rofi_theme_preview = self.configparser.get("general", "rofi_theme_preview")
self.server = self.configparser.get("stream", "server")
self.skip = self.configparser.getboolean("stream", "skip")
self.sort_by = self.configparser.get("anilist", "sort_by")
self.menu_order = self.configparser.get("general", "menu_order")
self.sub_lang = self.configparser.get("general", "sub_lang")
self.translation_type = self.configparser.get("stream", "translation_type")
self.use_fzf = self.configparser.getboolean("general", "use_fzf")
self.use_python_mpv = self.configparser.getboolean("stream", "use_python_mpv")
self.use_rofi = self.configparser.getboolean("general", "use_rofi")
self.use_persistent_provider_store = self.configparser.getboolean(
"general", "use_persistent_provider_store"
)
Rofi.rofi_theme = self.rofi_theme
self.rofi_theme_input = self.get_rofi_theme_input()
Rofi.rofi_theme_input = self.rofi_theme_input
self.rofi_theme_confirm = self.get_rofi_theme_confirm()
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
self.ffmpegthumbnailer_seek_time = self.get_ffmpegthumnailer_seek_time()
Rofi.rofi_theme_preview = self.rofi_theme_preview
os.environ["FZF_DEFAULT_OPTS"] = self.fzf_opts
# ---- setup user data ------
self.watch_history: dict = self.user_data.get("watch_history", {})
self.anime_list: list = self.user_data.get("animelist", [])
self.user: dict = self.user_data.get("user", {})
if not os.path.exists(USER_CONFIG_PATH):
with open(USER_CONFIG_PATH, "w", encoding="utf-8") as config:
config.write(self.__repr__())
def set_fastanime_config_environs(self):
current_config = []
for key in self.default_config:
current_config.append((f"FASTANIME_{key.upper()}", str(getattr(self, key))))
os.environ.update(current_config)
def update_user(self, user):
self.user = user
self.user_data["user"] = user
self._update_user_data()
def update_watch_history(
self, anime_id: int, episode: str, start_time="0", total_time="0"
def update_recent(self, recent_anime: list):
recent_anime_ids = []
_recent_anime = []
for anime in recent_anime:
if (
anime["id"] not in recent_anime_ids
and len(recent_anime_ids) <= self.recent
):
_recent_anime.append(anime)
recent_anime_ids.append(anime["id"])
self.user_data["recent_anime"] = _recent_anime
self._update_user_data()
def media_list_track(
self,
anime_id: int,
episode_no: str,
episode_stopped_at="0",
episode_total_length="0",
progress_tracking="prompt",
):
self.watch_history.update(
{
str(anime_id): {
"episode": episode,
"start_time": start_time,
"total_time": total_time,
"episode_no": episode_no,
"episode_stopped_at": episode_stopped_at,
"episode_total_length": episode_total_length,
"progress_tracking": progress_tracking,
}
}
)
self.user_data["watch_history"] = self.watch_history
self._update_user_data()
with open(USER_WATCH_HISTORY_PATH, "w") as f:
json.dump(self.watch_history, f)
def initialize_user_data(self):
def initialize_user_data_and_watch_history_recent_anime(self):
try:
if os.path.isfile(USER_DATA_PATH):
with open(USER_DATA_PATH, "r") as f:
@@ -169,200 +247,340 @@ class Config(object):
self.user_data.update(user_data)
except Exception as e:
logger.error(e)
try:
if os.path.isfile(USER_WATCH_HISTORY_PATH):
with open(USER_WATCH_HISTORY_PATH, "r") as f:
watch_history = json.load(f)
self.watch_history.update(watch_history)
except Exception as e:
logger.error(e)
def _update_user_data(self):
"""method that updates the actual user data file"""
with open(USER_DATA_PATH, "w") as f:
json.dump(self.user_data, f)
# getters for user configuration
# --- general section ---
def get_provider(self):
return self.configparser.get("general", "provider")
def get_ffmpegthumnailer_seek_time(self):
return self.configparser.getint("general", "ffmpegthumnailer_seek_time")
def get_preferred_language(self):
return self.configparser.get("general", "preferred_language")
def get_downloads_dir(self):
return self.configparser.get("general", "downloads_dir")
def get_icons(self):
return self.configparser.getboolean("general", "icons")
def get_preview(self):
return self.configparser.getboolean("general", "preview")
def get_use_fzf(self):
return self.configparser.getboolean("general", "use_fzf")
# rofi conifiguration
def get_use_rofi(self):
return self.configparser.getboolean("general", "use_rofi")
def get_rofi_theme(self):
return self.configparser.get("general", "rofi_theme")
def get_rofi_theme_input(self):
return self.configparser.get("general", "rofi_theme_input")
def get_rofi_theme_confirm(self):
return self.configparser.get("general", "rofi_theme_confirm")
# --- stream section ---
def get_skip(self):
return self.configparser.getboolean("stream", "skip")
def get_auto_next(self):
return self.configparser.getboolean("stream", "auto_next")
def get_auto_select(self):
return self.configparser.getboolean("stream", "auto_select")
def get_continue_from_history(self):
return self.configparser.getboolean("stream", "continue_from_history")
def get_use_mpv_mod(self):
return self.configparser.getboolean("stream", "use_mpv_mod")
def get_notification_duration(self):
return self.configparser.getint("general", "notification_duration")
def get_error(self):
return self.configparser.getint("stream", "error")
def get_force_window(self):
return self.configparser.get("stream", "force_window")
def get_translation_type(self):
return self.configparser.get("stream", "translation_type")
def get_preferred_history(self):
return self.configparser.get("stream", "preferred_history")
def get_quality(self):
return self.configparser.get("stream", "quality")
def get_server(self):
return self.configparser.get("stream", "server")
def get_format(self):
return self.configparser.get("stream", "format")
def get_sort_by(self):
return self.configparser.get("anilist", "sort_by")
def update_config(self, section: str, key: str, value: str):
self.configparser.set(section, key, value)
with open(USER_CONFIG_PATH, "w") as config:
self.configparser.write(config)
def __repr__(self):
current_config_state = f"""
new_line = "\n"
tab = "\t"
current_config_state = f"""\
#
# ███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗ ░█████╗░░█████╗░███╗░░██╗███████╗██╗░██████╗░
# ██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝ ██╔══██╗██╔══██╗████╗░██║██╔════╝██║██╔════╝░
# █████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░ ██║░░╚═╝██║░░██║██╔██╗██║█████╗░░██║██║░░██╗░
# ██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░ ██║░░██╗██║░░██║██║╚████║██╔══╝░░██║██║░░╚██╗
# ██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗ ╚█████╔╝╚█████╔╝██║░╚███║██║░░░░░██║╚██████╔╝
# ╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝ ░╚════╝░░╚════╝░╚═╝░░╚══╝╚═╝░░░░░╚═╝░╚═════╝░
#
[general]
# Can you rice it?
# for the preview pane
preview_separator_color = {self.preview_separator_color}
preview_header_color = {self.preview_header_color}
# for the header
# be sure to indent
header_ascii_art = {new_line.join([tab+line for line in self.header_ascii_art.split(new_line)])}
header_color = {self.header_color}
# to be passed to fzf
# be sure to indent
fzf_opts = {new_line.join([tab+line for line in self.fzf_opts.split(new_line)])}
# whether to show the icons in the tui [True/False]
# more like emojis
# by the way if you have any recommendations
# to which should be used where please
# don't hesitate to share your opinion
# cause it's a lot of work
# to look for the right one for each menu option
# be sure to also give the replacement emoji
icons = {self.icons}
# whether to normalize provider titles [True/False]
# basically takes the provider titles and finds the corresponding anilist title then changes the title to that
# useful for uniformity especially when downloading from different providers
# this also applies to episode titles
normalize_titles = {self.normalize_titles}
# whether to check for updates every time you run the script [True/False]
# this is useful for keeping your script up to date
# cause there are always new features being added 😄
check_for_updates = {self.check_for_updates}
# can be [allanime, animepahe, hianime, nyaa, yugen]
# allanime is the most realible
# animepahe provides different links to streams of different quality so a quality can be selected reliably with --quality option
# hianime usually provides subs in different languuages and its servers are generally faster
# NOTE: currently they are encrypting the video links
# though am working on it
# however, you can still get the links to the subs
# with ```fastanime grab``` command
# yugen meh
# nyaa those who prefer torrents, though not reliable due to auto selection of results
# as most of the data in nyaa is not structured
# though works relatively well for new anime
# esp with subsplease and horriblesubs
# oh and you should have webtorrent cli to use this
provider = {self.provider}
# Display language [english, romaji]
# this is passed to anilist directly and is used to set the language which the anime titles will be in
# when using the anilist interface
preferred_language = {self.preferred_language}
# Download directory
# where you will find your videos after downloading them with 'fastanime download' command
downloads_dir = {self.downloads_dir}
# whether to show a preview window when using fzf or rofi [True/False]
# the preview requires you have a commandline image viewer as documented in the README
# this is only when using fzf or rofi
# if you dont care about image and text previews it doesnt matter
# though its awesome
# try it and you will see
preview = {self.preview}
# whether to show images in the preview [true/false]
# windows users just swtich to linux 😄
# cause even if you enable it
# it won't look pretty
# just be satisfied with the text previews
# so forget it exists 🤣
image_previews = {self.image_previews}
# the time to seek when using ffmpegthumbnailer [-1 to 100]
# -1 means random and is the default
# ffmpegthumbnailer is used to generate previews
# and you can select at what time in the video to extract an image
# random makes things quite exciting cause you never no at what time it will extract the image from
# used by the ```fastanime downloads``` command
ffmpegthumbnailer_seek_time = {self.ffmpegthumbnailer_seek_time}
# specify the order of menu items in a comma-separated list.
# only include the base names of menu options (e.g., "Trending", "Recent").
# default value is 'Trending,Recent,Watching,Paused,Dropped,Planned,Completed,Rewatching,Recently Updated Anime,Search,Watch History,Random Anime,Most Popular Anime,Most Favourite Anime,Most Scored Anime,Upcoming Anime,Edit Config,Exit'
# leave blank to use the default menu order.
# you can also omit some options by not including them in the list
menu_order = {self.menu_order}
# whether to use fzf as the interface for the anilist command and others. [True/False]
use_fzf = {self.use_fzf}
# whether to use rofi for the ui [True/False]
# it's more useful if you want to create a desktop entry
# which can be setup with 'fastanime config --desktop-entry'
# though if you want it to be your sole interface even when fastanime is run directly from the terminal
use_rofi = {self.use_rofi}
# rofi themes to use <path>
# the values of this option is the path to the rofi config files to use
# i choose to split it into 4 since it gives the best look and feel
# you can refer to the rofi demo on github to see for your self
# i need help designing the default rofi themes
# if you fancy yourself a rofi ricer please contribute to making
# the default theme better
rofi_theme = {self.rofi_theme}
rofi_theme_preview = {self.rofi_theme_preview}
rofi_theme_input = {self.rofi_theme_input}
rofi_theme_confirm = {self.rofi_theme_confirm}
# the duration in minutes a notification will stay in the screen
# used by notifier command
notification_duration = {self.notification_duration}
# used when the provider gives subs of different languages
# currently its the case for:
# hianime
# the values for this option are the short names for languages
# regex is used to determine what you selected
sub_lang = {self.sub_lang}
# what is your default media list tracking [track/disabled/prompt]
# only affects your anilist anime list
# track - means your progress will always be reflected in your anilist anime list
# disabled - means progress tracking will no longer be reflected in your anime list
# prompt - means for every anime you will be prompted whether you want your progress to be tracked or not
default_media_list_tracking = {self.default_media_list_tracking}
# whether media list tracking should only be updated when the next episode is greater than the previous
# this affects only your anilist anime list
force_forward_tracking = {self.force_forward_tracking}
# whether to cache requests [true/false]
# this makes the experience better and more faster
# as data need not always be fetched from web server
# and instead can be gotten locally
# from the cached_requests_db
cache_requests = {self.cache_requests}
# the max lifetime for a cached request <days:hours:minutes>
# defaults to 3days = 03:00:00
# this is the time after which a cached request will be deleted (technically : )
max_cache_lifetime = {self._max_cache_lifetime}
# whether to use a persistent store (basically a sqlitedb) for storing some data the provider requires
# to enable a seamless experience [true/false]
# this option exists primarily because i think it may help in the optimization
# of fastanime as a library in a website project
# for now i don't recommend changing it
# leave it as is
use_persistent_provider_store = {self.use_persistent_provider_store}
# no of recent anime to keep [0-50]
# 0 will disable recent anime tracking
recent = {self.recent}
# enable or disable discord activity updater
# if you want to enable it, please follow the lnik below to register the app with discord account
# https://discord.com/oauth2/authorize?client_id=1292070065583165512
discord = {self.discord}
[stream]
# Auto continue from watch history
# the quality of the stream [1080,720,480,360]
# this option is usually only reliable when:
# provider=animepahe
# since it provides links that actually point to streams of different qualities
# while the rest just point to another link that can provide the anime from the same server
quality = {self.quality}
# Auto continue from watch history [True/False]
# this will make fastanime to choose the episode that you last watched to completion
# and increment it by one
# and use that to auto select the episode you want to watch
continue_from_history = {self.continue_from_history}
# which hostory to use [local/remote]
# which history to use [local/remote]
# local history means it will just use the watch history stored locally in your device
# the file that stores it is called watch_history.json
# and is stored next to your config file
# remote means it ignores the last episode stored locally
# and instead uses the one in your anilist anime list
# this config option is useful if you want to overwrite your local history
# or import history covered from another device or platform
# since remote history will take precendence over whats available locally
preferred_history = {self.preferred_history}
# Preferred language for anime (options: dub, sub)
# Preferred language for anime [dub/sub]
translation_type = {self.translation_type}
# Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top, wixmp)
# what server to use for a particular provider
# allanime: [dropbox, sharepoint, wetransfer, gogoanime, wixmp]
# animepahe: [kwik]
# hianime: [HD1, HD2, StreamSB, StreamTape] : only HD2 for now
# yugen: [gogoanime]
# 'top' can also be used as a value for this option
# 'top' will cause fastanime to auto select the first server it sees
# this saves on resources and is faster since not all servers are being fetched
server = {self.server}
# Auto-select next episode
# Auto select next episode [True/False]
# this makes fastanime increment the current episode number
# then after using that value to fetch the next episode instead of prompting
# this option is useful for binging
auto_next = {self.auto_next}
# Auto select the anime provider results with fuzzy find.
# Note this wont always be correct.But 99% of the time will be.
# Auto select the anime provider results with fuzzy find. [True/False]
# Note this won't always be correct
# this is because the providers sometime use non-standard names
# that are there own preference rather than the official names
# But 99% of the time will be accurate
# if this happens just turn off auto_select in the menus or from the commandline
# and manually select the correct anime title
# edit this file <https://github.com/Benexl/FastAnime/blob/master/fastanime/Utility/data.py>
# and to the dictionary of the provider
# the provider title (key) and their corresponding anilist names (value)
# and then please open a pr
# issues on the same will be ignored and then closed 😆
auto_select = {self.auto_select}
# whether to skip the opening and ending theme songs
# whether to skip the opening and ending theme songs [True/False]
# NOTE: requires ani-skip to be in path
# for python-mpv users am planning to create this functionality n python without the use of an external script
# so its disabled for now
# and anyways Dan Da Dan
# taught as the importance of letting it flow 🙃
skip = {self.skip}
# the maximum delta time in minutes after which the episode should be considered as completed
# used in the continue from time stamp
error = {self.error}
# at what percentage progress should the episode be considered as completed [0-100]
# this value is used to determine whether to increment the current episode number and save it to your local list
# so you can continue immediately to the next episode without select it the next time you decide to watch the anime
# it is also used to determine whether your anilist anime list should be updated or not
episode_complete_at = {self.episode_complete_at}
# whether to use python-mpv
# whether to use python-mpv [True/False]
# to enable superior control over the player
# adding more options to it
use_mpv_mod = {self.use_mpv_mod}
# Enabling this option and you will ask yourself
# why you did not discover fastanime sooner 🙃
# Since you basically don't have to close the player window
# to go to the next or previous episode, switch servers,
# change translation type or change to a given episode x
# so try it if you haven't already
# if you have any issues setting it up
# don't be afraid to ask
# especially on windows
# honestly it can be a pain to set it up there
# personally it took me quite sometime to figure it out
# this is because of how windows handles shared libraries
# so just ask when you find yourself stuck
# or just switch to nixos 😄
use_python_mpv = {self.use_python_mpv}
# whether to use popen to get the timestamps for continue_from_history
# implemented because popen does not work for some reason in nixos and apparently on mac as well
# if you are on nixos or mac and you have a solution to this problem please share
# i will be glad to hear it 😄
# So for now ignore this option
# and anyways the new method of getting timestamps is better
disable_mpv_popen = {self.disable_mpv_popen}
# force mpv window
# the default 'immediate' just makes mpv to open the window even if the video has not yet loaded
# done for asthetics
# passed directly to mpv so values are same
force_window = immediate
# the format of downloaded anime and trailer
# based on yt-dlp format and passed directly to it
# learn more by looking it up on their site
# only works for downloaded anime if server=gogoanime
# since its the only one that offers different formats
# the others tend not to
# only works for downloaded anime if:
# provider=allanime, server=gogoanime
# provider=allanime, server=wixmp
# provider=hianime
# this is because they provider a m3u8 file that contans multiple quality streams
format = {self.format}
[general]
# set the player to use for streaming [mpv/vlc]
# while this option exists i will still recommend that you use mpv
# since you will miss out on some features if you use the others
player = {self.player}
# can be [allanime,animepahe]
provider = {self.provider}
[anilist]
per_page = {self.per_page}
# Display language (options: english, romaji)
preferred_language = {self.preferred_language}
# Download directory
downloads_dir = {self.downloads_dir}
# whether to show a preview window when using fzf or rofi
preview = {self.preview}
# the time to seek when using ffmpegthumbnailer [-1 to 100]
# -1 means random and is the default
ffmpegthumbnailer_seek_time = {self.ffmpegthumbnailer_seek_time}
# whether to use fzf as the interface for the anilist command and others.
use_fzf = {self.use_fzf}
# whether to use rofi for the ui
use_rofi = {self.use_rofi}
# rofi theme to use
rofi_theme = {self.rofi_theme}
rofi_theme_input = {self.rofi_theme_input}
rofi_theme_confirm = {self.rofi_theme_confirm}
# whether to show the icons
icons = {self.icons}
# the duration in minutes a notification will stay in the screen
# used by notifier command
notification_duration = {self.notification_duration}
"""
#
# HOPE YOU ENJOY FASTANIME AND BE SURE TO STAR THE PROJECT ON GITHUB
# https://github.com/Benexl/FastAnime
#
# Also join the discord server
# where the anime tech community lives :)
# https://discord.gg/C4rhMA4mmK
#
"""
return current_config_state
def __str__(self):
return self.__repr__()
# WARNING: depracated and will probably be removed
def update_anime_list(self, anime_id: int, remove=False):
if remove:
try:
self.anime_list.remove(anime_id)
print("Succesfully removed :cry:")
except Exception:
print(anime_id, "Nothing to remove :confused:")
else:
self.anime_list.append(anime_id)
self.user_data["animelist"] = list(set(self.anime_list))
self._update_user_data()
print("Succesfully added :smile:")
input("Enter to continue...")

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,9 @@ import textwrap
from threading import Thread
import requests
from yt_dlp.utils import clean_html
from yt_dlp.utils import clean_html, sanitize_filename
from ...constants import APP_CACHE_DIR
from ...constants import APP_CACHE_DIR, S_PLATFORM
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...Utility import anilist_data_helper
from ..utils.scripts import fzf_preview
@@ -46,7 +46,13 @@ def aniskip(mal_id: int, episode: str):
# NOTE: May change this to a temp dir but there were issues so later
WORKING_DIR = APP_CACHE_DIR # tempfile.gettempdir()
_HEADER_COLOR = os.environ.get("FASTANIME_PREVIEW_HEADER_COLOR", "215,0,95").split(",")
HEADER_COLOR = _HEADER_COLOR[0], _HEADER_COLOR[1], _HEADER_COLOR[2]
_SEPARATOR_COLOR = os.environ.get(
"FASTANIME_PREVIEW_SEPARATOR_COLOR", "208,208,208"
).split(",")
SEPARATOR_COLOR = _SEPARATOR_COLOR[0], _SEPARATOR_COLOR[1], _SEPARATOR_COLOR[2]
SINGLE_QUOTE = "'"
IMAGES_CACHE_DIR = os.path.join(WORKING_DIR, "images")
if not os.path.exists(IMAGES_CACHE_DIR):
os.mkdir(IMAGES_CACHE_DIR)
@@ -63,7 +69,7 @@ def save_image_from_url(url: str, file_name: str):
file_name: filename to use
"""
image = requests.get(url)
with open(f"{IMAGES_CACHE_DIR}/{file_name}", "wb") as f:
with open(os.path.join(IMAGES_CACHE_DIR, f"{file_name}.png"), "wb") as f:
f.write(image.content)
@@ -74,7 +80,14 @@ def save_info_from_str(info: str, file_name: str):
info: the information anilist has on the anime
file_name: the filename to use
"""
with open(f"{ANIME_INFO_CACHE_DIR}/{file_name}", "w") as f:
with open(
os.path.join(
ANIME_INFO_CACHE_DIR,
file_name,
),
"w",
encoding="utf-8",
) as f:
f.write(info)
@@ -90,41 +103,75 @@ def write_search_results(
titles: sanitized anime titles
workers:number of threads to use defaults to as many as possible
"""
# NOTE: Will probably make this a configuraable option
HEADER_COLOR = 215, 0, 95
SEPARATOR_COLOR = 208, 208, 208
SEPARATOR_WIDTH = 45
# use concurency to download and write as fast as possible
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
future_to_task = {}
for anime, title in zip(anilist_results, titles):
# actual image url
image_url = anime["coverImage"]["large"]
future_to_task[executor.submit(save_image_from_url, image_url, title)] = (
image_url
)
if os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower() == "true":
image_url = anime["coverImage"]["large"]
future_to_task[
executor.submit(save_image_from_url, image_url, title)
] = image_url
mediaListName = "Not in any of your lists"
progress = "UNKNOWN"
if anime_list := anime["mediaListEntry"]:
mediaListName = anime_list["status"]
progress = anime_list["progress"]
# handle the text data
template = f"""
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
{get_true_fg('Title(jp):',*HEADER_COLOR)} {anime['title']['romaji']}
{get_true_fg('Title(eng):',*HEADER_COLOR)} {anime['title']['english']}
{get_true_fg('Popularity:',*HEADER_COLOR)} {anime['popularity']}
{get_true_fg('Favourites:',*HEADER_COLOR)} {anime['favourites']}
{get_true_fg('Status:',*HEADER_COLOR)} {anime['status']}
{get_true_fg('Episodes:',*HEADER_COLOR)} {anime['episodes']}
{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres'])}
{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode'])}
{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate'])}
{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate'])}
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
{get_true_fg('Description:',*HEADER_COLOR)}
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
echo
echo "{get_true_fg('Title(jp):',*HEADER_COLOR)} {(anime['title']['romaji'] or "").replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Title(eng):',*HEADER_COLOR)} {(anime['title']['english'] or "").replace('"',SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
echo
echo "{get_true_fg('Popularity:',*HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime['popularity'])}"
echo "{get_true_fg('Favourites:',*HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime['favourites'])}"
echo "{get_true_fg('Status:',*HEADER_COLOR)} {str(anime['status']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres']).replace('"',SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
echo
echo "{get_true_fg('Episodes:',*HEADER_COLOR)} {(anime['episodes']) or 'UNKNOWN'}"
echo "{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate']).replace('"',SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
echo
echo "{get_true_fg('Media List:',*HEADER_COLOR)} {mediaListName.replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Progress:',*HEADER_COLOR)} {progress}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
echo
# echo "{get_true_fg('Description:',*HEADER_COLOR).replace('"',SINGLE_QUOTE)}"
"""
template = textwrap.dedent(template)
template = f"""
{template}
echo "
{textwrap.fill(clean_html(
str(anime['description'])), width=45)}
(anime['description']) or "").replace('"',SINGLE_QUOTE), width=45)}
"
"""
future_to_task[executor.submit(save_info_from_str, template, title)] = title
@@ -168,7 +215,196 @@ def get_rofi_icons(
logger.error("%r generated an exception: %s" % (url, e))
def get_fzf_preview(
# get rofi icons
def get_fzf_manga_preview(manga_results, workers=None, wait=False):
"""A helper function to make sure that the images are downloaded so they can be used as icons
Args:
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
anilist_results: the anilist results from an anilist action
"""
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 manga in manga_results:
image_url = manga["poster"]
future_to_url[
executor.submit(
save_image_from_url,
image_url,
sanitize_filename(manga["title"]),
)
] = image_url
# execute the jobs
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
future.result()
except Exception as e:
logger.error("%r generated an exception: %s" % (url, e))
background_worker = Thread(
target=_worker,
)
background_worker.daemon = True
# ensure images and info exists
background_worker.start()
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then fzf-preview %s/{}
else echo Loading...
fi
""" % (
fzf_preview,
IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR,
)
if wait:
background_worker.join()
return preview
# get rofi icons
def get_fzf_episode_preview(
anilist_result: AnilistBaseMediaDataSchema, episodes, workers=None, wait=False
):
"""A helper function to make sure that the images are downloaded so they can be used as icons
Args:
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
anilist_results: the anilist results from an anilist action
"""
# HEADER_COLOR = 215, 0, 95
import re
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 episode in episodes:
episode_title = ""
image_url = ""
for episode_detail in anilist_result["streamingEpisodes"]:
if re.match(f".*Episode {episode} .*", episode_detail["title"]):
episode_title = episode_detail["title"]
image_url = episode_detail["thumbnail"]
if episode_title and image_url:
future_to_url[
executor.submit(save_image_from_url, image_url, episode)
] = image_url
template = textwrap.dedent(
f"""
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
echo "{get_true_fg('Anime Title(eng):',*HEADER_COLOR)} {('' or anilist_result['title']['english']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Anime Title(jp):',*HEADER_COLOR)} {(anilist_result['title']['romaji'] or '').replace('"',SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
echo "{str(episode_title).replace('"',SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
"""
)
future_to_url[
executor.submit(save_info_from_str, template, str(episode))
] = str(episode)
# 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))
background_worker = Thread(
target=_worker,
)
background_worker.daemon = True
# ensure images and info exists
background_worker.start()
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
os.environ["SHELL"] = shutil.which("bash") or "bash"
if S_PLATFORM == "win32":
preview = """
%s
title={}
show_image_previews="%s"
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [ $show_image_previews = "true" ];then
if [ -s "%s\\\\\\${title}.png" ]; then
if command -v "chafa">/dev/null;then
chafa -s $dim "%s\\\\\\${title}.png"
else
echo please install chafa to enjoy image previews
fi
echo
else
echo Loading...
fi
fi
if [ -s "%s\\\\\\$title" ]; then source "%s\\\\\\$title"
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
)
else:
preview = """
title={}
%s
show_image_previews="%s"
if [ $show_image_previews = "true" ];then
if [ -s %s/${title}.png ]; then fzf-preview %s/${title}.png
else echo Loading...
fi
fi
if [ -f %s/${title} ]; then source %s/${title}
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
)
if wait:
background_worker.join()
return preview
def get_fzf_anime_preview(
anilist_results: list[AnilistBaseMediaDataSchema], titles, wait=False
):
"""A helper function that constructs data to be used for the fzf preview
@@ -182,6 +418,7 @@ def get_fzf_preview(
THe fzf preview script to use
"""
# ensure images and info exists
background_worker = Thread(
target=write_search_results, args=(anilist_results, titles)
)
@@ -190,21 +427,56 @@ def get_fzf_preview(
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then fzf-preview %s/{}
else echo Loading...
fi
if [ -s %s/{} ]; then cat %s/{}
else echo Loading...
fi
""" % (
fzf_preview,
IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
)
if S_PLATFORM == "win32":
preview = """
%s
title={}
show_image_previews="%s"
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [ $show_image_previews = "true" ];then
if [ -s "%s\\\\\\${title}.png" ]; then
if command -v "chafa">/dev/null;then
chafa -s $dim "%s\\\\\\${title}.png"
else
echo please install chafa to enjoy image previews
fi
echo
else
echo Loading...
fi
fi
if [ -s "%s\\\\\\$title" ]; then source "%s\\\\\\$title"
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
)
else:
preview = """
%s
title={}
show_image_previews="%s"
if [ $show_image_previews = "true" ];then
if [ -s "%s/${title}.png" ]; then fzf-preview "%s/${title}.png"
else echo Loading...
fi
fi
if [ -s "%s/$title" ]; then source "%s/$title"
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
)
if wait:
background_worker.join()
return preview

View File

@@ -0,0 +1,12 @@
import shutil
import subprocess
from sys import exit
def feh_manga_viewer(image_links: list[str], window_title: str):
FEH_EXECUTABLE = shutil.which("feh")
if not FEH_EXECUTABLE:
print("feh not found")
exit(1)
commands = [FEH_EXECUTABLE, *image_links, "--title", window_title]
subprocess.run(commands)

View File

@@ -1,128 +1,212 @@
import logging
import os
import re
import shutil
import subprocess
import time
from fastanime.constants import S_PLATFORM
from ...constants import S_PLATFORM
logger = logging.getLogger(__name__)
mpv_av_time_pattern = re.compile(r"AV: ([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)")
def stream_video(MPV, url, mpv_args, custom_args):
process = subprocess.Popen(
[MPV, url, *mpv_args, *custom_args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
last_time = None
av_time_pattern = re.compile(r"AV: ([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)")
last_time = "0"
total_time = "0"
if os.environ.get("FASTANIME_DISABLE_MPV_POPEN", "False") == "False":
process = subprocess.Popen(
[
MPV,
url,
*mpv_args,
*custom_args,
"--no-terminal",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
encoding="utf-8",
)
try:
while True:
if not process.stderr:
continue
output = process.stderr.readline()
try:
while True:
if not process.stderr:
time.sleep(0.1)
continue
output = process.stderr.readline()
if output:
# Match the timestamp in the output
match = av_time_pattern.search(output.strip())
if output:
# Match the timestamp in the output
match = mpv_av_time_pattern.search(output.strip())
if match:
current_time = match.group(1)
total_time = match.group(2)
last_time = current_time
# Check if the process has terminated
retcode = process.poll()
if retcode is not None:
break
except Exception as e:
print(f"An error occurred: {e}")
logger.error(f"An error occurred: {e}")
finally:
process.terminate()
process.wait()
else:
proc = subprocess.run(
[MPV, url, *mpv_args, *custom_args],
capture_output=True,
text=True,
encoding="utf-8",
)
if proc.stdout:
for line in reversed(proc.stdout.split("\n")):
match = mpv_av_time_pattern.search(line.strip())
if match:
current_time = match.group(1)
last_time = match.group(1)
total_time = match.group(2)
match.group(3)
last_time = current_time
# print(f"Current stream time: {current_time}, Total time: {total_time}, Progress: {percentage}%")
# Check if the process has terminated
retcode = process.poll()
if retcode is not None:
print("Finshed at: ", last_time)
break
except Exception as e:
print(f"An error occurred: {e}")
finally:
process.terminate()
break
return last_time, total_time
def run_mpv(
link: str,
title: str | None = "",
title: str = "",
start_time: str = "0",
ytdl_format="",
custom_args=[],
headers={},
subtitles=[],
player="",
):
# Determine if mpv is available
MPV = shutil.which("mpv")
# If title is None, set a default value
# Regex to check if the link is a YouTube URL
youtube_regex = r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+"
if not MPV and not S_PLATFORM == "win32":
# Determine if the link is a YouTube URL
if re.match(youtube_regex, link):
# Android specific commands to launch mpv with a YouTube URL
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"com.google.android.youtube/.UrlActivity",
]
if link.endswith(".torrent"):
WEBTORRENT_CLI = shutil.which("webtorrent")
if not WEBTORRENT_CLI:
import time
print(
"webtorrent cli is not installed which is required for downloading and streaming from nyaa\nplease install it or use another provider"
)
time.sleep(120)
return "0", "0"
cmd = [WEBTORRENT_CLI, link, f"--{player}"]
subprocess.run(cmd, encoding="utf-8")
return "0", "0"
if player == "vlc":
VLC = shutil.which("vlc")
if not VLC and not S_PLATFORM == "win32":
# Determine if the link is a YouTube URL
if re.match(youtube_regex, link):
# Android specific commands to launch mpv with a YouTube URL
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"com.google.android.youtube/.UrlActivity",
]
return "0", "0"
else:
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity",
"-e",
"title",
title,
]
subprocess.run(args)
return "0", "0"
else:
# Android specific commands to launch mpv with a regular URL
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"is.xyz.mpv/.MPVActivity",
]
subprocess.run(args)
return "0", "0"
args = ["vlc", link]
for subtitle in subtitles:
args.append("--sub-file")
args.append(subtitle["url"])
break
if title:
args.append("--video-title")
args.append(title)
subprocess.run(args, encoding="utf-8")
return "0", "0"
else:
# General mpv command with custom arguments
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)
if start_time != "0":
mpv_args.append(f"--start={start_time}")
if title:
mpv_args.append(f"--title={title}")
if ytdl_format:
mpv_args.append(f"--ytdl-format={ytdl_format}")
stop_time, total_time = stream_video(MPV, link, mpv_args, custom_args)
return stop_time, total_time
# Determine if mpv is available
MPV = shutil.which("mpv")
if not MPV and not S_PLATFORM == "win32":
# Determine if the link is a YouTube URL
if re.match(youtube_regex, link):
# Android specific commands to launch mpv with a YouTube URL
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"com.google.android.youtube/.UrlActivity",
]
return "0", "0"
else:
# Android specific commands to launch mpv with a regular URL
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"is.xyz.mpv/.MPVActivity",
]
# Example usage
if __name__ == "__main__":
run_mpv(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"Example Video",
"--fullscreen",
"--volume=50",
)
subprocess.run(args)
return "0", "0"
else:
# General mpv command with custom arguments
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":
mpv_args.append(f"--start={start_time}")
if title:
mpv_args.append(f"--title={title}")
if ytdl_format:
mpv_args.append(f"--ytdl-format={ytdl_format}")
stop_time, total_time = stream_video(MPV, link, mpv_args, custom_args)
return stop_time, total_time

View File

@@ -3,13 +3,14 @@ from typing import TYPE_CHECKING
import mpv
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:
from typing import Literal
from ...AnimeProvider import AnimeProvider
from ..config import Config
from .tools import FastAnimeRuntimeState
def format_time(duration_in_secs: float):
@@ -22,6 +23,7 @@ def format_time(duration_in_secs: float):
class MpvPlayer(object):
anime_provider: "AnimeProvider"
config: "Config"
subs = []
mpv_player: "mpv.MPV"
last_stop_time: str = "0"
last_total_time: str = "0"
@@ -66,7 +68,11 @@ class MpvPlayer(object):
current_episode_number = (
fastanime_runtime_state.provider_current_episode_number
)
config.update_watch_history(anime_id_anilist, str(current_episode_number))
config.media_list_track(
anime_id_anilist,
episode_no=str(current_episode_number),
progress_tracking=fastanime_runtime_state.progress_tracking,
)
elif type == "reload":
if current_episode_number not in total_episodes:
self.mpv_player.show_text("Episode not available")
@@ -82,7 +88,11 @@ class MpvPlayer(object):
self.mpv_player.show_text(f"Fetching episode {ep_no}")
current_episode_number = ep_no
config.update_watch_history(anime_id_anilist, str(ep_no))
config.media_list_track(
anime_id_anilist,
episode_no=str(ep_no),
progress_tracking=fastanime_runtime_state.progress_tracking,
)
fastanime_runtime_state.provider_current_episode_number = str(ep_no)
else:
self.mpv_player.show_text("Fetching previous episode...")
@@ -95,7 +105,11 @@ class MpvPlayer(object):
current_episode_number = (
fastanime_runtime_state.provider_current_episode_number
)
config.update_watch_history(anime_id_anilist, str(current_episode_number))
config.media_list_track(
anime_id_anilist,
episode_no=str(current_episode_number),
progress_tracking=fastanime_runtime_state.progress_tracking,
)
# update episode progress
if config.user and current_episode_number:
AniList.update_anime_list(
@@ -106,14 +120,13 @@ class MpvPlayer(object):
)
# get them juicy streams
episode_streams = anime_provider.get_episode_streams(
provider_anime,
provider_anime["id"],
current_episode_number,
translation_type,
fastanime_runtime_state.selected_anime_anilist,
)
if not episode_streams:
self.mpv_player.show_text("No streams were found")
return None
return
# always select the first
if server == "top":
@@ -131,8 +144,20 @@ class MpvPlayer(object):
self.mpv_player.show_text(
f"Invalid server!!; servers available are: {episode_streams_dict.keys()}",
)
return None
return
self.current_media_title = selected_server["episode_title"]
if config.normalize_titles:
import re
for episode_detail in fastanime_runtime_state.selected_anime_anilist[
"streamingEpisodes"
]:
if re.match(
f"Episode {current_episode_number} ", episode_detail["title"]
):
self.current_media_title = episode_detail["title"]
break
links = selected_server["links"]
stream_link_ = filter_by_quality(quality, links)
@@ -142,17 +167,23 @@ class MpvPlayer(object):
self.mpv_player._set_property("start", "0")
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
def create_player(
self,
stream_link,
anime_provider: "AnimeProvider",
fastanime_runtime_state,
fastanime_runtime_state: "FastAnimeRuntimeState",
config: "Config",
title,
start_time,
headers={},
subtitles=[],
):
self.subs = subtitles
self.anime_provider = anime_provider
self.fastanime_runtime_state = fastanime_runtime_state
self.config = config
@@ -171,17 +202,6 @@ class MpvPlayer(object):
osc=True,
ytdl=True,
)
mpv_player.force_window = config.force_window
# mpv_player.cache = "yes"
# mpv_player.cache_pause = "no"
mpv_player.title = title
mpv_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)
# -- events --
@mpv_player.event_callback("file-loaded")
@@ -190,6 +210,22 @@ class MpvPlayer(object):
self.player_fetching = False
if isinstance(d, float):
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
except Exception:
pass
@mpv_player.property_observer("time-pos")
def handle_time_start_update(*args):
@@ -218,7 +254,9 @@ class MpvPlayer(object):
def _next_episode():
url = self.get_episode("next")
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.on_key_press("shift+p")
@@ -244,7 +282,6 @@ class MpvPlayer(object):
mpv_player.show_text("Changing translation type...")
anime = anime_provider.get_anime(
fastanime_runtime_state.provider_anime_search_result["id"],
fastanime_runtime_state.selected_anime_anilist,
)
if not anime:
mpv_player.show_text("Failed to update translation type")
@@ -327,7 +364,23 @@ class MpvPlayer(object):
mpv_player.register_message_handler("select-quality", select_quality)
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()

View File

@@ -24,4 +24,10 @@ def print_img(url: str):
print("Error fetching image")
return
img_bytes = res.content
"""
Change made in call to chafa. Chafa dev dropped abilty
to pull from urls. Keeping old line here just in case.
subprocess.run([EXECUTABLE, url, "--size=15x15"], input=img_bytes)
"""
subprocess.run([EXECUTABLE, "--size=15x15"], input=img_bytes)

View File

@@ -1,78 +1,53 @@
# 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.
# Adapted from the preview script in the fzf repo
#
# 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
#
fzf-preview() {
file=${1/#\~\//$HOME/}
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
file=${1/#\~\//$HOME/}
type=$(file --dereference --mime -- "$file")
# 1. Use kitty icat on kitty terminal
if [[ $KITTY_WINDOW_ID ]]; then
# 1. 'memory' is the fastest option but if you want the image to be scrollable,
# you have to use 'stream'.
#
# 2. The last line of the output is the ANSI reset code without newline.
# This confuses fzf and makes it render scroll offset indicator.
# So we remove the last line and append the reset code to its previous line.
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed '$d' | sed $'$s/$/\e[m/'
if [[ ! $type =~ image/ ]]; then
if [[ $type =~ =binary ]]; then
file "$1"
exit
fi
# 2. Use chafa with Sixel output
elif command -v chafa >/dev/null; then
case "$(uname -a)" in
# termux does not support sixel graphics
# and produces weird output
*ndroid*) chafa -s "$dim" "$file";;
*) chafa -f sixel -s "$dim" "$file";;
esac
# Add a new line character so that fzf can display multiple images in the preview window
echo
# 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
# 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"
${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
# 4. Cannot find any suitable method to preview the image
else
echo install chafa or imgcat or install kitty terminal so you can enjoy image previews
fi
}
"""

View File

@@ -4,7 +4,7 @@ import subprocess
from .tools import exit_app
def SyncPlayer(url: str, anime_title=None, headers={}, *args):
def SyncPlayer(url: str, anime_title=None, headers={}, subtitles=[], *args):
# TODO: handle m3u8 multi quality streams
#
# check for SyncPlay
@@ -20,6 +20,8 @@ def SyncPlayer(url: str, anime_title=None, headers={}, *args):
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(
[

View File

@@ -1,41 +1,51 @@
# TODO: add typing
class FastAnimeRuntimeState(dict):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Callable
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...libs.anime_provider.types import Anime, EpisodeStream, SearchResult, Server
class FastAnimeRuntimeState(object):
"""A class that manages fastanime runtime during anilist command runtime"""
def __getattr__(self, attr):
try:
return self.__getitem__(attr)
except KeyError:
raise AttributeError(
"%r object has no attribute %r" % (self.__class__.__name__, attr)
)
provider_current_episode_stream_link: str
provider_current_server: "Server"
provider_current_server_name: str
provider_available_episodes: list[str]
provider_current_episode_number: str
provider_server_episode_streams: list["EpisodeStream"]
provider_anime_title: str
provider_anime: "Anime"
provider_anime_search_result: "SearchResult"
progress_tracking: str = ""
def __setattr__(self, attr, value):
self.__setitem__(attr, value)
selected_anime_anilist: "AnilistBaseMediaDataSchema"
selected_anime_id_anilist: int
selected_anime_title_anilist: str
# current_anilist_data: "AnilistDataSchema | AnilistMediaList"
anilist_results_data: "Any"
current_page: int
current_data_loader: "Callable"
def exit_app(exit_code=0, *args):
import os
import shutil
def exit_app(exit_code=0, *args, **kwargs):
import sys
from rich.console import Console
from ...constants import APP_NAME, ICON_PATH, USER_NAME
def is_running_in_terminal():
console = Console()
if not console.is_terminal:
try:
shutil.get_terminal_size()
return (
sys.stdin
and sys.stdin.isatty()
and sys.stdout.isatty()
and os.getenv("TERM") is not None
from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
except OSError:
return False
if not is_running_in_terminal():
from plyer import notification
exit(1)
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,
@@ -43,7 +53,6 @@ def exit_app(exit_code=0, *args):
title="Shutting down",
) # pyright:ignore
else:
from rich import print
print("Have a good day :smile:", USER_NAME)
console.clear()
console.print("Have a good day :smile:", USER_NAME)
sys.exit(exit_code)

View File

@@ -19,6 +19,46 @@ BG_GREEN = "\033[48;2;120;233;12;m"
GREEN = "\033[38;2;45;24;45;m"
def get_requested_quality_or_default_to_first(url, quality):
import yt_dlp
with yt_dlp.YoutubeDL({"quiet": True, "silent": True, "no_warnings": True}) as ydl:
m3u8_info = ydl.extract_info(url, False)
if not m3u8_info:
return
m3u8_formats = m3u8_info["formats"]
quality = int(quality)
quality_u = quality - 80
quality_l = quality + 80
for m3u8_format in m3u8_formats:
if m3u8_format["height"] == quality or (
m3u8_format["height"] < quality_u and m3u8_format["height"] > quality_l
):
return m3u8_format["url"]
else:
return m3u8_formats[0]["url"]
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):
"""Helper function used to filter a list of EpisodeStream objects to one that has a corresponding quality
@@ -68,7 +108,7 @@ def format_bytes_to_human(num_of_bytes: float, suffix: str = "B"):
return f"{num_of_bytes:.1f}Yi{suffix}"
def get_true_fg(string: str, r: int, g: int, b: int, bold: bool = True) -> str:
def get_true_fg(string: str, r, g, b, bold: bool = True) -> str:
"""Custom helper function that enables colored text in the terminal
Args:
@@ -106,7 +146,7 @@ def fuzzy_inquirer(choices: list, prompt: str, **kwargs):
from click import clear
clear()
action = inquirer.fuzzy(
action = inquirer.fuzzy( # pyright:ignore
prompt,
choices,
height="100%",

View File

@@ -3,7 +3,9 @@ import sys
from pathlib import Path
from platform import system
from . import APP_NAME, AUTHOR, __version__
import click
from . import APP_NAME, __version__
PLATFORM = system()
@@ -17,19 +19,20 @@ if PLATFORM == "Windows":
ICON_PATH = os.path.join(ASSETS_DIR, "logo.ico")
else:
ICON_PATH = os.path.join(ASSETS_DIR, "logo.png")
PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
# PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
# ----- user configs and data -----
S_PLATFORM = sys.platform
APP_DATA_DIR = click.get_app_dir(APP_NAME, roaming=False)
if S_PLATFORM == "win32":
# app data
app_data_dir_base = os.getenv("LOCALAPPDATA")
if not app_data_dir_base:
raise RuntimeError("Could not determine app data dir please report to devs")
APP_DATA_DIR = os.path.join(app_data_dir_base, AUTHOR, APP_NAME)
# app_data_dir_base = os.getenv("LOCALAPPDATA")
# if not app_data_dir_base:
# raise RuntimeError("Could not determine app data dir please report to devs")
# APP_DATA_DIR = os.path.join(app_data_dir_base, AUTHOR, APP_NAME)
#
# cache dir
APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache")
@@ -39,9 +42,9 @@ if S_PLATFORM == "win32":
elif S_PLATFORM == "darwin":
# app data
app_data_dir_base = os.path.expanduser("~/Library/Application Support")
APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__)
# app_data_dir_base = os.path.expanduser("~/Library/Application Support")
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__)
#
# cache dir
cache_dir_base = os.path.expanduser("~/Library/Caches")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME, __version__)
@@ -50,12 +53,12 @@ elif S_PLATFORM == "darwin":
video_dir_base = os.path.expanduser("~/Movies")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
else:
# app data
app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "")
if not app_data_dir_base.strip():
app_data_dir_base = os.path.expanduser("~/.config")
APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME)
# # app data
# app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "")
# if not app_data_dir_base.strip():
# app_data_dir_base = os.path.expanduser("~/.config")
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME)
#
# cache dir
cache_dir_base = os.environ.get("XDG_CACHE_HOME", "")
if not cache_dir_base.strip():
@@ -75,6 +78,7 @@ Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
# useful paths
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
USER_WATCH_HISTORY_PATH = os.path.join(APP_DATA_DIR, "watch_history.json")
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log")

14
fastanime/fastanime.py Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env python3
import os
import sys
# Add the application root directory to Python path
if getattr(sys, "frozen", False):
application_path = os.path.dirname(sys.executable)
sys.path.insert(0, application_path)
# Import and run the main application
from fastanime import FastAnime
if __name__ == "__main__":
FastAnime()

View File

@@ -3,6 +3,7 @@ This is the core module availing all the abstractions of the anilist api
"""
import logging
import os
from typing import TYPE_CHECKING
import requests
@@ -15,6 +16,7 @@ from .queries_graphql import (
delete_list_entry_query,
get_logged_in_user_query,
get_medialist_item_query,
get_user_info,
media_list_mutation,
media_list_query,
most_favourite_query,
@@ -34,8 +36,9 @@ if TYPE_CHECKING:
AnilistMediaLists,
AnilistMediaListStatus,
AnilistNotifications,
AnilistUser,
AnilistUser_,
AnilistUserData,
AnilistViewerData,
)
logger = logging.getLogger(__name__)
ANILIST_ENDPOINT = "https://graphql.anilist.co"
@@ -77,7 +80,7 @@ class AniListApi:
return
if not success or not user:
return
user_info: AnilistUser = user["data"]["Viewer"]
user_info: "AnilistUser_" = user["data"]["Viewer"]
self.user_id = user_info["id"]
return user_info
@@ -91,7 +94,7 @@ class AniListApi:
"""
return self._make_authenticated_request(notification_query)
def update_login_info(self, user: "AnilistUser", token: str):
def update_login_info(self, user: "AnilistUser_", token: str):
"""method used to login a user enabling authenticated requests
Args:
@@ -103,7 +106,18 @@ class AniListApi:
self.session.headers.update(self.headers)
self.user_id = user["id"]
def get_logged_in_user(self) -> tuple[bool, "AnilistUserData"] | tuple[bool, None]:
def get_user_info(self) -> tuple[bool, "AnilistUserData"] | tuple[bool, None]:
"""get the details of the user who is currently logged in
Returns:
an anilist user
"""
return self._make_authenticated_request(get_user_info, {"userId": self.user_id})
def get_logged_in_user(
self,
) -> tuple[bool, "AnilistViewerData"] | tuple[bool, None]:
"""get the details of the user who is currently logged in
Returns:
@@ -129,6 +143,9 @@ class AniListApi:
self,
status: "AnilistMediaListStatus",
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
**kwargs,
) -> tuple[bool, "AnilistMediaLists"] | tuple[bool, None]:
"""gets an anime list from your media list given the list status
@@ -138,7 +155,13 @@ class AniListApi:
Returns:
a media list
"""
variables = {"status": status, "userId": self.user_id, "type": type}
variables = {
"status": status,
"userId": self.user_id,
"type": type,
"page": page,
"perPage": int(perPage),
}
return self._make_authenticated_request(media_list_query, variables)
def get_medialist_entry(
@@ -293,6 +316,7 @@ class AniListApi:
def search(
self,
max_results=50,
query: str | None = None,
sort: str | None = None,
genre_in: list[str] | None = None,
@@ -309,9 +333,14 @@ class AniListApi:
status_not_in: list[str] | None = None,
endDate_greater: int | None = None,
endDate_lesser: int | None = None,
start_greater: int | None = None,
start_lesser: int | None = None,
startDate_greater: int | None = None,
startDate_lesser: int | None = None,
startDate: str | None = None,
seasonYear: str | None = None,
page: int | None = None,
season: str | None = None,
format_in: list[str] | None = None,
on_list: bool | None = None,
type="ANIME",
**kwargs,
):
@@ -320,7 +349,7 @@ class AniListApi:
"""
variables = {}
for key, val in list(locals().items())[1:]:
if val is not None and key not in ["variables"]:
if (val or val is False) and key not in ["variables"]:
variables[key] = val
search_results = self.get_data(search_query, variables=variables)
return search_results
@@ -332,60 +361,98 @@ class AniListApi:
variables = {"id": id}
return self.get_data(anime_query, variables)
def get_trending(self, type="ANIME", *_, **kwargs):
def get_trending(
self,
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
*_,
**kwargs,
):
"""
Gets the currently trending anime
"""
variables = {"type": type}
variables = {"type": type, "page": page, "perPage": int(perPage)}
trending = self.get_data(trending_query, variables)
return trending
def get_most_favourite(self, type="ANIME", *_, **kwargs):
def get_most_favourite(
self,
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
*_,
**kwargs,
):
"""
Gets the most favoured anime on anilist
"""
variables = {"type": type}
variables = {"type": type, "page": page, "perPage": int(perPage)}
most_favourite = self.get_data(most_favourite_query, variables)
return most_favourite
def get_most_scored(self, type="ANIME", *_, **kwargs):
def get_most_scored(
self,
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
*_,
**kwargs,
):
"""
Gets most scored anime on anilist
"""
variables = {"type": type}
variables = {"type": type, "page": page, "perPage": int(perPage)}
most_scored = self.get_data(most_scored_query, variables)
return most_scored
def get_most_recently_updated(self, type="ANIME", *_, **kwargs):
def get_most_recently_updated(
self,
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
*_,
**kwargs,
):
"""
Gets most recently updated anime from anilist
"""
variables = {"type": type}
variables = {"type": type, "page": page, "perPage": int(perPage)}
most_recently_updated = self.get_data(most_recently_updated_query, variables)
return most_recently_updated
def get_most_popular(
self,
type="ANIME",
page=1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
**kwargs,
):
"""
Gets most popular anime on anilist
"""
variables = {"type": type}
variables = {"type": type, "page": page, "perPage": int(perPage)}
most_popular = self.get_data(most_popular_query, variables)
return most_popular
def get_upcoming_anime(self, type="ANIME", page: int = 1, *_, **kwargs):
def get_upcoming_anime(
self,
type="ANIME",
page: int = 1,
perPage=os.environ.get("FASTANIME_PER_PAGE", 15),
*_,
**kwargs,
):
"""
Gets upcoming anime from anilist
"""
variables = {"page": page, "type": type}
variables = {"page": page, "type": type, "perPage": int(perPage)}
upcoming_anime = self.get_data(upcoming_anime_query, variables)
return upcoming_anime
# NOTE: THe following methods will probably be scraped soon
def get_recommended_anime_for(self, id: int, type="ANIME", *_, **kwargs):
variables = {"type": type}
def get_recommended_anime_for(self, mediaRecommendationId, page=1, *_, **kwargs):
variables = {"mediaRecommendationId": mediaRecommendationId, "page": page}
recommended_anime = self.get_data(recommended_query, variables)
return recommended_anime
@@ -394,7 +461,7 @@ class AniListApi:
characters = self.get_data(anime_characters_query, variables)
return characters
def get_related_anime_for(self, id: int, type="ANIME", *_, **kwargs):
def get_related_anime_for(self, id: int, *_, **kwargs):
variables = {"id": id}
related_anime = self.get_data(anime_relations_query, variables)
return related_anime

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@ class AnilistImage(TypedDict):
large: str
class AnilistUser(TypedDict):
class AnilistUser_(TypedDict):
id: int
name: str
bannerImage: str | None
@@ -28,11 +28,26 @@ class AnilistUser(TypedDict):
class AnilistViewer(TypedDict):
Viewer: AnilistUser
Viewer: AnilistUser_
class AnilistViewerData(TypedDict):
data: AnilistViewer
class AnilistUser(TypedDict):
name: str
about: str | None
avatar: AnilistImage
bannerImage: str | None
class AnilistUserInfo(TypedDict):
User: AnilistUser
class AnilistUserData(TypedDict):
data: AnilistViewer
data: AnilistUserInfo
class AnilistMediaTrailer(TypedDict):
@@ -69,7 +84,7 @@ class AnilistMediaNextAiringEpisode(TypedDict):
class AnilistReview(TypedDict):
summary: str
user: AnilistUser
user: AnilistUser_
class AnilistReviewNodes(TypedDict):
@@ -114,16 +129,17 @@ class AnilistCharactersEdges(TypedDict):
edges: list[AnilistCharactersEdge]
class AnilistMediaList_(TypedDict):
id: int
progress: int
AnilistMediaListStatus = Literal[
"CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"
]
class AnilistMediaList_(TypedDict):
id: int
progress: int
status: AnilistMediaListStatus
class AnilistMediaListProperties(TypedDict):
status: AnilistMediaListStatus
score: float
@@ -136,6 +152,11 @@ class AnilistMediaListProperties(TypedDict):
hiddenFromStatusLists: bool
class StreamingEpisode(TypedDict):
title: str
thumbnail: str
class AnilistBaseMediaDataSchema(TypedDict):
"""
This a convenience class is used to type the received Anilist data to enhance dev experience
@@ -159,6 +180,8 @@ class AnilistBaseMediaDataSchema(TypedDict):
status: str
nextAiringEpisode: AnilistMediaNextAiringEpisode
season: str
streamingEpisodes: list[StreamingEpisode]
chapters: int
seasonYear: int
duration: int
synonyms: list[str]

View File

@@ -1,13 +1,12 @@
from .allanime.constants import SERVERS_AVAILABLE as ALLANIME_SERVERS
from .animepahe.constants import SERVERS_AVAILABLE as ANIMEPAHE_SERVERS
from .hianime.constants import SERVERS_AVAILABLE as HIANIME_SERVERS
anime_sources = {
"allanime": "api.AllAnimeAPI",
"animepahe": "api.AnimePaheApi",
"allanime": "api.AllAnime",
"animepahe": "api.AnimePahe",
"hianime": "api.HiAnime",
"nyaa": "api.Nyaa",
"yugen": "api.Yugen",
}
SERVERS_AVAILABLE = [
"sharepoint",
"dropbox",
"gogoanime",
"weTransfer",
"wixmp",
"kwik",
"Yt",
]
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS]

View File

@@ -1,406 +1,501 @@
"""a module that handles the scraping of allanime
abstraction over allanime api
"""
import json
import logging
from typing import TYPE_CHECKING
from requests.exceptions import Timeout
from ...anime_provider.base_provider import AnimeProvider
from ..decorators import debug_provider
from ..utils import give_random_quality, one_digit_symmetric_xor
from .constants import (
ALLANIME_API_ENDPOINT,
ALLANIME_BASE,
ALLANIME_REFERER,
USER_AGENT,
API_BASE_URL,
API_ENDPOINT,
API_REFERER,
DEFAULT_COUNTRY_OF_ORIGIN,
DEFAULT_NSFW,
DEFAULT_PAGE,
DEFAULT_PER_PAGE,
DEFAULT_UNKNOWN,
MP4_SERVER_JUICY_STREAM_REGEX,
)
from .gql_queries import ALLANIME_EPISODES_GQL, ALLANIME_SEARCH_GQL, ALLANIME_SHOW_GQL
from .gql_queries import EPISODES_GQL, SEARCH_GQL, SHOW_GQL
if TYPE_CHECKING:
from typing import Iterator
from ....libs.anime_provider.allanime.types import AllAnimeEpisode
from ....libs.anime_provider.types import Anime, Server
from .types import AllAnimeEpisode
logger = logging.getLogger(__name__)
# TODO: create tests for the api
#
# ** Based on ani-cli **
class AllAnimeAPI(AnimeProvider):
class AllAnime(AnimeProvider):
"""
Provides a fast and effective interface to AllAnime site.
AllAnime is a provider class for fetching anime data from the AllAnime API.
Attributes:
HEADERS (dict): Default headers for API requests.
Methods:
_execute_graphql_query(query: str, variables: dict) -> dict:
Executes a GraphQL query and returns the response data.
search_for_anime(
**kwargs
) -> dict:
Searches for anime based on the provided keywords and other parameters.
get_anime(show_id: str) -> dict:
Retrieves detailed information about a specific anime by its ID.
_get_anime_episode(
show_id: str, episode, translation_type: str = "sub"
Retrieves information about a specific episode of an anime.
get_episode_streams(
) -> generator:
Retrieves streaming links for a specific episode of an anime.
"""
api_endpoint = ALLANIME_API_ENDPOINT
HEADERS = {
"Referer": API_REFERER,
}
def _fetch_gql(self, query: str, variables: dict):
"""main abstraction over all requests to the allanime api
def _execute_graphql_query(self, query: str, variables: dict):
"""
Executes a GraphQL query using the provided query string and variables.
Args:
query: [TODO:description]
variables: [TODO:description]
query (str): The GraphQL query string to be executed.
variables (dict): A dictionary of variables to be used in the query.
Returns:
[TODO:return]
"""
try:
response = self.session.get(
self.api_endpoint,
params={
"variables": json.dumps(variables),
"query": query,
},
headers={"Referer": ALLANIME_REFERER, "User-Agent": USER_AGENT},
timeout=10,
)
if response.status_code == 200:
return response.json()["data"]
else:
logger.error("allanime(ERROR): ", response.text)
return {}
except Timeout:
logger.error(
"allanime(Error):Timeout exceeded this could mean allanime is down or you have lost internet connection"
)
return {}
except Exception as e:
logger.error(f"allanime:Error: {e}")
return {}
dict: The JSON response data from the GraphQL API.
Raises:
requests.exceptions.HTTPError: If the HTTP request returned an unsuccessful status code.
"""
response = self.session.get(
API_ENDPOINT,
params={
"variables": json.dumps(variables),
"query": query,
},
timeout=10,
)
response.raise_for_status()
return response.json()["data"]
@debug_provider
def search_for_anime(
self,
user_query: str,
translation_type: str = "sub",
nsfw=True,
unknown=True,
search_keywords: str,
translation_type: str,
*,
nsfw=DEFAULT_NSFW,
unknown=DEFAULT_UNKNOWN,
limit=DEFAULT_PER_PAGE,
page=DEFAULT_PAGE,
country_of_origin=DEFAULT_COUNTRY_OF_ORIGIN,
**kwargs,
):
"""search for an anime title using allanime provider
Args:
nsfw ([TODO:parameter]): [TODO:description]
unknown ([TODO:parameter]): [TODO:description]
user_query: [TODO:description]
translation_type: [TODO:description]
**kwargs: [TODO:args]
Returns:
[TODO:return]
"""
search = {"allowAdult": nsfw, "allowUnknown": unknown, "query": user_query}
limit = 40
translationtype = translation_type
countryorigin = "all"
page = 1
variables = {
"search": search,
"limit": limit,
"page": page,
"translationtype": translationtype,
"countryorigin": countryorigin,
}
try:
search_results = self._fetch_gql(ALLANIME_SEARCH_GQL, variables)
page_info = search_results["shows"]["pageInfo"]
results = []
for result in search_results["shows"]["edges"]:
normalized_result = {
Search for anime based on given keywords and filters.
Args:
search_keywords (str): The keywords to search for.
translation_type (str, optional): The type of translation to search for (e.g., "sub" or "dub"). Defaults to "sub".
limit (int, optional): The maximum number of results to return. Defaults to 40.
page (int, optional): The page number to return. Defaults to 1.
country_of_origin (str, optional): The country of origin filter. Defaults to "all".
nsfw (bool, optional): Whether to include adult content in the search results. Defaults to True.
unknown (bool, optional): Whether to include unknown content in the search results. Defaults to True.
**kwargs: Additional keyword arguments.
Returns:
dict: A dictionary containing the page information and a list of search results. Each result includes:
- id (str): The ID of the anime.
- title (str): The title of the anime.
- type (str): The type of the anime.
- availableEpisodes (int): The number of available episodes.
"""
search_results = self._execute_graphql_query(
SEARCH_GQL,
variables={
"search": {
"allowAdult": nsfw,
"allowUnknown": unknown,
"query": search_keywords,
},
"limit": limit,
"page": page,
"translationtype": translation_type,
"countryorigin": country_of_origin,
},
)
return {
"pageInfo": search_results["shows"]["pageInfo"],
"results": [
{
"id": result["_id"],
"title": result["name"],
"type": result["__typename"],
"availableEpisodes": result["availableEpisodes"],
}
results.append(normalized_result)
normalized_search_results = {
"pageInfo": page_info,
"results": results,
}
return normalized_search_results
except Exception as e:
logger.error(f"FA(AllAnime): {e}")
return {}
def get_anime(self, allanime_show_id: str):
"""get an anime details given its id
Args:
allanime_show_id: [TODO:description]
Returns:
[TODO:return]
"""
variables = {"showId": allanime_show_id}
try:
anime = self._fetch_gql(ALLANIME_SHOW_GQL, variables)
id: str = anime["show"]["_id"]
title: str = anime["show"]["name"]
availableEpisodesDetail = anime["show"]["availableEpisodesDetail"]
type = anime.get("__typename")
normalized_anime = {
"id": id,
"title": title,
"availableEpisodesDetail": availableEpisodesDetail,
"type": type,
}
return normalized_anime
except Exception as e:
logger.error(f"AllAnime(get_anime): {e}")
return None
def _get_anime_episode(
self, allanime_show_id: str, episode_string: str, translation_type: str = "sub"
) -> "AllAnimeEpisode | dict":
"""get the episode details and sources info
Args:
allanime_show_id: [TODO:description]
episode_string: [TODO:description]
translation_type: [TODO:description]
Returns:
[TODO:return]
"""
variables = {
"showId": allanime_show_id,
"translationType": translation_type,
"episodeString": episode_string,
for result in search_results["shows"]["edges"]
],
}
try:
episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables)
return episode["episode"]
except Exception as e:
logger.error(f"FA(AllAnime): {e}")
return {}
def get_episode_streams(
self, anime: "Anime", episode_number: str, translation_type="sub"
) -> "Iterator[Server] | None":
"""get the streams of an episode
Args:
translation_type ([TODO:parameter]): [TODO:description]
anime: [TODO:description]
episode_number: [TODO:description]
Yields:
[TODO:description]
@debug_provider
def get_anime(self, id: str, **kwargs):
"""
anime_id = anime["id"]
Fetches anime details using the provided show ID.
Args:
id (str): The ID of the anime show to fetch details for.
Returns:
dict: A dictionary containing the anime details, including:
- id (str): The unique identifier of the anime show.
- title (str): The title of the anime show.
- availableEpisodesDetail (list): A list of available episodes details.
- type (str, optional): The type of the anime show.
"""
anime = self._execute_graphql_query(SHOW_GQL, variables={"showId": id})
self.store.set(id, "anime_info", {"title": anime["show"]["name"]})
return {
"id": anime["show"]["_id"],
"title": anime["show"]["name"],
"availableEpisodesDetail": anime["show"]["availableEpisodesDetail"],
"type": anime.get("__typename"),
}
@debug_provider
def _get_anime_episode(
self, anime_id: str, episode, translation_type: str = "sub"
) -> "AllAnimeEpisode":
"""
Fetches a specific episode of an anime by its ID and episode number.
Args:
anime_id (str): The unique identifier of the anime.
episode (str): The episode number or string identifier.
translation_type (str, optional): The type of translation for the episode. Defaults to "sub".
Returns:
AllAnimeEpisode: The episode details retrieved from the GraphQL query.
"""
return self._execute_graphql_query(
EPISODES_GQL,
variables={
"showId": anime_id,
"translationType": translation_type,
"episodeString": episode,
},
)["episode"]
@debug_provider
def _get_server(
self,
embed,
anime_title: str,
allanime_episode: "AllAnimeEpisode",
episode_number,
):
"""
Retrieves the streaming server information for a given anime episode based on the provided embed data.
Args:
embed (dict): A dictionary containing the embed data, including the source URL and source name.
anime_title (str): The title of the anime.
allanime_episode (AllAnimeEpisode): An object representing the episode details.
Returns:
dict: A dictionary containing server information, headers, subtitles, episode title, and links to the stream.
Returns None if no valid URL or stream is found.
Raises:
requests.exceptions.RequestException: If there is an issue with the HTTP request.
"""
url = embed.get("sourceUrl")
#
if not url:
return
if url.startswith("--"):
url = one_digit_symmetric_xor(56, url[2:])
# FIRST CASE
match embed["sourceName"]:
case "Yt-mp4":
logger.debug("Found streams from Yt")
return {
"server": "Yt",
"episode_title": f"{anime_title}; Episode {episode_number}",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"links": [
{
"link": url,
"quality": "1080",
}
],
}
case "Mp4":
logger.debug("Found streams from Mp4")
response = self.session.get(
url,
fresh=1, # pyright: ignore
timeout=10,
)
response.raise_for_status()
embed_html = response.text.replace(" ", "").replace("\n", "")
vid = MP4_SERVER_JUICY_STREAM_REGEX.search(embed_html)
if not vid:
return
return {
"server": "mp4-upload",
"headers": {"Referer": "https://www.mp4upload.com/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": [{"link": vid.group(1), "quality": "1080"}],
}
case "Fm-Hls":
# TODO: requires decoding obsfucated js (filemoon)
logger.debug("Found streams from Fm-Hls")
response = self.session.get(
url,
timeout=10,
)
response.raise_for_status()
embed_html = response.text.replace(" ", "").replace("\n", "")
vid = MP4_SERVER_JUICY_STREAM_REGEX.search(embed_html)
if not vid:
return
return {
"server": "filemoon",
"headers": {"Referer": "https://www.mp4upload.com/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": [{"link": vid.group(1), "quality": "1080"}],
}
case "Ok":
# TODO: requires decoding the obsfucated js (filemoon)
response = self.session.get(
url,
timeout=10,
)
response.raise_for_status()
embed_html = response.text.replace(" ", "").replace("\n", "")
vid = MP4_SERVER_JUICY_STREAM_REGEX.search(embed_html)
logger.debug("Found streams from Ok")
return {
"server": "filemoon",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Vid-mp4":
# TODO: requires some serious work i think : )
response = self.session.get(
url,
timeout=10,
)
response.raise_for_status()
embed_html = response.text.replace(" ", "").replace("\n", "")
logger.debug("Found streams from vid-mp4")
return {
"server": "Vid-mp4",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Ss-Hls":
# TODO: requires some serious work i think : )
response = self.session.get(
url,
timeout=10,
)
response.raise_for_status()
embed_html = response.text.replace(" ", "").replace("\n", "")
logger.debug("Found streams from Ss-Hls")
return {
"server": "StreamSb",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
# get the stream url for an episode of the defined source names
response = self.session.get(
f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}",
timeout=10,
)
response.raise_for_status()
# SECOND CASE
match embed["sourceName"]:
case "Luf-mp4":
logger.debug("Found streams from gogoanime")
return {
"server": "gogoanime",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Kir":
logger.debug("Found streams from wetransfer")
return {
"server": "weTransfer",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "S-mp4":
logger.debug("Found streams from sharepoint")
return {
"server": "sharepoint",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Sak":
logger.debug("Found streams from dropbox")
return {
"server": "dropbox",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Default":
logger.debug("Found streams from wixmp")
return {
"server": "wixmp",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
case "Ak":
# TODO: works but needs further probing
logger.debug("Found streams from Ak")
return {
"server": "Ak",
"headers": {"Referer": f"https://{API_BASE_URL}/"},
"subtitles": [],
"episode_title": (allanime_episode["notes"] or f"{anime_title}")
+ f"; Episode {episode_number}",
"links": give_random_quality(response.json()["links"]),
}
@debug_provider
def get_episode_streams(
self, anime_id, episode_number: str, translation_type="sub", **kwargs
):
"""
Retrieve streaming information for a specific episode of an anime.
Args:
anime_id (str): The unique identifier for the anime.
episode_number (str): The episode number to retrieve streams for.
translation_type (str, optional): The type of translation for the episode (e.g., "sub" for subtitles). Defaults to "sub".
Yields:
dict: A dictionary containing streaming information for the episode, including:
- server (str): The name of the streaming server.
- episode_title (str): The title of the episode.
- headers (dict): HTTP headers required for accessing the stream.
- subtitles (list): A list of subtitles available for the episode.
- links (list): A list of dictionaries containing streaming links and their quality.
"""
anime_title = (self.store.get(anime_id, "anime_info", "") or {"title": ""})[
"title"
]
allanime_episode = self._get_anime_episode(
anime_id, episode_number, translation_type
)
if not allanime_episode:
return []
embeds = allanime_episode["sourceUrls"]
try:
for embed in embeds:
try:
# filter the working streams no need to get all since the others are mostly hsl
# TODO: should i just get all the servers and handle the hsl??
if embed.get("sourceName", "") not in (
# priorities based on death note
"Sak", # 7
"S-mp4", # 7.9
"Luf-mp4", # 7.7
"Default", # 8.5
"Yt-mp4", # 7.9
"Kir", # NA
# "Vid-mp4" # 4
# "Ok", # 3.5
# "Ss-Hls", # 5.5
# "Mp4", # 4
):
continue
url = embed.get("sourceUrl")
#
if not url:
continue
if url.startswith("--"):
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}/"},
"links": [
{
"link": url,
"quality": "1080",
}
],
} # pyright:ignore
continue
# get the stream url for an episode of the defined source names
embed_url = (
f"https://{ALLANIME_BASE}{url.replace('clock', 'clock.json')}"
)
resp = self.session.get(
embed_url,
headers={
"Referer": ALLANIME_REFERER,
"User-Agent": USER_AGENT,
},
timeout=10,
)
if resp.status_code == 200:
match embed["sourceName"]:
case "Luf-mp4":
logger.debug("allanime:Found streams from gogoanime")
yield {
"server": "gogoanime",
"headers": {},
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "Kir":
logger.debug("allanime:Found streams from wetransfer")
yield {
"server": "wetransfer",
"headers": {},
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "S-mp4":
logger.debug("allanime:Found streams from sharepoint")
yield {
"server": "sharepoint",
"headers": {},
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "Sak":
logger.debug("allanime:Found streams from dropbox")
yield {
"server": "dropbox",
"headers": {},
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
case "Default":
logger.debug("allanime:Found streams from wixmp")
yield {
"server": "wixmp",
"headers": {},
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
} # pyright:ignore
except Timeout:
logger.error(
"Timeout has been exceeded this could mean allanime is down or you have lost internet connection"
)
except Exception as e:
logger.error(f"FA(Allanime): {e}")
except Exception as e:
logger.error(f"FA(Allanime): {e}")
return []
for embed in allanime_episode["sourceUrls"]:
if embed.get("sourceName", "") not in (
# priorities based on death note
"Sak", # 7
"S-mp4", # 7.9
"Luf-mp4", # 7.7
"Default", # 8.5
"Yt-mp4", # 7.9
"Kir", # NA
"Mp4", # 4
# "Ak",#
# "Vid-mp4", # 4
# "Ok", # 3.5
# "Ss-Hls", # 5.5
# "Fm-Hls",#
):
logger.debug(f"Found {embed['sourceName']} but ignoring")
continue
if server := self._get_server(
embed, anime_title, allanime_episode, episode_number
):
yield server
if __name__ == "__main__":
anime_provider = AllAnimeAPI()
# lets see if it works :)
import subprocess
import sys
from InquirerPy import inquirer, validator # pyright:ignore
allanime = AllAnime(cache_requests="True", use_persistent_provider_store="False")
search_term = input("Enter the search term for the anime: ")
translation_type = input("Enter the translation type (sub/dub): ")
anime = input("Enter the anime name: ")
translation = input("Enter the translation type: ")
search_results = anime_provider.search_for_anime(
anime, translation_type=translation.strip()
search_results = allanime.search_for_anime(
search_keywords=search_term, translation_type=translation_type
)
if not search_results:
raise Exception("No results found")
if not search_results["results"]:
print("No results found.")
exit()
search_results = search_results["results"]
options = {show["title"]: show for show in search_results}
anime = inquirer.fuzzy(
"Enter the anime title",
list(options.keys()),
validate=validator.EmptyInputValidator(),
).execute()
if anime is None:
print("No anime was selected")
sys.exit(1)
print("Search Results:")
for idx, result in enumerate(search_results["results"], start=1):
print(f"{idx}. {result['title']} (ID: {result['id']})")
anime_result = options[anime]
anime_data = anime_provider.get_anime(anime_result["id"])
if not anime_data:
raise Exception("Anime not found")
availableEpisodesDetail = anime_data["availableEpisodesDetail"]
if not availableEpisodesDetail.get(translation.strip()):
raise Exception("No episodes found")
anime_choice = int(input("Enter the number of the anime you want to watch: ")) - 1
anime_id = search_results["results"][anime_choice]["id"]
stream_link = True
while stream_link != "quit":
print("select episode")
episode = inquirer.fuzzy(
"Choose an episode",
availableEpisodesDetail[translation.strip()],
validate=validator.EmptyInputValidator(),
).execute()
if episode is None:
print("No episode was selected")
sys.exit(1)
anime_details = allanime.get_anime(anime_id)
print(f"Selected Anime: {anime_details['title']}")
if not anime_data:
print("Sth went wrong")
break
episode_streams_ = anime_provider.get_episode_streams(
anime_data, # pyright: ignore
episode,
translation.strip(),
)
if episode_streams_ is None:
raise Exception("Episode not found")
print("Available Episodes:")
for idx, episode in enumerate(
sorted(anime_details["availableEpisodesDetail"][translation_type], key=float),
start=1,
):
print(f"{idx}. Episode {episode}")
episode_streams = list(episode_streams_)
stream_links = []
for server in episode_streams:
stream_links.extend([link["link"] for link in server["links"]])
stream_links.append("back")
stream_link = inquirer.fuzzy(
"Choose a link to stream",
stream_links,
validate=validator.EmptyInputValidator(),
).execute()
if stream_link == "quit":
print("Have a nice day")
sys.exit()
if not stream_link:
raise Exception("No stream was selected")
episode_choice = (
int(input("Enter the number of the episode you want to watch: ")) - 1
)
episode_number = anime_details["availableEpisodesDetail"][translation_type][
episode_choice
]
title = episode_streams[0].get(
"episode_title", "%s: Episode %s" % (anime_data["title"], episode)
)
subprocess.run(["mpv", f"--title={title}", stream_link])
streams = list(
allanime.get_episode_streams(anime_id, episode_number, translation_type)
)
if not streams:
print("No streams available.")
exit()
print("Available Streams:")
for idx, stream in enumerate(streams, start=1):
print(f"{idx}. Server: {stream['server']}")
server_choice = int(input("Enter the number of the server you want to use: ")) - 1
selected_stream = streams[server_choice]
stream_link = selected_stream["links"][0]["link"]
mpv_args = ["mpv", stream_link]
headers = selected_stream["headers"]
if headers:
mpv_headers = "--http-header-fields="
for header_name, header_value in headers.items():
mpv_headers += f"{header_name}:{header_value},"
mpv_args.append(mpv_headers)
subprocess.run(mpv_args)

View File

@@ -1,7 +1,27 @@
from yt_dlp.utils.networking import random_user_agent
import re
ALLANIME_BASE = "allanime.day"
ALLANIME_REFERER = "https://allanime.to/"
ALLANIME_API_ENDPOINT = "https://api.{}/api/".format(ALLANIME_BASE)
USER_AGENT = random_user_agent()
SERVERS_AVAILABLE = ["sharepoint", "dropbox", "gogoanime", "weTransfer", "wixmp"]
SERVERS_AVAILABLE = [
"sharepoint",
"dropbox",
"gogoanime",
"weTransfer",
"wixmp",
"Yt",
"mp4-upload",
]
API_BASE_URL = "allanime.day"
API_REFERER = "https://allanime.to/"
API_ENDPOINT = f"https://api.{API_BASE_URL}/api/"
# search constants
DEFAULT_COUNTRY_OF_ORIGIN = "all"
DEFAULT_NSFW = True
DEFAULT_UNKNOWN = True
DEFAULT_PER_PAGE = 40
DEFAULT_PAGE = 1
# regex stuff
MP4_SERVER_JUICY_STREAM_REGEX = re.compile(
r"video/mp4\",src:\"(https?://.*/video\.mp4)\""
)

View File

@@ -1,56 +1,56 @@
ALLANIME_SEARCH_GQL = """
query(
$search: SearchInput
$limit: Int
$page: Int
$translationType: VaildTranslationTypeEnumType
$countryOrigin: VaildCountryOriginEnumType
) {
shows(
search: $search
limit: $limit
page: $page
translationType: $translationType
countryOrigin: $countryOrigin
) {
pageInfo {
total
}
edges {
_id
name
availableEpisodes
__typename
}
SEARCH_GQL = """
query (
$search: SearchInput
$limit: Int
$page: Int
$translationType: VaildTranslationTypeEnumType
$countryOrigin: VaildCountryOriginEnumType
) {
shows(
search: $search
limit: $limit
page: $page
translationType: $translationType
countryOrigin: $countryOrigin
) {
pageInfo {
total
}
edges {
_id
name
availableEpisodes
__typename
}
}
}
"""
ALLANIME_EPISODES_GQL = """\
query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
episode(
showId: $showId
translationType: $translationType
episodeString: $episodeString
) {
episodeString
sourceUrls
notes
}
}"""
EPISODES_GQL = """\
query (
$showId: String!
$translationType: VaildTranslationTypeEnumType!
$episodeString: String!
) {
episode(
showId: $showId
translationType: $translationType
episodeString: $episodeString
) {
episodeString
sourceUrls
notes
}
}
"""
ALLANIME_SHOW_GQL = """
SHOW_GQL = """
query ($showId: String!) {
show(
_id: $showId
) {
_id
name
availableEpisodesDetail
}
show(_id: $showId) {
_id
name
availableEpisodesDetail
}
}
"""

View File

@@ -1,6 +1,5 @@
import logging
import random
import re
import time
from typing import TYPE_CHECKING
@@ -11,115 +10,129 @@ from yt_dlp.utils import (
)
from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from .constants import (
ANIMEPAHE_BASE,
ANIMEPAHE_ENDPOINT,
JUICY_STREAM_REGEX,
REQUEST_HEADERS,
SERVER_HEADERS,
)
from .utils import process_animepahe_embed_page
from .extractors import process_animepahe_embed_page
if TYPE_CHECKING:
from ..types import Anime
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimeSearchResult
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimePaheSearchResult
logger = logging.getLogger(__name__)
KWIK_RE = re.compile(r"Player\|(.+?)'")
# TODO: hack this to completion
class AnimePaheApi(AnimeProvider):
class AnimePahe(AnimeProvider):
search_page: "AnimePaheSearchPage"
anime: "AnimePaheAnimePage"
HEADERS = REQUEST_HEADERS
def search_for_anime(self, user_query: str, *args):
try:
url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}"
headers = {**REQUEST_HEADERS}
response = self.session.get(url, headers=headers)
if not response.status_code == 200:
return
data: "AnimePaheSearchPage" = response.json()
self.search_page = data
@debug_provider
def search_for_anime(self, search_keywords: str, translation_type, **kwargs):
response = self.session.get(
ANIMEPAHE_ENDPOINT, params={"m": "search", "q": search_keywords}
)
response.raise_for_status()
data: "AnimePaheSearchPage" = response.json()
results = []
for result in data["data"]:
results.append(
{
"availableEpisodes": list(range(result["episodes"])),
"id": result["session"],
"title": result["title"],
"type": result["type"],
"year": result["year"],
"score": result["score"],
"status": result["status"],
"season": result["season"],
"poster": result["poster"],
}
)
self.store.set(
str(result["session"]),
"search_result",
result,
)
return {
"pageInfo": {
"total": data["total"],
"perPage": data["per_page"],
"currentPage": data["current_page"],
return {
"pageInfo": {
"total": data["total"],
"perPage": data["per_page"],
"currentPage": data["current_page"],
},
"results": results,
}
@debug_provider
def _pages_loader(
self,
data,
session_id,
params,
page,
):
response = self.session.get(ANIMEPAHE_ENDPOINT, params=params)
response.raise_for_status()
if not data:
data.update(response.json())
else:
if ep_data := response.json().get("data"):
data["data"].extend(ep_data)
if response.json()["next_page_url"]:
# TODO: Refine this
time.sleep(
random.choice(
[
0.25,
0.1,
0.5,
0.75,
1,
]
)
)
page += 1
self._pages_loader(
data,
session_id,
params={
"m": "release",
"page": page,
"id": session_id,
"sort": "episode_asc",
},
"results": [
{
"availableEpisodes": list(range(result["episodes"])),
"id": result["session"],
"title": result["title"],
"type": result["type"],
"year": result["year"],
"score": result["score"],
"status": result["status"],
"season": result["season"],
"poster": result["poster"],
}
for result in data["data"]
],
}
page=page,
)
return data
except Exception as e:
logger.error(f"AnimePahe(search): {e}")
return {}
def get_anime(self, session_id: str, *args):
@debug_provider
def get_anime(self, session_id: str, **kwargs):
page = 1
try:
anime_result: "AnimeSearchResult" = [
anime
for anime in self.search_page["data"]
if anime["session"] == session_id
][0]
if d := self.store.get(str(session_id), "search_result"):
anime_result: "AnimePaheSearchResult" = d
data: "AnimePaheAnimePage" = {} # pyright:ignore
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
def _pages_loader(
url,
page,
):
response = self.session.get(url, headers=REQUEST_HEADERS)
if response.status_code == 200:
if not data:
data.update(response.json())
else:
if ep_data := response.json().get("data"):
data["data"].extend(ep_data)
if response.json()["next_page_url"]:
# TODO: Refine this
time.sleep(
random.choice(
[
0.25,
0.1,
0.5,
0.75,
1,
]
)
)
page += 1
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
_pages_loader(
url,
page,
)
_pages_loader(
url,
page,
data = self._pages_loader(
data,
session_id,
params={
"m": "release",
"id": session_id,
"sort": "episode_asc",
"page": page,
},
page=page,
)
if not data:
return {}
self.anime = data # pyright:ignore
data["title"] = anime_result["title"] # pyright:ignore
self.store.set(str(session_id), "anime_info", data)
episodes = list(map(str, [episode["episode"] for episode in data["data"]]))
title = ""
return {
@@ -136,7 +149,7 @@ class AnimePaheApi(AnimeProvider):
},
"episodesInfo": [
{
"title": episode["title"] or f"{title};{episode['episode']}",
"title": f"{episode['title'] or title};{episode['episode']}",
"episode": episode["episode"],
"id": episode["session"],
"translation_type": episode["audio"],
@@ -146,87 +159,167 @@ class AnimePaheApi(AnimeProvider):
for episode in data["data"]
],
}
except Exception as e:
logger.error(f"AnimePahe(anime): {e}")
return {}
def get_episode_streams(
self, anime: "Anime", episode_number: str, translation_type, *args
):
try:
# extract episode details from memory
episode = [
episode
for episode in self.anime["data"]
if float(episode["episode"]) == float(episode_number)
]
@debug_provider
def _get_server(self, episode, res_dicts, anime_title, translation_type):
# get all links
streams = {
"server": "kwik",
"links": [],
"episode_title": f"{episode['title'] or anime_title}; Episode {episode['episode']}",
"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
if not episode:
logger.error(
f"AnimePahe(streams): episode {episode_number} doesn't exist"
if not embed_url:
logger.warning(
"[ANIMEPAHE-WARN]: embed url not found please report to the developers"
)
return []
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"] + f"; {episode['episode']}"
or f"{anime['title']}; Episode {episode['episode']}"
continue
# get embed page
embed_response = self.session.get(
embed_url, headers={"User-Agent": self.USER_AGENT, **SERVER_HEADERS}
)
# get all links
streams = {
"server": "kwik",
"links": [],
"episode_title": episode_title,
"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
embed_response.raise_for_status()
embed_page = embed_response.text
if not embed_url:
logger.warn(
"AnimePahe: embed url not found please report to the developers"
)
return []
# get embed page
embed_response = self.session.get(embed_url, headers=SERVER_HEADERS)
embed_page = embed_response.text
decoded_js = process_animepahe_embed_page(embed_page)
if not decoded_js:
logger.error("[ANIMEPAHE-ERROR]: failed to decode embed page")
continue
juicy_stream = JUICY_STREAM_REGEX.search(decoded_js)
if not juicy_stream:
logger.error("[ANIMEPAHE-ERROR]: failed to find juicy stream")
continue
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 streams
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,
}
)
yield streams
except Exception as e:
logger.error(f"Animepahe: {e}")
@debug_provider
def get_episode_streams(
self, anime_id, episode_number: str, translation_type, **kwargs
):
anime_title = ""
# extract episode details from memory
anime_info = self.store.get(str(anime_id), "anime_info")
if not anime_info:
logger.error(
f"[ANIMEPAHE-ERROR]: Anime with ID {anime_id} not found in store"
)
return
anime_title = anime_info["title"]
episode = next(
(
ep
for ep in anime_info["data"]
if float(ep["episode"]) == float(episode_number)
),
None,
)
if not episode:
logger.error(
f"[ANIMEPAHE-ERROR]: Episode {episode_number} doesn't exist for anime {anime_title}"
)
return
# fetch the episode page
url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
response = self.session.get(url)
response.raise_for_status()
# get the element containing links to juicy streams
c = get_element_by_id("resolutionMenu", response.text)
resolutionMenuItems = get_elements_html_by_class("dropdown-item", c)
# convert the elements containing embed links to a neat dict containing:
# data-src
# data-audio
# data-resolution
res_dicts = [extract_attributes(item) for item in resolutionMenuItems]
if _server := self._get_server(
episode, res_dicts, anime_title, translation_type
):
yield _server
if __name__ == "__main__":
import subprocess
animepahe = AnimePahe(cache_requests="True", use_persistent_provider_store="False")
search_term = input("Enter the search term for the anime: ")
translation_type = input("Enter the translation type (sub/dub): ")
search_results = animepahe.search_for_anime(
search_keywords=search_term, translation_type=translation_type
)
if not search_results or not search_results["results"]:
print("No results found.")
exit()
print("Search Results:")
for idx, result in enumerate(search_results["results"], start=1):
print(f"{idx}. {result['title']} (ID: {result['id']})")
anime_choice = int(input("Enter the number of the anime you want to watch: ")) - 1
anime_id = search_results["results"][anime_choice]["id"]
anime_details = animepahe.get_anime(anime_id)
if anime_details is None:
print("Failed to get anime details.")
exit()
print(f"Selected Anime: {anime_details['title']}")
print("Available Episodes:")
for idx, episode in enumerate(
sorted(anime_details["availableEpisodesDetail"][translation_type], key=float),
start=1,
):
print(f"{idx}. Episode {episode}")
episode_choice = (
int(input("Enter the number of the episode you want to watch: ")) - 1
)
episode_number = anime_details["availableEpisodesDetail"][translation_type][
episode_choice
]
streams = list(
animepahe.get_episode_streams(anime_id, episode_number, translation_type)
)
if not streams:
print("No streams available.")
exit()
print("Available Streams:")
for idx, stream in enumerate(streams, start=1):
print(f"{idx}. Server: {stream['server']}")
server_choice = int(input("Enter the number of the server you want to use: ")) - 1
selected_stream = streams[server_choice]
stream_link = selected_stream["links"][0]["link"]
mpv_args = ["mpv", stream_link]
headers = selected_stream["headers"]
if headers:
mpv_headers = "--http-header-fields="
for header_name, header_value in headers.items():
mpv_headers += f"{header_name}:{header_value},"
mpv_args.append(mpv_headers)
subprocess.run(mpv_args)

View File

@@ -1,18 +1,16 @@
from yt_dlp.utils.networking import random_user_agent
import re
USER_AGENT = random_user_agent()
ANIMEPAHE = "animepahe.ru"
ANIMEPAHE_BASE = f"https://{ANIMEPAHE}"
ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api?"
ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api"
SERVERS_AVAILABLE = ["kwik"]
REQUEST_HEADERS = {
"Cookie": "__ddgid_=VvX0ebHrH2DsFZo4; __ddgmark_=3savRpSVFhvZcn5x; __ddg2_=buBJ3c4pNBYKFZNp; __ddg1_=rbVADKr9URtt55zoIGFa; SERVERID=janna; XSRF-TOKEN=eyJpdiI6IjV5bFNtd0phUHgvWGJxc25wL0VJSUE9PSIsInZhbHVlIjoicEJTZktlR2hxR2JZTWhnL0JzazlvZU5TQTR2bjBWZ2dDb0RwUXVUUWNSclhQWUhLRStYSmJmWmUxWkpiYkFRYU12RjFWejlSWHorME1wZG5qQ1U0TnFlNnBFR2laQjN1MjdyNjc5TjVPdXdJb2o5VkU1bEduRW9pRHNDTHh6Sy8iLCJtYWMiOiI0OTc0ZmNjY2UwMGJkOWY2MWNkM2NlMjk2ZGMyZGJmMWE0NTdjZTdkNGI2Y2IwNTIzZmFiZWU5ZTE2OTk0YmU4IiwidGFnIjoiIn0%3D; laravel_session=eyJpdiI6ImxvdlpqREFnTjdaeFJubUlXQWlJVWc9PSIsInZhbHVlIjoiQnE4R3VHdjZ4M1NDdEVWM1ZqMUxtNnVERnJCcmtCUHZKNzRPR2RFbzNFcStTL29xdnVTbWhsNVRBUXEybVZWNU1UYVlTazFqYlN5UjJva1k4czNGaXBTbkJJK01oTUd3VHRYVHBoc3dGUWxHYnFlS2NJVVNFbTFqMVBWdFpuVUgiLCJtYWMiOiI1NDdjZTVkYmNhNjUwZTMxZmRlZmVmMmRlMGNiYjAwYjlmYjFjY2U0MDc1YTQzZThiMTIxMjJlYTg1NTA4YjBmIiwidGFnIjoiIn0%3D; latest=5592 ",
"Host": ANIMEPAHE,
"User-Agent": USER_AGENT,
"Accept": "application , text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Encoding": "Utf-8",
"Referer": ANIMEPAHE_BASE,
"X-Requested-With": "XMLHttpRequest",
"DNT": "1",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
@@ -21,19 +19,19 @@ REQUEST_HEADERS = {
"TE": "trailers",
}
SERVER_HEADERS = {
"User-Agent": USER_AGENT,
"Host": "kwik.si",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Encoding": "Utf-8",
"DNT": "1",
"Alt-Used": "kwik.si",
"Connection": "keep-alive",
"Referer": ANIMEPAHE_BASE,
"Cookie": "kwik_session=eyJpdiI6IlZ5UDd0c0lKTDB1NXlhTHZPeWxFc2c9PSIsInZhbHVlIjoieDJZbGhZUG1QZDNaeWtqR3lwWFNnREdhaHBxNVZRMWNDOHVucGpiMHRJOVdhVmpBc3lpTko1VExRMTFWcE1yUVJtVitoTWdOOU5ObTQ0Q0dHU0MzZU0yRUVvNmtWcUdmY3R4UWx4YklJTmpUL0ZodjhtVEpjWU96cEZoUUhUbVYiLCJtYWMiOiI2OGY2YThkOGU0MTgwOThmYzcyZThmNzFlZjlhMzQzMDgwNjlmMTc4NTIzMzc2YjE3YjNmMWQyNTk4NzczMmZiIiwidGFnIjoiIn0%3D; srv=s0; cf_clearance=QMoZtUpZrX0Mh4XJiFmFSSmoWndISPne5FcsGmKKvTQ-1723297585-1.0.1.1-6tVUnP.aef9XeNj0CnN.19D1el_r53t.lhqddX.J88gohH9UnsPWKeJ4yT0pTbcaGRbPuXTLOS.U72.wdy.gMg",
"Referer": "https://animepahe.ru/",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "iframe",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "cross-site",
"Sec-Fetch-User": "?1",
"Priority": "u=4",
"TE": "trailers",
}
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
KWIK_RE = re.compile(r"Player\|(.+?)'")

View File

@@ -0,0 +1,75 @@
# 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,
values_to_replace_with_k: list,
):
decode_mapper_d: dict = {}
for i in range(no_of_keys_c):
key = animepahe_key_creator(i, base_a)
val = values_to_replace_with_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__":
# Testing time
filepath = input("Enter file name: ")
if filepath:
with open(filepath, "r") as file:
data = file.read()
else:
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,{}))</script>"""
print(process_animepahe_embed_page(data))

View File

@@ -1,7 +1,7 @@
from typing import Literal, TypedDict
class AnimeSearchResult(TypedDict):
class AnimePaheSearchResult(TypedDict):
id: int
title: str
type: str
@@ -21,7 +21,7 @@ class AnimePaheSearchPage(TypedDict):
last_page: int
_from: int
to: int
data: list[AnimeSearchResult]
data: list[AnimePaheSearchResult]
class Episode(TypedDict):

View File

@@ -1,81 +0,0 @@
# 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

@@ -1,8 +1,36 @@
import os
import requests
from yt_dlp.utils.networking import random_user_agent
from ...constants import APP_CACHE_DIR
from .providers_store import ProviderStore
class AnimeProvider:
session: requests.Session
def __init__(self) -> None:
self.session = requests.session()
USER_AGENT = random_user_agent()
HEADERS = {}
def __init__(self, cache_requests, use_persistent_provider_store) -> None:
if cache_requests.lower() == "true":
from ..common.requests_cacher import CachedRequestsSession
self.session = CachedRequestsSession(
os.path.join(APP_CACHE_DIR, "cached_requests.db"),
max_lifetime=int(
os.environ.get("FASTANIME_MAX_CACHE_LIFETIME", 259200)
),
)
else:
self.session = requests.session()
self.session.headers.update({"User-Agent": self.USER_AGENT, **self.HEADERS})
if use_persistent_provider_store.lower() == "true":
self.store = ProviderStore(
"persistent",
self.__class__.__name__,
os.path.join(APP_CACHE_DIR, "anime_providers_store.db"),
)
else:
self.store = ProviderStore("memory")

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,37 @@
import functools
import logging
import os
logger = logging.getLogger(__name__)
def debug_provider(provider_function):
@functools.wraps(provider_function)
def _provider_function_wrapper(self, *args, **kwargs):
provider_name = self.__class__.__name__.upper()
if not os.environ.get("FASTANIME_DEBUG"):
try:
return provider_function(self, *args, **kwargs)
except Exception as e:
logger.error(f"[{provider_name}@{provider_function.__name__}]: {e}")
else:
return provider_function(self, *args, **kwargs)
return _provider_function_wrapper
def ensure_internet_connection(provider_function):
@functools.wraps(provider_function)
def _wrapper(*args, **kwargs):
import requests
try:
requests.get("https://google.com", timeout=5)
except requests.ConnectionError:
from sys import exit
print("You are not connected to the internet;Aborting...")
exit(1)
return provider_function(*args, **kwargs)
return _wrapper

View File

@@ -0,0 +1,274 @@
import logging
import re
from html.parser import HTMLParser
from itertools import cycle
from urllib.parse import quote_plus
from yt_dlp.utils import (
clean_html,
extract_attributes,
get_element_by_class,
get_element_html_by_class,
get_elements_by_class,
get_elements_html_by_class,
)
from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from ..utils import give_random_quality
from .constants import SERVERS_AVAILABLE
from .extractors import MegaCloud
from .types import HiAnimeStream
logger = logging.getLogger(__name__)
LINK_TO_STREAMS_REGEX = re.compile(r".*://(.*)/embed-(2|4|6)/e-([0-9])/(.*)\?.*")
IMAGE_HTML_ELEMENT_REGEX = re.compile(r"<img.*?>")
class ParseAnchorAndImgTag(HTMLParser):
def __init__(self):
super().__init__()
self.img_tag = None
self.a_tag = None
def handle_starttag(self, tag, attrs):
if tag == "img":
self.img_tag = {attr[0]: attr[1] for attr in attrs}
if tag == "a":
self.a_tag = {attr[0]: attr[1] for attr in attrs}
class HiAnime(AnimeProvider):
# HEADERS = {"Referer": "https://hianime.to/home"}
@debug_provider
def search_for_anime(self, anime_title: str, translation_type, **kwargs):
query = quote_plus(anime_title)
url = f"https://hianime.to/search?keyword={query}"
response = self.session.get(url)
if not response.ok:
return
search_page = response.text
search_results_html_items = get_elements_by_class("flw-item", search_page)
results = []
for search_results_html_item in search_results_html_items:
film_poster_html = get_element_by_class(
"film-poster", search_results_html_item
)
if not film_poster_html:
continue
# get availableEpisodes
episodes_html = get_element_html_by_class("tick-sub", film_poster_html)
episodes = clean_html(episodes_html) or 12
# get anime id and poster image url
parser = ParseAnchorAndImgTag()
parser.feed(film_poster_html)
image_data = parser.img_tag
anime_link_data = parser.a_tag
if not image_data or not anime_link_data:
continue
episodes = int(episodes)
# finally!!
image_link = image_data["data-src"]
anime_id = anime_link_data["data-id"]
title = anime_link_data["title"]
result = {
"availableEpisodes": list(range(1, episodes)),
"id": anime_id,
"title": title,
"poster": image_link,
}
results.append(result)
self.store.set(result["id"], "search_result", result)
return {"pageInfo": {}, "results": results}
@debug_provider
def get_anime(self, hianime_id, **kwargs):
anime_result = {}
if d := self.store.get(str(hianime_id), "search_result"):
anime_result = d
anime_url = f"https://hianime.to/ajax/v2/episode/list/{hianime_id}"
response = self.session.get(anime_url, timeout=10)
if response.ok:
response_json = response.json()
hianime_anime_page = response_json["html"]
episodes_info_container_html = get_element_html_by_class(
"ss-list", hianime_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]
episodes_info = [
{
"id": episode["data-id"],
"title": (
(episode["title"] or "").replace(
f"Episode {episode['data-number']}", ""
)
or anime_result["title"]
)
+ f"; Episode {episode['data-number']}",
"episode": episode["data-number"],
}
for episode in episodes_info_dicts
]
self.store.set(
str(hianime_id),
"anime_info",
episodes_info,
)
return {
"id": hianime_id,
"availableEpisodesDetail": {
"dub": episodes,
"sub": episodes,
"raw": episodes,
},
"poster": anime_result["poster"],
"title": anime_result["title"],
"episodes_info": episodes_info,
}
@debug_provider
def get_episode_streams(self, anime_id, episode, translation_type, **kwargs):
if d := self.store.get(str(anime_id), "anime_info"):
episodes_info = d
episode_details = [
episode_details
for episode_details in 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.ok:
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.warning("HiAnime: 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.warning("HiAnime: 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
@debug_provider
def _get_server(server_name, server_html):
# keys: [ data-type: translation_type, data-id: embed_id, data-server-id: server_id ]
servers_info = extract_attributes(server_html)
server_id = servers_info["data-id"]
embed_url = (
f"https://hianime.to/ajax/v2/episode/sources?id={server_id}"
)
embed_response = self.session.get(embed_url)
if embed_response.ok:
embed_json = embed_response.json()
raw_link_to_streams = embed_json["link"]
match server_name:
# TODO: Finish the other servers
case "HD2":
data = MegaCloud(self.session).extract(
raw_link_to_streams
)
return {
"headers": {},
"subtitles": [
{
"url": track["file"],
"language": track["label"],
}
for track in data["tracks"]
if track["kind"] == "captions"
],
"server": server_name,
"episode_title": episode_details["title"],
"links": give_random_quality(
[
{"link": link["url"]}
for link in data["sources"]
]
),
}
case _:
# NOTE: THIS METHOD DOES'NT WORK will get the other servers later
match = LINK_TO_STREAMS_REGEX.match(raw_link_to_streams)
if not match:
return
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.ok:
juicy_streams_json: "HiAnimeStream" = (
link_to_streams_response.json()
)
return {
"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"]}
for link in juicy_streams_json["tracks"]
]
),
}
for server_name, server_html in zip(
cycle(SERVERS_AVAILABLE), servers_html
):
if server_name == "HD2":
if server := _get_server(server_name, server_html):
yield server

View File

@@ -0,0 +1,26 @@
SERVERS_AVAILABLE = ["HD1", "HD2", "StreamSB", "StreamTape"]
""""
| "hd-1"
| "hd-2"
| "megacloud"
| "streamsb"
| "streamtape";
"""
"""
VidStreaming = "hd-1",
MegaCloud = "megacloud",
StreamSB = "streamsb",
StreamTape = "streamtape",
VidCloud = "hd-2",
AsianLoad = "asianload",
GogoCDN = "gogocdn",
MixDrop = "mixdrop",
UpCloud = "upcloud",
VizCloud = "vizcloud",
MyCloud = "mycloud",
Filemoon = "filemoon",
"""

View File

@@ -0,0 +1,191 @@
import hashlib
import json
import re
import time
from base64 import b64decode
from typing import TYPE_CHECKING, Dict, List
from Crypto.Cipher import AES
if TYPE_CHECKING:
from ...common.requests_cacher import CachedRequestsSession
# Constants
megacloud = {
"script": "https://megacloud.tv/js/player/a/prod/e1-player.min.js?v=",
"sources": "https://megacloud.tv/embed-2/ajax/e-1/getSources?id=",
}
class HiAnimeError(Exception):
def __init__(self, message, context, status_code):
super().__init__(f"{context}: {message} (Status: {status_code})")
self.context = context
self.status_code = status_code
# Adapted from https://github.com/ghoshRitesh12/aniwatch
class MegaCloud:
def __init__(self, session):
self.session: "CachedRequestsSession" = session
def extract(self, video_url: str) -> Dict:
try:
extracted_data = {
"tracks": [],
"intro": {"start": 0, "end": 0},
"outro": {"start": 0, "end": 0},
"sources": [],
}
video_id = video_url.split("/")[-1].split("?")[0]
response = self.session.get(
megacloud["sources"] + video_id,
headers={
"Accept": "*/*",
"X-Requested-With": "XMLHttpRequest",
"Referer": video_url,
},
fresh=1, # pyright: ignore
)
srcs_data = response.json()
if not srcs_data:
raise HiAnimeError(
"Url may have an invalid video id", "getAnimeEpisodeSources", 400
)
encrypted_string = srcs_data["sources"]
if not srcs_data["encrypted"] and isinstance(encrypted_string, list):
extracted_data.update(
{
"intro": srcs_data["intro"],
"outro": srcs_data["outro"],
"tracks": srcs_data["tracks"],
"sources": [
{"url": s["file"], "type": s["type"]}
for s in encrypted_string
],
}
)
return extracted_data
# Fetch decryption script
script_response = self.session.get(
megacloud["script"] + str(int(time.time() * 1000)),
fresh=1, # pyright: ignore
)
script_text = script_response.text
if not script_text:
raise HiAnimeError(
"Couldn't fetch script to decrypt resource",
"getAnimeEpisodeSources",
500,
)
vars_ = self.extract_variables(script_text)
if not vars_:
raise Exception(
"Can't find variables. Perhaps the extractor is outdated."
)
secret, encrypted_source = self.get_secret(encrypted_string, vars_)
decrypted = self.decrypt(encrypted_source, secret)
try:
sources = json.loads(decrypted)
extracted_data.update(
{
"intro": srcs_data["intro"],
"outro": srcs_data["outro"],
"tracks": srcs_data["tracks"],
"sources": [
{"url": s["file"], "type": s["type"]} for s in sources
],
}
)
return extracted_data
except Exception:
raise HiAnimeError(
"Failed to decrypt resource", "getAnimeEpisodeSources", 500
)
except Exception as err:
raise err
def extract_variables(self, text: str) -> List[List[int]]:
regex = r"case\s*0x[0-9a-f]+:(?![^;]*=partKey)\s*\w+\s*=\s*(\w+)\s*,\s*\w+\s*=\s*(\w+);"
matches = re.finditer(regex, text)
vars_ = []
for match in matches:
key1 = self.matching_key(match[1], text)
key2 = self.matching_key(match[2], text)
try:
vars_.append([int(key1, 16), int(key2, 16)])
except ValueError:
continue
return vars_
def get_secret(
self, encrypted_string: str, values: List[List[int]]
) -> tuple[str, str]:
secret = []
encrypted_source_array = list(encrypted_string)
current_index = 0
for start, length in values:
start += current_index
end = start + length
secret.extend(encrypted_string[start:end])
encrypted_source_array[start:end] = [""] * length
current_index += length
encrypted_source = "".join(encrypted_source_array) # .replace("\x00", "")
return ("".join(secret), encrypted_source)
def decrypt(self, encrypted: str, key_or_secret: str, maybe_iv: str = "") -> str:
if maybe_iv:
key = key_or_secret.encode()
iv = maybe_iv.encode()
contents = encrypted
else:
# Decode the Base64 string
cypher = b64decode(encrypted)
# Extract the salt from the cypher text
salt = cypher[8:16]
# Combine the key_or_secret with the salt
password = key_or_secret.encode() + salt
# Generate MD5 hashes
md5_hashes = []
digest = password
for _ in range(3):
md5 = hashlib.md5()
md5.update(digest)
md5_hashes.append(md5.digest())
digest = md5_hashes[-1] + password
# Derive the key and IV
key = md5_hashes[0] + md5_hashes[1]
iv = md5_hashes[2]
# Extract the encrypted contents
contents = cypher[16:]
# Initialize the AES decipher
decipher = AES.new(key, AES.MODE_CBC, iv)
# Decrypt and decode
decrypted = decipher.decrypt(contents).decode("utf-8") # pyright: ignore
# Remove any padding (PKCS#7)
pad = ord(decrypted[-1])
return decrypted[:-pad]
def matching_key(self, value: str, script: str) -> str:
match = re.search(rf",{value}=((?:0x)?[0-9a-fA-F]+)", script)
if match:
return match.group(1).replace("0x", "")
raise Exception("Failed to match the key")

View File

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

View File

@@ -0,0 +1,344 @@
import os
import re
from logging import getLogger
from yt_dlp.utils import (
extract_attributes,
get_element_html_by_attribute,
get_element_html_by_class,
get_element_text_and_html_by_tag,
get_elements_html_by_class,
)
from ...common.mini_anilist import search_for_anime_with_anilist
from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from ..types import SearchResults
from .constants import NYAA_ENDPOINT
logger = getLogger(__name__)
EXTRACT_USEFUL_INFO_PATTERN_1 = re.compile(
r"\[(\w+)\] (.+) - (\d+) [\[\(](\d+)p[\]\)].*"
)
EXTRACT_USEFUL_INFO_PATTERN_2 = re.compile(
r"\[(\w+)\] (.+)E(\d+) [\[\(]?(\d+)p.*[\]\)]?.*"
)
class Nyaa(AnimeProvider):
search_results: SearchResults
@debug_provider
def search_for_anime(self, user_query: str, *args, **_):
self.search_results = search_for_anime_with_anilist(
user_query, True
) # pyright: ignore
self.user_query = user_query
return self.search_results
@debug_provider
def get_anime(self, anilist_id: str, *_):
for anime in self.search_results["results"]:
if anime["id"] == anilist_id:
self.titles = [anime["title"], *anime["otherTitles"], self.user_query]
return {
"id": anime["id"],
"title": anime["title"],
"poster": anime["poster"],
"availableEpisodesDetail": {
"dub": anime["availableEpisodes"],
"sub": anime["availableEpisodes"],
"raw": anime["availableEpisodes"],
},
}
@debug_provider
def get_episode_streams(
self,
anime_id: str,
episode_number: str,
translation_type: str,
trusted_only=bool(int(os.environ.get("FA_NYAA_TRUSTED_ONLY", "0"))),
allow_dangerous=bool(int(os.environ.get("FA_NYAA_ALLOW_DANGEROUS", "0"))),
sort_by="seeders",
*args,
):
anime_title = self.titles[0]
logger.debug(f"Searching nyaa for query: '{anime_title} {episode_number}'")
servers = {}
torrents_table = ""
for title in self.titles:
try:
url_arguments: dict[str, str] = {
"c": "1_2", # Language (English)
"q": f"{title} {'0' if len(episode_number)==1 else ''}{episode_number}", # Search Query
}
# url_arguments["q"] = anime_title
# if trusted_only:
# url_arguments["f"] = "2" # Trusted uploaders only
# What to sort torrents by
if sort_by == "seeders":
url_arguments["s"] = "seeders"
elif sort_by == "date":
url_arguments["s"] = "id"
elif sort_by == "size":
url_arguments["s"] = "size"
elif sort_by == "comments":
url_arguments["s"] = "comments"
logger.debug(f"URL Arguments: {url_arguments}")
response = self.session.get(NYAA_ENDPOINT, params=url_arguments)
if not response.ok:
logger.error(f"[NYAA]: {response.text}")
return
try:
torrents_table = get_element_text_and_html_by_tag(
"table", response.text
)
except Exception as e:
logger.error(f"[NYAA]: {e}")
continue
if not torrents_table:
continue
for anime_torrent in get_elements_html_by_class(
"success", torrents_table[1]
):
td_title = get_element_html_by_attribute(
"colspan", "2", anime_torrent
)
if not td_title:
continue
title_anchor_tag = get_element_text_and_html_by_tag("a", td_title)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
if "class" in title_anchor_tag_attrs:
td_title = td_title.replace(title_anchor_tag[1], "")
title_anchor_tag = get_element_text_and_html_by_tag(
"a", td_title
)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
anime_title_info = title_anchor_tag_attrs["title"]
if not anime_title_info:
continue
match = EXTRACT_USEFUL_INFO_PATTERN_1.search(
anime_title_info.strip()
)
if not match:
continue
server = match[1]
match[2]
_episode_number = match[3]
quality = match[4]
if float(episode_number) != float(_episode_number):
continue
links_td = get_element_html_by_class("text-center", anime_torrent)
if not links_td:
continue
torrent_anchor_tag = get_element_text_and_html_by_tag("a", links_td)
if not torrent_anchor_tag:
continue
torrent_anchor_tag_atrrs = extract_attributes(torrent_anchor_tag[1])
if not torrent_anchor_tag_atrrs:
continue
torrent_file_url = (
f'{NYAA_ENDPOINT}{torrent_anchor_tag_atrrs["href"]}'
)
if server in servers:
link = {
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
if link not in servers[server]["links"]:
servers[server]["links"].append(link)
else:
servers[server] = {
"server": server,
"headers": {},
"episode_title": f"{anime_title}; Episode {episode_number}",
"subtitles": [],
"links": [
{
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
],
}
for anime_torrent in get_elements_html_by_class(
"default", torrents_table[1]
):
td_title = get_element_html_by_attribute(
"colspan", "2", anime_torrent
)
if not td_title:
continue
title_anchor_tag = get_element_text_and_html_by_tag("a", td_title)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
if "class" in title_anchor_tag_attrs:
td_title = td_title.replace(title_anchor_tag[1], "")
title_anchor_tag = get_element_text_and_html_by_tag(
"a", td_title
)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
anime_title_info = title_anchor_tag_attrs["title"]
if not anime_title_info:
continue
match = EXTRACT_USEFUL_INFO_PATTERN_2.search(
anime_title_info.strip()
)
if not match:
continue
server = match[1]
match[2]
_episode_number = match[3]
quality = match[4]
if float(episode_number) != float(_episode_number):
continue
links_td = get_element_html_by_class("text-center", anime_torrent)
if not links_td:
continue
torrent_anchor_tag = get_element_text_and_html_by_tag("a", links_td)
if not torrent_anchor_tag:
continue
torrent_anchor_tag_atrrs = extract_attributes(torrent_anchor_tag[1])
if not torrent_anchor_tag_atrrs:
continue
torrent_file_url = (
f'{NYAA_ENDPOINT}{torrent_anchor_tag_atrrs["href"]}'
)
if server in servers:
link = {
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
if link not in servers[server]["links"]:
servers[server]["links"].append(link)
else:
servers[server] = {
"server": server,
"headers": {},
"episode_title": f"{anime_title}; Episode {episode_number}",
"subtitles": [],
"links": [
{
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
],
}
if not allow_dangerous:
break
for anime_torrent in get_elements_html_by_class(
"danger", torrents_table[1]
):
td_title = get_element_html_by_attribute(
"colspan", "2", anime_torrent
)
if not td_title:
continue
title_anchor_tag = get_element_text_and_html_by_tag("a", td_title)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
if "class" in title_anchor_tag_attrs:
td_title = td_title.replace(title_anchor_tag[1], "")
title_anchor_tag = get_element_text_and_html_by_tag(
"a", td_title
)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
anime_title_info = title_anchor_tag_attrs["title"]
if not anime_title_info:
continue
match = EXTRACT_USEFUL_INFO_PATTERN_2.search(
anime_title_info.strip()
)
if not match:
continue
server = match[1]
match[2]
_episode_number = match[3]
quality = match[4]
if float(episode_number) != float(_episode_number):
continue
links_td = get_element_html_by_class("text-center", anime_torrent)
if not links_td:
continue
torrent_anchor_tag = get_element_text_and_html_by_tag("a", links_td)
if not torrent_anchor_tag:
continue
torrent_anchor_tag_atrrs = extract_attributes(torrent_anchor_tag[1])
if not torrent_anchor_tag_atrrs:
continue
torrent_file_url = (
f'{NYAA_ENDPOINT}{torrent_anchor_tag_atrrs["href"]}'
)
if server in servers:
link = {
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
if link not in servers[server]["links"]:
servers[server]["links"].append(link)
else:
servers[server] = {
"server": server,
"headers": {},
"episode_title": f"{anime_title}; Episode {episode_number}",
"subtitles": [],
"links": [
{
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
],
}
except Exception as e:
logger.error(f"[NYAA]: {e}")
continue
for server in servers:
yield servers[server]

View File

@@ -0,0 +1 @@
NYAA_ENDPOINT = "https://nyaa.si"

View File

@@ -0,0 +1,126 @@
import logging
import os
import sys
import time
import libtorrent # pyright: ignore
from rich import print
from rich.progress import (
BarColumn,
DownloadColumn,
Progress,
TextColumn,
TimeRemainingColumn,
TransferSpeedColumn,
)
logger = logging.getLogger("nyaa")
def download_torrent(
filename: str,
result_filename: str | None = None,
show_progress: bool = True,
base_path: str = "Anime",
) -> str:
session = libtorrent.session({"listen_interfaces": "0.0.0.0:6881"})
logger.debug("Started libtorrent session")
base_path = os.path.expanduser(base_path)
logger.debug(f"Downloading output to: '{base_path}'")
info = libtorrent.torrent_info(filename)
logger.debug("Started downloading torrent")
handle: libtorrent.torrent_handle = session.add_torrent(
{"ti": info, "save_path": base_path}
)
status: libtorrent.session_status = handle.status()
progress_bar = Progress(
"[progress.description]{task.description}",
BarColumn(bar_width=None),
"[progress.percentage]{task.percentage:>3.1f}%",
"",
DownloadColumn(),
"",
TransferSpeedColumn(),
"",
TimeRemainingColumn(),
"",
TextColumn("[green]Peers: {task.fields[peers]}[/green]"),
)
if show_progress:
with progress_bar:
download_task = progress_bar.add_task(
"downloading",
filename=status.name,
total=status.total_wanted,
peers=0,
start=False,
)
while not status.total_done:
# Checking files
status = handle.status()
description = "[bold yellow]Checking files[/bold yellow]"
progress_bar.update(
download_task,
completed=status.total_done,
peers=status.num_peers,
description=description,
)
# Started download
progress_bar.start_task(download_task)
description = f"[bold blue]Downloading[/bold blue] [bold yellow]{result_filename}[/bold yellow]"
while not status.is_seeding:
status = handle.status()
progress_bar.update(
download_task,
completed=status.total_done,
peers=status.num_peers,
description=description,
)
alerts = session.pop_alerts()
alert: libtorrent.alert
for alert in alerts:
if (
alert.category()
& libtorrent.alert.category_t.error_notification
):
logger.debug(f"[Alert] {alert}")
time.sleep(1)
progress_bar.update(
download_task,
description=f"[bold blue]Finished Downloading[/bold blue] [bold green]{result_filename}[/bold green]",
completed=status.total_wanted,
)
if result_filename:
old_name = f"{base_path}/{status.name}"
new_name = f"{base_path}/{result_filename}"
os.rename(old_name, new_name)
logger.debug(f"Finished torrent download, renamed '{old_name}' to '{new_name}'")
return new_name
return ""
if __name__ == "__main__":
if len(sys.argv) < 2:
print("You need to pass in the .torrent file path.")
sys.exit(1)
download_torrent(sys.argv[1])

View File

@@ -0,0 +1,114 @@
import json
import logging
import time
logger = logging.getLogger(__name__)
class ProviderStoreDB:
def __init__(
self,
provider_name,
cache_db_path: str,
max_lifetime: int = 604800,
max_size: int = (1024**2) * 10,
table_name: str = "fastanime_providers_store",
clean_db=False,
):
from ..common.sqlitedb_helper import SqliteDB
self.cache_db_path = cache_db_path
self.clean_db = clean_db
self.provider_name = provider_name
self.max_lifetime = max_lifetime
self.max_size = max_size
self.table_name = table_name
self.sqlite_db_connection = SqliteDB(self.cache_db_path)
# Prepare the cache table if it doesn't exist
self._create_store_table()
def _create_store_table(self):
"""Create cache table if it doesn't exist."""
with self.sqlite_db_connection as conn:
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS {self.table_name} (
id TEXT,
data_type TEXT,
provider_name TEXT,
data TEXT,
cache_expiry INTEGER
)"""
)
def get(self, id: str, data_type: str, default=None):
with self.sqlite_db_connection as conn:
cursor = conn.cursor()
cursor.execute(
f"""
SELECT
data
FROM {self.table_name}
WHERE
id = ?
AND data_type = ?
AND provider_name = ?
AND cache_expiry > ?
""",
(id, data_type, self.provider_name, int(time.time())),
)
cached_data = cursor.fetchone()
if cached_data:
logger.debug("Found existing request in cache")
(json_data,) = cached_data
return json.loads(json_data)
return default
def set(self, id: str, data_type: str, data):
with self.sqlite_db_connection as connection:
cursor = connection.cursor()
cursor.execute(
f"""
INSERT INTO {self.table_name}
VALUES ( ?, ?,?, ?, ?)
""",
(
id,
data_type,
self.provider_name,
json.dumps(data),
int(time.time()) + self.max_lifetime,
),
)
class ProviderStoreMem:
def __init__(self) -> None:
from collections import defaultdict
self._store = defaultdict(dict)
def get(self, id: str, data_type: str, default=None):
return self._store[id][data_type]
def set(self, id: str, data_type: str, data):
self._store[id][data_type] = data
def ProviderStore(store_type, *args, **kwargs):
if store_type == "persistent":
return ProviderStoreDB(*args, **kwargs)
else:
return ProviderStoreMem()
if __name__ == "__main__":
store = ProviderStore("persistent", "test_provider", "provider_store")
store.set("123", "test", {"hello": "world"})
print(store.get("123", "test"))
print("-------------------------------")
store = ProviderStore("memory")
store.set("1", "test", {"hello": "world"})
print(store.get("1", "test"))

View File

@@ -19,6 +19,7 @@ class PageInfo(TypedDict):
class SearchResult(TypedDict):
id: str
title: str
otherTitles: list[str]
availableEpisodes: list[str]
type: str
score: int
@@ -39,9 +40,20 @@ class AnimeEpisodeDetails(TypedDict):
raw: list[str]
class AnimeEpisode(TypedDict):
#
# class AnimeEpisode(TypedDict):
# id: str
# title: str
#
class AnimeEpisodeInfo(TypedDict):
id: str
title: str
episode: str
poster: str | None
duration: str | None
translation_type: str | None
class Anime(TypedDict):
@@ -49,7 +61,7 @@ class Anime(TypedDict):
title: str
availableEpisodesDetail: AnimeEpisodeDetails
type: str | None
episodesInfo: list[AnimeEpisode] | None
episodesInfo: list[AnimeEpisodeInfo] | None
poster: str
year: str
@@ -64,8 +76,15 @@ class EpisodeStream(TypedDict):
translation_type: Literal["dub", "sub"]
class Subtitle(TypedDict):
url: str
language: str
class Server(TypedDict):
headers: dict
subtitles: list[Subtitle]
audio: list
server: str
episode_title: str
links: list[EpisodeStream]

View File

@@ -35,12 +35,12 @@ hex_to_char = {
}
def give_random_quality(links: list[dict]):
def give_random_quality(links):
qualities = cycle(["1080", "720", "480", "360"])
return [
{"link": link["link"], "quality": quality}
for link, quality in zip(links, qualities)
{**episode_stream, "quality": quality}
for episode_stream, quality in zip(links, qualities)
]

View File

@@ -0,0 +1,225 @@
import base64
import re
from itertools import cycle
from yt_dlp.utils import (
extract_attributes,
get_element_by_attribute,
get_element_text_and_html_by_tag,
get_elements_text_and_html_by_attribute,
)
from yt_dlp.utils.traversal import get_element_html_by_attribute
from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from .constants import SEARCH_URL, YUGEN_ENDPOINT
# ** Adapted from anipy-cli **
class Yugen(AnimeProvider):
"""
Provides a fast and effective interface to YugenApi site.
"""
api_endpoint = YUGEN_ENDPOINT
# HEADERS = {
# "Referer": ALLANIME_REFERER,
# }
@debug_provider
def search_for_anime(
self,
user_query: str,
translation_type: str = "sub",
nsfw=True,
unknown=True,
**kwargs,
):
results = []
has_next = True
page = 0
while has_next:
page += 1
response = self.session.get(
SEARCH_URL, params={"q": user_query, "page": page}
)
search_results = response.json()
has_next = search_results["hasNext"]
results_html = search_results["query"]
anime = get_elements_text_and_html_by_attribute(
"class", "anime-meta", results_html, tag="a"
)
id_regex = re.compile(r"(\d+)\/([^\/]+)")
for _a in anime:
if not _a:
continue
a = extract_attributes(_a[1])
if not a:
continue
uri = a["href"]
identifier = id_regex.search(uri) # pyright:ignore
if identifier is None:
continue
if len(identifier.groups()) != 2:
continue
identifier = base64.b64encode(
f"{identifier.group(1)}/{identifier.group(2)}".encode()
).decode()
anime_title = a["title"]
languages = {"sub": 1, "dub": 0}
excl = get_element_by_attribute(
"class", "ani-exclamation", _a[1], tag="div"
)
if excl is not None:
if "dub" in excl.lower():
languages["dub"] = 1
#
results.append(
{
"id": identifier,
"title": anime_title,
"availableEpisodes": languages,
}
)
page += 1
return {
"pageInfo": {"total": len(results)},
"results": results,
}
@debug_provider
def get_anime(self, anime_id: str, **kwargs):
identifier = base64.b64decode(anime_id).decode()
response = self.session.get(f"{YUGEN_ENDPOINT}/anime/{identifier}")
html_page = response.text
data_map = {
"id": anime_id,
"title": None,
"poster": None,
"genres": [],
"synopsis": None,
"release_year": None,
"status": None,
"otherTitles": [],
"availableEpisodesDetail": {},
}
sub_match = re.search(
r'<div class="ap-.+?">Episodes</div><span class="description" .+?>(\d+)</span></div>',
html_page,
)
if sub_match:
eps = int(sub_match.group(1))
data_map["availableEpisodesDetail"]["sub"] = list(
map(str, range(1, eps + 1))
)
dub_match = re.search(
r'<div class="ap-.+?">Episodes \(Dub\)</div><span class="description" .+?>(\d+)</span></div>',
html_page,
)
if dub_match:
eps = int(dub_match.group(1))
data_map["availableEpisodesDetail"]["dub"] = list(
map(str, range(1, eps + 1))
)
name = get_element_text_and_html_by_tag("h1", html_page)
if name is not None:
data_map["title"] = name[0].strip()
synopsis = get_element_by_attribute("class", "description", html_page, tag="p")
if synopsis is not None:
data_map["synopsis"] = synopsis
# FIXME: This is not working because ytdl is too strict on also getting a closing tag
try:
image = get_element_html_by_attribute(
"class", "cover", html_page, tag="img"
)
img_attrs = extract_attributes(image)
if img_attrs is not None:
data_map["image"] = img_attrs.get("src")
except Exception:
pass
data = get_elements_text_and_html_by_attribute(
"class", "data", html_page, tag="div"
)
for d in data:
title = get_element_text_and_html_by_tag("div", d[1])
desc = get_element_text_and_html_by_tag("span", d[1])
if title is None or desc is None:
continue
title = title[0]
desc = desc[0]
if title in ["Native", "Romaji"]:
data_map["alternative_names"].append(desc)
elif title == "Synonyms":
data_map["alternative_names"].extend(desc.split(","))
elif title == "Premiered":
try:
data_map["release_year"] = int(desc.split()[-1])
except (ValueError, TypeError):
pass
elif title == "Status":
data_map["status"] = title
elif title == "Genres":
data_map["genres"].extend([g.strip() for g in desc.split(",")])
return data_map
@debug_provider
def get_episode_streams(
self, anime_id, episode_number: str, translation_type="sub"
):
"""get the streams of an episode
Args:
translation_type ([TODO:parameter]): [TODO:description]
anime: [TODO:description]
episode_number: [TODO:description]
Yields:
[TODO:description]
"""
identifier = base64.b64decode(anime_id).decode()
id_num, anime_title = identifier.split("/")
if translation_type == "dub":
video_query = f"{id_num}|{episode_number}|dub"
else:
video_query = f"{id_num}|{episode_number}"
#
res = self.session.post(
f"{YUGEN_ENDPOINT}/api/embed/",
data={
"id": base64.b64encode(video_query.encode()).decode(),
"ac": "0",
},
headers={"x-requested-with": "XMLHttpRequest"},
)
res = res.json()
yield {
"server": "gogoanime",
"episode_title": f"{anime_title}; Episode {episode_number}",
"headers": {},
"subtitles": [],
"links": [
{"quality": quality, "link": link}
for quality, link in zip(
cycle(["1080", "720", "480", "360"]), res["hls"]
)
],
}

View File

@@ -0,0 +1,4 @@
YUGEN_ENDPOINT: str = "https://yugenanime.tv"
SEARCH_URL = YUGEN_ENDPOINT + "/api/discover/"
SERVERS_AVAILABLE = ["gogoanime"]

Some files were not shown because too many files have changed in this diff Show More