Compare commits

...

709 Commits

Author SHA1 Message Date
Benexl
8aacbcc35b Update README.md 2025-08-16 19:11:21 +03:00
Benexl
5976ab43b2 chore: correct package issues 2025-08-16 19:08:39 +03:00
Benexl
99c67a4bc0 fix: publish.yml 2025-08-16 19:00:44 +03:00
Benexl
34851fd3e4 chore: update publish.yml 2025-08-16 18:58:59 +03:00
Benexl
e74b5977bb chore: update workflow 2025-08-16 18:56:31 +03:00
Benexl
0650f45fba revert 2025-08-16 18:55:33 +03:00
Benexl
0c8f2a70ba chore: update project name 2025-08-16 18:50:42 +03:00
Benexl
ce7cd98783 feat(constants): _LOWER 2025-08-16 16:57:08 +03:00
Benexl
ec14c40c77 feat(config-generator): include computed fields + show defaults 2025-08-16 16:54:47 +03:00
Benexl
6a6e03c744 feat(cli): invoke the default media api on no subcommand 2025-08-16 16:32:07 +03:00
Benexl
26de1a0fb4 feat(cli): register completions cmd 2025-08-16 16:21:41 +03:00
Benexl
e49fb4898c refactor: rename to viu 2025-08-16 16:18:41 +03:00
Benexl
e2407d4948 Update README.md 2025-08-15 13:45:52 +03:00
Benexl
cd16ab50e3 feat(rofi-selector): use plyer for notifications when using it 2025-08-15 13:08:33 +03:00
Benexl
b53a7d9b03 chore: bump version 2025-08-15 12:31:13 +03:00
Benexl
2b9fdb99b1 chore: priority to the enabled lol 2025-08-13 16:27:42 +03:00
Benexl
9c6e1877ed feat(rofi-selector): force exit on no input 2025-08-12 16:56:18 +03:00
Benexl
73bb77fe46 feat(rofi-theme-defaults): enhance ui and ux 2025-08-12 16:50:21 +03:00
Benexl
4cdc5bfd34 feat(config): make desktop notification duration configurable 2025-08-12 14:45:35 +03:00
Benexl
ca491d95a0 chore: update deps 2025-08-12 14:36:41 +03:00
Benexl
1003f75db3 feat(assets): update logo 2025-08-12 14:32:53 +03:00
Benexl
5821c4ca97 Update README.md 2025-08-12 13:46:05 +03:00
Benexl
de774a58d2 feat(anilist-downloads-command): create it 2025-08-12 11:40:20 +03:00
Benexl
ee25cbba10 fix(normalizer): convert media api title to lower 2025-08-12 10:57:15 +03:00
Benexl
278a771f64 Merge pull request #119 from Benexl/minor-fixes
fix: add back missing yt-dlp's "external_downloader" logic
2025-08-12 10:37:50 +03:00
Type-Delta
0d8c287e2f Merge branch 'master' of https://github.com/Benexl/FastAnime into minor-fixes 2025-08-12 14:35:52 +07:00
Type-Delta
74308dfdc5 fix: add back missing yt-dlp's "external_downloader" logic 2025-08-12 14:35:23 +07:00
Benexl
7ca1b8572e fix(rofi-previews): ensure the cover image is used if episode image unavailable 2025-08-12 02:26:19 +03:00
Benexl
54aed9e5a0 feat(config-command): use title case for the desktop entry title 2025-08-12 02:25:25 +03:00
Benexl
4511d14e8b feat(previews): implement rofi image preview logic 2025-08-12 02:11:27 +03:00
Benexl
bff684e8cb refactor(config-command): rename desktop entry option to generate-desktop-entry 2025-08-12 02:11:04 +03:00
Benexl
cfc83450c8 feat(config): include possible values for pygment style 2025-08-12 01:07:06 +03:00
Benexl
04a6a425b7 feat(config): show possible types and possible values in config comments 2025-08-12 00:55:51 +03:00
Benexl
088d232bfd feat(normalizer): add user normalizer json 2025-08-12 00:39:43 +03:00
Benexl
03fd8c0bf8 feat(selectors): complete contracts 2025-08-11 23:57:34 +03:00
Benexl
17f1744025 feat(watch-history): intelligently switch to repeating if was initially completed 2025-08-11 23:38:24 +03:00
Benexl
9a5f3d46be feat(download-service): notify on completed episode download if plyer available 2025-08-11 23:37:50 +03:00
Benexl
66eb854da5 feat: the worker command lol 2025-08-11 22:42:13 +03:00
Benexl
ae62adf233 feat: scaffhold worker command with gpt 5, it is actually pretty good lol 2025-08-11 22:42:13 +03:00
Benexl
55a7c7facf Merge pull request #117 from Benexl/minor-fixes
fix: anilist auth failed to open link on Windows
2025-08-08 11:17:49 +03:00
Type-Delta
2340c34d02 fix: anilist auth failed to open link on Windows
Switches to using the standard web browser module for opening the authentication URL, providing clearer feedback to the user about browser launch success or failure.
2025-08-08 12:28:23 +07:00
Benexl
40b29ba6e5 Merge pull request #115 from Benexl/minor-fixes
fix: anilist auth example
2025-08-07 13:14:48 +03:00
Type-Delta
5dc768f7e8 fix: anilist auth example 2025-08-07 15:19:38 +07:00
Benexl
b343bfb645 docs: enhance docstrings across player modules for clarity and completeness 2025-08-05 12:11:02 +03:00
Benexl
37773265ce Merge pull request #111 from khachbe/bugfix_formatter
Use int() cast instead of is_integer() for renumbered_val
2025-08-03 15:19:03 +03:00
khachbe
70ef1bf633 Merge branch 'master' into bugfix_formatter 2025-08-03 13:58:54 +02:00
khachbe
bee97acd35 Update formatter.py 2025-08-03 13:58:12 +02:00
Benexl
fb61fd17f1 fix(preview-script): quote $value to prevent interpretation of special characters 2025-08-02 14:01:30 +03:00
Benexl
98fff7d00f chore: bump lockfile 2025-08-02 14:00:47 +03:00
Benexl
3cc9ae50b6 Merge pull request #112 from khachbe/bugfix_typedict
Add list to AllAnimeEpisodeStreams
2025-08-02 13:46:37 +03:00
khachbe
26f7de172a add list to AllAnimeEpisodeStreams 2025-08-01 11:46:05 +02:00
khachbe
673b6280e4 Use int() cast instead of is_integer() for renumbered_val 2025-08-01 11:20:57 +02:00
Benexl
7943dcc3db Merge pull request #110 from theobori/fix/ruff-check-errors
Fixed the `ruff check` command line errors
2025-07-30 22:38:25 +03:00
Théo Bori
49ee1f9bbd Fixed the ruff check command line errors 2025-07-30 18:38:15 +02:00
Benexl
fd80149e74 Merge pull request #109 from iMithrellas/preview-scaling
Add optional --scale-up flag for icat image previews
2025-07-30 00:49:09 +03:00
iMithrellas
7c11616bea fix(fzf-preview): Make --scale-up flag opt in instead of opt out 2025-07-29 23:20:56 +02:00
iMithrellas
b9130018ca Merge branch 'master' into preview-scaling 2025-07-29 21:59:06 +02:00
iMithrellas
70ade13017 feat(fzf-preview): make --scale-up flag a config option 2025-07-29 21:52:48 +02:00
iMithrellas
071c0daf4f feat(fzf-preview): add --scale-up flag so that previews fill the available space 2025-07-29 21:52:48 +02:00
Benexl
ed136fc8a0 chore: bump version 2025-07-29 18:52:31 +03:00
Benexl
3a29127366 chore: update deps 2025-07-29 18:51:09 +03:00
Benexl
b7c938fec4 refactor: remove deprecated file 2025-07-29 18:14:40 +03:00
Benexl
c6aada6139 fix(play-downloads-menu): add missing imports 2025-07-29 18:14:21 +03:00
Benexl
e4c4203364 style: ruff check + fix 2025-07-29 18:13:58 +03:00
Benexl
52cd4a8d85 fix(feedback-service): just default to creating progress 2025-07-29 18:04:20 +03:00
Benexl
b9b0d49530 fix(registry-commands): make it work 2025-07-29 18:03:05 +03:00
Benexl
ba4df96dc8 fix(anilist-stats): should be user_profile 2025-07-29 18:02:28 +03:00
Benexl
335ba86367 Update README.md 2025-07-29 17:25:48 +03:00
Benexl
92c18f850f Update README.md 2025-07-29 17:25:30 +03:00
Benexl
70763807de fix(media-actions-menu): typo 2025-07-29 17:19:11 +03:00
Benexl
51438c8864 feat(dynamic-search): title is enough 2025-07-29 17:18:56 +03:00
Benexl
03426bd0da feat(watch-history-service): only update remote progress when episode is complete 2025-07-29 16:56:23 +03:00
Benexl
ccdb0346eb feat(download-service): correct search params and episode title 2025-07-29 16:32:58 +03:00
Benexl
6fba74b3ca docs: update copilot instructions 2025-07-29 15:42:58 +03:00
Benexl
b436f23f65 style: ruff check + format 2025-07-29 15:36:32 +03:00
Benexl
cd7b70dd6b feat(character-preview): attempt to display character image 2025-07-29 15:32:21 +03:00
Benexl
ac9b000ce8 chore: upgrade deps 2025-07-29 15:31:40 +03:00
Benexl
84a3b6185b fix(media-types): age should be string 2025-07-29 14:01:05 +03:00
Benexl
7f52d8cb39 feat(feedback-service): make it configurable 2025-07-29 14:00:44 +03:00
Benexl
87372e41be feat(media-actions-menu): bulk media list actions 2025-07-29 12:36:17 +03:00
Benexl
9cfbc0bdcf style: ruff check 2025-07-29 11:31:18 +03:00
Benexl
af6e64ee0c feat(anilist-download-command): add previews 2025-07-29 11:30:56 +03:00
Benexl
4a2f272e14 Merge pull request #108 from theobori/feature/ci
Added GitHub action step to build the Nix derivation
2025-07-29 10:24:26 +03:00
Benexl
a2e2ae8dd3 Merge branch 'master' into feature/ci 2025-07-29 10:23:44 +03:00
Benexl
5ce9bbaa0d fix(anilist-notifications): add large to coverImage 2025-07-29 10:20:26 +03:00
Benexl
25812b6562 feat(worker-service): draft 2025-07-29 02:02:32 +03:00
Benexl
ee52b945ea feat(media-api): notifications 2025-07-29 01:40:18 +03:00
Benexl
be14e6a135 fix: failure to update remote history 2025-07-29 01:13:53 +03:00
Benexl
9402e7a2b6 feat(mpv-ipc): basic support for media registry 2025-07-29 00:29:51 +03:00
Benexl
590d6a1851 feat(downloads-menu): improve 2025-07-28 23:29:44 +03:00
Benexl
8186fe9991 feat(menus): intergrate download service and downloads in menus 2025-07-28 22:16:46 +03:00
Benexl
4d2831eee1 feat(downloads-config): add support for no check certs 2025-07-28 22:16:14 +03:00
Benexl
ea918909b9 chore(pyproject.toml): add yt-dlp and pycryptodomex to standard optional and downwoal optional 2025-07-28 22:15:46 +03:00
Benexl
93c0f2ab83 feat(cli-service-download): basic implementation 2025-07-28 21:22:11 +03:00
Théo Bori
985e7fee18 Removed shell.nix 2025-07-28 19:37:15 +02:00
Théo Bori
7bd7ddecae Added a envrc file to use nix via direnv 2025-07-28 19:37:14 +02:00
Théo Bori
9afb9a9a32 Added GitHub action step to build the Nix derivation 2025-07-28 19:37:07 +02:00
Benexl
40065478cc feat(cli-sevice-download): prepare 2025-07-28 20:03:48 +03:00
Benexl
65aa8fcb4e chore: cleanup 2025-07-28 19:48:20 +03:00
Benexl
2717d0b012 style: ruff check + format 2025-07-28 15:37:40 +03:00
Benexl
9f0cf5f8dc fix(dynamic-search-menu): update search for media chosen logic 2025-07-28 15:36:39 +03:00
Benexl
ef4a850d75 fix(dynamic-search-menu): preview script 2025-07-28 15:20:05 +03:00
Benexl
d8804c711e fix(search): revert QUERY variable to use placeholder for dynamic input 2025-07-28 14:36:48 +03:00
Benexl
adf75f65b2 refactor: streamline authentication and search command preparation in dynamic search 2025-07-28 14:25:36 +03:00
Benexl
6e7e75b514 fix(scripts-dynamic-preview): should be {} 2025-07-28 14:13:47 +03:00
Benexl
9515559afb docs: include file structure in copilot-instructions 2025-07-28 14:13:06 +03:00
Benexl
e8849940e1 feat: Add media airing schedule and character selection features
- Implemented media airing schedule functionality in `media_airing_schedule.py` to fetch and display upcoming episodes with air dates and countdown timers.
- Created character selection feature in `media_characters.py` to fetch and display a list of characters, showing details upon selection.
- Updated state management to include new menu options for characters and airing schedules.
- Enhanced preview functionality to support character and airing schedule previews.
- Added necessary API methods and data models for handling character and airing schedule data.
2025-07-28 13:37:27 +03:00
Benexl
007954802f chore: cleanup old testing strategy 2025-07-28 13:36:55 +03:00
Benexl
b874bef2d5 docs: draft copilot chatmodes 2025-07-28 12:54:05 +03:00
Benexl
6c1bcebd99 docs: draft copilot-instructions.md 2025-07-28 12:41:30 +03:00
Benexl
fe5e8c641d docs: update CONTRIBUTIONS.md 2025-07-28 12:36:49 +03:00
Benexl
45a4913ead docs: draft CONTRIBUTIONS.md 2025-07-28 12:35:51 +03:00
Benexl
03f0f40c9a fix(cli-service-player): default to regular player instead of raising error 2025-07-28 11:02:56 +03:00
Benexl
af616e0047 refactor(cli-service-feedback): use click.pause 2025-07-28 02:28:31 +03:00
Benexl
6000ed2a84 refactor(assets-anilist-graphql): rename get-medialist-item.gql to media-list-item 2025-07-28 02:23:14 +03:00
Benexl
5a869060d8 feat(media-api): media reviews 2025-07-28 02:13:10 +03:00
Benexl
58618bd82d chore: cleanup unused files 2025-07-27 23:50:25 +03:00
Benexl
84570c5595 fix(cli-utils-preview-worker): default to unknown when format is none 2025-07-27 23:41:43 +03:00
Benexl
18b4071ad9 Merge pull request #106 from theobori/fix/nix-flake
Fixed the Nix Flake default package
2025-07-27 23:31:09 +03:00
Benexl
fd052c87de Merge branch 'master' into fix/nix-flake 2025-07-27 23:29:24 +03:00
Benexl
ee959b3428 chore: update ignore file 2025-07-27 23:27:28 +03:00
Benexl
ad6bdad594 refactor(cli): remove old log_file option 2025-07-27 23:22:14 +03:00
Benexl
316832e771 fix(mpv-ipc-player): deadlock from subprocess.PIPE being filled up causing mpv to wait resulting in the player freezing 2025-07-27 23:21:51 +03:00
Benexl
9edeeb5ca4 feat(mpv-ipc-player): intergrate it as cli level service and polish it to the max 2025-07-27 21:56:20 +03:00
Théo Bori
7eb6054d5c Fixed the Nix Flake default package
I also upgraded the flake.nix file.
- `mkShell` now use `packages` instead of `buildInputs` (See https://discourse.nixos.org/t/difference-between-buildinputs-and-packages-in-mkshell/60598/2)
- Now using `venvShellHook` to create the Python virtual environment
- Remove useless variables
- Added `meta` attrset
- Now using `python3Packages` variables instead
- Explicitly use the `build-system` field
- Now using `substituteInPlace` within `postPatch`
- Using the `pyproject` field instead of `format`
- Removed bad pratices, etc..
2025-07-27 18:39:39 +02:00
Benexl
5b06039cef chore: format with ruff 2025-07-27 12:49:44 +03:00
Benexl
abe36296c1 feat(cli): log the current command 2025-07-27 12:46:24 +03:00
Benexl
dcbf0df1a0 refactor(provider-search-menu): import only when needed and use feedback service for progress 2025-07-27 12:29:33 +03:00
Benexl
c2acbcdb68 fix(cli-utils-logging): add rich logger as one of the handlers if enabled 2025-07-27 12:28:28 +03:00
Benexl
96233b14ff refactor(cli-utils-image): rename render_image to render 2025-07-27 12:27:40 +03:00
Benexl
a8f2579f82 feat(mpv-ipc-player): cleanup implementation 2025-07-27 11:48:55 +03:00
Benexl
5ed9700c5c feat(cli): always log to a file 2025-07-27 11:48:34 +03:00
Benexl
fd74fbe2ef feat: rename config path var and add dedicated folder for logs 2025-07-27 11:48:18 +03:00
Benexl
19426019a2 fix(anilist-api): user media list pagination 2025-07-27 00:38:55 +03:00
Benexl
276c8d48d9 feat(player-controls-menu): add media actions menu option 2025-07-27 00:17:41 +03:00
Benexl
99809f3fd3 fix(player-controls-menu): remove back directive 2025-07-27 00:07:04 +03:00
Benexl
f79c8540c3 feat(mpv-ipc): scaffhold mpv ipc implementation 2025-07-26 23:38:31 +03:00
Benexl
e602a6fbc4 feat(local-watch-history): auto set progress on completed status 2025-07-26 22:57:42 +03:00
Benexl
8abfaed7bf feat(cli-previews): always reload info script 2025-07-26 22:16:14 +03:00
Benexl
15b920698a fix(anilist-media-api): media relations 2025-07-26 22:15:52 +03:00
Benexl
44cf9c3da7 feat(media-action-menu): add exit option 2025-07-26 21:32:42 +03:00
Benexl
460d3c7d94 fix(servers-menu): should be BACKX3 2025-07-26 21:21:22 +03:00
Benexl
5f030a5d9e fix(player-controls): should be BACKX2 2025-07-26 21:20:54 +03:00
Benexl
8091e23196 fix: episode option in player controls menu 2025-07-26 21:08:36 +03:00
Benexl
e641a48156 fix: page formatting 2025-07-26 19:42:26 +03:00
Benexl
c59babc30d feat: return some original menu options and functionality 2025-07-26 19:40:55 +03:00
Benexl
494104ee19 feat: make the session more performant by lazyloading the context 2025-07-26 17:06:10 +03:00
Benexl
159136cfb1 Merge pull request #104 from Aethar01/master
Fixes for auto enum increment and except clause outside a try block.
2025-07-26 13:04:28 +03:00
Elliott Ashby
7e211f109e Merge branch 'master' into master 2025-07-26 19:01:19 +09:00
Benexl
48e906e464 feat: dynamic search 2025-07-26 12:38:47 +03:00
Benexl
98c2fef8cd chore: remove copilot instructions 2025-07-26 11:55:25 +03:00
Benexl
7088b8ce18 Refactor preview management and caching system
- Introduced a new PreviewWorkerManager to handle both anime and episode preview caching.
- Implemented PreviewCacheWorker and EpisodeCacheWorker for specialized background tasks.
- Added context management for preview operations to ensure proper cleanup.
- Enhanced error handling and logging during image downloads and info text generation.
- Removed redundant caching logic and consolidated functionality into worker classes.
- Updated session management to clean up preview workers on session end.
- Removed unused utility functions and streamlined the codebase.
2025-07-26 11:54:01 +03:00
Aethar
6cfc766db3 fix for auto enum 2025-07-26 17:44:19 +09:00
Aethar
89ff453778 stats fix 2025-07-26 17:37:43 +09:00
Benexl
1c95d45be4 chore: ruff check 2025-07-26 10:57:05 +03:00
Benexl
75e67c22d2 chore: format with ruff 2025-07-26 10:56:26 +03:00
Benexl
1a1d8cc8f4 feat: enhance final title generation for media playback 2025-07-26 10:49:15 +03:00
Benexl
3ea37c4079 feat: add title normalization utilities and integrate into provider search 2025-07-26 10:37:49 +03:00
Benexl
b18e419831 chore: make some packages optional and cleanup deprecated 2025-07-26 10:15:56 +03:00
Benexl
fe06c8e0f1 hack: potential anilist api issue 2025-07-25 22:18:33 +03:00
Benexl
759276237f feat: proper auto status 2025-07-25 22:18:11 +03:00
Benexl
d475dda41c feat: icon for downloads 2025-07-25 22:17:42 +03:00
Benexl
ad499657e0 feat: reload menu instead 2025-07-25 22:17:24 +03:00
Benexl
dbf96afea7 fix: sync command 2025-07-25 03:05:22 +03:00
Benexl
001a63d3df feat: fix: registry command 2025-07-25 03:05:15 +03:00
Benexl
1207426a96 feat: correct import path 2025-07-25 03:04:39 +03:00
Benexl
82ca5f32b1 fix: logical issues with registry 2025-07-25 03:04:16 +03:00
Benexl
2924fcd077 feat: Add registry commands for restore, search, stats, sync, and examples
- Implemented `restore` command to restore the media registry from backup files, with options for verification and backup of current registry.
- Created `search` command to search through the local media registry with various filtering options.
- Added `stats` command to display detailed statistics about the local media registry, including breakdowns by genre, format, and year.
- Developed `sync` command to synchronize the local registry with a remote media API, allowing for both download and upload of media lists.
- Included example usage for the registry commands in `examples.py`.
- Fixed tag filtering logic in `MediaRegistryService` to ensure correct filtering based on tags.
2025-07-25 01:33:22 +03:00
Benexl
9fc66db248 feat: Add downloads menu and related actions for local media management 2025-07-25 01:05:51 +03:00
Benexl
f4e73c3335 Add AniList download command and download service integration
- Implemented a new command for downloading anime episodes using the AniList API.
- Created a DownloadService to manage episode downloads and track their status in the media registry.
- Added comprehensive command-line options for filtering and selecting anime based on various criteria.
- Integrated feedback mechanisms to inform users about download progress and issues.
- Established validation for command options to ensure correct input.
- Enhanced error handling and logging for better debugging and user experience.
- Included functionality for managing multiple downloads concurrently.
2025-07-25 00:38:07 +03:00
Benexl
5246a2fc4b feat: Implement TorrentDownloader class with libtorrent and webtorrent CLI support
- Added TorrentDownloader class for robust torrent downloading.
- Integrated libtorrent for torrent management when available.
- Implemented fallback to webtorrent CLI for downloading torrents.
- Added methods for downloading via libtorrent and webtorrent CLI.
- Included progress tracking and callback functionality.
- Updated pyproject.toml and uv.lock to include libtorrent as a dependency.
- Created unit tests for TorrentDownloader and legacy function for backward compatibility.
2025-07-24 23:37:00 +03:00
Benexl
4bbfe221f2 feat: refactor provider imports and enhance HTML parsing utilities 2025-07-24 23:36:22 +03:00
Benexl
6017833605 feat: implement DefaultDownloader for video downloads and subtitle management 2025-07-24 22:37:32 +03:00
Benexl
f2538f5341 feat: remove code reviews and mentoring from development practices 2025-07-24 22:09:51 +03:00
Benexl
fec09e9b74 feat: implement download command with examples and enhance downloader structure 2025-07-24 22:07:56 +03:00
Benexl
67a174158d feat: implement episode range parsing and enhance search functionality with improved filtering options 2025-07-24 21:19:18 +03:00
Benexl
ae3a59d116 feat: add update command to FastAnime CLI for version management 2025-07-24 20:41:17 +03:00
Benexl
fd59d64b76 feat: add Jikan API integration and enhance media fetching capabilities 2025-07-24 20:16:42 +03:00
Benexl
b1ac4a6558 feat: add character and airing schedule views, enhance media info display 2025-07-24 20:01:03 +03:00
Benexl
8e9aeb660f feat: enhance error handling in media recommendations and relations mapping 2025-07-24 19:49:58 +03:00
Benexl
4f401aa91c Refactor code structure for improved readability and maintainability 2025-07-24 19:29:03 +03:00
Benexl
b8733eccbd feat: implement recommendations and relations functionality in media actions 2025-07-24 19:24:15 +03:00
Benexl
3617465f64 fix: add missing format field in media relations query 2025-07-24 19:22:55 +03:00
Benexl
4c9ecafb9b fix: add missing format field in media recommendations query 2025-07-24 19:21:05 +03:00
Benexl
e87e4e5639 refactor: update GraphQL query paths for consistency and clarity 2025-07-24 19:15:04 +03:00
Benexl
16fa39397d refactor: improve variable naming and return types in API methods for clarity 2025-07-24 19:11:55 +03:00
Benexl
24ffc4ee53 refactor: consolidate and enhance GraphQL queries for media airing schedule, characters, and recommendations 2025-07-24 19:03:44 +03:00
Benexl
3e0be026eb refactor: rename Service to Services for consistency in context management 2025-07-24 18:55:50 +03:00
Benexl
a04643d36a fix: import issues 2025-07-24 18:52:18 +03:00
Benexl
5392d4f25a refactor: limit use of plural for package names and module names 2025-07-24 18:46:43 +03:00
Benexl
63e107ba53 refactor: providers 2025-07-24 18:41:35 +03:00
Benexl
9ba3f88813 refactor: converters 2025-07-24 18:38:11 +03:00
Benexl
5da5dc5dcc refactor: factory to api 2025-07-24 18:37:02 +03:00
Benexl
3c42f660ce refactor: formatters 2025-07-24 18:35:35 +03:00
Benexl
4349b9fc22 refactor: reorganise menus 2025-07-24 18:30:51 +03:00
Benexl
048d008ca1 refactore: rename api to media api 2025-07-24 18:21:49 +03:00
Benexl
17636d766a feat: improve performance 2025-07-24 18:17:06 +03:00
Benexl
367520450d fix: merge issues 2025-07-24 17:39:26 +03:00
Benexl
d6f773f41f feat: make config parseing more efficient 2025-07-24 17:27:39 +03:00
Benexl
f76350bc5b feat: improve preview logic 2025-07-24 16:46:53 +03:00
Benexl
d299355d90 Update README.md 2025-07-24 15:17:34 +03:00
Benexl
4aa9fa9253 Update README.md 2025-07-24 15:15:22 +03:00
Benexl
fcb16a574e chore: format 2025-07-24 15:04:13 +03:00
Benexl
8b52a1ef27 chore: format 2025-07-24 14:57:36 +03:00
Benexl
574a739cb6 chore: remove redundant commands 2025-07-24 14:55:46 +03:00
Benexl
f27a98aaa6 chore: update entry point 2025-07-24 14:53:43 +03:00
Benexl
a266a7100f chore: move dockerfile to bundle 2025-07-24 14:43:15 +03:00
Benexl
d1e07930f9 feat: cleanup 2025-07-24 14:40:17 +03:00
Benexl
bc7936d9cc Merge branch 'roadmap-to-3.0' 2025-07-24 14:35:00 +03:00
Benexl
19c6656cdf feat: fallback to cover image if episode thumbnail not available 2025-07-24 14:29:25 +03:00
Benexl
06506fb47f chore: update lock files 2025-07-24 14:18:01 +03:00
Benexl
29480c64cd chore: update lock files 2025-07-24 14:14:58 +03:00
Benexl
474da2f1fd chore: bump version (v2.9.9) 2025-07-24 14:14:44 +03:00
Benexl
abac604ccd Merge pull request #97 from cornservant/feat/no-check-certificate-flag
Add a --no-check-certificate flag
2025-07-24 14:11:30 +03:00
Benexl
48f46cdf3d refactor: reorganize assets 2025-07-24 13:38:31 +03:00
Benexl
9cafcde9e1 feat: performance review 2025-07-24 11:36:23 +03:00
Benexl
e908c793c6 feat: import configs toplevel 2025-07-24 11:11:30 +03:00
Benexl
0fd69d03dd feat: relations recommendation stubs 2025-07-24 02:47:49 +03:00
Benexl
3a9be3f699 feat: duration 2025-07-24 02:28:55 +03:00
Benexl
d3f08ea9c4 feat: show airing time 2025-07-24 02:09:44 +03:00
Benexl
9efe9f9949 feat: media actions 2025-07-24 01:54:59 +03:00
Benexl
83933f7a63 feat: results menu 2025-07-24 01:04:45 +03:00
Benexl
afe1cb68f6 feat: results menu 2025-07-24 00:07:26 +03:00
Benexl
a6ddb10734 feat: improve main menu 2025-07-24 00:07:13 +03:00
Benexl
f678fa13f0 feat: improve state models 2025-07-23 21:17:03 +03:00
Benexl
2067467134 feat: improve provider types 2025-07-23 20:02:25 +03:00
Benexl
d78b62fcee feat: improve api types 2025-07-23 18:48:57 +03:00
Benexl
6c30cf808b chore: remove broken config field 2025-07-23 15:49:54 +03:00
Benexl
6e9babf270 feat: animepahe provider 2025-07-23 11:29:52 +03:00
Benexl
aa50ab62b5 feat: only require specifying the package folder 2025-07-23 00:38:05 +03:00
Benexl
88975e59c0 feat: make episode previews unique by using a prefix 2025-07-23 00:32:55 +03:00
Benexl
64c427fe41 fix: per page 2025-07-22 23:46:48 +03:00
Benexl
987ae57e33 feat: media list sort 2025-07-22 22:46:42 +03:00
Benexl
ac36e24a32 feat: improve preview 2025-07-22 19:02:37 +03:00
Benexl
04d877a72e chore: upgrade deps 2025-07-22 18:59:45 +03:00
Benexl
43174db8e4 feat: improve preview 2025-07-22 18:46:15 +03:00
Benexl
3092ef0887 feat: properly normalize episodes 2025-07-22 17:25:33 +03:00
Benexl
5e45fba66d chore: remove crazy ai tests 2025-07-22 15:42:51 +03:00
Benexl
65e4726f82 feat: re-add the download cmd 2025-07-22 14:59:29 +03:00
Benexl
384d326fa8 feat: cleanup 2025-07-22 14:55:38 +03:00
Benexl
60c583d115 feat: anilist auth cmd 2025-07-22 14:39:16 +03:00
Benexl
f716f9687a chore: add todo 2025-07-22 01:23:58 +03:00
Benexl
db1006a6b2 fix: date error 2025-07-22 01:23:49 +03:00
Benexl
9163b1394d feat: improve previews 2025-07-22 01:23:21 +03:00
Benexl
0ce27f8e50 feat: menus 2025-07-22 00:47:42 +03:00
Benexl
0e6aeeea18 feat: update interactive session logic 2025-07-21 22:28:09 +03:00
Benexl
452c2cf764 feat: session service 2025-07-21 21:43:16 +03:00
Benexl
a1de0548f4 feat: auth service 2025-07-21 20:35:28 +03:00
Benexl
f60cdea2e1 feat: watch history service 2025-07-21 19:24:32 +03:00
Benexl
b67284cfeb refactor: service import paths 2025-07-21 17:47:53 +03:00
Benexl
17161f5f78 feat: feedback service 2025-07-21 17:36:29 +03:00
Benexl
c0d87c4351 feat: registry service 2025-07-21 17:27:51 +03:00
Benexl
725fe4875d feat: cleanup 2025-07-20 19:34:19 +03:00
Benexl
ac3c6801d7 feat: implement unified media registry and tracking system for anime 2025-07-16 01:16:38 +03:00
Benexl
27b1f3f792 feat: god help me 2025-07-16 00:54:55 +03:00
Benexl
49cdd440df feat: working with ai is a mess lol 2025-07-16 00:46:02 +03:00
Benexl
490f8b0e8b feat: stuff happened 2025-07-15 23:37:15 +03:00
Benexl
5dde02570a chore: leave testing for later 2025-07-15 22:53:10 +03:00
Benexl
e3deb28d26 chore:cleanup 2025-07-15 22:36:08 +03:00
Benexl
1a85b2f216 refactor: improve media actions tests with enhanced mocking and assertions 2025-07-15 01:23:38 +03:00
Benexl
0639a3c949 test: enhance authentication and main menu tests with detailed user profile and pagination handling 2025-07-15 01:12:25 +03:00
Benexl
bdbf0821c5 fix: tests 2025-07-15 00:44:49 +03:00
Benexl
5e81c44312 feat: copilot instructions 2025-07-15 00:16:58 +03:00
Benexl
41ed56f395 feat: tests 2025-07-15 00:02:55 +03:00
Benexl
26f9c3b8de chore: clean up 2025-07-14 23:53:25 +03:00
Benexl
ecdd1b5f20 refactor: anilist subcommands 2025-07-14 23:31:55 +03:00
Benexl
b6dd965e49 feat: switch to pydantic types for api 2025-07-14 23:00:20 +03:00
Benexl
273dd56680 fix: next 2025-07-14 22:46:54 +03:00
Benexl
be4cc58e47 feat: pagination 2025-07-14 22:44:27 +03:00
Benexl
c882691412 feat: episode preview 2025-07-14 22:34:26 +03:00
Benexl
f4c4c874df feat:auth 2025-07-14 22:14:07 +03:00
Benexl
f8992d46dd Implement watch history management system with tracking and data models
- Added WatchHistoryManager for managing local watch history storage, including methods for adding, updating, removing, and retrieving entries.
- Introduced WatchHistoryTracker to automatically track episode viewing and progress updates.
- Created data models for watch history entries and overall history management, including serialization to and from JSON.
- Implemented comprehensive error handling and logging throughout the system.
- Developed a test script to validate the functionality of the watch history management system, covering basic operations and statistics.
2025-07-14 22:00:44 +03:00
Benexl
222c50b4b2 feat: implement session management functionality with save/load capabilities and error handling 2025-07-14 21:23:31 +03:00
Benexl
064401f8e8 feat: implement authentication utilities and integrate with menus 2025-07-14 21:07:47 +03:00
Benexl
a079f9919c feat: implement enhanced feedback system for user interactions 2025-07-14 20:58:52 +03:00
Benexl
a88df7f3ef chore: remove comment 2025-07-14 20:11:42 +03:00
Benexl
d1dfddf290 feat: stabilize the interactive workflow 2025-07-14 20:09:57 +03:00
Benexl
e8491e3723 feat: anilist auth 2025-07-14 02:27:05 +03:00
Benexl
2f21e7139b feat: episode number 2025-07-14 02:26:53 +03:00
Benexl
f5c831077d feat: previews 2025-07-14 02:26:25 +03:00
Benexl
badd10bf97 feat: interactive 2025-07-14 02:24:44 +03:00
Benexl
42bd4963b8 feat: interactive search 2025-07-14 02:24:04 +03:00
Benexl
f08ff7155c feat: use auth manager in login 2025-07-14 02:23:34 +03:00
Benexl
e487435d7e feat: auth manager 2025-07-14 02:23:15 +03:00
Benexl
54f7327ed7 feat(player): pass only list of sub urls 2025-07-13 17:55:57 +03:00
Benexl
ba620bae96 feat: cli download 2025-07-13 17:46:30 +03:00
Benexl
48eac48738 feat: single source of app level constants 2025-07-13 14:52:40 +03:00
Benexl
194b8ca2df feat: update fzf selector 2025-07-13 13:46:29 +03:00
Benexl
96c2d4976c fix: inquirerpy 2025-07-13 13:41:22 +03:00
Benexl
c5034a5829 feat: update fzf opts 2025-07-13 13:41:13 +03:00
Benexl
7c91288e6e feat: improve desktop entry generation 2025-07-13 13:19:50 +03:00
Benexl
de2ba342ad feat: improve config logic 2025-07-13 13:10:49 +03:00
Benexl
b847e02fe0 feat: pass fzf opts 2025-07-13 12:30:09 +03:00
Benexl
f02f92b80b feat: custom exception handling 2025-07-13 12:30:09 +03:00
Benexl
a2da6974fa feat: cli search 2025-07-13 01:39:52 +03:00
Benexl
be1babbedc feat: mpv player syncplay 2025-07-12 23:52:04 +03:00
Benexl
5c804f7aa6 feat: add vlc player 2025-07-12 23:25:03 +03:00
Benexl
723a7ab24f feat: player mpv 2025-07-12 22:55:13 +03:00
Benexl
18a9b07144 feat: dont import any networking lib unless used 2025-07-12 19:05:25 +03:00
Benexl
d279cc70b9 feat: interactively edit config 2025-07-12 18:57:02 +03:00
Long Huynh Huu
27d71cbb23 feat: add --no-check-certificate flag 2025-07-09 03:52:19 +02:00
Long Huynh Huu
615b420c74 feat: reduce inefficient double copy (for determinate nix) 2025-07-09 03:45:43 +02:00
Long Huynh Huu
f9c2b6e939 fix: dev shell 2025-07-09 03:45:27 +02:00
Benexl
85368393fc feat: begin animepahe refactor 2025-07-07 22:34:34 +03:00
Benexl
b9636c94d3 feat: write all anilist graphls to files 2025-07-07 22:09:53 +03:00
Benexl
4920ee508a feat: make anilist api functional 2025-07-07 22:01:01 +03:00
Benexl
fd448ad701 Update README.md 2025-07-07 19:12:17 +03:00
Benexl
b223a34879 Update FUNDING.yml 2025-07-07 19:09:38 +03:00
Benexl
d5e1e60266 feat: abstract provider testing 2025-07-07 19:02:12 +03:00
Benexl
783b63219f feat: make allanime provider functional 2025-07-07 17:35:19 +03:00
Benexl
317fee916b chore: remove api from project 2025-07-07 13:49:25 +03:00
Benexl
870bb24e1b feat: recreate all allanime extractors 2025-07-07 13:48:19 +03:00
Benexl
f51ceaacd7 test: placeholder tests 2025-07-07 00:41:24 +03:00
Benexl
cdad70e40d feat: add jikan api 2025-07-07 00:23:33 +03:00
Benexl
0737c5c14b feat: mass refactor 2025-07-06 23:59:18 +03:00
Benexl
32f4d9271f feat: phase 1 of anilist_interfaces refactor 2025-07-06 22:23:14 +03:00
Benexl
355f10dd9e feat: mass refactor 2025-07-06 18:52:14 +03:00
Benexl
2f2ffc0a84 feat: mass refactor 2025-07-06 18:51:25 +03:00
Benexl
e35683e90a fix: config update logic 2025-07-06 17:40:20 +03:00
Benexl
2bd02c7e99 feat: mass refactor 2025-07-06 14:15:13 +03:00
Benexl
5a50e79216 feat: anilist to stay in libs 2025-07-06 12:34:29 +03:00
Benexl
ec78c81381 feat: mass refactor 2025-07-06 12:31:40 +03:00
Benexl
f042e5042b fix: minor error 2025-07-05 17:14:33 +03:00
Benexl
428bbb20bd feat: new config logic 2025-07-05 17:13:49 +03:00
Benexl
3af31a2dfd feat: update config logic with new philosophy 2025-07-05 17:13:21 +03:00
Benexl
759889acd4 feat: new config logic 2025-07-05 03:06:49 +03:00
Benexl
d106bf7c5d chore: update deps 2025-07-04 20:30:33 +03:00
Benexl
00ff89d14f chore: update deps 2025-07-04 20:29:36 +03:00
Benexl
76460b6c54 chore: remove deprected attr 2025-07-04 16:53:20 +03:00
Benexl
e58fd33fe0 feat: allow going back on empty search term when using fzf anilist search 2025-07-04 16:44:58 +03:00
Benexl
931f9f10f8 chore: update deps 2025-07-04 16:39:01 +03:00
Benexl
0e9dbd2c6b feat: make experimental fzf anilist search disablable lol 2025-07-04 16:33:18 +03:00
Benexl
3bdfa27e1c feat: experimental search using fzf reload 2025-07-04 16:20:48 +03:00
Benedict Xavier
f46f09ffdf Merge pull request #91 from DerDestroyer/episode-number-animepahe
fix: fixed episode number for animepahe with multiple seasons
2025-05-12 13:09:39 +03:00
Benedict Xavier
d309c04214 Merge pull request #92 from DerDestroyer/anime-relations
fix: fixed relations menu and only show ANIME
2025-05-12 13:07:37 +03:00
Benedict Xavier
b19a323d15 Merge pull request #93 from iMithrellas/manga-icat
This pull request introduces a new manga viewer option, icat.
2025-05-12 13:04:55 +03:00
iMithrellas
ff94edfd05 fix: unbound test error 2025-05-07 01:01:13 +02:00
iMithrellas
59e1a82646 feat: config option for selecting manga viewer 2025-05-07 00:44:39 +02:00
iMithrellas
2e902fa4e7 feat: PoC icat for viewing manga 2025-05-07 00:13:00 +02:00
DerDestroyer
d2e17af7a9 fix .5 episodes being numbered as whole episodes 2025-05-04 20:13:50 +02:00
DerDestroyer
f1fa40c419 fixed relations menu and only show ANIME 2025-05-02 01:51:18 +02:00
DerDestroyer
8bbde97403 fixed episode number for animepahe with multiple seasons 2025-05-01 02:30:56 +02:00
Benexl
a2b7d71eb2 Merge branch 'sudoAlphaX-main-menu-on-blacnk-search' 2025-03-30 21:48:33 +03:00
Alpha
67b4f0ea38 Merge branch 'master' into main-menu-on-blacnk-search 2025-03-27 08:22:49 +00:00
Benedict Xavier
a6d5d5f37c Merge pull request #82 from sudoAlphaX/runtime-auto-next
feat: toggle auto-next during runtime from media_player_controls
2025-03-18 04:59:57 +03:00
Alpha
67a066f16e feat: toggle auto-next during runtime from media_player_controls
Allows user to set or unset auto-next episode from media_player_controls
during runtime. This feature was only available in media_actions_menu or
config file.
2025-03-18 06:49:20 +05:30
Alpha
e6297619d4 feat: return to main menu on blank search term
Return to fastanime_main_menu on blank search term. Can be used to
cancel the search.
2025-03-16 19:04:29 +05:30
Benedict Xavier
8f514858f2 Merge pull request #80 from sudoAlphaX/hide-next-episode-button-on-last
feat: hide next episode button on reaching last episode
2025-03-16 14:42:09 +03:00
Alpha
eb9cffbd7a feat: hide next episode button on reaching last episode
Hides the next episode button if the currently completed episode is the
last available episode on the server. Also affects auto-next feature,
where it returns to media actions menu on completion of last episode.
2025-03-16 16:06:46 +05:30
Benexl
c5f9c37d3a fix: preview images not showing in rofi menu 2025-03-16 09:46:23 +03:00
Benedict Xavier
44fd65ebab Merge pull request #74 from crispy-caesus/patch-1
remove yugen from description
2025-03-03 06:22:48 +03:00
crispy-caesus
e919980ff7 remove yugen from description
yugen and gogoanime shut down
2025-03-02 12:37:01 +01:00
Benedict Xavier
6887c6ff10 Merge pull request #72 from sudoAlphaX/multiple-download-ranges
feat: multiple download ranges in download in menu
2025-03-02 12:12:59 +03:00
Benedict Xavier
b394de0b23 Merge pull request #71 from crasband1/use_preffered_history_config_option
fix: config preferred_history option was unused
2025-02-24 08:45:59 +03:00
Benedict Xavier
71003049d6 Merge branch 'master' into use_preffered_history_config_option 2025-02-24 08:45:41 +03:00
Benexl
6f69b785d8 feat(config): mpv pre args 2025-02-23 20:52:52 +03:00
Alpha
6756540fd1 feat: multiple download ranges in download in menu
Improvement to 98e41e1e which allows selection of multiple download
ranges.

Example: :3 5:7 10 13 15:16 19:
will download 1,2,3,5,7,10,13,15,16,19 till the end

Variables can be named better.
2025-02-23 23:07:09 +05:30
Benexl
e6b9df25dd feat: ensure the environs externally provided by user are preferred 2025-02-23 20:28:41 +03:00
Benexl
f40dd2363a feat(updater): add instructions for post update 2025-02-23 20:24:26 +03:00
Benexl
61a525ff94 feat(config): pass custom mpv args 2025-02-23 20:18:23 +03:00
relive010
27d671f89d fix: config preferred_history option was unused due to switched if statements 2025-02-23 10:14:02 -07:00
Benedict Xavier
58edb0427f Merge pull request #70 from crasband1/not_history_continue_off_by_one_fix
fix: fixed off by one error in condition of continuing anime from Wat…
2025-02-23 19:39:09 +03:00
Benedict Xavier
7abcdc8f7c Merge pull request #69 from sudoAlphaX/move-next-eps-button-position
refactor: just move next episode to the top
2025-02-23 19:35:51 +03:00
Benedict Xavier
ca4ca0d476 Merge pull request #68 from sudoAlphaX/download-menu-range-fix
fix: download in menu range fix
2025-02-23 19:33:51 +03:00
relive010
5a337b1c97 fix: fixed off by one error in condition of continuing anime from Watching tab when logged into anilist when the anime is not in watch_history.json 2025-02-23 09:00:30 -07:00
Alpha
6c94dd22fc refactor: just move next episode to the top
When watching episodes, it makes sense to go to the next episode after
completing the current one. Currently, when an episode is completed,
replay button appears first in media_player_controls menu.

This patch just moves replay button below such that the next episode
button takes priority, and the user can watch the next episode with a
single key press (<return>) which is less immersion breaking that
(<down> <return>).
2025-02-23 16:48:53 +05:30
Alpha
8c7e1e201f fix: download in menu range fix
When using the download in menu feature introduced by 98e41e1,
downloading using range (3:5) doesn't work as expected. It starts
incremented by 1. Example: 3:5 selects episodes 4 and 5.

This patch addresses this issue by simply decrementing the start_episode
variable by 1 before adding adding to range list.
2025-02-23 16:44:19 +05:30
Benexl
98e41e1eb5 feat: download anime menu option 2025-02-23 09:53:31 +03:00
Benexl
bd0e7db73c chore: update lock files 2025-02-22 13:02:45 +03:00
Benexl
fb705b4ac2 chore: bump version (v2.8.8) 2025-02-22 13:02:36 +03:00
Benexl
93654be74f feat: enhance login experience 2025-02-22 12:58:45 +03:00
Benedict Xavier
9b14a4c723 Merge pull request #62 from crasband1/master
added support for macOS login
2025-02-22 11:34:38 +03:00
relive010
bce5acf7b5 added support for macOS login via key pasted into anilist_key.txt in Downloads 2025-02-19 02:16:38 -07:00
Benexl
228be7e1f7 feat: add support for the year 2025 in available years list 2025-02-18 11:23:40 +03:00
Benedict Xavier
2eb434e42a Update README.md 2025-02-04 09:16:34 +03:00
Benedict Xavier
fbb3a00ab0 Create FUNDING.yml 2025-01-30 07:51:46 +03:00
Benedict Xavier
8341ffe8fd Update README.md 2025-01-29 22:13:03 +03:00
benexl
0822e2e92c feat: improve preview logic 2025-01-29 19:47:56 +03:00
benexl
3b9fbd0665 chore: upgrade deps 2025-01-29 19:47:42 +03:00
benexl
60b74bee18 feat: update the preview script 2025-01-29 18:47:12 +03:00
Benedict Xavier
d7dc63e003 Merge pull request #59 from Benexl/minor-fixes
Minor fixes
2025-01-29 04:38:53 +03:00
Type-Delta
d40edb6ff6 🧹 cleanup: lint error E402, ehe ;p 2025-01-29 00:52:07 +07:00
Benedict Xavier
803712649f Update README.md 2025-01-27 17:26:07 +03:00
Benedict Xavier
7bc0d33f69 Update README.md 2025-01-27 17:25:15 +03:00
Benedict Xavier
5885d134df Update README.md 2025-01-26 10:33:55 +03:00
Type-Delta
5500ec49c8 💫 update: which_win32_gitbash() to handle git.exe in bin dir 2025-01-24 12:10:52 +07:00
Type-Delta
8c94380050 🛠️ fix: fzf preview use the wrong bash.exe on Windows 2025-01-23 19:40:45 +07:00
Type-Delta
87a97dd0c6 🧹 cleanup: fix typos 2025-01-23 07:40:42 +07:00
Type-Delta
80d9f732b1 🛠️ fix: handle ctrl+C termination of fzf 2025-01-23 07:03:21 +07:00
Benedict Xavier
051273dac9 Merge pull request #57 from Abdisto/master
Indent Fix for Anime Description in fzf preview
2025-01-21 09:25:47 +03:00
Abdist
036f448906 Update utils.py 2025-01-18 21:25:32 +01:00
Abdist
b5aeed9268 Update utils.py
strip() was not neccessary
2025-01-18 15:47:19 +01:00
Abdist
4257502b85 Update utils.py 2025-01-16 14:28:43 +01:00
Abdist
28a857520f Update utils.py 2025-01-16 14:26:12 +01:00
Abdist
4f9fff375c Update utils.py 2025-01-16 14:21:43 +01:00
Abdist
ce31f63788 Update utils.py 2025-01-16 14:01:53 +01:00
Abdist
9412c2491e Update utils.py 2025-01-16 13:56:20 +01:00
Abdist
8209adec62 Update utils.py 2025-01-16 13:54:07 +01:00
Abdist
39703d9eca Update utils.py 2025-01-16 13:44:01 +01:00
Abdist
57d16b3e18 Update utils.py 2025-01-16 13:10:06 +01:00
Abdist
73a99f8b96 Update utils.py 2025-01-16 13:07:54 +01:00
Abdist
309d7d5858 Update utils.py 2025-01-16 13:04:49 +01:00
Abdist
8d20e490ca Update utils.py 2025-01-16 13:00:26 +01:00
Abdist
3a6e005f3a Update utils.py 2025-01-16 12:55:04 +01:00
Benedict Xavier
bdf49bd7ce Merge pull request #52 from Benexl/fix-missing-meta
Fix(downloader):  handle hls stream's properly
2025-01-06 22:43:56 +03:00
Type-Delta
c4df2587d0 Merge branch 'master' into fix-missing-meta 2025-01-06 21:28:18 +07:00
Benedict Xavier
b38f66767f Merge pull request #50 from sudoAlphaX/notify-timeout
refactor!: change notifier timeout config to seconds
2025-01-05 23:31:39 +03:00
Benedict Xavier
6c0e0ccf72 Merge pull request #49 from sudoAlphaX/anilist-login-typo
docs: fix typo for anilist login command
2025-01-05 23:29:53 +03:00
Alpha
e39c992883 refactor!: change notifier timeout config to seconds
BREAKING CHANGE: change notification timeout from minutes to seconds in
config.
2025-01-05 22:04:09 +05:30
Alpha
a1744fc9b3 docs: fix typo for anilist login command 2025-01-05 21:59:32 +05:30
Benedict Xavier
3c5106c32c Merge pull request #48 from Aethar01/master
Add arch to installation instructions in README.md
2025-01-05 00:45:30 +03:00
Elliott Ashby
fd0d899f72 grammar 2025-01-04 21:31:32 +00:00
Elliott Ashby
c753873f61 Update README.md to include arch installation 2025-01-04 21:30:39 +00:00
benexl
4c8ff2ae9b chore: update lock files 2025-01-04 23:47:48 +03:00
benexl
23274de367 chore: bump version (v2.8.7) 2025-01-04 23:47:45 +03:00
benexl
2aec40ead0 refactor: temporarily remove nix from make - release 2025-01-04 23:47:42 +03:00
benexl
172f2bb1de chore: bump version in uv.lock 2025-01-04 23:37:55 +03:00
benexl
2f5684a93a feat(cli): add option to disable user config 2025-01-04 23:37:30 +03:00
benexl
1d40160abf chore: update rofi configs 2025-01-04 23:37:11 +03:00
Benedict Xavier
af84d80137 Merge pull request #46 from piradata/patch-2
docs(config): correct some typos
2025-01-01 01:31:58 +03:00
Piradata
e6412631ae correct some typos 2024-12-31 19:06:53 -03:00
Type-Delta
978d8d45ba 🛠️ fix: prevent clipping from HE-AAC to AAC convertion 2024-12-28 12:06:21 +07:00
Type-Delta
06575120d6 Merge branch 'master' into fix-missing-meta 2024-12-28 09:39:18 +07:00
Type-Delta
72cec28613 add: --hls-use-h264 to convert ts streams to mp4 2024-12-28 09:35:49 +07:00
Benedict Xavier
8023edcf3a Update README.md 2024-12-23 20:45:07 +03:00
Benedict Xavier
0cb50cd506 Update README.md 2024-12-20 13:55:39 +03:00
Type-Delta
9981b3dec8 🛠️ fix: missing metadata when --force-ffmpeg is used 2024-12-19 07:11:48 +07:00
Benedict Xavier
50c048e158 Merge pull request #40 from serialjaywalker/master
feat: Check if in venv before attempting user install/update.
2024-12-17 20:34:51 +03:00
Serial_Jaywalker
c0a57c7814 Check if in venv before attempting user install/update. 2024-12-16 01:52:03 -08:00
benexl
bcdd88c725 chore: bump version 2024-12-13 08:34:24 +03:00
benexl
d45d438663 chore: bump version (vv2.8.6) 2024-12-13 08:18:54 +03:00
Benexl
3d12059e27 Update README.md 2024-12-12 20:04:31 +03:00
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
348 changed files with 31786 additions and 12928 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: benexl # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: benexl # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -0,0 +1,30 @@
---
description: "Generate a new 'click' command following the project's lazy-loading pattern and service architecture."
tools: ['codebase']
---
# viu: CLI Command Generation Mode
You are an expert on the `viu` CLI structure, which uses `click` and a custom `LazyGroup` for performance. Your task is to generate the boilerplate for a new command.
**First, ask the user if this is a top-level command (like `viu new-cmd`) or a subcommand (like `viu anilist new-sub-cmd`).**
---
### If Top-Level Command:
1. **File Location:** State that the new command file should be created at: `viu/cli/commands/{command_name}.py`.
2. **Boilerplate:** Generate the `click.command()` function.
* It **must** accept `config: AppConfig` as the first argument using `@click.pass_obj`.
* It **must not** contain business logic. Instead, show how to instantiate a service from `viu.cli.service` and call its methods.
3. **Registration:** Instruct the user to register the command by adding it to the `commands` dictionary in `viu/cli/cli.py`. Provide the exact line to add, like: `"new-cmd": "new_cmd.new_cmd_function"`.
---
### If Subcommand:
1. **Ask for Parent:** Ask for the parent command group (e.g., `anilist`, `registry`).
2. **File Location:** State that the new command file should be created at: `viu/cli/commands/{parent_name}/commands/{command_name}.py`.
3. **Boilerplate:** Generate the `click.command()` function, similar to the top-level command.
4. **Registration:** Instruct the user to register the subcommand in the parent's `cmd.py` file (e.g., `viu/cli/commands/anilist/cmd.py`) by adding it to the `lazy_subcommands` dictionary within the `@click.group` decorator.
**Final Instruction:** Remind the user that if the command introduces new logic, it should be encapsulated in a new or existing **Service** class in the `viu/cli/service/` directory. The CLI command function should only handle argument parsing and calling the service.

View File

@@ -0,0 +1,34 @@
---
description: "Scaffold the necessary files and code for a new Player or Selector component, including configuration."
tools: ['codebase', 'search']
---
# viu: New Component Generation Mode
You are an expert on `viu`'s modular architecture. Your task is to help the developer add a new **Player** or **Selector** component.
**First, ask the user whether they want to create a 'Player' or a 'Selector'.** Then, follow the appropriate path below.
---
### If the user chooses 'Player':
1. **Scaffold Directory:** Create a directory at `viu/libs/player/{player_name}/`.
2. **Implement `BasePlayer`:** Create a `player.py` file with a class `NewPlayer` that inherits from `viu.libs.player.base.BasePlayer`. Implement the `play` and `play_with_ipc` methods. The `play` method should use `subprocess` to call the player's executable.
3. **Add Configuration:**
* Instruct to create a new Pydantic model `NewPlayerConfig(OtherConfig)` in `viu/core/config/model.py`.
* Add the new config model to the main `AppConfig`.
* Add defaults in `viu/core/config/defaults.py` and descriptions in `viu/core/config/descriptions.py`.
4. **Register Player:** Instruct to modify `viu/libs/player/player.py` by:
* Adding the player name to the `PLAYERS` list.
* Adding the instantiation logic to the `PlayerFactory.create` method.
---
### If the user chooses 'Selector':
1. **Scaffold Directory:** Create a directory at `viu/libs/selectors/{selector_name}/`.
2. **Implement `BaseSelector`:** Create a `selector.py` file with a class `NewSelector` that inherits from `viu.libs.selectors.base.BaseSelector`. Implement the `choose`, `confirm`, and `ask` methods.
3. **Add Configuration:** (Follow the same steps as for a Player).
4. **Register Selector:**
* Instruct to modify `viu/libs/selectors/selector.py` by adding the selector name to the `SELECTORS` list and the factory logic to `SelectorFactory.create`.
* Instruct to update the `Literal` type hint for the `selector` field in `GeneralConfig` (`viu/core/config/model.py`).

View File

@@ -0,0 +1,27 @@
---
description: "Scaffold and implement a new anime provider, following all architectural patterns of the viu project."
tools: ['codebase', 'search', 'fetch']
---
# viu: New Provider Generation Mode
You are an expert on the `viu` codebase, specializing in its provider architecture. Your task is to guide the developer in creating a new anime provider. You must strictly adhere to the project's structure and coding conventions.
**Your process is as follows:**
1. **Ask for the Provider's Name:** First, ask the user for the name of the new provider (e.g., `gogoanime`, `crunchyroll`). Use this name (in lowercase) for all subsequent file and directory naming.
2. **Scaffold the Directory Structure:** Based on the name, state the required directory structure that needs to be created:
`viu/libs/provider/anime/{provider_name}/`
3. **Scaffold the Core Files:** Generate the initial code for the following files inside the new directory. Ensure all code is fully type-hinted.
* **`__init__.py`**: Can be an empty file.
* **`types.py`**: Create placeholder `TypedDict` models for the provider's specific API responses (e.g., `GogoAnimeSearchResult`, `GogoAnimeEpisode`).
* **`mappers.py`**: Create empty mapping functions that will convert the provider-specific types into the generic types from `viu.libs.provider.anime.types`. For example: `map_to_search_results(data: GogoAnimeSearchPage) -> SearchResults:`.
* **`provider.py`**: Generate the main provider class. It **MUST** inherit from `viu.libs.provider.anime.base.BaseAnimeProvider`. Include stubs for the required abstract methods: `search`, `get`, and `episode_streams`. Remind the user to use `httpx.Client` for requests and to call the mapper functions.
4. **Instruct on Registration:** Clearly state the two files that **must** be modified to register the new provider:
* **`viu/libs/provider/anime/types.py`**: Add the new provider's name to the `ProviderName` enum.
* **`viu/libs/provider/anime/provider.py`**: Add an entry to the `PROVIDERS_AVAILABLE` dictionary.
5. **Final Guidance:** Remind the developer to add any title normalization rules to `viu/assets/normalizer.json` if the provider uses different anime titles than AniList.

73
.github/chatmodes/plan.chatmode.md vendored Normal file
View File

@@ -0,0 +1,73 @@
---
description: "Plan new features or bug fixes with architectural guidance for the viu project. Does not write implementation code."
tools: ['codebase', 'search', 'githubRepo', 'fetch']
model: "gpt-4o"
---
# viu: Feature & Fix Planner Mode
You are a senior software architect and project planner for the `viu` project. You are an expert in its layered architecture (`Core`, `Libs`, `Service`, `CLI`) and its commitment to modular, testable code.
Your primary goal is to help the user break down a feature request or bug report into a clear, actionable implementation plan.
**Crucially, you MUST NOT write the full implementation code.** Your output is the plan itself, which will then guide the developer (or another AI agent in "Edit" mode) to write the code.
### Your Process:
1. **Understand the Goal:** Start by asking the user to describe the feature they want to build or the bug they want to fix. If they reference a GitHub issue, use the `githubRepo` tool to get the context.
2. **Analyze the Codebase:** Use the `codebase` and `search` tools to understand how the request fits into the existing architecture. Identify all potentially affected modules, classes, and layers.
3. **Ask Clarifying Questions:** Ask questions to refine the requirements. For example:
* "Will this feature need a new configuration option? If so, what should the default be?"
* "How should this behave in the interactive TUI versus the direct CLI command?"
* "Which architectural layer does the core logic for this fix belong in?"
4. **Generate the Implementation Plan:** Once you have enough information, produce a comprehensive plan in the following Markdown format:
---
### Implementation Plan: [Feature/Fix Name]
**1. Overview**
> A brief, one-sentence summary of the goal.
**2. Architectural Impact Analysis**
> This is the most important section. Detail which parts of the codebase will be touched and why.
> - **Core Layer (`viu/core`):**
> - *Config (`config/model.py`):* Will a new Pydantic model or field be needed?
> - *Utils (`utils/`):* Are any new low-level, reusable functions required?
> - *Exceptions (`exceptions.py`):* Does this introduce a new failure case that needs a custom exception?
> - **Libs Layer (`viu/libs`):**
> - *Media API (`media_api/`):* Does this involve a new call to the AniList API?
> - *Provider (`provider/`):* Does this affect how data is scraped?
> - *Player/Selector (`player/`, `selectors/`):* Does this change how we interact with external tools?
> - **Service Layer (`viu/cli/service`):**
> - Which service will orchestrate this logic? (e.g., `DownloadService`, `PlayerService`). Will a new service be needed?
> - **CLI Layer (`viu/cli`):**
> - *Commands (`commands/`):* Which `click` command(s) will expose this feature?
> - *Interactive UI (`interactive/`):* Which TUI menu(s) need to be added or modified?
**3. Implementation Steps**
> A step-by-step checklist for the developer.
> 1. [ ] **Config:** Add `new_setting` to `GeneralConfig` in `core/config/model.py`.
> 2. [ ] **Core:** Implement `new_util()` in `core/utils/helpers.py`.
> 3. [ ] **Service:** Add method `handle_new_feature()` to `MyService`.
> 4. [ ] **CLI:** Add `--new-feature` option to the `viu anilist search` command.
> 5. [ ] **Tests:** Write a unit test for `new_util()` and an integration test for the service method.
**4. Configuration Changes**
> If new settings are needed, list them here and specify which files to update.
> - **`core/config/model.py`:** Add field `new_setting: bool`.
> - **`core/config/defaults.py`:** Add `GENERAL_NEW_SETTING = False`.
> - **`core/config/descriptions.py`:** Add `GENERAL_NEW_SETTING = "Description of the new setting."`
**5. Testing Strategy**
> Briefly describe how to test this change.
> - A unit test for the pure logic in the `Core` or `Libs` layer.
> - An integration test for the `Service` layer.
> - Manual verification steps for the CLI and interactive UI.
**6. Potential Risks & Open Questions**
> - Will this change impact the performance of the provider scraping?
> - Do we need to handle a case where the external API does not support this feature?
---

101
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,101 @@
# GitHub Copilot Instructions for the viu Repository
Hello, Copilot! This document provides instructions and context to help you understand the `viu` codebase. Following these guidelines will help you generate code that is consistent, maintainable, and aligned with the project's architecture.
## 1. High-Level Project Goal
`viu` is a command-line tool that brings the anime browsing, streaming, and management experience to the terminal. It integrates with metadata providers like AniList and scrapes streaming links from various anime provider websites. The core goals are efficiency, extensibility, and providing a powerful, scriptable user experience.
## 2. Core Architectural Concepts
The project follows a clean, layered architecture. When generating code, please adhere to this structure.
#### Layer 1: CLI (`viu/cli`)
* **Purpose:** Handles user interaction, command parsing, and displaying output.
* **Key Libraries:** `click` for command structure, `rich` for styled output.
* **Interactive Mode:** The interactive TUI is managed by the `Session` object in `viu/cli/interactive/session.py`. It's a state machine where each menu is a function that returns the next `State` or an `InternalDirective` (like `BACK` or `EXIT`).
* **Guideline:** **CLI files should not contain complex business logic.** They should parse arguments and delegate tasks to the Service Layer.
#### Layer 2: Service (`viu/cli/service`)
* **Purpose:** Contains the core application logic. Services act as orchestrators, connecting the CLI layer with the various library components.
* **Examples:** `DownloadService`, `PlayerService`, `MediaRegistryService`, `WatchHistoryService`.
* **Guideline:** When adding new functionality (e.g., a new way to manage downloads), it should likely be implemented in a service or an existing service should be extended. Services are the "brains" of the application.
#### Layer 3: Libraries (`viu/libs`)
* **Purpose:** A collection of independent, reusable modules with well-defined contracts (Abstract Base Classes).
* **`media_api`:** Interfaces with metadata services like AniList. All new metadata clients **must** inherit from `BaseApiClient`.
* **`provider`:** Interfaces with anime streaming websites. All new providers **must** inherit from `BaseAnimeProvider`.
* **`player`:** Wrappers around external media players like MPV. All new players **must** inherit from `BasePlayer`.
* **`selectors`:** Wrappers for interactive UI tools like FZF or Rofi. All new selectors **must** inherit from `BaseSelector`.
* **Guideline:** Libraries should be self-contained and not depend on the CLI or Service layers. They receive configuration and perform their specific task.
#### Layer 4: Core (`viu/core`)
* **Purpose:** Foundational code shared across the entire application.
* **`config`:** Pydantic models defining the application's configuration structure. **This is the single source of truth for all settings.**
* **`downloader`:** The underlying logic for downloading files (using `yt-dlp` or `httpx`).
* **`exceptions`:** Custom exception classes used throughout the project.
* **`utils`:** Common, low-level utility functions.
* **Guideline:** Code in `core` should be generic and have no dependencies on other layers except for other `core` modules.
## 3. Key Technologies
* **Dependency Management:** `uv` is used for all package management and task running. Refer to `pyproject.toml` for dependencies.
* **Configuration:** **Pydantic** is used exclusively. The entire configuration is defined in `viu/core/config/model.py`.
* **CLI Framework:** `click`. We use a custom `LazyGroup` to load commands on demand for faster startup.
* **HTTP Client:** `httpx` is the standard for all network requests.
## 4. How to Add New Features
Follow these patterns to ensure your contributions fit the existing architecture.
### How to Add a New Provider
1. **Create Directory:** Add a new folder in `viu/libs/provider/anime/newprovider/`.
2. **Implement `BaseAnimeProvider`:** In `provider.py`, create a class `NewProvider` that inherits from `BaseAnimeProvider` and implement the `search`, `get`, and `episode_streams` methods.
3. **Create Mappers:** In `mappers.py`, write functions to convert the provider's API/HTML data into the generic Pydantic models from `viu/libs/provider/anime/types.py` (e.g., `SearchResult`, `Anime`, `Server`).
4. **Register Provider:**
* Add the provider's name to the `ProviderName` enum in `viu/libs/provider/anime/types.py`.
* Add it to the `PROVIDERS_AVAILABLE` dictionary in `viu/libs/provider/anime/provider.py`.
### How to Add a New Player
1. **Create Directory:** Add a new folder in `viu/libs/player/newplayer/`.
2. **Implement `BasePlayer`:** In `player.py`, create a class `NewPlayer` that inherits from `BasePlayer` and implement the `play` method. It should call the player's executable via `subprocess`.
3. **Add Configuration:** If the player has settings, add a `NewPlayerConfig` Pydantic model in `viu/core/config/model.py`, and add it to the main `AppConfig`. Also add defaults and descriptions.
4. **Register Player:** Add the player's name to the `PLAYERS` list and the factory logic in `viu/libs/player/player.py`.
### How to Add a New Selector
1. **Create Directory:** Add a new folder in `viu/libs/selectors/newselector/`.
2. **Implement `BaseSelector`:** In `selector.py`, create a class `NewSelector` that inherits from `BaseSelector` and implement `choose`, `confirm`, and `ask`.
3. **Add Configuration:** If needed, add a `NewSelectorConfig` to `viu/core/config/model.py`.
4. **Register Selector:** Add the selector's name to the `SELECTORS` list and the factory logic in `viu/libs/selectors/selector.py`. Update the `Literal` type hint for `selector` in `GeneralConfig`.
### How to Add a New CLI Command
* **Top-Level Command (`viu my-command`):**
1. Create `viu/cli/commands/my_command.py` with your `click.command()`.
2. Register it in the `commands` dictionary in `viu/cli/cli.py`.
* **Subcommand (`viu anilist my-subcommand`):**
1. Create `viu/cli/commands/anilist/commands/my_subcommand.py`.
2. Register it in the `lazy_subcommands` dictionary of the parent `click.group()` (e.g., in `viu/cli/commands/anilist/cmd.py`).
### How to Add a New Configuration Option
1. **Add to Model:** Add the field to the appropriate Pydantic model in `viu/core/config/model.py`.
2. **Add Default:** Add a default value in `viu/core/config/defaults.py`.
3. **Add Description:** Add a user-friendly description in `viu/core/config/descriptions.py`.
4. The config loader and CLI option generation will handle the rest automatically.
## 5. Code Style and Conventions
* **Style:** `ruff` for formatting, `ruff` for linting. The `pre-commit` hooks handle this.
* **Types:** Full type hinting is mandatory. All code must pass `pyright`.
* **Commits:** Adhere to the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard.
* **Logging:** Use Python's `logging` module. Do not use `print()` for debugging or informational messages in library or service code.
## 6. Do's and Don'ts
***DO** use the abstract base classes (`BaseProvider`, `BasePlayer`, etc.) as contracts.
***DO** place business logic in the `service` layer.
***DO** use the Pydantic models in `viu/core/config/model.py` as the single source of truth for configuration.
***DO** use the `Context` object in interactive menus to access services and configuration.
***DON'T** hardcode configuration values. Access them via the `config` object.
***DON'T** put complex logic directly into `click` command functions. Delegate to a service.
***DON'T** make direct `httpx` calls outside of a `provider` or `media_api` library.
***DON'T** introduce new dependencies without updating `pyproject.toml` and discussing it first.

View File

@@ -1,38 +1,43 @@
name: debug_build
name: build
on:
workflow_run:
workflows: ["Test Workflow"]
types:
- completed
jobs:
debug_build:
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 viu
run: uv build
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: fastanime_debug_build
name: viu_debug_build
path: |
dist
!dist/*.whl
# - name: Run the automated tests (for example)
# run: poetry run pytest -v
- name: Install nix
uses: DeterminateSystems/nix-installer-action@main
- name: Use GitHub Action built-in cache
uses: DeterminateSystems/magic-nix-cache-action@main
- name: Nix Flake check (evaluation + tests)
run: nix flake check
- name: Build the nix derivation
run: nix build

View File

@@ -1,17 +1,10 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# GitHub recommends pinning actions to a commit SHA.
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.
name: Upload Python Package
on:
release:
types: [published]
workflow_dispatch:
types: [published]
permissions:
contents: read
@@ -27,11 +20,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 viu
run: uv build
- name: Upload distributions
uses: actions/upload-artifact@v4
@@ -49,12 +44,6 @@ jobs:
# IMPORTANT: this permission is mandatory for trusted publishing
id-token: write
# Dedicated environments with protections for publishing are strongly recommended.
environment:
name: pypi
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
# url: https://pypi.org/p/YOURPROJECT
steps:
- name: Retrieve release distributions
uses: actions/download-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

86
.gitignore vendored
View File

@@ -1,25 +1,16 @@
# mine
*.mp4
*.mp3
*.ass
vids
data/
.project/
fastanime.ini
crashdump.txt
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*.py[codz]
*$py.class
anixstream.ini
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
bin/
downloads/
eggs/
.eggs/
@@ -39,7 +30,7 @@ MANIFEST
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
# *.spec
*.spec
# Installer logs
pip-log.txt
@@ -55,7 +46,7 @@ htmlcov/
nosetests.xml
coverage.xml
*.cover
*.py,cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
@@ -103,23 +94,36 @@ ipython_config.py
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
@@ -168,11 +172,41 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
app/anixstream.ini
app/settings.json
app/user_data.json
app/View/SearchScreen/.search_screen.py.un~
app/View/SearchScreen/search_screen.py~
app/user_data.json
.buildozer
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# custom
repomix-output.xml
.project/
result
.direnv

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
}

208
CONTRIBUTIONS.md Normal file
View File

@@ -0,0 +1,208 @@
# Contributing to Viu
First off, thank you for considering contributing to Viu! We welcome any help, whether it's reporting a bug, proposing a feature, or writing code. This document will guide you through the process.
## How Can I Contribute?
There are many ways to contribute to the Viu project:
* **Reporting Bugs:** If you find a bug, please create an issue in our [issue tracker](https://github.com/Benexl/Viu/issues).
* **Suggesting Enhancements:** Have an idea for a new feature or an improvement to an existing one? We'd love to hear it.
* **Writing Code:** Help us fix bugs or implement new features.
* **Improving Documentation:** Enhance our README, add examples, or clarify our contribution guidelines.
* **Adding a Provider, Player, or Selector:** Extend Viu's capabilities by integrating new tools and services.
## Contribution Workflow
We follow the standard GitHub Fork & Pull Request workflow.
1. **Create an Issue:** Before starting work on a new feature or a significant bug fix, please [create an issue](https://github.com/Benexl/Viu/issues/new/choose) to discuss your idea. This allows us to give feedback and prevent duplicate work. For small bugs or documentation typos, you can skip this step.
2. **Fork the Repository:** Create your own fork of the Viu repository.
3. **Clone Your Fork:**
```bash
git clone https://github.com/YOUR_USERNAME/Viu.git
cd Viu
```
4. **Create a Branch:** Create a new branch for your changes. Use a descriptive name.
```bash
# For a new feature
git checkout -b feat/my-new-feature
# For a bug fix
git checkout -b fix/bug-description
```
5. **Make Your Changes:** Write your code, following the guidelines below.
6. **Run Quality Checks:** Before committing, ensure your code passes all quality checks.
```bash
# Format, lint, and sort imports
uv run ruff check --fix .
uv run ruff format .
# Run type checking
uv run pyright
# Run tests
uv run pytest
```
7. **Commit Your Changes:** We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. This helps us automate releases and makes the commit history more readable.
```bash
# Example commit messages
git commit -m "feat: add support for XYZ provider"
git commit -m "fix(anilist): correctly parse episode numbers with decimals"
git commit -m "docs: update installation instructions in README"
git commit -m "chore: upgrade httpx to version 0.28.1"
```
8. **Push to Your Fork:**
```bash
git push origin feat/my-new-feature
```
9. **Submit a Pull Request:** Open a pull request from your branch to the `master` branch of the main Viu repository. Provide a clear title and description of your changes.
## Setting Up Your Development Environment
### Prerequisites
* Git
* Python 3.10+
* [uv](https://github.com/astral-sh/uv) (recommended)
* **External Tools (for full functionality):** `mpv`, `fzf`, `rofi`, `webtorrent-cli`, `ffmpeg`.
### Nix / NixOS Users
The easiest way to get a development environment with all dependencies is to use our Nix flake.
```bash
nix develop
```
This command will drop you into a shell with all the necessary tools and a Python environment ready to go.
### Standard Setup (uv + venv)
1. **Clone your fork** (as described above).
2. **Create and activate a virtual environment:**
```bash
uv venv
source .venv/bin/activate
```
3. **Install all dependencies:** This command installs both runtime and development dependencies, including all optional extras.
```bash
uv sync --all-extras --dev
```
4. **Set up pre-commit hooks:** This will automatically run linters and formatters before each commit, ensuring your code meets our quality standards.
```bash
pre-commit install
```
## Coding Guidelines
To maintain code quality and consistency, please adhere to the following guidelines.
* **Formatting:** We use **Black** for code formatting and **isort** (via Ruff) for import sorting. The pre-commit hooks will handle this for you.
* **Linting:** We use **Ruff** for linting. Please ensure your code has no linting errors before submitting a PR.
* **Type Hinting:** All new code should be fully type-hinted and pass `pyright` checks. We rely on Pydantic for data validation and configuration, so leverage it where possible.
* **Modularity and Architecture:**
* **Services:** Business logic is organized into services (e.g., `PlayerService`, `DownloadService`).
* **Factories:** Use factory patterns (`create_provider`, `create_selector`) for creating instances of different implementations.
* **Configuration:** All configuration is managed through Pydantic models in `viu/core/config/model.py`. When adding new config options, update the model, defaults, and descriptions.
* **Commit Messages:** Follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard.
* **Testing:** New features should be accompanied by tests. Bug fixes should ideally include a regression test.
## How to Add a New Provider
Adding a new anime provider is a great way to contribute. Here are the steps:
1. **Create a New Provider Directory:** Inside `viu/libs/provider/anime/`, create a new directory with the provider's name (e.g., `viu/libs/provider/anime/newprovider/`).
2. **Implement the Provider:**
* Create a `provider.py` file.
* Define a class (e.g., `NewProviderApi`) that inherits from `BaseAnimeProvider`.
* Implement the abstract methods: `search`, `get`, and `episode_streams`.
* Create `mappers.py` to convert the provider's data structures into the generic types defined in `viu/libs/provider/anime/types.py`.
* Create `types.py` for any provider-specific data structures you need.
* If the provider requires complex scraping, place extractor logic in an `extractors/` subdirectory.
3. **Register the Provider:**
* Add your new provider to the `ProviderName` enum in `viu/libs/provider/anime/types.py`.
* Register it in the `PROVIDERS_AVAILABLE` dictionary in `viu/libs/provider/anime/provider.py`.
4. **Add Normalization Rules (Optional):** If the provider uses different anime titles than AniList, add mappings to `viu/assets/normalizer.json`.
## How to Add a New Player
1. **Create a New Player Directory:** Inside `viu/libs/player/`, create a directory for your player (e.g., `viu/libs/player/myplayer/`).
2. **Implement the Player Class:**
* In `myplayer/player.py`, create a class (e.g., `MyPlayer`) that inherits from `BasePlayer`.
* Implement the required abstract methods: `play(self, params: PlayerParams)` and `play_with_ipc(self, params: PlayerParams, socket_path: str)`. The IPC method is optional but recommended for advanced features.
* The `play` method should handle launching the player as a subprocess and return a `PlayerResult`.
3. **Add Configuration (if needed):**
* If your player has configurable options, add a new Pydantic model (e.g., `MyPlayerConfig`) in `viu/core/config/model.py`. It should inherit from `OtherConfig`.
* Add this new config model as a field in the main `AppConfig` model.
* Add default values in `defaults.py` and descriptions in `descriptions.py`.
4. **Register the Player:**
* Add your player's name to the `PLAYERS` list in `viu/libs/player/player.py`.
* Add the logic to instantiate your player class within the `PlayerFactory.create` method.
## How to Add a New Selector
1. **Create a New Selector Directory:** Inside `viu/libs/selectors/`, create a new directory (e.g., `viu/libs/selectors/myselector/`).
2. **Implement the Selector Class:**
* In `myselector/selector.py`, create a class (e.g., `MySelector`) that inherits from `BaseSelector`.
* Implement the abstract methods: `choose`, `confirm`, and `ask`.
* Optionally, you can override `choose_multiple` and `search` for more advanced functionality.
3. **Add Configuration (if needed):** Follow the same configuration steps as for adding a new player.
4. **Register the Selector:**
* Add your selector's name to the `SELECTORS` list in `viu/libs/selectors/selector.py`.
* Add the instantiation logic to the `SelectorFactory.create` method.
* Update the `Literal` type hint for the `selector` field in `GeneralConfig` (`viu/core/config/model.py`).
## How to Add a New CLI Command or Service
Our CLI uses `click` and a `LazyGroup` class to load commands on demand.
### Adding a Top-Level Command (e.g., `viu my-command`)
1. **Create the Command File:** Create a new Python file in `viu/cli/commands/` (e.g., `my_command.py`). This file should contain your `click.command()` function.
2. **Register the Command:** In `viu/cli/cli.py`, add your command to the `commands` dictionary.
```python
commands = {
# ... existing commands
"my-command": "my_command.my_command_function",
}
```
### Adding a Subcommand (e.g., `viu anilist my-subcommand`)
1. **Create the Command File:** Place your new command file inside the appropriate subdirectory, for example, `viu/cli/commands/anilist/commands/my_subcommand.py`.
2. **Register the Subcommand:** In the parent command's entry point file (e.g., `viu/cli/commands/anilist/cmd.py`), add your subcommand to the `commands` dictionary within the `LazyGroup`.
```python
@click.group(
cls=LazyGroup,
# ... other options
lazy_subcommands={
# ... existing subcommands
"my-subcommand": "my_subcommand.my_subcommand_function",
}
)
```
### Creating a Service
If your command involves complex logic, consider creating a service in `viu/cli/service/` to keep the business logic separate from the command-line interface. This service can then be instantiated and used within your `click` command function. This follows the existing pattern for services like `DownloadService` and `PlayerService`.
---
Thank you for contributing to Viu

38
DISCLAIMER.md Normal file
View File

@@ -0,0 +1,38 @@
<h1 align="center">Disclaimer</h1>
<div align="center">
<h2>This project: viu</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 +0,0 @@
FROM ubuntu
RUN apt-get update
RUN apt-get -y install python3
RUN apt-get update
RUN apt-get -y install pipx
RUN pipx ensurepath
COPY . /fastanime
WORKDIR /fastanime
RUN pipx install .
CMD ["bash"]

1113
README.md

File diff suppressed because it is too large Load Diff

7
bundle/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM python:3.12-slim-bookworm
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY . /viu
ENV PATH=/root/.local/bin:$PATH
WORKDIR /viu
RUN uv tool install .
CMD ["bash"]

65
bundle/pyinstaller.spec Normal file
View File

@@ -0,0 +1,65 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
block_cipher = None
# Collect all required data files
datas = [
('viu/assets/*', 'viu/assets'),
]
# Collect all required hidden imports
hiddenimports = [
'click',
'rich',
'requests',
'yt_dlp',
'python_mpv',
'fuzzywuzzy',
'viu',
] + collect_submodules('viu')
a = Analysis(
['./viu/viu.py'], # Changed entry point
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
strip=True, # Strip debug information
optimize=2 # Optimize bytecode noarchive=False
)
pyz = PYZ(
a.pure,
a.zipped_data,
optimize=2 # Optimize bytecode cipher=block_cipher
)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='viu',
debug=False,
bootloader_ignore_signals=False,
strip=True,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='viu/assets/logo.ico'
)

View File

@@ -1,8 +1,8 @@
_fastanime_completion() {
_viu_completion() {
local IFS=$'\n'
local response
response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _FASTANIME_COMPLETE=bash_complete $1)
response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _VIU_COMPLETE=bash_complete $1)
for completion in $response; do
IFS=',' read type value <<< "$completion"
@@ -21,9 +21,9 @@ _fastanime_completion() {
return 0
}
_fastanime_completion_setup() {
complete -o nosort -F _fastanime_completion fastanime
_viu_completion_setup() {
complete -o nosort -F _viu_completion viu
}
_fastanime_completion_setup;
_viu_completion_setup;

View File

@@ -1,5 +1,5 @@
function _fastanime_completion;
set -l response (env _FASTANIME_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) fastanime);
function _viu_completion;
set -l response (env _VIU_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) viu);
for completion in $response;
set -l metadata (string split "," $completion);
@@ -14,5 +14,5 @@ function _fastanime_completion;
end;
end;
complete --no-files --command fastanime --arguments "(_fastanime_completion)";
complete --no-files --command viu --arguments "(_viu_completion)";

View File

@@ -1,12 +1,12 @@
#compdef fastanime
#compdef viu
_fastanime_completion() {
_viu_completion() {
local -a completions
local -a completions_with_descriptions
local -a response
(( ! $+commands[fastanime] )) && return 1
(( ! $+commands[viu] )) && return 1
response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _FASTANIME_COMPLETE=zsh_complete fastanime)}")
response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _VIU_COMPLETE=zsh_complete viu)}")
for type key descr in ${response}; do
if [[ "$type" == "plain" ]]; then
@@ -33,9 +33,9 @@ _fastanime_completion() {
if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
# autoload from fpath, call function directly
_fastanime_completion "$@"
_viu_completion "$@"
else
# eval/source/. command, register function for later
compdef _fastanime_completion fastanime
compdef _viu_completion viu
fi

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
APP_DIR="$(
cd -- "$(dirname "$0")" >/dev/null 2>&1
pwd -P
)"
# fish shell completions
_VIU_COMPLETE=fish_source viu >"$APP_DIR/completions/viu.fish"
# zsh completions
_VIU_COMPLETE=zsh_source viu >"$APP_DIR/completions/viu.zsh"
# bash completions
_VIU_COMPLETE=bash_source viu >"$APP_DIR/completions/viu.bash"

File diff suppressed because it is too large Load Diff

16
dev/make_release Normal file
View File

@@ -0,0 +1,16 @@
#! /usr/bin/env sh
CLI_DIR="$(dirname "$(realpath "$0")")"
VERSION=$1
[ -z "$VERSION" ] && echo no version provided && exit 1
[ "$VERSION" = "current" ] && viu --version && exit 0
sed -i "s/^version.*/version = \"$VERSION\"/" "$CLI_DIR/pyproject.toml" &&
sed -i "s/__version__.*/__version__ = \"v$VERSION\"/" "$CLI_DIR/viu/__init__.py" &&
sed -i "s/version = .*/version = \"$VERSION\";/" "$CLI_DIR/flake.nix" &&
git stage "$CLI_DIR/pyproject.toml" "$CLI_DIR/viu/__init__.py" "$CLI_DIR/flake.nix" &&
git commit -m "chore: bump version (v$VERSION)" &&
# nix flake lock &&
uv lock &&
git stage "$CLI_DIR/flake.lock" "$CLI_DIR/uv.lock" &&
git commit -m "chore: update lock files" &&
git push &&
gh release create "v$VERSION"

8
fa Executable file → Normal file
View File

@@ -1,4 +1,6 @@
#!/usr/bin/env sh
# exec "${PYTHON:-python3}" -Werror -Xdev -m "$(dirname "$(realpath "$0")")/fastanime" "$@"
cd "$(dirname "$(realpath "$0")")" || exit 1
exec python -m fastanime "$@"
provider_type=$1
provider_name=$2
[ -z "$provider_type" ] && echo "Please specify provider type" && exit
[ -z "$provider_name" ] && echo "Please specify provider type" && exit
uv run python -m viu_cli.libs.provider.${provider_type}.${provider_name}.provider

View File

@@ -1,127 +0,0 @@
"""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.anime_provider import anime_sources
if TYPE_CHECKING:
from typing import Iterator
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
class AnimeProvider:
"""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]
anime_provider: [TODO:attribute]
"""
PROVIDERS = list(anime_sources.keys())
provider = PROVIDERS[0]
def __init__(self, provider, 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 = 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()
def search_for_anime(
self,
user_query,
translation_type,
nsfw=True,
unknown=True,
) -> "SearchResults | None":
"""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]
unknown ([TODO:parameter]): [TODO:description]
anilist_obj: [TODO:description]
Returns:
[TODO:return]
"""
anime_provider = self.anime_provider
try:
results = anime_provider.search_for_anime(
user_query, translation_type, nsfw, unknown
)
except Exception as e:
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
results = None
return results
def get_anime(
self,
anime_id: str,
) -> "Anime | None":
"""core abstraction over getting info of an anime from all providers
Args:
anime_id: [TODO:description]
anilist_obj: [TODO:description]
Returns:
[TODO:return]
"""
anime_provider = self.anime_provider
try:
results = anime_provider.get_anime(anime_id)
except Exception as e:
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
results = None
return results
def get_episode_streams(
self,
anime,
episode: str,
translation_type: str,
) -> "Iterator[Server] | None":
"""core abstractions for getting juicy streams from all providers
Args:
anime ([TODO:parameter]): [TODO:description]
episode: [TODO:description]
translation_type: [TODO:description]
anilist_obj: [TODO:description]
Returns:
[TODO:return]
"""
anime_provider = self.anime_provider
try:
results = anime_provider.get_episode_streams(
anime, episode, translation_type
)
except Exception as e:
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
results = None
return results

View File

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

View File

@@ -1,34 +0,0 @@
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
# TODO: Add formating options for the final date
def format_anilist_date_object(anilist_date_object: "AnilistDateObject"):
if anilist_date_object:
return f"{anilist_date_object['day']}/{anilist_date_object['month']}/{anilist_date_object['year']}"
else:
return "Unknown"
def format_anilist_timestamp(anilist_timestamp: int | None):
if anilist_timestamp:
return datetime.fromtimestamp(anilist_timestamp).strftime("%d/%m/%Y %H:%M:%S")
else:
return "Unknown"
def format_list_data_with_comma(data: list | None):
if data:
return ", ".join(data)
else:
return "None"
def extract_next_airing_episode(airing_episode: "AnilistMediaNextAiringEpisode"):
if airing_episode:
return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"
else:
return "Completed"

View File

@@ -1,26 +0,0 @@
"""
Just contains some useful data used across the codebase
"""
# useful incases where the anilist title is too different from the provider title
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",
},
"aniwatch": {"My Star": "Oshi no Ko"},
"animepahe": {"Azumanga Daiou The Animation": "Azumanga Daioh"},
}
def get_anime_normalizer():
"""Used because there are different providers"""
import os
current_provider = os.environ["CURRENT_FASTANIME_PROVIDER"]
return anime_normalizer_raw[current_provider]
anime_normalizer = get_anime_normalizer()

View File

@@ -1,163 +0,0 @@
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__)
class YtDLPDownloader:
downloads_queue = Queue()
def _worker(self):
while True:
task, args = self.downloads_queue.get()
try:
task(*args)
except Exception as e:
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()
def _download_file(
self,
url: str,
anime_title: str,
episode_title: str,
download_dir: str,
silent: bool,
vid_format: str = "best",
force_unknown_ext=False,
verbose=False,
headers={},
sub="",
merge=False,
clean=False,
prompt=True,
):
"""Helper function that downloads anime given url and path details
Args:
url: [TODO:description]
anime_title: [TODO:description]
episode_title: [TODO:description]
download_dir: [TODO:description]
silent: [TODO:description]
vid_format: [TODO:description]
"""
anime_title = sanitize_filename(anime_title)
episode_title = sanitize_filename(episode_title)
ydl_opts = {
# Specify the output path and template
"http_headers": headers,
"outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s",
"silent": silent,
"verbose": verbose,
"format": vid_format,
"compat_opts": ("allow-unsafe-ext",) if force_unknown_ext else tuple(),
}
urls = [url]
if sub:
urls.append(sub)
vid_path = ""
sub_path = ""
for i, url in enumerate(urls):
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
if not info:
continue
if i == 0:
vid_path = info["requested_downloads"][0]["filepath"]
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}")
# WARN: May remove this legacy functionality
def download_file(self, url: str, title, silent=True):
"""A helper that just does things in the background
Args:
title ([TODO:parameter]): [TODO:description]
silent ([TODO:parameter]): [TODO:description]
url: [TODO:description]
"""
self.downloads_queue.put((self._download_file, (url, title, silent)))
downloader = YtDLPDownloader()

View File

@@ -1,44 +0,0 @@
import logging
from typing import TYPE_CHECKING
from thefuzz import fuzz
from .data import anime_normalizer
if TYPE_CHECKING:
from ..libs.anilist.types import AnilistBaseMediaDataSchema
logger = logging.getLogger(__name__)
def sort_by_episode_number(filename: str):
import re
match = re.search(r"\d+", filename)
return int(match.group()) if match else 0
def anime_title_percentage_match(
possible_user_requested_anime_title: str, anime: "AnilistBaseMediaDataSchema"
) -> float:
"""Returns the percentage match between the possible title and user title
Args:
possible_user_requested_anime_title (str): an Animdl search result title
title (str): the anime title the user wants
Returns:
int: the percentage match
"""
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_a.lower(), possible_user_requested_anime_title.lower()),
fuzz.ratio(title_b.lower(), possible_user_requested_anime_title.lower()),
)
logger.info(f"{locals()}")
return percentage_ratio

View File

@@ -1,20 +0,0 @@
import sys
if sys.version_info < (3, 10):
raise ImportError(
"You are using an unsupported version of Python. Only Python versions 3.8 and above are supported by yt-dlp"
) # noqa: F541
__version__ = "v2.5.0"
APP_NAME = "FastAnime"
AUTHOR = "Benex254"
GIT_REPO = "github.com"
REPO = f"{GIT_REPO}/{AUTHOR}/{APP_NAME}"
def FastAnime():
from .cli import run_cli
run_cli()

View File

@@ -1,3 +0,0 @@
from .libs.anilist.api import AniListApi
AniList = AniListApi()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

View File

@@ -1,299 +0,0 @@
import signal
import click
from .. import __version__
from ..libs.anime_provider import SERVERS_AVAILABLE, anime_sources
from .commands import LazyGroup
commands = {
"search": "search.search",
"download": "download.download",
"anilist": "anilist.anilist",
"config": "config.config",
"downloads": "downloads.downloads",
"cache": "cache.cache",
"completions": "completions.completions",
"update": "update.update",
"grab": "grab.grab",
}
# handle keyboard interupt
def handle_exit(signum, frame):
from click import clear
from .utils.tools import exit_app
clear()
exit_app()
signal.signal(signal.SIGINT, handle_exit)
@click.group(
lazy_subcommands=commands,
cls=LazyGroup,
help="A command line application for streaming anime that provides a complete and featureful interface",
short_help="Stream Anime",
)
@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)
@click.option(
"-p",
"--provider",
type=click.Choice(list(anime_sources.keys()), case_sensitive=False),
help="Provider of your choice",
)
@click.option(
"-s",
"--server",
type=click.Choice([*SERVERS_AVAILABLE, "top"]),
help="Server of choice",
)
@click.option(
"-f",
"--format",
type=str,
help="yt-dlp format to use",
)
@click.option(
"-c/-no-c",
"--continue/--no-continue",
"continue_",
type=bool,
help="Continue from last episode?",
)
@click.option(
"--local-history/--remote-history",
type=bool,
help="Whether to continue from local history or remote history",
)
@click.option(
"--skip/--no-skip",
type=bool,
help="Skip opening and ending theme songs?",
)
@click.option(
"-q",
"--quality",
type=click.Choice(
[
"360",
"480",
"720",
"1080",
]
),
help="set the quality of the stream",
)
@click.option(
"-t",
"--translation-type",
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",
type=bool,
help="Auto select next episode?",
)
@click.option(
"-a/-no-a",
"--auto-select/--no-auto-select",
type=bool,
help="Auto select anime title?",
)
@click.option(
"--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")
@click.option("--default", is_flag=True, help="Use the default interface")
@click.option("--preview", is_flag=True, help="Show preview when using fzf")
@click.option("--no-preview", is_flag=True, help="Dont show preview when using fzf")
@click.option(
"--icons/--no-icons",
type=bool,
help="Use icons in the interfaces",
)
@click.option("--dub", help="Set the translation type to dub", is_flag=True)
@click.option("--sub", help="Set the translation type to sub", is_flag=True)
@click.option("--rofi", help="Use rofi for the ui", is_flag=True)
@click.option("--rofi-theme", help="Rofi theme to use", type=click.Path())
@click.option(
"--rofi-theme-confirm",
help="Rofi theme to use for the confirm prompt",
type=click.Path(),
)
@click.option(
"--rofi-theme-input",
help="Rofi theme to use for the user input prompt",
type=click.Path(),
)
@click.option(
"--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.pass_context
def run_cli(
ctx: click.Context,
manga,
log,
log_file,
rich_traceback,
provider,
server,
format,
continue_,
local_history,
skip,
translation_type,
sub_lang,
quality,
auto_next,
auto_select,
normalize_titles,
downloads_dir,
fzf,
default,
preview,
no_preview,
icons,
dub,
sub,
rofi,
rofi_theme,
rofi_theme_confirm,
rofi_theme_input,
use_python_mpv,
sync_play,
):
from .config import Config
ctx.obj = Config()
ctx.obj.manga = manga
if log:
import logging
from rich.logging import RichHandler
FORMAT = "%(message)s"
logging.basicConfig(
level=logging.DEBUG, format=FORMAT, datefmt="[%X]", handlers=[RichHandler()]
)
logger = logging.getLogger(__name__)
logger.info("logging has been initialized")
elif log_file:
import logging
from ..constants import LOG_FILE_PATH
format = "%(asctime)s%(levelname)s: %(message)s"
logging.basicConfig(
level=logging.DEBUG,
filename=LOG_FILE_PATH,
format=format,
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 sync_play:
ctx.obj.sync_play = sync_play
if provider:
import os
ctx.obj.provider = provider
os.environ["CURRENT_FASTANIME_PROVIDER"] = provider
if server:
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("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
if ctx.get_parameter_source("auto_next") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.auto_next = auto_next
if ctx.get_parameter_source("icons") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.icons = icons
if (
ctx.get_parameter_source("local_history")
== click.core.ParameterSource.COMMANDLINE
):
ctx.obj.preferred_history = "local" if local_history else "remote"
if (
ctx.get_parameter_source("auto_select")
== click.core.ParameterSource.COMMANDLINE
):
ctx.obj.auto_select = auto_select
if (
ctx.get_parameter_source("use_python_mpv")
== click.core.ParameterSource.COMMANDLINE
):
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
if preview:
ctx.obj.preview = True
if no_preview:
ctx.obj.preview = False
if dub:
ctx.obj.translation_type = "dub"
if sub:
ctx.obj.translation_type = "sub"
if rofi:
ctx.obj.use_fzf = False
ctx.obj.use_rofi = True
if rofi:
from ..libs.rofi import Rofi
if rofi_theme:
ctx.obj.rofi_theme = rofi_theme
Rofi.rofi_theme = rofi_theme
if rofi_theme_input:
ctx.obj.rofi_theme_input = rofi_theme_input
Rofi.rofi_theme_input = rofi_theme_input
if rofi_theme_confirm:
ctx.obj.rofi_theme_confirm = rofi_theme_confirm
Rofi.rofi_theme_confirm = rofi_theme_confirm

View File

@@ -1,52 +0,0 @@
import click
from ...utils.tools import FastAnimeRuntimeState
from .__lazyloader__ import LazyGroup
commands = {
"trending": "trending.trending",
"recent": "recent.recent",
"search": "search.search",
"upcoming": "upcoming.upcoming",
"scores": "scores.scores",
"popular": "popular.popular",
"favourites": "favourites.favourites",
"random": "random_anime.random_anime",
"login": "login.login",
"watching": "watching.watching",
"paused": "paused.paused",
"rewatching": "rewatching.rewatching",
"dropped": "dropped.dropped",
"completed": "completed.completed",
"planning": "planning.planning",
"notifier": "notifier.notifier",
"stats": "stats.stats",
}
@click.group(
lazy_subcommands=commands,
cls=LazyGroup,
invoke_without_command=True,
help="A beautiful interface that gives you access to a commplete streaming experience",
short_help="Access all streaming options",
)
@click.pass_context
def anilist(ctx: click.Context):
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
config: Config = ctx.obj
config.anime_provider = AnimeProvider(config.provider)
if user := ctx.obj.user:
AniList.update_login_info(user, user["token"])
if ctx.invoked_subcommand is None:
fastanime_runtime_state = FastAnimeRuntimeState()
anilist_interface(ctx.obj, fastanime_runtime_state)

View File

@@ -1,42 +0,0 @@
# in lazy_group.py
import importlib
import click
class LazyGroup(click.Group):
def __init__(self, *args, lazy_subcommands=None, **kwargs):
super().__init__(*args, **kwargs)
# lazy_subcommands is a map of the form:
#
# {command-name} -> {module-name}.{command-object-name}
#
self.lazy_subcommands = lazy_subcommands or {}
def list_commands(self, ctx):
base = super().list_commands(ctx)
lazy = sorted(self.lazy_subcommands.keys())
return base + lazy
def get_command(self, ctx, cmd_name): # pyright:ignore
if cmd_name in self.lazy_subcommands:
return self._lazy_load(cmd_name)
return super().get_command(ctx, cmd_name)
def _lazy_load(self, cmd_name: str):
# lazily loading a command, first get the module name and attribute name
import_path: str = self.lazy_subcommands[cmd_name]
modname, cmd_object_name = import_path.rsplit(".", 1)
# do the import
mod = importlib.import_module(
f".{modname}", package="fastanime.cli.commands.anilist"
)
# get the Command object from that module
cmd_object = getattr(mod, cmd_object_name)
# check the result to make debugging easier
if not isinstance(cmd_object, click.BaseCommand):
raise ValueError(
f"Lazy loading of {import_path} failed by returning "
"a non-command object"
)
return cmd_object

View File

@@ -1,46 +0,0 @@
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from ...config import Config
@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", dump_json):
from sys import exit
from ....anilist import AniList
from ...utils.tools import FastAnimeRuntimeState
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit(1)
anime_list = AniList.get_anime_list("COMPLETED")
if not anime_list or not anime_list[1]:
return
if not anime_list[0]:
return
media = [
mediaListItem["media"]
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
if dump_json:
import json
print(json.dumps(anime_list))
else:
from ...interfaces import anilist_interfaces
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -1,46 +0,0 @@
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from fastanime.cli.config import Config
@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", dump_json):
from sys import exit
from ....anilist import AniList
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit(1)
anime_list = AniList.get_anime_list("DROPPED")
if not anime_list:
exit(1)
if not anime_list[0] or not anime_list[1]:
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
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.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -1,34 +0,0 @@
import click
@click.command(
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, dump_json):
from ....anilist import AniList
anime_data = AniList.get_most_favourite()
if anime_data[0]:
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:
from sys import exit
exit(1)

View File

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

View File

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

View File

@@ -1,46 +0,0 @@
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from ...config import Config
@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", dump_json):
from sys import exit
from ....anilist import AniList
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit(1)
anime_list = AniList.get_anime_list("PAUSED")
if not anime_list:
exit(1)
if not anime_list[0] or not anime_list[1]:
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
if dump_json:
import json
print(json.dumps(anime_list[1]))
else:
from ...interfaces import anilist_interfaces
from ...utils.tools import FastAnimeRuntimeState
anilist_config = FastAnimeRuntimeState()
anilist_config.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, anilist_config)

View File

@@ -1,46 +0,0 @@
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from ...config import Config
@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", dump_json):
from sys import exit
from ....anilist import AniList
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit(1)
anime_list = AniList.get_anime_list("PLANNING")
if not anime_list:
exit(1)
if not anime_list[0] or not anime_list[1]:
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
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.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -1,33 +0,0 @@
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, dump_json):
from ....anilist import AniList
anime_data = AniList.get_most_popular()
if anime_data[0]:
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:
from sys import exit
exit(1)

View File

@@ -1,39 +0,0 @@
import click
@click.command(
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, dump_json):
import random
from ....anilist import AniList
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]:
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:
exit(1)

View File

@@ -1,34 +0,0 @@
import click
@click.command(
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, dump_json):
from ....anilist import AniList
anime_data = AniList.get_most_recently_updated()
if anime_data[0]:
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:
from sys import exit
exit(1)

View File

@@ -1,46 +0,0 @@
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from ...config import Config
@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", dump_json):
from sys import exit
from ....anilist import AniList
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit(1)
anime_list = AniList.get_anime_list("REPEATING")
if not anime_list:
exit(1)
if not anime_list[0] or not anime_list[1]:
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
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.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -1,33 +0,0 @@
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, dump_json):
from ....anilist import AniList
anime_data = AniList.get_most_scored()
if anime_data[0]:
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:
from sys import exit
exit(1)

View File

@@ -1,574 +0,0 @@
import click
from ...completion_functions import anime_titles_shell_complete
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)
@click.command(
help="Search for anime using anilists api and get top ~50 results",
short_help="Search for anime",
)
@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(["WINTER", "SPRING", "SUMMER", "FALL"]),
)
@click.option(
"--status",
"-S",
help="The media status of the anime",
multiple=True,
type=click.Choice(
["FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"]
),
)
@click.option(
"--sort",
"-s",
help="What to sort the search results on",
type=click.Choice(
[
"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",
]
),
)
@click.option(
"--genres",
"-g",
multiple=True,
help="the genres to filter by",
type=click.Choice(
[
"Action",
"Adventure",
"Comedy",
"Drama",
"Ecchi",
"Fantasy",
"Horror",
"Mahou Shoujo",
"Mecha",
"Music",
"Mystery",
"Psychological",
"Romance",
"Sci-Fi",
"Slice of Life",
"Sports",
"Supernatural",
"Thriller",
"Hentai",
]
),
)
@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(
["TV", "TV_SHORT", "MOVIE", "SPECIAL", "OVA", "MUSIC", "NOVEL", "ONE_SHOT"]
),
)
@click.option(
"--year",
"-y",
type=click.Choice(
[
"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",
]
),
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,
dump_json,
season,
status,
sort,
genres,
tags,
media_format,
year,
on_list,
):
from ....anilist import AniList
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:
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.anilist_results_data = search_results
anilist_results_menu(config, fastanime_runtime_state)
else:
from sys import exit
exit(1)

View File

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

@@ -1,34 +0,0 @@
import click
@click.command(
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, dump_json):
from ....anilist import AniList
success, data = AniList.get_trending()
if success:
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.anilist_results_data = data
anilist_results_menu(config, fastanime_runtime_state)
else:
from sys import exit
exit(1)

View File

@@ -1,33 +0,0 @@
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, dump_json):
from ....anilist import AniList
success, data = AniList.get_upcoming_anime()
if success:
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.anilist_results_data = data
anilist_results_menu(config, fastanime_runtime_state)
else:
from sys import exit
exit(1)

View File

@@ -1,46 +0,0 @@
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from ...config import Config
@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", dump_json):
from sys import exit
from ....anilist import AniList
if not config.user:
print("Not authenticated")
print("Please run: fastanime anilist loggin")
exit(1)
anime_list = AniList.get_anime_list("CURRENT")
if not anime_list:
exit(1)
if not anime_list[0] or not anime_list[1]:
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
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.anilist_results_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -1,39 +0,0 @@
import click
@click.command(help="Helper command to manage 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)
def cache(clean, path, size):
from ...constants import APP_CACHE_DIR
if path:
print(APP_CACHE_DIR)
elif clean:
import shutil
from rich.prompt import Confirm
if Confirm.ask(
f"Are you sure you want to clean the following path: {APP_CACHE_DIR};(NOTE: !!The action is irreversible and will clean your cache!!)",
default=False,
):
print("Cleaning...")
shutil.rmtree(APP_CACHE_DIR)
print("Successfully removed: ", APP_CACHE_DIR)
elif size:
import os
from ..utils.utils import format_bytes_to_human
total_size = 0
for dirpath, dirnames, filenames in os.walk(APP_CACHE_DIR):
for f in filenames:
fp = os.path.join(dirpath, f)
total_size += os.path.getsize(fp)
print("Total Size: ", format_bytes_to_human(total_size))
else:
import click
click.launch(APP_CACHE_DIR)

View File

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

View File

@@ -1,332 +0,0 @@
from typing import TYPE_CHECKING
import click
from ..completion_functions import anime_titles_shell_complete
if TYPE_CHECKING:
from ..config import Config
@click.command(
help="Download anime using the anime provider for a specified range",
short_help="Download anime",
)
@click.option(
"--anime-titles",
"--anime_title",
"-t",
required=True,
shell_complete=anime_titles_shell_complete,
multiple=True,
help="Specify which anime to download",
)
@click.option(
"--episode-range",
"-r",
help="A range of episodes to download (start-end)",
)
@click.option(
"--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,
)
@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.pass_obj
def download(
config: "Config",
anime_titles: tuple,
episode_range,
file,
force_unknown_ext,
silent,
verbose,
merge,
clean,
wait_time,
prompt,
):
import time
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 ...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
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:
progress.add_task("Fetching Search Results...", total=None)
search_results = anime_provider.search_for_anime(
anime_title, translation_type=translation_type
)
if not search_results:
print("Search results failed")
input("Enter to retry")
download(
config,
anime_title,
episode_range,
file,
force_unknown_ext,
silent,
verbose,
merge,
clean,
wait_time,
prompt,
)
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:
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("Sth went wring anime no found")
input("Enter to continue...")
download(
config,
anime_title,
episode_range,
file,
force_unknown_ext,
silent,
verbose,
merge,
clean,
wait_time,
prompt,
)
return
episodes = sorted(
anime["availableEpisodesDetail"][config.translation_type], key=float
)
# where the magic happens
if episode_range:
if ":" in episode_range:
ep_range_tuple = episode_range.split(":")
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
episodes_start, episodes_end = ep_range_tuple
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
episodes_start, episodes_end, step = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end) : int(step)
]
else:
episodes_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range = episodes[int(episodes_start) :]
elif episodes_end.strip():
episodes_range = episodes[: int(episodes_end)]
else:
episodes_range = episodes
else:
episodes_range = episodes[int(episode_range) :]
print(f"[green bold]Downloading: [/] {episodes_range}")
else:
episodes_range = sorted(episodes, key=float)
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:
episode = str(episode)
if episode not in episodes:
print(f"[cyan]Warning[/]: Episode {episode} not found, skipping")
continue
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
)
if not streams:
print("No streams skipping")
continue
# ---- fetch servers ----
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server_name = next(streams, None)
if not server_name:
print("Sth went wrong when fetching the server")
continue
stream_link = filter_by_quality(
config.quality, server_name["links"]
)
if not stream_link:
print("[yellow bold]WARNING:[/] No streams found")
time.sleep(1)
print("Continuing...")
continue
link = stream_link["link"]
provider_headers = server_name["headers"]
episode_title = server_name["episode_title"]
subtitles = server_name["subtitles"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.server in servers_names:
server_name = config.server
else:
if config.use_fzf:
server_name = fzf.run(servers_names, "Select an link: ")
else:
server_name = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server_name]["links"]
)
if not stream_link:
print("[yellow bold]WARNING:[/] No streams found")
time.sleep(1)
print("Continuing...")
continue
link = stream_link["link"]
provider_headers = servers[server_name]["headers"]
subtitles = servers[server_name]["subtitles"]
episode_title = servers[server_name]["episode_title"]
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,
selected_anime_title,
episode_title,
download_dir,
silent,
config.format,
force_unknown_ext,
verbose,
headers=provider_headers,
sub=subtitles[0]["url"] if subtitles else "",
merge=merge,
clean=clean,
prompt=prompt,
)
except Exception as e:
print(e)
time.sleep(1)
print("Continuing...")
print("Done Downloading")
time.sleep(wait_time)
exit_app()

View File

@@ -1,327 +0,0 @@
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"
)
@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)
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)
stream_anime()
stream_anime(title)

View File

@@ -1,208 +0,0 @@
from typing import TYPE_CHECKING
import click
from ..completion_functions import anime_titles_shell_complete
if TYPE_CHECKING:
from ..config import Config
@click.command(
help="Helper command to get streams for anime to use externally in a non-python application",
short_help="Print anime streams to standard out",
)
@click.option(
"--anime-titles",
"--anime_title",
"-t",
required=True,
shell_complete=anime_titles_shell_complete,
multiple=True,
help="Specify which anime to download",
)
@click.option(
"--episode-range",
"-r",
help="A range of episodes to download (start-end)",
)
@click.option(
"--search-results-only",
"-s",
help="print only the search results to stdout",
is_flag=True,
)
@click.option(
"--anime-info-only", "-i", help="print only selected anime title info", is_flag=True
)
@click.option(
"--episode-streams-only",
"-e",
help="print only selected anime episodes streams of given range",
is_flag=True,
)
@click.pass_obj
def grab(
config: "Config",
anime_titles: tuple,
episode_range,
search_results_only,
anime_info_only,
episode_streams_only,
):
import json
from logging import getLogger
from sys import exit
from thefuzz import fuzz
logger = getLogger(__name__)
if config.manga:
manga_title = anime_titles[0]
from ...MangaProvider import MangaProvider
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_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_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)
chapter_info = manga_provider.get_chapter_thumbnails(
manga_info["id"], str(episode_range)
)
if not chapter_info:
exit(1)
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_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range = episodes[int(episodes_start) :]
elif episodes_end.strip():
episodes_range = episodes[: int(episodes_end)]
else:
episodes_range = episodes
else:
episodes_range = episodes[int(episode_range) :]
else:
episodes_range = sorted(episodes, key=float)
if not episode_streams_only:
grabbed_anime = dict(anime)
grabbed_anime["requested_episodes"] = episodes_range
grabbed_anime["translation_type"] = config.translation_type
grabbed_anime["episodes_streams"] = {}
else:
grabbed_anime = {}
# lets download em
for episode in episodes_range:
try:
if episode not in episodes:
continue
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
)
if not streams:
continue
episode_streams = {server["server"]: server for server in streams}
if episode_streams_only:
grabbed_anime[episode] = episode_streams
else:
grabbed_anime["episodes_streams"][ # pyright:ignore
episode
] = episode_streams
except Exception as e:
logger.error(e)
# grab the full data for single title and appen to final result or episode streams
grabbed_animes.append(grabbed_anime)
# print out the final result either {} or [] depending if more than one title os requested
if len(grabbed_animes) == 1:
print(json.dumps(grabbed_animes[0]))
else:
print(json.dumps(grabbed_animes))

View File

@@ -1,361 +0,0 @@
from typing import TYPE_CHECKING
import click
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",
)
@click.option(
"--anime-titles",
"--anime_title",
"-t",
required=True,
shell_complete=anime_titles_shell_complete,
multiple=True,
help="Specify which anime to download",
)
@click.option(
"--episode-range",
"-r",
help="A range of episodes to binge (start-end)",
)
@click.pass_obj
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 ...libs.fzf import fzf
from ...libs.rofi import Rofi
from ..utils.tools import exit_app
from ..utils.utils import fuzzy_inquirer
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"]
search_results_ = {
sanitize_filename(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(title, manga_title),
)
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_manga_title = fzf.run(
choices, "Please Select title: ", preview=preview
)
elif config.use_rofi:
search_result_manga_title = Rofi.run(choices, "Please Select Title")
else:
search_result_manga_title = fuzzy_inquirer(
choices,
"Please Select Title",
)
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 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)
else:
choices = list(search_results_.keys())
if config.use_fzf:
search_result_manga_title = fzf.run(
choices, "Please Select title: ", "FastAnime"
)
elif config.use_rofi:
search_result_manga_title = Rofi.run(choices, "Please Select Title")
else:
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_manga_title]["id"]
)
if not anime:
print("Sth went wring anime no found")
input("Enter to continue...")
search(config, anime_title, episode_range)
return
episodes_range = []
episodes: list[str] = sorted(
anime["availableEpisodesDetail"][config.translation_type], key=float
)
if episode_range:
if ":" in episode_range:
ep_range_tuple = episode_range.split(":")
if len(ep_range_tuple) == 3 and all(ep_range_tuple):
episodes_start, episodes_end, step = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end) : int(step)
]
elif len(ep_range_tuple) == 2 and all(ep_range_tuple):
episodes_start, episodes_end = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end)
]
else:
episodes_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range = episodes[int(episodes_start) :]
elif episodes_end.strip():
episodes_range = episodes[: int(episodes_end)]
else:
episodes_range = episodes
else:
episodes_range = episodes[int(episode_range) :]
episodes_range = iter(episodes_range)
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():
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
# ---- fetch streams ----
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
)
if not streams:
print("Failed to get streams")
return
try:
# ---- fetch servers ----
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams, None)
if not server:
print("Sth went wrong when fetching the episode")
input("Enter to continue")
stream_anime()
return
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
return
link = stream_link["link"]
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()
return
link = stream_link["link"]
stream_headers = servers[server]["headers"]
subtitles = servers[server]["subtitles"]
episode_title = servers[server]["episode_title"]
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
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,
)
except IndexError as e:
print(e)
input("Enter to continue")
stream_anime()
stream_anime()

View File

@@ -1,42 +0,0 @@
import click
@click.command(help="Helper command to update fastanime to latest")
@click.option("--check", "-c", help="Check for the latest release", is_flag=True)
def update(
check,
):
from rich.console import Console
from rich.markdown import Markdown
from ... import __version__
from ..app_updater import check_for_updates, update_app
def _print_release(release_data):
console = Console()
body = Markdown(release_data["body"])
tag = github_release_data["tag_name"]
tag_title = release_data["name"]
github_page_url = release_data["html_url"]
console.print(f"Release Page: {github_page_url}")
console.print(f"Tag: {tag}")
console.print(f"Title: {tag_title}")
console.print(body)
if check:
is_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"
)
_print_release(github_release_data)
else:
print(f"You are running the latest version ({__version__}) of fastanime")
_print_release(github_release_data)
else:
success, github_release_data = update_app()
_print_release(github_release_data)
if success:
print("Successfully updated")
else:
print("failed to update")

View File

@@ -1,426 +0,0 @@
import json
import logging
import os
from configparser import ConfigParser
from typing import TYPE_CHECKING
from ..constants import USER_CONFIG_PATH, USER_DATA_PATH, USER_VIDEOS_DIR
from ..libs.rofi import Rofi
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ..AnimeProvider import AnimeProvider
class Config(object):
manga = False
sync_play = False
anime_list: list
watch_history: dict
fastanime_anilist_app_login_url = (
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
)
anime_provider: "AnimeProvider"
user_data = {"watch_history": {}, "animelist": [], "user": {}}
default_options = {
"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_python_mpv": "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",
"sub_lang": "eng",
"normalize_titles": "true",
}
def __init__(self) -> None:
self.initialize_user_data()
self.load_config()
def load_config(self):
self.configparser = ConfigParser(self.default_options)
self.configparser.add_section("stream")
self.configparser.add_section("general")
self.configparser.add_section("anilist")
# --- set config values from file or using defaults ---
if os.path.exists(USER_CONFIG_PATH):
self.configparser.read(USER_CONFIG_PATH)
self.downloads_dir = self.get_downloads_dir()
self.sub_lang = self.get_sub_lang()
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.normalize_titles = self.get_normalize_titles()
self.auto_select = self.get_auto_select()
self.use_python_mpv = 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()
Rofi.rofi_theme = self.rofi_theme
self.rofi_theme_input = self.get_rofi_theme_input()
Rofi.rofi_theme_input = self.rofi_theme_input
self.rofi_theme_confirm = self.get_rofi_theme_confirm()
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
self.ffmpegthumbnailer_seek_time = self.get_ffmpegthumnailer_seek_time()
# ---- setup user data ------
self.watch_history: dict = self.user_data.get("watch_history", {})
self.anime_list: list = self.user_data.get("animelist", [])
self.user: dict = self.user_data.get("user", {})
os.environ["CURRENT_FASTANIME_PROVIDER"] = self.provider
if not os.path.exists(USER_CONFIG_PATH):
with open(USER_CONFIG_PATH, "w") as config:
config.write(self.__repr__())
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"
):
self.watch_history.update(
{
str(anime_id): {
"episode": episode,
"start_time": start_time,
"total_time": total_time,
}
}
)
self.user_data["watch_history"] = self.watch_history
self._update_user_data()
def initialize_user_data(self):
try:
if os.path.isfile(USER_DATA_PATH):
with open(USER_DATA_PATH, "r") as f:
user_data = json.load(f)
self.user_data.update(user_data)
except Exception as e:
logger.error(e)
def _update_user_data(self):
"""method that updates the actual user data file"""
with open(USER_DATA_PATH, "w") as f:
json.dump(self.user_data, f)
# getters for user configuration
# --- general section ---
def get_provider(self):
return self.configparser.get("general", "provider")
def get_ffmpegthumnailer_seek_time(self):
return self.configparser.getint("general", "ffmpegthumnailer_seek_time")
def get_preferred_language(self):
return self.configparser.get("general", "preferred_language")
def get_sub_lang(self):
return self.configparser.get("general", "sub_lang")
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")
def get_normalize_titles(self):
return self.configparser.getboolean("general", "normalize_titles")
# --- 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_python_mpv")
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"""\
#
# ███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗ ░█████╗░░█████╗░███╗░░██╗███████╗██╗░██████╗░
# ██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝ ██╔══██╗██╔══██╗████╗░██║██╔════╝██║██╔════╝░
# █████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░ ██║░░╚═╝██║░░██║██╔██╗██║█████╗░░██║██║░░██╗░
# ██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░ ██║░░██╗██║░░██║██║╚████║██╔══╝░░██║██║░░╚██╗
# ██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗ ╚█████╔╝╚█████╔╝██║░╚███║██║░░░░░██║╚██████╔╝
# ╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝ ░╚════╝░░╚════╝░╚═╝░░╚══╝╚═╝░░░░░╚═╝░╚═════╝░
#
[general]
# 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}
# 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}
# 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}
# can be [allanime, animepahe, aniwatch]
# allanime is the most realible
# animepahe provides different links to streams of different quality so a quality can be selected reliably with --quality option
# aniwatch which is now hianime usually provides subs in different languuages and its servers are generally faster
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 usinf fzf
# if you dont care about image previews it doesnt matter
# though its awesome
# try it and you will see
preview = {self.preview}
# 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
ffmpegthumbnailer_seek_time = {self.ffmpegthumbnailer_seek_time}
# 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
# the values of this option is the path to the rofi config files to use
# i choose to split it into three since it gives the best look and feel
# you can refer to the rofi demo on github to see for your self
# by the way i recommend getting the rofi themes from this project;
rofi_theme = {self.rofi_theme}
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:
# aniwatch
# the values for this option are the short names for countries
# regex is used to determine what you selected
sub_lang = {self.sub_lang}
[stream]
# 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 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 [dub/sub]
translation_type = {self.translation_type}
# what server to use for a particular provider
# allanime: [dropbox, sharepoint, wetransfer, gogoanime, wixmp]
# animepahe: [kwik]
# aniwatch: [HD1, HD2, StreamSB, StreamTape]
# '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 [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. [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 of auto_select in the menus or from the commandline and manually select the correct anime title
# and then please open an issue at <> highlighting the normalized title and the title given by the provider for the anime you wished to watch
# or even better edit this file <> and open a pull request
auto_select = {self.auto_select}
# 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
skip = {self.skip}
# the maximum delta time in minutes after which the episode should be considered as completed
# used in the continue from time stamp
error = {self.error}
# whether to use python-mpv [True/False]
# to enable superior control over the player
# adding more options to it
# Enable this one and you will be wonder 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 arch linux
use_python_mpv = {self.use_python_mpv}
# 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:
# provider=allanime, server=gogoanime
# provider=allanime, server=wixmp
# provider=aniwatch
# this is because they provider a m3u8 file that contans multiple quality streams
format = {self.format}
# NOTE:
# if you have any trouble setting up your config
# please don't be afraid to ask in our discord
# plus if there are any errors, improvements or suggestions please tell us in the discord
# or help us by contributing
# we appreciate all the help we can get
# since we may not always have the time to immediately implement the changes
#
# HOPE YOU ENJOY FASTANIME AND BE SURE TO STAR THE PROJECT ON GITHUB
#
"""
return current_config_state
def __str__(self):
return self.__repr__()

File diff suppressed because it is too large Load Diff

View File

@@ -1,380 +0,0 @@
import concurrent.futures
import logging
import os
import shutil
import subprocess
import textwrap
from threading import Thread
import requests
from yt_dlp.utils import clean_html, sanitize_filename
from ...constants import APP_CACHE_DIR
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...Utility import anilist_data_helper
from ..utils.scripts import fzf_preview
from ..utils.utils import get_true_fg
logger = logging.getLogger(__name__)
# ---- aniskip intergration ----
def aniskip(mal_id: int, episode: str):
"""helper function to be used for setting and getting skip data
Args:
mal_id: mal id of the anime
episode: episode number
Returns:
mpv chapter options
"""
ANISKIP = shutil.which("ani-skip")
if not ANISKIP:
print("Aniskip not found, please install and try again")
return
args = [ANISKIP, "-q", str(mal_id), "-e", str(episode)]
aniskip_result = subprocess.run(args, text=True, stdout=subprocess.PIPE)
if aniskip_result.returncode != 0:
return
mpv_skip_args = aniskip_result.stdout.strip()
return mpv_skip_args.split(" ")
# ---- prevew stuff ----
# import tempfile
# NOTE: May change this to a temp dir but there were issues so later
WORKING_DIR = APP_CACHE_DIR # tempfile.gettempdir()
IMAGES_CACHE_DIR = os.path.join(WORKING_DIR, "images")
if not os.path.exists(IMAGES_CACHE_DIR):
os.mkdir(IMAGES_CACHE_DIR)
ANIME_INFO_CACHE_DIR = os.path.join(WORKING_DIR, "info")
if not os.path.exists(ANIME_INFO_CACHE_DIR):
os.mkdir(ANIME_INFO_CACHE_DIR)
def save_image_from_url(url: str, file_name: str):
"""Helper function that downloads an image to the FastAnime images cache dir given its url and filename
Args:
url: image url to download
file_name: filename to use
"""
image = requests.get(url)
with open(f"{IMAGES_CACHE_DIR}/{file_name}", "wb") as f:
f.write(image.content)
def save_info_from_str(info: str, file_name: str):
"""Helper function that writes text (anime details and info) to a file given its filename
Args:
info: the information anilist has on the anime
file_name: the filename to use
"""
with open(f"{ANIME_INFO_CACHE_DIR}/{file_name}", "w") as f:
f.write(info)
def write_search_results(
anilist_results: list[AnilistBaseMediaDataSchema],
titles: list[str],
workers: int | None = None,
):
"""A helper function used by and run in a background thread by get_fzf_preview function inorder to get the actual preview data to be displayed by fzf
Args:
anilist_results: the anilist results from an anilist action
titles: sanitized anime titles
workers:number of threads to use defaults to as many as possible
"""
# NOTE: Will probably make this a configuraable option
HEADER_COLOR = 215, 0, 95
SEPARATOR_COLOR = 208, 208, 208
SEPARATOR_WIDTH = 30
# 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
)
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('Media List:',*HEADER_COLOR)} {mediaListName}
{get_true_fg('Progress:',*HEADER_COLOR)} {progress}
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
{get_true_fg('Description:',*HEADER_COLOR)}
"""
template = textwrap.dedent(template)
template = f"""
{template}
{textwrap.fill(clean_html(
str(anime['description'])), width=45)}
"""
future_to_task[executor.submit(save_info_from_str, template, title)] = title
# execute the jobs
for future in concurrent.futures.as_completed(future_to_task):
task = future_to_task[future]
try:
future.result()
except Exception as exc:
logger.error("%r generated an exception: %s" % (task, exc))
# get rofi icons
def get_rofi_icons(
anilist_results: list[AnilistBaseMediaDataSchema], titles, workers=None
):
"""A helper function to make sure that the images are downloaded so they can be used as icons
Args:
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
anilist_results: the anilist results from an anilist action
"""
# use concurrency to download the images as fast as possible
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs
future_to_url = {}
for anime, title in zip(anilist_results, titles):
# actual link to download image from
image_url = anime["coverImage"]["large"]
future_to_url[executor.submit(save_image_from_url, image_url, title)] = (
image_url
)
# 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))
# 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,
)
# ensure images and info exists
background_worker.daemon = True
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:
# actual link to download image from
if not image_url:
continue
future_to_url[
executor.submit(save_image_from_url, image_url, episode)
] = image_url
template = textwrap.dedent(
f"""
{get_true_fg('Anime Title:',*HEADER_COLOR)} {anilist_result['title']['romaji'] or anilist_result['title']['english']}
{get_true_fg('Episode Title:',*HEADER_COLOR)} {episode_title}
"""
)
future_to_url[
executor.submit(save_info_from_str, template, episode)
] = 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))
background_worker = Thread(
target=_worker,
)
# ensure images and info exists
background_worker.daemon = True
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
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 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
Args:
titles (list[str]): The sanitized titles to use, NOTE: its important that they are sanitized since thay will be used as filenames
wait (bool): whether to block the ui as we wait for preview defaults to false
anilist_results: the anilist results got from an anilist action
Returns:
THe fzf preview script to use
"""
# ensure images and info exists
from ...constants import S_PLATFORM
background_worker = Thread(
target=write_search_results, args=(anilist_results, titles)
)
background_worker.daemon = True
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={}
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [ -s "%s\\\\\\$title" ]; then
if command -v chafa >/dev/null;then
chafa -f kitty -s $dim "%s\\\\\\$title"
fi
else echo Loading...
fi
if [ -s "%s\\\\\\$title" ]; then cat "%s\\\\\\$title"
else echo Loading...
fi
""" % (
fzf_preview,
IMAGES_CACHE_DIR.replace("\\","\\\\\\"),
IMAGES_CACHE_DIR.replace("\\","\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\","\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\","\\\\\\"),
)
else:
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 wait:
background_worker.join()
return preview

View File

@@ -1,131 +0,0 @@
import re
import shutil
import subprocess
from fastanime.constants import S_PLATFORM
def stream_video(MPV, url, mpv_args, custom_args):
process = subprocess.Popen(
[MPV, url, *mpv_args, *custom_args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
last_time = None
av_time_pattern = re.compile(r"AV: ([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)")
last_time = "0"
total_time = "0"
try:
while True:
if not process.stderr:
continue
output = process.stderr.readline()
if output:
# Match the timestamp in the output
match = av_time_pattern.search(output.strip())
if match:
current_time = match.group(1)
total_time = match.group(2)
match.group(3)
last_time = current_time
# print(f"Current stream time: {current_time}, Total time: {total_time}, Progress: {percentage}%")
# Check if the process has terminated
retcode = process.poll()
if retcode is not None:
print("Finshed at: ", last_time)
break
except Exception as e:
print(f"An error occurred: {e}")
finally:
process.terminate()
return last_time, total_time
def run_mpv(
link: str,
title: str | None = "",
start_time: str = "0",
ytdl_format="",
custom_args=[],
headers={},
subtitles=[],
):
# 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",
]
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"
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
# Example usage
if __name__ == "__main__":
run_mpv(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"Example Video",
"--fullscreen",
"--volume=50",
)

View File

@@ -1,374 +0,0 @@
from typing import TYPE_CHECKING
import mpv
from ...anilist import AniList
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):
h = duration_in_secs // 3600
m = duration_in_secs // 60
s = duration_in_secs - ((h * 3600) + (m * 60))
return f"{int(h):2d}:{int(m):2d}:{int(s):2d}".replace(" ", "0")
class MpvPlayer(object):
anime_provider: "AnimeProvider"
config: "Config"
subs = []
mpv_player: "mpv.MPV"
last_stop_time: str = "0"
last_total_time: str = "0"
last_stop_time_secs = 0
last_total_time_secs = 0
current_media_title = ""
player_fetching = False
def get_episode(
self,
type: "Literal['next','previous','reload','custom']",
ep_no=None,
server="top",
):
fastanime_runtime_state = self.fastanime_runtime_state
config = self.config
current_episode_number: str = (
fastanime_runtime_state.provider_current_episode_number
)
quality = config.quality
total_episodes: list = sorted(
fastanime_runtime_state.provider_available_episodes, key=float
)
anime_id_anilist: int = fastanime_runtime_state.selected_anime_id_anilist
provider_anime = fastanime_runtime_state.provider_anime
translation_type = config.translation_type
anime_provider = config.anime_provider
self.last_stop_time: str = "0"
self.last_total_time: str = "0"
self.last_stop_time_secs = 0
self.last_total_time_secs = 0
# next or prev
if type == "next":
self.mpv_player.show_text("Fetching next episode...")
next_episode = total_episodes.index(current_episode_number) + 1
if next_episode >= len(total_episodes):
next_episode = len(total_episodes) - 1
fastanime_runtime_state.provider_current_episode_number = total_episodes[
next_episode
]
current_episode_number = (
fastanime_runtime_state.provider_current_episode_number
)
config.update_watch_history(anime_id_anilist, str(current_episode_number))
elif type == "reload":
if current_episode_number not in total_episodes:
self.mpv_player.show_text("Episode not available")
return
self.mpv_player.show_text("Replaying Episode...")
elif type == "custom":
if not ep_no or ep_no not in total_episodes:
self.mpv_player.show_text("Episode number not specified or invalid")
self.mpv_player.show_text(
f"Acceptable episodes are: {total_episodes}",
)
return
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))
fastanime_runtime_state.provider_current_episode_number = str(ep_no)
else:
self.mpv_player.show_text("Fetching previous episode...")
prev_episode = total_episodes.index(current_episode_number) - 1
if prev_episode <= 0:
prev_episode = 0
fastanime_runtime_state.provider_current_episode_number = total_episodes[
prev_episode
]
current_episode_number = (
fastanime_runtime_state.provider_current_episode_number
)
config.update_watch_history(anime_id_anilist, str(current_episode_number))
# update episode progress
if config.user and current_episode_number:
AniList.update_anime_list(
{
"mediaId": anime_id_anilist,
"progress": int(float(current_episode_number)),
}
)
# get them juicy streams
episode_streams = anime_provider.get_episode_streams(
provider_anime,
current_episode_number,
translation_type,
)
if not episode_streams:
self.mpv_player.show_text("No streams were found")
return
# always select the first
if server == "top":
selected_server = next(episode_streams, None)
if not selected_server:
self.mpv_player.show_text("Sth went wrong when loading the episode")
return
else:
episode_streams_dict = {
episode_stream["server"]: episode_stream
for episode_stream in episode_streams
}
selected_server = episode_streams_dict.get(server)
if selected_server is None:
self.mpv_player.show_text(
f"Invalid server!!; servers available are: {episode_streams_dict.keys()}",
)
return
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)
if not stream_link_:
self.mpv_player.show_text("Quality not found")
return
self.mpv_player._set_property("start", "0")
stream_link = stream_link_["link"]
fastanime_runtime_state.provider_current_episode_stream_link = stream_link
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: "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
self.last_stop_time: str = "0"
self.last_total_time: str = "0"
self.last_stop_time_secs = 0
self.last_total_time_secs = 0
self.current_media_title = ""
mpv_player = mpv.MPV(
log_handler=print,
loglevel="error",
config=True,
input_default_bindings=True,
input_vo_keyboard=True,
osc=True,
ytdl=True,
)
# -- events --
@mpv_player.event_callback("file-loaded")
def set_total_time(event, *args):
d = mpv_player._get_property("duration")
self.player_fetching = False
if isinstance(d, float):
self.last_total_time = format_time(d)
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):
if len(args) > 1:
value = args[1]
if value is not None:
self.last_stop_time = format_time(value)
@mpv_player.property_observer("time-remaining")
def handle_time_remaining_update(
property, time_remaining: float | None = None, *args
):
if time_remaining is not None:
if time_remaining < 1 and config.auto_next and not self.player_fetching:
print("Auto Fetching Next Episode")
self.player_fetching = True
url = self.get_episode("next")
if url:
mpv_player.loadfile(
url,
)
mpv_player.title = self.current_media_title
# -- keybindings --
@mpv_player.on_key_press("shift+n")
def _next_episode():
url = self.get_episode("next")
if url:
mpv_player.loadfile(
url,
)
mpv_player.title = self.current_media_title
@mpv_player.on_key_press("shift+p")
def _previous_episode():
url = self.get_episode("previous")
if url:
mpv_player.loadfile(
url,
)
mpv_player.title = self.current_media_title
@mpv_player.on_key_press("shift+a")
def _toggle_auto_next():
config.auto_next = not config.auto_next
if config.auto_next:
mpv_player.show_text("Auto next enabled")
else:
mpv_player.show_text("Auto next disabled")
@mpv_player.on_key_press("shift+t")
def _toggle_translation_type():
translation_type = "sub" if config.translation_type == "dub" else "dub"
mpv_player.show_text("Changing translation type...")
anime = anime_provider.get_anime(
fastanime_runtime_state.provider_anime_search_result["id"],
)
if not anime:
mpv_player.show_text("Failed to update translation type")
return
fastanime_runtime_state.provider_available_episodes = anime[
"availableEpisodesDetail"
][translation_type]
config.translation_type = translation_type
if config.translation_type == "dub":
mpv_player.show_text("Translation Type set to dub")
else:
mpv_player.show_text("Translation Type set to sub")
@mpv_player.on_key_press("shift+r")
def _reload():
url = self.get_episode("reload")
if url:
mpv_player.loadfile(
url,
)
mpv_player.title = self.current_media_title
# -- script messages --
@mpv_player.message_handler("select-episode")
def select_episode(episode: bytes | None = None, *args):
if not episode:
mpv_player.show_text("No episode was selected")
return
url = self.get_episode("custom", episode.decode())
if url:
mpv_player.loadfile(
url,
)
mpv_player.title = self.current_media_title
@mpv_player.message_handler("select-server")
def select_server(server: bytes | None = None, *args):
if not server:
mpv_player.show_text("No server was selected")
return
url = self.get_episode("reload", server=server.decode())
if url:
mpv_player.loadfile(
url,
)
mpv_player.title = self.current_media_title
else:
pass
@mpv_player.message_handler("select-quality")
def select_quality(quality_raw: bytes | None = None, *args):
if not quality_raw:
mpv_player.show_text("No quality was selected")
return
q = ["360", "720", "1080"]
quality = quality_raw.decode()
links: list = fastanime_runtime_state.provider_server_episode_streams
q = [link["quality"] for link in links]
if quality in q:
config.quality = quality
stream_link_ = filter_by_quality(quality, links)
if not stream_link_:
mpv_player.show_text("Quality not found")
return
mpv_player.show_text(f"Changing to stream of quality {quality}")
stream_link = stream_link_["link"]
mpv_player.loadfile(stream_link)
else:
mpv_player.show_text(f"invalid quality!! Valid quality includes: {q}")
# -- events --
mpv_player.observe_property("time-pos", handle_time_start_update)
mpv_player.observe_property("time-remaining", handle_time_remaining_update)
mpv_player.register_event_callback(set_total_time)
# --script-messages --
mpv_player.register_message_handler("select-episode", select_episode)
mpv_player.register_message_handler("select-server", select_server)
mpv_player.register_message_handler("select-quality", select_quality)
self.mpv_player = mpv_player
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

@@ -1,27 +0,0 @@
import shutil
import subprocess
import requests
def print_img(url: str):
"""helper funtion to print an image given its url
Args:
url: [TODO:description]
"""
if EXECUTABLE := shutil.which("icat"):
subprocess.run([EXECUTABLE, url])
else:
EXECUTABLE = shutil.which("chafa")
if EXECUTABLE is None:
print("chafanot found")
return
res = requests.get(url)
if res.status_code != 200:
print("Error fetching image")
return
img_bytes = res.content
subprocess.run([EXECUTABLE, url, "--size=15x15"], input=img_bytes)

View File

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

View File

@@ -1,50 +0,0 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any
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"""
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"
selected_anime_anilist: "AnilistBaseMediaDataSchema"
selected_anime_id_anilist: int
selected_anime_title_anilist: str
# current_anilist_data: "AnilistDataSchema | AnilistMediaList"
anilist_results_data: "Any"
def exit_app(exit_code=0, *args):
import sys
from rich.console import Console
from ...constants import APP_NAME, ICON_PATH, USER_NAME
console = Console()
if not console.is_terminal:
from plyer import notification
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,
message=f"Have a good day {USER_NAME}",
title="Shutting down",
) # pyright:ignore
else:
console.clear()
console.print("Have a good day :smile:", USER_NAME)
sys.exit(exit_code)

View File

@@ -1,157 +0,0 @@
import logging
from typing import TYPE_CHECKING
from InquirerPy import inquirer
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ...libs.anime_provider.types import EpisodeStream
# Define ANSI escape codes as constants
RESET = "\033[0m"
BOLD = "\033[1m"
INVISIBLE_CURSOR = "\033[?25l"
VISIBLE_CURSOR = "\033[?25h"
UNDERLINE = "\033[4m"
# ESC[38;2;{r};{g};{b}m
BG_GREEN = "\033[48;2;120;233;12;m"
GREEN = "\033[38;2;45;24;45;m"
def get_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
Args:
quality: the quality to use
stream_links: a list of EpisodeStream objects
Returns:
an EpisodeStream object or None incase the quality was not found
"""
for stream_link in stream_links:
q = float(quality)
Q = float(stream_link["quality"])
# some providers have inaccurate/weird/non-standard eg qualities 718 instead of 720
if Q <= q + 80 and Q >= q - 80:
return stream_link
else:
if stream_links and default:
from rich import print
try:
print("[yellow bold]WARNING Qualities were:[/] ", stream_links)
print(
"[cyan bold]Using default of quality:[/] ",
stream_links[0]["quality"],
)
return stream_links[0]
except Exception as e:
print(e)
return
def format_bytes_to_human(num_of_bytes: float, suffix: str = "B"):
"""Helper function usedd to format bytes to human
Args:
num_of_bytes: the number of bytes to format
suffix: the suffix to use
Returns:
formated bytes
"""
for unit in ("", "K", "M", "G", "T", "P", "E", "Z"):
if abs(num_of_bytes) < 1024.0:
return f"{num_of_bytes:3.1f}{unit}{suffix}"
num_of_bytes /= 1024.0
return f"{num_of_bytes:.1f}Yi{suffix}"
def get_true_fg(string: str, r: int, g: int, b: int, bold: bool = True) -> str:
"""Custom helper function that enables colored text in the terminal
Args:
bold: whether to bolden the text
string: string to color
r: red
g: green
b: blue
Returns:
colored string
"""
# NOTE: Currently only supports terminals that support true color
if bold:
return f"{BOLD}\033[38;2;{r};{g};{b};m{string}{RESET}"
else:
return f"\033[38;2;{r};{g};{b};m{string}{RESET}"
def get_true_bg(string, r: int, g: int, b: int) -> str:
return f"\033[48;2;{r};{g};{b};m{string}{RESET}"
def fuzzy_inquirer(choices: list, prompt: str, **kwargs):
"""helper function that enables easier interaction with InquirerPy lib
Args:
choices: the choices to prompt
prompt: the prompt string to use
**kwargs: other options to pass to fuzzy_inquirer
Returns:
a choice
"""
from click import clear
clear()
action = inquirer.fuzzy( # pyright:ignore
prompt,
choices,
height="100%",
border=True,
validate=lambda result: result in choices,
**kwargs,
).execute()
return action

View File

@@ -1,85 +0,0 @@
import os
import sys
from pathlib import Path
from platform import system
import click
from . import APP_NAME, __version__
PLATFORM = system()
# ---- app deps ----
APP_DIR = os.path.abspath(os.path.dirname(__file__))
ASSETS_DIR = os.path.join(APP_DIR, "assets")
# --- icon stuff ---
if PLATFORM == "Windows":
ICON_PATH = os.path.join(ASSETS_DIR, "logo.ico")
else:
ICON_PATH = os.path.join(ASSETS_DIR, "logo.png")
# PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
# ----- user configs and data -----
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)
#
# cache dir
APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache")
# videos dir
video_dir_base = os.path.join(Path().home(), "Videos")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
elif S_PLATFORM == "darwin":
# app data
# app_data_dir_base = os.path.expanduser("~/Library/Application Support")
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__)
#
# cache dir
cache_dir_base = os.path.expanduser("~/Library/Caches")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME, __version__)
# videos dir
video_dir_base = os.path.expanduser("~/Movies")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
else:
# # app data
# app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "")
# if not app_data_dir_base.strip():
# app_data_dir_base = os.path.expanduser("~/.config")
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME)
#
# cache dir
cache_dir_base = os.environ.get("XDG_CACHE_HOME", "")
if not cache_dir_base.strip():
cache_dir_base = os.path.expanduser("~/.cache")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME)
# videos dir
video_dir_base = os.environ.get("XDG_VIDEOS_DIR", "")
if not video_dir_base.strip():
video_dir_base = os.path.expanduser("~/Videos")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
# ensure paths exist
Path(APP_DATA_DIR).mkdir(parents=True, exist_ok=True)
Path(APP_CACHE_DIR).mkdir(parents=True, exist_ok=True)
Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
# useful paths
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log")
USER_NAME = os.environ.get("USERNAME", "Anime fun")

View File

@@ -1,3 +0,0 @@
"""
his module contains an abstraction for interaction with the anilist api making it easy and efficient
"""

View File

@@ -1,423 +0,0 @@
"""
This is the core module availing all the abstractions of the anilist api
"""
import logging
from typing import TYPE_CHECKING
import requests
from .queries_graphql import (
airing_schedule_query,
anime_characters_query,
anime_query,
anime_relations_query,
delete_list_entry_query,
get_logged_in_user_query,
get_medialist_item_query,
get_user_info,
media_list_mutation,
media_list_query,
most_favourite_query,
most_popular_query,
most_recently_updated_query,
most_scored_query,
notification_query,
recommended_query,
search_query,
trending_query,
upcoming_anime_query,
)
if TYPE_CHECKING:
from .types import (
AnilistDataSchema,
AnilistMediaLists,
AnilistMediaListStatus,
AnilistNotifications,
AnilistUser_,
AnilistUserData,
AnilistViewerData,
)
logger = logging.getLogger(__name__)
ANILIST_ENDPOINT = "https://graphql.anilist.co"
class AniListApi:
"""An abstraction over the anilist api offering an easy and simple interface
Attributes:
session: [TODO:attribute]
session: [TODO:attribute]
token: [TODO:attribute]
headers: [TODO:attribute]
user_id: [TODO:attribute]
token: [TODO:attribute]
headers: [TODO:attribute]
user_id: [TODO:attribute]
"""
session: requests.Session
def __init__(self) -> None:
self.session = requests.session()
def login_user(self, token: str):
"""methosd used to login a new user enabling authenticated requests
Args:
token: anilist app token
Returns:
the logged in user
"""
self.token = token
self.headers = {"Authorization": f"Bearer {self.token}"}
self.session.headers.update(self.headers)
success, user = self.get_logged_in_user()
if not user:
return
if not success or not user:
return
user_info: "AnilistUser_" = user["data"]["Viewer"]
self.user_id = user_info["id"]
return user_info
def get_notification(
self,
) -> tuple[bool, "AnilistNotifications"] | tuple[bool, None]:
"""get the top five latest notifications for anime thats airing
Returns:
airing notifications
"""
return self._make_authenticated_request(notification_query)
def update_login_info(self, user: "AnilistUser_", token: str):
"""method used to login a user enabling authenticated requests
Args:
user: an anilist user object
token: the login token
"""
self.token = token
self.headers = {"Authorization": f"Bearer {self.token}"}
self.session.headers.update(self.headers)
self.user_id = user["id"]
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:
an anilist user
"""
if not self.headers:
return (False, None)
return self._make_authenticated_request(get_logged_in_user_query)
def update_anime_list(self, values_to_update: dict):
"""a powerful method for managing mediaLists giving full power to the user
Args:
values_to_update: a dict containing valid media list options
Returns:
an anilist object indicating success
"""
variables = {"userId": self.user_id, **values_to_update}
return self._make_authenticated_request(media_list_mutation, variables)
def get_anime_list(
self,
status: "AnilistMediaListStatus",
type="ANIME",
) -> tuple[bool, "AnilistMediaLists"] | tuple[bool, None]:
"""gets an anime list from your media list given the list status
Args:
status: the mediaListStatus of the anime list
Returns:
a media list
"""
variables = {"status": status, "userId": self.user_id, "type": type}
return self._make_authenticated_request(media_list_query, variables)
def get_medialist_entry(
self, mediaId: int
) -> tuple[bool, dict] | tuple[bool, None]:
"""Get the id entry of the items in an Anilist MediaList
Args:
mediaId: The mediaList item entry mediaId
Returns:
a boolean indicating whether the request succeeded and either a dict object containing the id of the media list entry
"""
variables = {"mediaId": mediaId}
return self._make_authenticated_request(get_medialist_item_query, variables)
def delete_medialist_entry(self, mediaId: int):
"""Deletes a mediaList item given its mediaId
Args:
mediaId: the media id of the anime
Returns:
a tuple containing a boolean whether the operation was successful and either an anilist object or none depending on success
"""
result = self.get_medialist_entry(mediaId)
data = result[1]
if not result[0] or not data:
return result
id = data["data"]["MediaList"]["id"]
variables = {"id": id}
return self._make_authenticated_request(delete_list_entry_query, variables)
# TODO: unify the _make_authenticated_request with original since sessions are now in use
def _make_authenticated_request(self, query: str, variables: dict = {}):
"""the abstraction over all authenticated requests
Args:
query: the anilist query to make
variables: the anilist variables to use
Returns:
an anilist object containing the queried data or none and a boolean indicating whether the request was successful
"""
try:
response = self.session.post(
ANILIST_ENDPOINT,
json={"query": query, "variables": variables},
timeout=10,
headers=self.headers,
)
anilist_data = response.json()
# ensuring you dont get blocked
if (
int(response.headers.get("X-RateLimit-Remaining", 0)) < 30
and not response.status_code == 500
):
print(
"Warning you are exceeding the allowed number of calls per minute"
)
logger.warning(
"You are exceeding the allowed number of calls per minute for the AniList api enforcing timeout"
)
print("Forced timeout will now be initiated")
import time
print("sleeping...")
time.sleep(1 * 60)
if response.status_code == 200:
return (True, anilist_data)
else:
return (False, anilist_data)
except requests.exceptions.Timeout:
logger.warning(
"Timeout has been exceeded this could mean anilist is down or you have lost internet connection"
)
return (False, None)
except requests.exceptions.ConnectionError:
logger.warning(
"ConnectionError this could mean anilist is down or you have lost internet connection"
)
return (False, None)
except Exception as e:
logger.error(f"Something unexpected occured {e}")
return (False, None) # type: ignore
def get_data(
self, query: str, variables: dict = {}
) -> tuple[bool, "AnilistDataSchema"]:
"""the abstraction over all none authenticated requests and that returns data of a similar type
Args:
query: the anilist query
variables: the anilist api variables
Returns:
a boolean indicating success and none or an anilist object depending on success
"""
try:
response = self.session.post(
ANILIST_ENDPOINT,
json={"query": query, "variables": variables},
timeout=10,
)
anilist_data: AnilistDataSchema = response.json()
# ensuring you dont get blocked
if (
int(response.headers.get("X-RateLimit-Remaining", 0)) < 30
and not response.status_code == 500
):
print(
"Warning you are exceeding the allowed number of calls per minute"
)
logger.warning(
"You are exceeding the allowed number of calls per minute for the AniList api enforcing timeout"
)
print("Forced timeout will now be initiated")
import time
print("sleeping...")
time.sleep(1 * 60)
if response.status_code == 200:
return (True, anilist_data)
else:
return (False, anilist_data)
except requests.exceptions.Timeout:
logger.warning(
"Timeout has been exceeded this could mean anilist is down or you have lost internet connection"
)
return (
False,
{
"Error": "Timeout Exceeded for connection there might be a problem with your internet or anilist is down."
},
) # type: ignore
except requests.exceptions.ConnectionError:
logger.warning(
"ConnectionError this could mean anilist is down or you have lost internet connection"
)
return (
False,
{
"Error": "There might be a problem with your internet or anilist is down."
},
) # type: ignore
except Exception as e:
logger.error(f"Something unexpected occured {e}")
return (False, {"Error": f"{e}"}) # type: ignore
def search(
self,
query: str | None = None,
sort: str | None = None,
genre_in: list[str] | None = None,
id_in: list[int] | None = None,
genre_not_in: list[str] = ["hentai"],
popularity_greater: int | None = None,
popularity_lesser: int | None = None,
averageScore_greater: int | None = None,
averageScore_lesser: int | None = None,
tag_in: list[str] | None = None,
tag_not_in: list[str] | None = None,
status: str | None = None,
status_in: list[str] | None = None,
status_not_in: list[str] | None = None,
endDate_greater: int | None = None,
endDate_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,
):
"""
A powerful method abstracting all of anilist media queries
"""
variables = {}
for key, val in list(locals().items())[1:]:
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
def get_anime(self, id: int):
"""
Gets a single anime by a valid anilist anime id
"""
variables = {"id": id}
return self.get_data(anime_query, variables)
def get_trending(self, type="ANIME", *_, **kwargs):
"""
Gets the currently trending anime
"""
variables = {"type": type}
trending = self.get_data(trending_query, variables)
return trending
def get_most_favourite(self, type="ANIME", *_, **kwargs):
"""
Gets the most favoured anime on anilist
"""
variables = {"type": type}
most_favourite = self.get_data(most_favourite_query, variables)
return most_favourite
def get_most_scored(self, type="ANIME", *_, **kwargs):
"""
Gets most scored anime on anilist
"""
variables = {"type": type}
most_scored = self.get_data(most_scored_query, variables)
return most_scored
def get_most_recently_updated(self, type="ANIME", *_, **kwargs):
"""
Gets most recently updated anime from anilist
"""
variables = {"type": type}
most_recently_updated = self.get_data(most_recently_updated_query, variables)
return most_recently_updated
def get_most_popular(
self,
type="ANIME",
):
"""
Gets most popular anime on anilist
"""
variables = {"type": type}
most_popular = self.get_data(most_popular_query, variables)
return most_popular
def get_upcoming_anime(self, type="ANIME", page: int = 1, *_, **kwargs):
"""
Gets upcoming anime from anilist
"""
variables = {"page": page, "type": type}
upcoming_anime = self.get_data(upcoming_anime_query, variables)
return upcoming_anime
# NOTE: THe following methods will probably be scraped soon
def get_recommended_anime_for(self, id: int, type="ANIME", *_, **kwargs):
variables = {"type": type}
recommended_anime = self.get_data(recommended_query, variables)
return recommended_anime
def get_charcters_of(self, id: int, type="ANIME", *_, **kwargs):
variables = {"id": id}
characters = self.get_data(anime_characters_query, variables)
return characters
def get_related_anime_for(self, id: int, type="ANIME", *_, **kwargs):
variables = {"id": id}
related_anime = self.get_data(anime_relations_query, variables)
return related_anime
def get_airing_schedule_for(self, id: int, type="ANIME", *_, **kwargs):
variables = {"id": id}
airing_schedule = self.get_data(airing_schedule_query, variables)
return airing_schedule

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +0,0 @@
from .allanime.constants import SERVERS_AVAILABLE as ALLANIME_SERVERS
from .animepahe.constants import SERVERS_AVAILABLE as ANIMEPAHESERVERS
from .aniwatch.constants import SERVERS_AVAILABLE as ANIWATCHSERVERS
anime_sources = {
"allanime": "api.AllAnimeAPI",
"animepahe": "api.AnimePaheApi",
"aniwatch": "api.AniWatchApi",
"aniwave": "api.AniWaveApi",
}
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHESERVERS, *ANIWATCHSERVERS]

View File

@@ -1,315 +0,0 @@
"""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 ..utils import give_random_quality, one_digit_symmetric_xor
from .constants import ALLANIME_API_ENDPOINT, ALLANIME_BASE, ALLANIME_REFERER
from .gql_queries import ALLANIME_EPISODES_GQL, ALLANIME_SEARCH_GQL, ALLANIME_SHOW_GQL
if TYPE_CHECKING:
from .types import AllAnimeEpisode
logger = logging.getLogger(__name__)
# TODO: create tests for the api
#
# ** Based on ani-cli **
class AllAnimeAPI(AnimeProvider):
"""
Provides a fast and effective interface to AllAnime site.
"""
api_endpoint = ALLANIME_API_ENDPOINT
HEADERS = {
"Referer": ALLANIME_REFERER,
}
def _fetch_gql(self, query: str, variables: dict):
"""main abstraction over all requests to the allanime api
Args:
query: [TODO:description]
variables: [TODO:description]
Returns:
[TODO:return]
"""
try:
response = self.session.get(
self.api_endpoint,
params={
"variables": json.dumps(variables),
"query": query,
},
timeout=10,
)
if response.ok:
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 {}
def search_for_anime(
self,
user_query: str,
translation_type: str = "sub",
nsfw=True,
unknown=True,
**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 = {
"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"[ALLANIME-ERROR]: {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-ERROR]: {e}")
return {}
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,
}
try:
episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables)
return episode["episode"]
except Exception as e:
logger.error(f"[ALLANIME-ERROR]: {e}")
return {}
def get_episode_streams(self, anime, 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]
"""
anime_id = anime["id"]
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}/"},
"subtitles": [],
"links": [
{
"link": url,
"quality": "1080",
}
],
}
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,
timeout=10,
)
if resp.ok:
match embed["sourceName"]:
case "Luf-mp4":
logger.debug("allanime:Found streams from gogoanime")
yield {
"server": "gogoanime",
"headers": {},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
case "Kir":
logger.debug("allanime:Found streams from wetransfer")
yield {
"server": "wetransfer",
"headers": {},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
case "S-mp4":
logger.debug("allanime:Found streams from sharepoint")
yield {
"server": "sharepoint",
"headers": {},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
case "Sak":
logger.debug("allanime:Found streams from dropbox")
yield {
"server": "dropbox",
"headers": {},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
case "Default":
logger.debug("allanime:Found streams from wixmp")
yield {
"server": "wixmp",
"headers": {},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f'{anime["title"]}'
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
except Timeout:
logger.error(
"[ALLANIME-ERROR]: Timeout has been exceeded this could mean allanime is down or you have lost internet connection"
)
except Exception as e:
logger.error(f"[ALLANIME-ERROR]: {e}")
except Exception as e:
logger.error(f"[ALLANIME-ERROR]: {e}")
return []

View File

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

View File

@@ -1,56 +0,0 @@
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
}
}
}
"""
ALLANIME_EPISODES_GQL = """\
query (
$showId: String!
$translationType: VaildTranslationTypeEnumType!
$episodeString: String!
) {
episode(
showId: $showId
translationType: $translationType
episodeString: $episodeString
) {
episodeString
sourceUrls
notes
}
}
"""
ALLANIME_SHOW_GQL = """
query ($showId: String!) {
show(_id: $showId) {
_id
name
availableEpisodesDetail
}
}
"""

View File

@@ -1,73 +0,0 @@
from typing import Literal, TypedDict
class AllAnimeEpisodesInfo(TypedDict):
dub: int
sub: int
raw: int
class AllAnimePageInfo(TypedDict):
total: int
class AllAnimeShow(TypedDict):
_id: str
name: str
availableEpisodesDetail: AllAnimeEpisodesInfo
__typename: str
class AllAnimeSearchResult(TypedDict):
_id: str
name: str
availableEpisodes: list[str]
__typename: str | None
class AllAnimeShows(TypedDict):
pageInfo: AllAnimePageInfo
edges: list[AllAnimeSearchResult]
class AllAnimeSearchResults(TypedDict):
shows: AllAnimeShows
class AllAnimeSourcesDownloads(TypedDict):
sourceName: str
dowloadUrl: str
class AllAnimeSources(TypedDict):
sourceUrl: str
priority: float
sandbox: str
sourceName: str
type: str
className: str
streamerId: str
downloads: AllAnimeSourcesDownloads
Server = Literal["gogoanime", "dropbox", "wetransfer", "sharepoint"]
class AllAnimeEpisode(TypedDict):
episodeString: str
sourceUrls: list[AllAnimeSources]
notes: str | None
class AllAnimeStream:
link: str
mp4: bool
hls: bool | None
resolutionStr: str
fromCache: str
priority: int
headers: dict | None
class AllAnimeStreams:
links: list[AllAnimeStream]

View File

@@ -1,237 +0,0 @@
import logging
import random
import re
import time
from typing import TYPE_CHECKING
from yt_dlp.utils import (
extract_attributes,
get_element_by_id,
get_elements_html_by_class,
)
from ..base_provider import AnimeProvider
from .constants import (
ANIMEPAHE_BASE,
ANIMEPAHE_ENDPOINT,
REQUEST_HEADERS,
SERVER_HEADERS,
)
from .utils import process_animepahe_embed_page
if TYPE_CHECKING:
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimeSearchResult
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
logger = logging.getLogger(__name__)
KWIK_RE = re.compile(r"Player\|(.+?)'")
# TODO: hack this to completion
class AnimePaheApi(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}"
response = self.session.get(
url,
)
if not response.ok:
return
data: "AnimePaheSearchPage" = response.json()
self.search_page = data
return {
"pageInfo": {
"total": data["total"],
"perPage": data["per_page"],
"currentPage": data["current_page"],
},
"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"]
],
}
except Exception as e:
logger.error(f"[ANIMEPAHE-ERROR]: {e}")
return {}
def get_anime(self, session_id: str, *args):
page = 1
try:
anime_result: "AnimeSearchResult" = [
anime
for anime in self.search_page["data"]
if anime["session"] == session_id
][0]
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,
)
if response.ok:
if not data:
data.update(response.json())
else:
if ep_data := response.json().get("data"):
data["data"].extend(ep_data)
if response.json()["next_page_url"]:
# TODO: Refine this
time.sleep(
random.choice(
[
0.25,
0.1,
0.5,
0.75,
1,
]
)
)
page += 1
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
_pages_loader(
url,
page,
)
_pages_loader(
url,
page,
)
if not data:
return {}
self.anime = data # pyright:ignore
episodes = list(map(str, [episode["episode"] for episode in data["data"]]))
title = ""
return {
"id": session_id,
"title": anime_result["title"],
"year": anime_result["year"],
"season": anime_result["season"],
"poster": anime_result["poster"],
"score": anime_result["score"],
"availableEpisodesDetail": {
"sub": episodes,
"dub": episodes,
"raw": episodes,
},
"episodesInfo": [
{
"title": f"{episode['title'] or title};{episode['episode']}",
"episode": episode["episode"],
"id": episode["session"],
"translation_type": episode["audio"],
"duration": episode["duration"],
"poster": episode["snapshot"],
}
for episode in data["data"]
],
}
except Exception as e:
logger.error(f"[ANIMEPAHE-ERROR]: {e}")
return {}
def get_episode_streams(self, 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)
]
if not episode:
logger.error(
f"[ANIMEPAHE-ERROR]: episode {episode_number} doesn't exist"
)
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)
# 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 = (
f"{episode['title'] or anime['title']}; Episode {episode['episode']}"
)
# get all links
streams = {
"server": "kwik",
"links": [],
"episode_title": episode_title,
"subtitles": [],
"headers": {},
}
for res_dict in res_dicts:
# get embed url
embed_url = res_dict["data-src"]
data_audio = "dub" if res_dict["data-audio"] == "eng" else "sub"
# filter streams by translation_type
if data_audio != translation_type:
continue
if not embed_url:
logger.warn(
"[ANIMEPAHE-WARN]: embed url not found please report to the developers"
)
return []
# get embed page
embed_response = self.session.get(
embed_url, headers={"User-Agent": self.USER_AGENT, **SERVER_HEADERS}
)
if not response.ok:
continue
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")
return
juicy_stream = JUICY_STREAM_REGEX.search(decoded_js)
if not juicy_stream:
logger.error("[ANIMEPAHE-ERROR]: 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-ERROR]: {e}")

View File

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

View File

@@ -1,236 +0,0 @@
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 ..utils import give_random_quality
from .constants import SERVERS_AVAILABLE
from .types import AniWatchStream
logger = logging.getLogger(__name__)
LINK_TO_STREAMS_REGEX = re.compile(r".*://(.*)/embed-(2|4|6)/e-([0-9])/(.*)\?.*")
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 AniWatchApi(AnimeProvider):
# HEADERS = {"Referer": "https://hianime.to/home"}
def search_for_anime(self, anime_title: str, *args):
try:
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"]
results.append(
{
"availableEpisodes": list(range(1, episodes)),
"id": anime_id,
"title": title,
"poster": image_link,
}
)
self.search_results = results
return {"pageInfo": {}, "results": results}
except Exception as e:
logger.error(f"[ANIWATCH-ERROR]: {e}")
def get_anime(self, aniwatch_id, *args):
try:
anime_result = {}
for anime in self.search_results:
if anime["id"] == aniwatch_id:
anime_result = anime
break
anime_url = f"https://hianime.to/ajax/v2/episode/list/{aniwatch_id}"
response = self.session.get(anime_url, timeout=10)
if response.ok:
response_json = response.json()
aniwatch_anime_page = response_json["html"]
episodes_info_container_html = get_element_html_by_class(
"ss-list", aniwatch_anime_page
)
episodes_info_html_list = get_elements_html_by_class(
"ep-item", episodes_info_container_html
)
# keys: [ data-number: episode_number, data-id: episode_id, title: episode_title , href:episode_page_url]
episodes_info_dicts = [
extract_attributes(episode_dict)
for episode_dict in episodes_info_html_list
]
episodes = [episode["data-number"] for episode in episodes_info_dicts]
self.episodes_info = [
{
"id": episode["data-id"],
"title": (
(episode["title"] or "").replace(
f"Episode {episode['data-number']}", ""
)
or anime_result["title"]
)
+ f"; Episode {episode['data-number']}",
"episode": episode["data-number"],
}
for episode in episodes_info_dicts
]
return {
"id": aniwatch_id,
"availableEpisodesDetail": {
"dub": episodes,
"sub": episodes,
"raw": episodes,
},
"poster": anime_result["poster"],
"title": anime_result["title"],
"episodes_info": self.episodes_info,
}
except Exception as e:
logger.error(f"[ANIWACTCH-ERROR]: {e}")
def get_episode_streams(self, anime, episode, translation_type, *args):
try:
episode_details = [
episode_details
for episode_details in self.episodes_info
if episode_details["episode"] == episode
]
if not episode_details:
return
episode_details = episode_details[0]
episode_url = f"https://hianime.to/ajax/v2/episode/servers?episodeId={episode_details['id']}"
response = self.session.get(episode_url)
if response.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.warn("AniWatch: sub not found")
servers_html_sub = None
# dub servers
try:
servers_html_dub = get_elements_html_by_class(
"server-item", servers_containers_html[1]
)
except Exception:
logger.warn("AniWatch: dub not found")
servers_html_dub = None
if translation_type == "dub":
servers_html = servers_html_dub
else:
servers_html = servers_html_sub
if not servers_html:
return
for server_name, server_html in zip(
cycle(SERVERS_AVAILABLE), servers_html
):
try:
# keys: [ data-type: translation_type, data-id: embed_id, data-server-id: server_id ]
servers_info = extract_attributes(server_html)
embed_url = f"https://hianime.to/ajax/v2/episode/sources?id={servers_info['data-id']}"
embed_response = self.session.get(embed_url)
if embed_response.ok:
embed_json = embed_response.json()
raw_link_to_streams = embed_json["link"]
match = LINK_TO_STREAMS_REGEX.match(raw_link_to_streams)
if not match:
continue
provider_domain = match.group(1)
embed_type = match.group(2)
episode_number = match.group(3)
source_id = match.group(4)
link_to_streams = f"https://{provider_domain}/embed-{embed_type}/ajax/e-{episode_number}/getSources?id={source_id}"
link_to_streams_response = self.session.get(link_to_streams)
if link_to_streams_response.ok:
juicy_streams_json: "AniWatchStream" = (
link_to_streams_response.json()
)
yield {
"headers": {},
"subtitles": [
{
"url": track["file"],
"language": track["label"],
}
for track in juicy_streams_json["tracks"]
if track["kind"] == "captions"
],
"server": server_name,
"episode_title": episode_details["title"],
"links": give_random_quality(
[
{"link": link["file"], "type": link["type"]}
for link in juicy_streams_json["sources"]
]
),
}
except Exception as e:
logger.error(f"[ANIWATCH_ERROR]: {e}")
except Exception as e:
logger.error(f"[ANIWATCH_ERROR]: {e}")

View File

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

View File

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

View File

@@ -1,65 +0,0 @@
from html.parser import HTMLParser
from yt_dlp.utils import clean_html, get_element_by_class, get_elements_by_class
from ..base_provider import AnimeProvider
from .constants import ANIWAVE_BASE, SEARCH_HEADERS
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 AniWaveApi(AnimeProvider):
def search_for_anime(self, anime_title, *args):
self.session.headers.update(SEARCH_HEADERS)
search_url = f"{ANIWAVE_BASE}/filter"
params = {"keyword": anime_title}
res = self.session.get(search_url, params=params)
search_page = res.text
search_results_html_list = get_elements_by_class("item", search_page)
results = []
for result_html in search_results_html_list:
aniposter_html = get_element_by_class("poster", result_html)
episode_html = get_element_by_class("sub", aniposter_html)
episodes = clean_html(episode_html) or 12
if not aniposter_html:
return
parser = ParseAnchorAndImgTag()
parser.feed(aniposter_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["src"]
title = image_data["alt"]
anime_id = anime_link_data["href"]
results.append(
{
"availableEpisodes": list(range(1, episodes)),
"id": anime_id,
"title": title,
"poster": image_link,
}
)
self.search_results = results
return {"pageInfo": {}, "results": results}
def get_anime(self, anime_id, *args):
anime_page_url = f"{ANIWAVE_BASE}{anime_id}"
self.session.get(anime_page_url)
# TODO: to be continued; mostly js so very difficult

View File

@@ -1,20 +0,0 @@
ANIWAVE_BASE = "https://aniwave.to"
SEARCH_HEADERS = {
"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': 'Utf-8',
"Referer": "https://aniwave.to/filter",
"DNT": "1",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Connection": "keep-alive",
"Alt-Used": "aniwave.to",
# 'Cookie': '__pf=1; usertype=guest; session=BElk9DJdO3sFdDmLiGxuNiM9eGYO1TjktGsmdwjV',
"Priority": "u=0, i",
# Requests doesn't support trailers
# 'TE': 'trailers',
}

View File

@@ -1,13 +0,0 @@
import requests
from yt_dlp.utils.networking import random_user_agent
class AnimeProvider:
session: requests.Session
USER_AGENT = random_user_agent()
HEADERS = {}
def __init__(self) -> None:
self.session = requests.session()
self.session.headers.update({"User-Agent": self.USER_AGENT, **self.HEADERS})

View File

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

@@ -1,89 +0,0 @@
from typing import Literal, TypedDict
class PageInfo(TypedDict):
total: int
perPage: int
currentPage: int
#
# class EpisodesDetail(TypedDict):
# dub: int
# sub: int
# raw: int
#
# search data
class SearchResult(TypedDict):
id: str
title: str
availableEpisodes: list[str]
type: str
score: int
status: str
season: str
poster: str
class SearchResults(TypedDict):
pageInfo: PageInfo
results: list[SearchResult]
# anime data
class AnimeEpisodeDetails(TypedDict):
dub: list[str]
sub: list[str]
raw: list[str]
#
# 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):
id: str
title: str
availableEpisodesDetail: AnimeEpisodeDetails
type: str | None
episodesInfo: list[AnimeEpisodeInfo] | None
poster: str
year: str
class EpisodeStream(TypedDict):
resolution: str | None
link: str
hls: bool | None
mp4: bool | None
priority: int | None
quality: Literal["360", "720", "1080", "unknown"]
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

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

@@ -1,286 +0,0 @@
import logging
from typing import TYPE_CHECKING
from requests import post
from thefuzz import fuzz
if TYPE_CHECKING:
from ..anilist.types import AnilistDataSchema
logger = logging.getLogger(__name__)
ANILIST_ENDPOINT = "https://graphql.anilist.co"
"""
query ($query: String) {
Page(perPage: 50) {
pageInfo {
total
currentPage
hasNextPage
}
media(search: $query, type: ANIME) {
id
idMal
title {
romaji
english
}
episodes
status
nextAiringEpisode {
timeUntilAiring
airingAt
episode
}
}
}
}
"""
def search_for_manga_with_anilist(manga_title: str):
query = """
query ($query: String) {
Page(perPage: 50) {
pageInfo {
currentPage
}
media(search: $query, type: MANGA) {
id
idMal
title {
romaji
english
}
chapters
status
coverImage {
medium
large
}
}
}
}
"""
response = post(
ANILIST_ENDPOINT,
json={"query": query, "variables": {"query": manga_title}},
timeout=10,
)
if response.status_code == 200:
anilist_data: "AnilistDataSchema" = response.json()
return {
"pageInfo": anilist_data["data"]["Page"]["pageInfo"],
"results": [
{
"id": anime_result["id"],
"poster": anime_result["coverImage"]["large"],
"title": (
anime_result["title"]["romaji"]
or anime_result["title"]["english"]
)
+ f" [Chapters: {anime_result['chapters']}]",
"type": "manga",
"availableChapters": list(
range(
1,
(
anime_result["chapters"]
if anime_result["chapters"]
else 0
),
)
),
}
for anime_result in anilist_data["data"]["Page"]["media"]
],
}
def search_for_anime_with_anilist(anime_title: str):
query = """
query ($query: String) {
Page(perPage: 50) {
pageInfo {
total
currentPage
hasNextPage
}
media(search: $query, type: ANIME) {
id
idMal
title {
romaji
english
}
episodes
status
nextAiringEpisode {
timeUntilAiring
airingAt
episode
}
}
}
}
"""
response = post(
ANILIST_ENDPOINT,
json={"query": query, "variables": {"query": anime_title}},
timeout=10,
)
if response.status_code == 200:
anilist_data: "AnilistDataSchema" = response.json()
return {
"pageInfo": anilist_data["data"]["Page"]["pageInfo"],
"results": [
{
"id": anime_result["id"],
"title": anime_result["title"]["romaji"]
or anime_result["title"]["english"],
"type": "anime",
"availableEpisodes": list(
range(
1,
(
anime_result["episodes"]
if not anime_result["status"] == "RELEASING"
and anime_result["episodes"]
else (
anime_result["nextAiringEpisode"]["episode"] - 1
if anime_result["nextAiringEpisode"]
else 0
)
),
)
),
}
for anime_result in anilist_data["data"]["Page"]["media"]
],
}
def get_mal_id_and_anilist_id(anime_title: str) -> "dict[str,int] | None":
"""the abstraction over all none authenticated requests and that returns data of a similar type
Args:
query: the anilist query
variables: the anilist api variables
Returns:
a boolean indicating success and none or an anilist object depending on success
"""
query = """
query ($query: String) {
Page(perPage: 50) {
pageInfo {
total
currentPage
hasNextPage
}
media(search: $query, type: ANIME) {
id
idMal
title {
romaji
english
}
}
}
}
"""
try:
variables = {"query": anime_title}
response = post(
ANILIST_ENDPOINT,
json={"query": query, "variables": variables},
timeout=10,
)
anilist_data: "AnilistDataSchema" = response.json()
if response.status_code == 200:
anime = max(
anilist_data["data"]["Page"]["media"],
key=lambda anime: max(
(
fuzz.ratio(anime, str(anime["title"]["romaji"])),
fuzz.ratio(anime_title, str(anime["title"]["english"])),
)
),
)
return {"id_anilist": anime["id"], "id_mal": anime["idMal"]}
except Exception as e:
logger.error(f"Something unexpected occured {e}")
def get_basic_anime_info_by_title(anime_title: str):
"""the abstraction over all none authenticated requests and that returns data of a similar type
Args:
query: the anilist query
variables: the anilist api variables
Returns:
a boolean indicating success and none or an anilist object depending on success
"""
query = """
query ($query: String) {
Page(perPage: 50) {
pageInfo {
total
}
media(search: $query, type: ANIME) {
id
idMal
title {
romaji
english
}
streamingEpisodes {
title
}
}
}
}
"""
from ...Utility.data import anime_normalizer
# normalize the title
anime_title = anime_normalizer.get(anime_title, anime_title)
try:
variables = {"query": anime_title}
response = post(
ANILIST_ENDPOINT,
json={"query": query, "variables": variables},
timeout=10,
)
anilist_data: "AnilistDataSchema" = response.json()
if response.status_code == 200:
anime = max(
anilist_data["data"]["Page"]["media"],
key=lambda anime: max(
(
fuzz.ratio(
anime_title.lower(), str(anime["title"]["romaji"]).lower()
),
fuzz.ratio(
anime_title.lower(), str(anime["title"]["english"]).lower()
),
)
),
)
return {
"idAnilist": anime["id"],
"idMal": anime["idMal"],
"title": {
"english": anime["title"]["english"],
"romaji": anime["title"]["romaji"],
},
"episodes": [
{"title": episode["title"]}
for episode in anime["streamingEpisodes"]
if episode
],
}
except Exception as e:
logger.error(f"Something unexpected occured {e}")

View File

@@ -1,192 +0,0 @@
import logging
import os
import shutil
import subprocess
import sys
from typing import Callable, List
from click import clear
from rich import print
logger = logging.getLogger(__name__)
FZF_DEFAULT_OPTS = """
--color=fg:#d0d0d0,fg+:#d0d0d0,bg:#121212,bg+:#262626
--color=hl:#5f87af,hl+:#5fd7ff,info:#afaf87,marker:#87ff00
--color=prompt:#d7005f,spinner:#af5fff,pointer:#af5fff,header:#87afaf
--color=border:#262626,label:#aeaeae,query:#d9d9d9
--border="rounded" --border-label="" --preview-window="border-rounded" --prompt="> "
--marker=">" --pointer="" --separator="" --scrollbar=""
"""
HEADER = """
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝
"""
class FZF:
"""an abstraction over the fzf commandline utility
Attributes:
FZF_EXECUTABLE: [TODO:attribute]
default_options: [TODO:attribute]
stdout: [TODO:attribute]
"""
if not os.getenv("FZF_DEFAULT_OPTS"):
os.environ["FZF_DEFAULT_OPTS"] = FZF_DEFAULT_OPTS
FZF_EXECUTABLE = shutil.which("fzf")
default_options = [
"--cycle",
"--info=hidden",
"--layout=reverse",
"--height=100%",
"--bind=right:accept",
"--no-margin",
"+m",
"-i",
"--exact",
"--tabstop=1",
"--preview-window=left,35%,wrap",
"--wrap",
]
def _with_filter(self, command: str, work: Callable) -> List[str]:
"""ported from the fzf docs demo
Args:
command: [TODO:description]
work: [TODO:description]
Returns:
[TODO:return]
"""
try:
process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
shell=True,
)
except subprocess.SubprocessError as e:
print(f"Failed to start subprocess: {e}", file=sys.stderr)
return []
original_stdout = sys.stdout
sys.stdout = process.stdin
try:
work()
if process.stdin:
process.stdin.close()
except Exception as e:
print(f"Exception during work execution: {e}", file=sys.stderr)
finally:
sys.stdout = original_stdout
output = []
if process.stdout:
output = process.stdout.read().splitlines()
process.stdout.close()
return output
def _run_fzf(self, commands: list[str], _fzf_input) -> str:
"""core abstraction
Args:
_fzf_input ([TODO:parameter]): [TODO:description]
commands: [TODO:description]
Raises:
Exception: [TODO:throw]
Returns:
[TODO:return]
"""
fzf_input = "\n".join(_fzf_input)
if not self.FZF_EXECUTABLE:
raise Exception("fzf executable not found")
result = subprocess.run(
[self.FZF_EXECUTABLE, *commands],
input=fzf_input,
stdout=subprocess.PIPE,
universal_newlines=True,
text=True,
encoding="utf-8"
)
if not result or result.returncode != 0 or not result.stdout:
print("sth went wrong:confused:")
input("press enter to try again...")
clear()
return self._run_fzf(commands, _fzf_input)
clear()
return result.stdout.strip()
def run(
self,
fzf_input: list[str],
prompt: str,
header: str = HEADER,
preview: str | None = None,
expect: str | None = None,
validator: Callable | None = None,
) -> str:
"""a helper method that wraps common use cases over the fzf utility
Args:
fzf_input: [TODO:description]
prompt: [TODO:description]
header: [TODO:description]
preview: [TODO:description]
expect: [TODO:description]
validator: [TODO:description]
Returns:
[TODO:return]
"""
_commands = [
*self.default_options,
"--header",
HEADER,
"--header-first",
"--prompt",
prompt.title(),
] # pyright:ignore
if preview:
_commands.append(f"--preview={preview}")
if expect:
_commands.append(f"--expect={expect}")
result = self._run_fzf(_commands, fzf_input) # pyright:ignore
if not result:
print("Please enter a value")
input("Enter to do it right")
return self.run(fzf_input, prompt, header, preview, expect, validator)
elif validator:
success, info = validator(result)
if not success:
print(info)
input("Enter to try again")
return self.run(fzf_input, prompt, header, preview, expect, validator)
return result
fzf = FZF()
if __name__ == "__main__":
action = fzf.run([*os.listdir(), "exit"], "Prompt: ", "Header", preview="bat {}")
print(action)

View File

@@ -1,13 +0,0 @@
import requests
from yt_dlp.utils.networking import random_user_agent
class MangaProvider:
session: requests.Session
USER_AGENT = random_user_agent()
HEADERS = {}
def __init__(self) -> None:
self.session = requests.session()
self.session.headers.update({"User-Agent": self.USER_AGENT, **self.HEADERS})

View File

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

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1753345091,
"narHash": "sha256-CdX2Rtvp5I8HGu9swBmYuq+ILwRxpXdJwlpg8jvN4tU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3ff0e34b1383648053bba8ed03f201d3466f90c9",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3ff0e34b1383648053bba8ed03f201d3466f90c9",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

104
flake.nix Normal file
View File

@@ -0,0 +1,104 @@
{
description = "Viu Project Flake";
inputs = {
# The nixpkgs unstable latest commit breaks the plyer python package
nixpkgs.url = "github:nixos/nixpkgs/3ff0e34b1383648053bba8ed03f201d3466f90c9";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) lib python3Packages;
version = "3.1.0";
in
{
packages.default = python3Packages.buildPythonApplication {
pname = "viu";
inherit version;
pyproject = true;
src = self;
build-system = with python3Packages; [ hatchling ];
dependencies = with python3Packages; [
click
inquirerpy
requests
rich
thefuzz
yt-dlp
dbus-python
hatchling
plyer
mpv
fastapi
pycryptodome
pypresence
httpx
];
postPatch = ''
substituteInPlace pyproject.toml \
--replace-fail "pydantic>=2.11.7" "pydantic>=2.11.4"
'';
makeWrapperArgs = [
"--prefix PATH : ${
lib.makeBinPath (
with pkgs;
[
mpv
]
)
}"
];
# Needs to be adapted for the nix derivation build
doCheck = false;
pythonImportsCheck = [ "viu" ];
meta = {
description = "Your browser anime experience from the terminal";
homepage = "https://github.com/Benexl/Viu";
changelog = "https://github.com/Benexl/Viu/releases/tag/v${version}";
mainProgram = "viu";
license = lib.licenses.unlicense;
maintainers = with lib.maintainers; [ theobori ];
};
};
devShells.default = pkgs.mkShell {
venvDir = ".venv";
env.LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.libxcrypt-legacy ];
packages =
with pkgs;
[
mpv
fzf
rofi
uv
pyright
]
++ (with python3Packages; [
venvShellHook
hatchling
])
++ self.packages.${system}.default.dependencies;
};
}
);
}

View File

@@ -1,14 +0,0 @@
#!/usr/bin/env bash
APP_DIR="$(
cd -- "$(dirname "$0")" >/dev/null 2>&1
pwd -P
)"
# fish shell completions
_FASTANIME_COMPLETE=fish_source fastanime >"$APP_DIR/completions/fastanime.fish"
# zsh completions
_FASTANIME_COMPLETE=zsh_source fastanime >"$APP_DIR/completions/fastanime.zsh"
# bash completions
_FASTANIME_COMPLETE=bash_source fastanime >"$APP_DIR/completions/fastanime.bash"

1373
poetry.lock generated

File diff suppressed because it is too large Load Diff

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