Compare commits

...

55 Commits

Author SHA1 Message Date
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
56 changed files with 2839 additions and 1555 deletions

View File

@@ -24,7 +24,7 @@ jobs:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies
run: poetry install
run: poetry install --all-extras
- name: build app
run: poetry build
- name: Archive production artifacts

View File

@@ -30,7 +30,7 @@ jobs:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies
run: poetry install
run: poetry install --all-extras
- name: run linter, formatters and sort imports
run: |
poetry run black .

200
README.md
View File

@@ -1,4 +1,10 @@
# FastAnime
# **FastAnime**
![PyPI - Downloads](https://img.shields.io/pypi/dm/fastanime) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Benex254/FastAnime/test.yml?label=Tests)
![Discord](https://img.shields.io/discord/1250887070906323096?label=Discord)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/Benex254/FastAnime)
![GitHub deployments](https://img.shields.io/github/deployments/Benex254/fastanime/pypi?label=PyPi%20Publish)
![PyPI - License](https://img.shields.io/pypi/l/fastanime)
Welcome to **FastAnime**, anime site experience from the terminal.
@@ -29,7 +35,7 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [jerr
<!--toc:start-->
- [FastAnime](#fastanime)
- [**FastAnime**](#fastanime)
- [Installation](#installation)
- [Installation using your favourite package manager](#installation-using-your-favourite-package-manager)
- [Using pipx](#using-pipx)
@@ -62,13 +68,19 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [jerr
> [!IMPORTANT]
>
> This project currently scrapes allanime, aniwatch and animepahe. The site is in the public domain and can be accessed by any one with a browser.
> This project currently scrapes allanime, hianime and animepahe, nyaa. The site is in the public domain and can be accessed by any one with a browser.
## Installation
![Windows](https://img.shields.io/badge/-Windows_x64-blue.svg?style=for-the-badge&logo=windows)
![Linux/BSD](https://img.shields.io/badge/-Linux/BSD-red.svg?style=for-the-badge&logo=linux)
![Arch Linux](https://img.shields.io/badge/-Arch_Linux-black.svg?style=for-the-badge&logo=archlinux)
![MacOS](https://img.shields.io/badge/-MacOS-lightblue.svg?style=for-the-badge&logo=apple)
![Android](https://img.shields.io/badge/-Android-green.svg?style=for-the-badge&logo=android)
The app can run wherever python can run. So all you need to have is python installed on your device.
On android you can use [termux](https://github.com/termux/termux-app).
If you have any difficulty consult for help on the [discord channel](https://discord.gg/HRjySFjQ)
If you have any difficulty consult for help on the [discord channel](https://discord.gg/HBEmAwvbHV)
### Installation using your favourite package manager
@@ -166,9 +178,11 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
> player because we believe nothing beats **MPV** and it provides
> everything you could ever need with a small footprint.
> But if you have a reason feel free to encourage as to do so.
> However, on android this is not the case so vlc is also supported
**Other external dependencies that will just make your experience better:**
- [webtorrent-cli](https://github.com/webtorrent/webtorrent-cli) used when the provider is nyaa
- [ffmpeg](https://www.ffmpeg.org/) is required to be in your path environment variables to properly download [hls](https://www.cloudflare.com/en-gb/learning/video/what-is-http-live-streaming/) streams.
- [fzf](https://github.com/junegunn/fzf) 🔥 which is used as a better alternative to the ui.
- [rofi](https://github.com/davatorium/rofi) 🔥 which is used as another alternative ui + the the desktop entry ui
@@ -178,16 +192,17 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
- [ani-skip](https://github.com/synacktraa/ani-skip) used for skipping the opening and ending theme songs
- [ffmpegthumbnailer](https://github.com/dirkvdb/ffmpegthumbnailer) used for local previews of downloaded anime
- [syncplay](https://syncplay.pl/) to enable watch together.
- [feh]() used in manga mode
- [feh](https://github.com/derf/feh) used in manga mode
## Usage
The project offers a featureful command-line interface and MPV interface through the use of python-mpv.
The project also offers subs in different languages thanks to aniwatch provider.
The project also offers subs in different languages thanks to hianime provider.
### The Commandline interface :fire:
Designed for efficiency and automation. Plus has a beautiful pseudo-TUI in some of the commands.
If you are stuck anywhere just use `--help` before the command you would like to get help on
**Overview of main commands:**
@@ -226,7 +241,7 @@ Available options for the fastanime include:
- `--default` use the default ui
- `--preview` show a preview when using fzf
- `--no-preview` dont show a preview when using fzf
- `--format <yt-dlp format string>` or `-f <yt-dlp format string>` set the format of anime downloaded and streamed based on [yt-dlp format](https://github.com/yt-dlp/yt-dlp#format-selection). Works when `--server gogoanime` or on providers that provide multi quality streams eg aniwatch
- `--format <yt-dlp format string>` or `-f <yt-dlp format string>` set the format of anime downloaded and streamed based on [yt-dlp format](https://github.com/yt-dlp/yt-dlp#format-selection). Works when `--server gogoanime` or on providers that provide multi quality streams eg hianime
- `--icons/--no-icons` toggle the visibility of the icons
- `--skip/--no-skip` whether to skip the opening and ending theme songs.
- `--rofi` use rofi for the ui
@@ -237,9 +252,9 @@ Available options for the fastanime include:
- `--log-file` allow logging to a file
- `--rich-traceback` allow rich traceback
- `--use-mpv-mod/--use-default-player` whether to use python-mpv
- `--provider <allanime/animepahe>` anime site of choice to scrape from
- `--provider <allanime/animepahe/hianime/nyaa>` anime site of choice to scrape from
- `--sync-play` or `-sp` use syncplay for streaming anime so you can watch with your friends
- `--sub-lang <en/or any other common shortform for country>` regex is used to determine the appropriate. Only works when provider is aniwatch.
- `--sub-lang <en/or any other common shortform for country>` regex is used to determine the appropriate. Only works when provider is hianime.
- `--normalize-titles/--no-normalize-titles` whether to normalize provider titles
- `--manga` toggle experimental manga mode
@@ -417,7 +432,7 @@ fastanime download -t <anime-title> -r ':<episodes-end>'
# remember python indexing starts at 0
fastanime download -t <anime-title> -r '<episode-1>:<episode>'
# merge subtitles with ffmpeg to mkv format; aniwatch tends to give subs as separate files
# merge subtitles with ffmpeg to mkv format; hianime tends to give subs as separate files
# and dont prompt for anything
# eg existing file in destination instead remove
# and clean
@@ -664,205 +679,80 @@ The default interface uses inquirerPy which is customizable. Read here to findou
The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on arch linux; for the other operating systems you can check by running `fastanime config --path`.
> [!TIP]
> You can now use the option `--update` to update your config file from the command-line
> For Example:
> `fastanime --icons --fzf --preview config --update`
> `fastanime --icons --fzf --preview config --update`
> the above will set icons to true, use_fzf to true and preview to true in your config file
>
By default if a config file does not exist it will be auto created with comments to explain each and every option.
The default config:
```ini
#
# ███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗ ░█████╗░░█████╗░███╗░░██╗███████╗██╗░██████╗░
# ██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝ ██╔══██╗██╔══██╗████╗░██║██╔════╝██║██╔════╝░
# █████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░ ██║░░╚═╝██║░░██║██╔██╗██║█████╗░░██║██║░░██╗░
# ██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░ ██║░░██╗██║░░██║██║╚████║██╔══╝░░██║██║░░╚██╗
# ██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗ ╚█████╔╝╚█████╔╝██║░╚███║██║░░░░░██║╚██████╔╝
# ╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝ ░╚════╝░░╚════╝░╚═╝░░╚══╝╚═╝░░░░░╚═╝░╚═════╝░
#
[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 = False
# 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 = 1080
# 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 = True
# 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 = allanime
# 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 = english
# Download directory
# where you will find your videos after downloading them with 'fastanime download' command
downloads_dir = ~/Videos/FastAnime
# 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 = False
preview = False
# 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 = -1
# whether to use fzf as the interface for the anilist command and others. [True/False]
use_fzf = False
use_fzf = False
# 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 = False
# 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 =
rofi_theme =
rofi_theme_input =
rofi_theme_input =
rofi_theme_confirm =
rofi_theme_confirm =
# the duration in minutes a notification will stay in the screen
# used by notifier command
notification_duration = 2
# 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 = eng
default_media_list_tracking = None
force_forward_tracking = True
cache_requests = True
use_persistent_provider_store = False
[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 = True
continue_from_history = True
# 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 = local
# Preferred language for anime [dub/sub]
translation_type = sub
# 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 = top
# 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 = False
# 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 = True
# 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 = False
# the maximum delta time in minutes after which the episode should be considered as completed
# used in the continue from time stamp
error = 3
episode_complete_at = 80
# 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 = False
# 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 = best[height<=1080]/bestvideo[height<=1080]+bestaudio/best
# 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
#
player = mpv
```
## Contributing
@@ -875,10 +765,10 @@ If you find an anime title that does not correspond with a provider or is just w
## Receiving Support
For inquiries, join our [Discord Server](https://discord.gg/C4rhMA4mmK).
For inquiries, join our [Discord Server](https://discord.gg/HBEmAwvbHV).
<p align="center">
<a href="https://discord.gg/C4rhMA4mmK">
<a href="https://discord.gg/HBEmAwvbHV">
<img src="https://invidget.switchblade.xyz/C4rhMA4mmK">
</a>
</p>

View File

@@ -1,10 +1,8 @@
"""An abstraction over all providers offering added features with a simple and well typed api
[TODO:description]
"""
"""An abstraction over all providers offering added features with a simple and well typed api"""
import importlib
import logging
import os
from typing import TYPE_CHECKING
from .libs.anime_provider import anime_sources
@@ -32,19 +30,36 @@ class AnimeProvider:
PROVIDERS = list(anime_sources.keys())
provider = PROVIDERS[0]
def __init__(self, provider, dynamic=False, retries=0) -> None:
def __init__(
self,
provider,
cache_requests=os.environ.get("FASTANIME_CACHE_REQUESTS", "false"),
use_persistent_provider_store=os.environ.get(
"FASTANIME_USE_PERSISTENT_PROVIDER_STORE", "false"
),
dynamic=False,
retries=0,
) -> None:
self.provider = provider
self.dynamic = dynamic
self.retries = retries
self.cache_requests = cache_requests
self.use_persistent_provider_store = use_persistent_provider_store
self.lazyload_provider(self.provider)
def lazyload_provider(self, provider):
"""updates the current provider being used"""
try:
self.anime_provider.session.kill_connection_to_db()
except Exception:
pass
_, anime_provider_cls_name = anime_sources[provider].split(".", 1)
package = f"fastanime.libs.anime_provider.{provider}"
provider_api = importlib.import_module(".api", package)
anime_provider = getattr(provider_api, anime_provider_cls_name)
self.anime_provider = anime_provider()
self.anime_provider = anime_provider(
self.cache_requests, self.use_persistent_provider_store
)
def search_for_anime(
self,
@@ -66,13 +81,9 @@ class AnimeProvider:
[TODO:return]
"""
anime_provider = self.anime_provider
try:
results = anime_provider.search_for_anime(
user_query, translation_type, nsfw, unknown
)
except Exception as e:
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
results = None
results = anime_provider.search_for_anime(
user_query, translation_type, nsfw, unknown
)
return results
@@ -90,17 +101,13 @@ class AnimeProvider:
[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 = anime_provider.get_anime(anime_id)
results = None
return results
def get_episode_streams(
self,
anime,
anime_id,
episode: str,
translation_type: str,
) -> "Iterator[Server] | None":
@@ -116,12 +123,7 @@ class AnimeProvider:
[TODO:return]
"""
anime_provider = self.anime_provider
try:
results = anime_provider.get_episode_streams(
anime, episode, translation_type
)
except Exception as e:
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
results = None
results = anime_provider.get_episode_streams(
anime_id, episode, translation_type
)
return results

View File

@@ -10,8 +10,9 @@ anime_normalizer_raw = {
"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"},
"hianime": {"My Star": "Oshi no Ko"},
"animepahe": {"Azumanga Daiou The Animation": "Azumanga Daioh"},
"nyaa": {},
}
@@ -19,7 +20,7 @@ def get_anime_normalizer():
"""Used because there are different providers"""
import os
current_provider = os.environ["CURRENT_FASTANIME_PROVIDER"]
current_provider = os.environ["FASTANIME_PROVIDER"]
return anime_normalizer_raw[current_provider]

View File

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

View File

@@ -59,6 +59,25 @@ class YtDLPDownloader:
"""
anime_title = sanitize_filename(anime_title)
episode_title = sanitize_filename(episode_title)
if url.endswith(".torrent"):
WEBTORRENT_CLI = shutil.which("webtorrent")
if not WEBTORRENT_CLI:
import time
print(
"webtorrent cli is not installed which is required for downloading and streaming from nyaa\nplease install it or use another provider"
)
time.sleep(120)
return
cmd = [
WEBTORRENT_CLI,
"download",
url,
"--out",
os.path.join(download_dir, anime_title, episode_title),
]
subprocess.run(cmd)
return
ydl_opts = {
# Specify the output path and template
"http_headers": headers,
@@ -79,7 +98,14 @@ class YtDLPDownloader:
if not info:
continue
if i == 0:
vid_path = info["requested_downloads"][0]["filepath"]
vid_path: str = info["requested_downloads"][0]["filepath"]
if vid_path.endswith(".unknown_video"):
print("Normalizing path...")
_vid_path = vid_path.replace(".unknown_video", ".mp4")
shutil.move(vid_path, _vid_path)
vid_path = _vid_path
print("successfully normalized path")
else:
sub_path = info["requested_downloads"][0]["filepath"]
if sub_path and vid_path and merge:

View File

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

View File

@@ -38,6 +38,29 @@ signal.signal(signal.SIGINT, handle_exit)
cls=LazyGroup,
help="A command line application for streaming anime that provides a complete and featureful interface",
short_help="Stream Anime",
epilog="""
\b
\b\bExamples:
# example of syncplay intergration
fastanime --sync-play --server sharepoint search -t <anime-title>
\b
# --- or ---
\b
# to watch with anilist intergration
fastanime --sync-play --server sharepoint anilist
\b
# downloading dubbed anime
fastanime --dub download -t <anime>
\b
# use icons and fzf for a more elegant ui with preview
fastanime --icons --preview --fzf anilist
\b
# use icons with default ui
fastanime --icons --default anilist
\b
# viewing manga
fastanime --manga search -t <manga-title>
""",
)
@click.version_option(__version__, "--version")
@click.option("--manga", "-m", help="Enable manga mode", is_flag=True)
@@ -148,6 +171,12 @@ signal.signal(signal.SIGINT, handle_exit)
"--use-python-mpv/--use-default-player", help="Whether to use python-mpv", type=bool
)
@click.option("--sync-play", "-sp", help="Use sync play", is_flag=True)
@click.option(
"--player",
"-P",
help="the player to use when streaming",
type=click.Choice(["mpv", "vlc"]),
)
@click.pass_context
def run_cli(
ctx: click.Context,
@@ -181,6 +210,7 @@ def run_cli(
rofi_theme_input,
use_python_mpv,
sync_play,
player,
):
from .config import Config
@@ -223,10 +253,7 @@ def run_cli(
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:
@@ -235,6 +262,8 @@ def run_cli(
ctx.obj.sub_lang = sub_lang
if ctx.get_parameter_source("continue_") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.continue_from_history = continue_
if ctx.get_parameter_source("player") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.player = player
if ctx.get_parameter_source("skip") == click.core.ParameterSource.COMMANDLINE:
ctx.obj.skip = skip
if (
@@ -268,10 +297,11 @@ def run_cli(
ctx.obj.downloads_dir = downloads_dir
if translation_type:
ctx.obj.translation_type = translation_type
if fzf:
ctx.obj.use_fzf = True
if default:
ctx.obj.use_fzf = False
ctx.obj.use_rofi = False
if fzf:
ctx.obj.use_fzf = True
if preview:
ctx.obj.preview = True
if no_preview:
@@ -297,3 +327,4 @@ def run_cli(
if rofi_theme_confirm:
ctx.obj.rofi_theme_confirm = rofi_theme_confirm
Rofi.rofi_theme_confirm = rofi_theme_confirm
ctx.obj.set_fastanime_config_environs()

View File

@@ -112,6 +112,7 @@ def update_app():
"pip",
"install",
APP_NAME,
"-U",
"--user",
"--no-warn-script-location",
]

View File

@@ -30,6 +30,53 @@ commands = {
invoke_without_command=True,
help="A beautiful interface that gives you access to a commplete streaming experience",
short_help="Access all streaming options",
epilog="""
\b
\b\bExamples:
# ---- search ----
\b
# get anime with the tag of isekai
fastanime anilist search -T isekai
\b
# get anime of 2024 and sort by popularity
# that has already finished airing or is releasing
# and is not in your anime lists
fastanime anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
\b
# get anime of 2024 season WINTER
fastanime anilist search -y 2024 --season WINTER
\b
# get anime genre action and tag isekai,magic
fastanime anilist search -g Action -T Isekai -T Magic
\b
# get anime of 2024 thats finished airing
fastanime anilist search -y 2024 -S FINISHED
\b
# get the most favourite anime movies
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
\b
# ---- login ----
\b
# To sign in just run
fastanime anilist login
\b
# To view your login status
fastanime anilist login --status
\b
# To erase login data
fastanime anilist login --erase
\b
# ---- notifier ----
\b
# basic form
fastanime anilist notifier
\b
# with logging to stdout
fastanime --log anilist notifier
\b
# with logging to a file. stored in the same place as your config
fastanime --log-file anilist notifier
""",
)
@click.pass_context
def anilist(ctx: click.Context):

View File

@@ -16,7 +16,12 @@ def notifier(config: "Config"):
from sys import exit
import requests
from plyer import notification
try:
from plyer import notification
except ImportError:
print("Please install plyer to use this command")
exit(1)
from ....anilist import AniList
from ....constants import APP_CACHE_DIR, APP_DATA_DIR, APP_NAME, ICON_PATH, PLATFORM

View File

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

View File

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

View File

@@ -9,6 +9,25 @@ if TYPE_CHECKING:
@click.command(
help="Manage your config with ease",
short_help="Edit your config",
epilog="""
\b
\b\bExamples:
# Edit your config in your default editor
# NB: If it opens vim or vi exit with `:q`
fastanime config
\b
# get the path of the config file
fastanime config --path
\b
# print desktop entry info
fastanime config --desktop-entry
\b
# update your config without opening an editor
fastanime --icons --fzf --preview config --update
\b
# view the current contents of your config
fastanime config --view
""",
)
@click.option("--path", "-p", help="Print the config location and exit", is_flag=True)
@click.option(
@@ -94,8 +113,8 @@ def config(user_config: "Config", path, view, desktop_entry, update):
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__())
with open(USER_CONFIG_PATH, "w", encoding="utf-8") as file:
file.write(user_config.__str__())
print("update successfull")
else:
click.edit(filename=USER_CONFIG_PATH)

View File

@@ -11,6 +11,53 @@ if TYPE_CHECKING:
@click.command(
help="Download anime using the anime provider for a specified range",
short_help="Download anime",
epilog="""
\b
\b\bExamples:
# Download all available episodes
# multiple titles can be specified with -t option
fastanime download -t <anime-title> -t <anime-title>
# -- or --
fastanime download -t <anime-title> -t <anime-title> -r ':'
\b
# download latest episode for the two anime titles
# the number can be any no of latest episodes but a minus sign
# must be present
fastanime download -t <anime-title> -t <anime-title> -r '-1'
\b
# latest 5
fastanime download -t <anime-title> -t <anime-title> -r '-5'
\b
# Download specific episode range
# be sure to observe the range Syntax
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
\b
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>'
\b
fastanime download -t <anime-title> -r '<episodes-start>:'
\b
fastanime download -t <anime-title> -r ':<episodes-end>'
\b
# download specific episode
# remember python indexing starts at 0
fastanime download -t <anime-title> -r '<episode-1>:<episode>'
\b
# merge subtitles with ffmpeg to mkv format; hianime tends to give subs as separate files
# and dont prompt for anything
# eg existing file in destination instead remove
# and clean
# ie remove original files (sub file and vid file)
# only keep merged files
fastanime download -t <anime-title> --merge --clean --no-prompt
\b
# EOF is used since -t always expects a title
# you can supply anime titles from file or -t at the same time
# from stdin
echo -e "<anime-title>\\n<anime-title>\\n<anime-title>" | fastanime download -t "EOF" -r <range> -f -
\b
# from file
fastanime download -t "EOF" -r <range> -f <file-path>
""",
)
@click.option(
"--anime-titles",
@@ -160,7 +207,7 @@ def download(
choices = list(search_results_.keys())
if config.use_fzf:
selected_anime_title = fzf.run(
choices, "Please Select title: ", "FastAnime"
choices, "Please Select title", "FastAnime"
)
else:
selected_anime_title = fuzzy_inquirer(
@@ -237,7 +284,7 @@ def download(
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
anime["id"], episode, config.translation_type
)
if not streams:
print("No streams skipping")
@@ -272,7 +319,7 @@ def download(
server_name = config.server
else:
if config.use_fzf:
server_name = fzf.run(servers_names, "Select an link: ")
server_name = fzf.run(servers_names, "Select an link")
else:
server_name = fuzzy_inquirer(
servers_names,

View File

@@ -11,7 +11,32 @@ if TYPE_CHECKING:
@click.command(
help="View and watch your downloads using mpv", short_help="Watch downloads"
help="View and watch your downloads using mpv",
short_help="Watch downloads",
epilog="""
\b
\b\bExamples:
fastanime downloads
\b
# view individual episodes
fastanime downloads --view-episodes
# --- or ---
fastanime downloads -v
\b
# to set seek time when using ffmpegthumbnailer for local previews
# -1 means random and is the default
fastanime downloads --time-to-seek <intRange(-1,100)>
# --- or ---
fastanime downloads -t <intRange(-1,100)>
\b
# to watch a specific title
# be sure to get the completions for the best experience
fastanime downloads --title <title>
\b
# to get the path to the downloads folder set
fastanime downloads --path
# useful when you want to use the value for other programs
""",
)
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
@click.option(
@@ -144,7 +169,6 @@ def downloads(
from threading import Thread
worker = Thread(target=_worker)
worker.daemon = True
worker.start()
else:
_worker()
@@ -217,7 +241,6 @@ def downloads(
from threading import Thread
worker = Thread(target=_worker)
worker.daemon = True
worker.start()
else:
_worker()
@@ -268,7 +291,7 @@ def downloads(
else:
episode_title = fuzzy_inquirer(
downloaded_episodes,
"Enter Playlist Name: ",
"Enter Playlist Name",
)
if episode_title == "Back":
stream_anime()
@@ -279,7 +302,10 @@ def downloads(
SyncPlayer(episode_path)
else:
run_mpv(episode_path)
run_mpv(
episode_path,
player=config.player,
)
stream_episode(anime_playlist_path)
def stream_anime(title=None):
@@ -305,7 +331,7 @@ def downloads(
else:
playlist_name = fuzzy_inquirer(
anime_downloads,
"Enter Playlist Name: ",
"Enter Playlist Name",
)
if playlist_name == "Exit":
exit_app()
@@ -321,7 +347,10 @@ def downloads(
SyncPlayer(playlist)
else:
run_mpv(playlist)
run_mpv(
playlist,
player=config.player,
)
stream_anime()
stream_anime(title)

View File

@@ -11,6 +11,41 @@ if TYPE_CHECKING:
@click.command(
help="Helper command to get streams for anime to use externally in a non-python application",
short_help="Print anime streams to standard out",
epilog="""
\b
\b\bExamples:
# --- print anime info + episode streams ---
\b
# multiple titles can be specified with the -t option
fastanime grab -t <anime-title> -t <anime-title>
# -- or --
# print all available episodes
fastanime grab -t <anime-title> -r ':'
\b
# print the latest episode
fastanime grab -t <anime-title> -r '-1'
\b
# print a specific episode range
# be sure to observe the range Syntax
fastanime grab -t <anime-title> -r '<start>:<stop>'
\b
fastanime grab -t <anime-title> -r '<start>:<stop>:<step>'
\b
fastanime grab -t <anime-title> -r '<start>:'
\b
fastanime grab -t <anime-title> -r ':<end>'
\b
# --- grab options ---
\b
# print search results only
fastanime grab -t <anime-title> -r <range> --search-results-only
\b
# print anime info only
fastanime grab -t <anime-title> -r <range> --anime-info-only
\b
# print episode streams only
fastanime grab -t <anime-title> -r <range> --episode-streams-only
""",
)
@click.option(
"--anime-titles",
@@ -182,7 +217,7 @@ def grab(
if episode not in episodes:
continue
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
anime["id"], episode, config.translation_type
)
if not streams:
continue

View File

@@ -11,6 +11,29 @@ if TYPE_CHECKING:
@click.command(
help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.",
short_help="Binge anime",
epilog="""
\b
\b\bExamples:
# basic form where you will still be prompted for the episode number
# multiple titles can be specified with the -t option
fastanime search -t <anime-title> -t <anime-title>
\b
# binge all episodes with this command
fastanime search -t <anime-title> -r ':'
\b
# watch latest episode
fastanime search -t <anime-title> -r '-1'
\b
# binge a specific episode range with this command
# be sure to observe the range Syntax
fastanime search -t <anime-title> -r '<start>:<stop>'
\b
fastanime search -t <anime-title> -r '<start>:<stop>:<step>'
\b
fastanime search -t <anime-title> -r '<start>:'
\b
fastanime search -t <anime-title> -r ':<end>'
""",
)
@click.option(
"--anime-titles",
@@ -76,7 +99,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
preview = get_fzf_manga_preview(search_results)
if config.use_fzf:
search_result_manga_title = fzf.run(
choices, "Please Select title: ", preview=preview
choices, "Please Select title", preview=preview
)
elif config.use_rofi:
search_result_manga_title = Rofi.run(choices, "Please Select Title")
@@ -166,7 +189,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
choices = list(search_results_.keys())
if config.use_fzf:
search_result_manga_title = fzf.run(
choices, "Please Select title: ", "FastAnime"
choices, "Please Select title", "FastAnime"
)
elif config.use_rofi:
search_result_manga_title = Rofi.run(choices, "Please Select Title")
@@ -224,7 +247,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
anilist_anime_info = get_basic_anime_info_by_title(anime["title"])
def stream_anime():
def stream_anime(anime: "Anime"):
clear()
episode = None
@@ -243,7 +266,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
if config.use_fzf:
episode = fzf.run(
choices,
"Select an episode: ",
"Select an episode",
header=search_result_manga_title,
)
elif config.use_rofi:
@@ -260,7 +283,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type
anime["id"], episode, config.translation_type
)
if not streams:
print("Failed to get streams")
@@ -275,13 +298,13 @@ def search(config: "Config", anime_titles: str, episode_range: str):
if not server:
print("Sth went wrong when fetching the episode")
input("Enter to continue")
stream_anime()
stream_anime(anime)
return
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
stream_anime(anime)
return
link = stream_link["link"]
subtitles = server["subtitles"]
@@ -297,7 +320,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
server = config.server
else:
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
server = fzf.run(servers_names, "Select an link")
elif config.use_rofi:
server = Rofi.run(servers_names, "Select an link")
else:
@@ -311,7 +334,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
if not stream_link:
print("Quality not found")
input("Enter to continue")
stream_anime()
stream_anime(anime)
return
link = stream_link["link"]
stream_headers = servers[server]["headers"]
@@ -352,10 +375,11 @@ def search(config: "Config", anime_titles: str, episode_range: str):
episode_title,
headers=stream_headers,
subtitles=subtitles,
player=config.player,
)
except IndexError as e:
print(e)
input("Enter to continue")
stream_anime()
stream_anime(anime)
stream_anime()
stream_anime(anime)

View File

@@ -1,7 +1,18 @@
import click
@click.command(help="Helper command to update fastanime to latest")
@click.command(
help="Helper command to update fastanime to latest",
epilog="""
\b
\b\bExamples:
# update fastanime to latest
fastanime update
\b
# check for latest release
fastanime update --check
""",
)
@click.option("--check", "-c", help="Check for the latest release", is_flag=True)
def update(
check,

View File

@@ -4,7 +4,12 @@ import os
from configparser import ConfigParser
from typing import TYPE_CHECKING
from ..constants import USER_CONFIG_PATH, USER_DATA_PATH, USER_VIDEOS_DIR
from ..constants import (
USER_CONFIG_PATH,
USER_DATA_PATH,
USER_VIDEOS_DIR,
USER_WATCH_HISTORY_PATH,
)
from ..libs.rofi import Rofi
logger = logging.getLogger(__name__)
@@ -16,117 +21,141 @@ class Config(object):
manga = False
sync_play = False
anime_list: list
watch_history: dict
watch_history: dict = {}
fastanime_anilist_app_login_url = (
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
)
anime_provider: "AnimeProvider"
user_data = {"watch_history": {}, "animelist": [], "user": {}}
default_options = {
"quality": "1080",
default_config = {
"auto_next": "False",
"auto_select": "True",
"sort_by": "search match",
"downloads_dir": USER_VIDEOS_DIR,
"translation_type": "sub",
"server": "top",
"cache_requests": "true",
"continue_from_history": "True",
"preferred_history": "local",
"use_python_mpv": "false",
"default_media_list_tracking": "None",
"downloads_dir": USER_VIDEOS_DIR,
"episode_complete_at": "80",
"ffmpegthumbnailer_seek_time": "-1",
"force_forward_tracking": "true",
"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",
"notification_duration": "2",
"player": "mpv",
"preferred_history": "local",
"preferred_language": "english",
"preview": "False",
"provider": "allanime",
"quality": "1080",
"rofi_theme": "",
"rofi_theme_confirm": "",
"rofi_theme_input": "",
"server": "top",
"skip": "false",
"sort_by": "search match",
"sub_lang": "eng",
"translation_type": "sub",
"use_fzf": "False",
"use_persistent_provider_store": "false",
"use_python_mpv": "false",
"use_rofi": "false",
}
def __init__(self) -> None:
self.initialize_user_data()
self.initialize_user_data_and_watch_history()
self.load_config()
def load_config(self):
self.configparser = ConfigParser(self.default_options)
self.configparser = ConfigParser(self.default_config)
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.configparser.read(USER_CONFIG_PATH, encoding="utf-8")
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.cache_requests = self.get_cache_requests()
self.continue_from_history = self.get_continue_from_history()
self.default_media_list_tracking = self.get_default_media_list_tracking()
self.downloads_dir = self.get_downloads_dir()
self.episode_complete_at = self.get_episode_complete_at()
self.ffmpegthumbnailer_seek_time = self.get_ffmpegthumnailer_seek_time()
self.force_forward_tracking = self.get_force_forward_tracking()
self.force_window = self.get_force_window()
self.format = self.get_format()
self.icons = self.get_icons()
self.normalize_titles = self.get_normalize_titles()
self.notification_duration = self.get_notification_duration()
self.player = self.get_player()
self.preferred_history = self.get_preferred_history()
self.preferred_language = self.get_preferred_language()
self.preview = self.get_preview()
self.provider = self.get_provider()
self.quality = self.get_quality()
self.rofi_theme_confirm = self.get_rofi_theme_confirm()
self.rofi_theme_input = self.get_rofi_theme_input()
self.rofi_theme = self.get_rofi_theme()
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
Rofi.rofi_theme_input = self.rofi_theme_input
Rofi.rofi_theme = self.rofi_theme
self.server = self.get_server()
self.skip = self.get_skip()
self.sort_by = self.get_sort_by()
self.sub_lang = self.get_sub_lang()
self.translation_type = self.get_translation_type()
self.use_fzf = self.get_use_fzf()
self.use_python_mpv = self.get_use_mpv_mod()
self.use_rofi = self.get_use_rofi()
self.use_persistent_provider_store = self.get_use_persistent_provider_store()
# ---- 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:
with open(USER_CONFIG_PATH, "w", encoding="utf-8") as config:
config.write(self.__repr__())
def set_fastanime_config_environs(self):
current_config = []
for key in self.default_config:
current_config.append((f"FASTANIME_{key.upper()}", str(getattr(self, key))))
os.environ.update(current_config)
def update_user(self, user):
self.user = user
self.user_data["user"] = user
self._update_user_data()
def update_watch_history(
self, anime_id: int, episode: str, start_time="0", total_time="0"
def media_list_track(
self,
anime_id: int,
episode_no: str,
episode_stopped_at="0",
episode_total_length="0",
progress_tracking="prompt",
):
self.watch_history.update(
{
str(anime_id): {
"episode": episode,
"start_time": start_time,
"total_time": total_time,
"episode_no": episode_no,
"episode_stopped_at": episode_stopped_at,
"episode_total_length": episode_total_length,
"progress_tracking": progress_tracking,
}
}
)
self.user_data["watch_history"] = self.watch_history
self._update_user_data()
with open(USER_WATCH_HISTORY_PATH, "w") as f:
json.dump(self.watch_history, f)
def initialize_user_data(self):
def initialize_user_data_and_watch_history(self):
try:
if os.path.isfile(USER_DATA_PATH):
with open(USER_DATA_PATH, "r") as f:
@@ -134,6 +163,13 @@ class Config(object):
self.user_data.update(user_data)
except Exception as e:
logger.error(e)
try:
if os.path.isfile(USER_WATCH_HISTORY_PATH):
with open(USER_WATCH_HISTORY_PATH, "r") as f:
watch_history = json.load(f)
self.watch_history.update(watch_history)
except Exception as e:
logger.error(e)
def _update_user_data(self):
"""method that updates the actual user data file"""
@@ -147,7 +183,7 @@ class Config(object):
return self.configparser.get("general", "provider")
def get_ffmpegthumnailer_seek_time(self):
return self.configparser.getint("general", "ffmpegthumnailer_seek_time")
return self.configparser.getint("general", "ffmpegthumbnailer_seek_time")
def get_preferred_language(self):
return self.configparser.get("general", "preferred_language")
@@ -167,6 +203,9 @@ class Config(object):
def get_use_fzf(self):
return self.configparser.getboolean("general", "use_fzf")
def get_use_persistent_provider_store(self):
return self.configparser.getboolean("general", "use_persistent_provider_store")
# rofi conifiguration
def get_use_rofi(self):
return self.configparser.getboolean("general", "use_rofi")
@@ -180,6 +219,15 @@ class Config(object):
def get_rofi_theme_confirm(self):
return self.configparser.get("general", "rofi_theme_confirm")
def get_force_forward_tracking(self):
return self.configparser.getboolean("general", "force_forward_tracking")
def get_cache_requests(self):
return self.configparser.getboolean("general", "cache_requests")
def get_default_media_list_tracking(self):
return self.configparser.get("general", "default_media_list_tracking")
def get_normalize_titles(self):
return self.configparser.getboolean("general", "normalize_titles")
@@ -202,8 +250,8 @@ class Config(object):
def get_notification_duration(self):
return self.configparser.getint("general", "notification_duration")
def get_error(self):
return self.configparser.getint("stream", "error")
def get_episode_complete_at(self):
return self.configparser.getint("stream", "episode_complete_at")
def get_force_window(self):
return self.configparser.get("stream", "force_window")
@@ -223,6 +271,9 @@ class Config(object):
def get_format(self):
return self.configparser.get("stream", "format")
def get_player(self):
return self.configparser.get("stream", "player")
def get_sort_by(self):
return self.configparser.get("anilist", "sort_by")
@@ -263,10 +314,10 @@ quality = {self.quality}
# this also applies to episode titles
normalize_titles = {self.normalize_titles}
# can be [allanime, animepahe, aniwatch]
# can be [allanime, animepahe, hianime]
# 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
# hianime which is now hianime usually provides subs in different languuages and its servers are generally faster
provider = {self.provider}
# Display language [english, romaji]
@@ -318,11 +369,37 @@ notification_duration = {self.notification_duration}
# used when the provider gives subs of different languages
# currently its the case for:
# aniwatch
# hianime
# the values for this option are the short names for countries
# regex is used to determine what you selected
sub_lang = {self.sub_lang}
# what is your default media list tracking [track/disabled/prompt]
# only affects your anilist anime list
# track - means your progress will always be reflected in your anilist anime list
# disabled - means progress tracking will no longer be reflected in your anime list
# prompt - means for every anime you will be prompted whether you want your progress to be tracked or not
default_media_list_tracking = {self.default_media_list_tracking}
# whether media list tracking should only be updated when the next episode is greater than the previous
# this affects only your anilist anime list
force_forward_tracking = {self.force_forward_tracking}
# whether to cache requests [true/false]
# this makes the experience better and more faster
# as data need not always be fetched from web server
# and instead can be gotten locally
# from the cached_requests_db
cache_requests = {self.cache_requests}
# whether to use a persistent store (basically a sqlitedb) for storing some data the provider requires
# to enable a seamless experience [true/false]
# this option exists primarily because i think it may help in the optimization
# of fastanime as a library in a website project
# for now i don't recommend changing it
# leave it as is
use_persistent_provider_store = {self.use_persistent_provider_store}
[stream]
# Auto continue from watch history [True/False]
@@ -345,7 +422,7 @@ 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]
# hianime: [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
@@ -373,15 +450,19 @@ auto_select = {self.auto_select}
# 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}
# at what percentage progress should the episode be considered as completed [0-100]
# this value is used to determine whether to increment the current episode number and save it to your local list
# so you can continue immediately to the next episode without select it the next time you decide to watch the anime
# it is also used to determine whether your anilist anime list should be updated or not
episode_complete_at = {self.episode_complete_at}
# whether to use python-mpv [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
# 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
@@ -405,10 +486,15 @@ force_window = immediate
# only works for downloaded anime if:
# provider=allanime, server=gogoanime
# provider=allanime, server=wixmp
# provider=aniwatch
# provider=hianime
# this is because they provider a m3u8 file that contans multiple quality streams
format = {self.format}
# set the player to use for streaming [mpv/vlc]
# while this option exists i will still recommend that you use mpv
# since you will miss out on some features if you use the others
player = {self.player}
# NOTE:
# if you have any trouble setting up your config
# please don't be afraid to ask in our discord

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import os
import random
from datetime import datetime
from typing import TYPE_CHECKING
from click import clear
@@ -35,8 +34,7 @@ if TYPE_CHECKING:
from ..utils.tools import FastAnimeRuntimeState
# TODO: make the error handling more sane
def calculate_time_delta(start_time, end_time):
def calculate_percentage_completion(start_time, end_time):
"""helper function used to calculate the difference between two timestamps in seconds
Args:
@@ -46,16 +44,12 @@ def calculate_time_delta(start_time, end_time):
Returns:
[TODO:return]
"""
time_format = "%H:%M:%S"
# Convert string times to datetime objects
start = datetime.strptime(start_time, time_format)
end = datetime.strptime(end_time, time_format)
# Calculate the difference
delta = end - start
return delta
start = start_time.split(":")
end = end_time.split(":")
start_secs = int(start[0]) * 3600 + int(start[1]) * 60 + int(start[2])
end_secs = int(end[0]) * 3600 + int(end[1]) * 60 + int(end[2])
return start_secs / end_secs * 100
def media_player_controls(
@@ -103,10 +97,12 @@ def media_player_controls(
)
if (
config.watch_history[str(anime_id_anilist)]["episode"]
config.watch_history[str(anime_id_anilist)]["episode_no"]
== current_episode_number
):
start_time = config.watch_history[str(anime_id_anilist)]["start_time"]
start_time = config.watch_history[str(anime_id_anilist)][
"episode_stopped_at"
]
print("[green]Continuing from:[/] ", start_time)
else:
start_time = "0"
@@ -164,15 +160,17 @@ def media_player_controls(
custom_args=custom_args,
headers=selected_server["headers"],
subtitles=subtitles,
player=config.player,
)
# either update the watch history to the next episode or current depending on progress
if stop_time == "0" or total_time == "0":
episode = str(int(current_episode_number) + 1)
else:
error = 5 * 60
delta = calculate_time_delta(stop_time, total_time)
if delta.total_seconds() > error:
percentage_completion_of_episode = calculate_percentage_completion(
stop_time, total_time
)
if percentage_completion_of_episode < config.episode_complete_at:
episode = current_episode_number
else:
episode = str(int(current_episode_number) + 1)
@@ -180,28 +178,34 @@ def media_player_controls(
total_time = "0"
clear()
config.update_watch_history(anime_id_anilist, episode, stop_time, total_time)
config.media_list_track(
anime_id_anilist,
episode_no=episode,
episode_stopped_at=stop_time,
episode_total_length=total_time,
progress_tracking=fastanime_runtime_state.progress_tracking,
)
media_player_controls(config, fastanime_runtime_state)
def _next_episode():
"""watch the next episode"""
# ensures you dont accidentally erase your progress for an in complete episode
stop_time = config.watch_history.get(str(anime_id_anilist), {}).get(
"start_time", "0"
"episode_stopped_at", "0"
)
total_time = config.watch_history.get(str(anime_id_anilist), {}).get(
"total_time", "0"
"episode_total_length", "0"
)
# compute if the episode is actually completed
error = config.error * 60
if stop_time == "0" or total_time == "0":
dt = 0
percentage_completion_of_episode = 0
else:
delta = calculate_time_delta(stop_time, total_time)
dt = delta.total_seconds()
if dt > error:
percentage_completion_of_episode = calculate_percentage_completion(
stop_time, total_time
)
if percentage_completion_of_episode < config.episode_complete_at:
if config.auto_next:
if config.use_rofi:
if not Rofi.confirm(
@@ -235,7 +239,11 @@ def media_player_controls(
]
# update user config
config.update_watch_history(anime_id_anilist, available_episodes[next_episode])
config.media_list_track(
anime_id_anilist,
episode_no=available_episodes[next_episode],
progress_tracking=fastanime_runtime_state.progress_tracking,
)
# call interface
provider_anime_episode_servers_menu(config, fastanime_runtime_state)
@@ -259,7 +267,11 @@ def media_player_controls(
]
# update user config
config.update_watch_history(anime_id_anilist, available_episodes[prev_episode])
config.media_list_track(
anime_id_anilist,
episode_no=available_episodes[prev_episode],
progress_tracking=fastanime_runtime_state.progress_tracking,
)
# call interface
provider_anime_episode_servers_menu(config, fastanime_runtime_state)
@@ -272,7 +284,7 @@ def media_player_controls(
# prompt for new quality
if config.use_fzf:
quality = fzf.run(
options, prompt="Select Quality:", header="Quality Options"
options, prompt="Select Quality", header="Quality Options"
)
elif config.use_rofi:
quality = Rofi.run(options, "Select Quality")
@@ -290,7 +302,7 @@ def media_player_controls(
options = ["sub", "dub"]
if config.use_fzf:
translation_type = fzf.run(
options, prompt="Select Translation Type: ", header="Lang Options"
options, prompt="Select Translation Type", header="Lang Options"
).lower()
elif config.use_rofi:
translation_type = Rofi.run(options, "Select Translation Type")
@@ -336,7 +348,7 @@ def media_player_controls(
if config.use_fzf:
action = fzf.run(
choices,
prompt="Select Action:",
prompt="Select Action",
)
elif config.use_rofi:
action = Rofi.run(choices, "Select Action")
@@ -375,7 +387,7 @@ def provider_anime_episode_servers_menu(
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
episode_streams_generator = anime_provider.get_episode_streams(
provider_anime,
provider_anime["id"],
current_episode_number,
translation_type,
)
@@ -434,7 +446,7 @@ def provider_anime_episode_servers_menu(
if config.use_fzf:
server_name = fzf.run(
choices,
prompt="Select Server: ",
prompt="Select Server",
header="Servers",
)
elif config.use_rofi:
@@ -495,21 +507,12 @@ def provider_anime_episode_servers_menu(
"[bold magenta] Episode: [/]",
current_episode_number,
)
# -- update anilist progress if user --
if config.user and current_episode_number:
AniList.update_anime_list(
{
"mediaId": anime_id_anilist,
"progress": int(float(current_episode_number)),
}
)
# try to get the timestamp you left off from if available
start_time = config.watch_history.get(str(anime_id_anilist), {}).get(
"start_time", "0"
"episode_stopped_at", "0"
)
episode_in_history = config.watch_history.get(str(anime_id_anilist), {}).get(
"episode", ""
"episode_no", ""
)
if start_time != "0" and episode_in_history == current_episode_number:
print("[green]Continuing from:[/] ", start_time)
@@ -574,6 +577,7 @@ def provider_anime_episode_servers_menu(
custom_args=custom_args,
headers=selected_server["headers"],
subtitles=subtitles,
player=config.player,
)
print("Finished at: ", stop_time)
@@ -591,11 +595,36 @@ def provider_anime_episode_servers_menu(
next_episode = len(available_episodes) - 1
episode = available_episodes[next_episode]
else:
error = config.error * 60
delta = calculate_time_delta(stop_time, total_time)
if delta.total_seconds() > error:
percentage_completion_of_episode = calculate_percentage_completion(
stop_time, total_time
)
if percentage_completion_of_episode < config.episode_complete_at:
episode = current_episode_number
else:
# -- update anilist progress if user --
remote_progress = (
fastanime_runtime_state.selected_anime_anilist["mediaListEntry"] or {}
).get("progress")
disable_anilist_update = False
if remote_progress:
if (
float(remote_progress) > float(current_episode_number)
and config.force_forward_tracking
):
disable_anilist_update = True
if (
fastanime_runtime_state.progress_tracking == "track"
and config.user
and not disable_anilist_update
and current_episode_number
):
AniList.update_anime_list(
{
"mediaId": anime_id_anilist,
"progress": int(float(current_episode_number)),
}
)
# increment the episodes
next_episode = available_episodes.index(current_episode_number) + 1
if next_episode >= len(available_episodes):
@@ -604,11 +633,12 @@ def provider_anime_episode_servers_menu(
stop_time = "0"
total_time = "0"
config.update_watch_history(
config.media_list_track(
anime_id_anilist,
episode,
start_time=stop_time,
total_time=total_time,
episode_no=episode,
episode_stopped_at=stop_time,
episode_total_length=total_time,
progress_tracking=fastanime_runtime_state.progress_tracking,
)
# switch to controls
@@ -650,7 +680,7 @@ def provider_anime_episodes_menu(
# the user watch history thats locally available
# will be preferred over remote
if (
user_watch_history.get(str(anime_id_anilist), {}).get("episode")
user_watch_history.get(str(anime_id_anilist), {}).get("episode_no")
in total_episodes
):
if (
@@ -658,7 +688,7 @@ def provider_anime_episodes_menu(
or not selected_anime_anilist["mediaListEntry"]
):
current_episode_number = user_watch_history[str(anime_id_anilist)][
"episode"
"episode_no"
]
else:
current_episode_number = str(
@@ -703,7 +733,7 @@ def provider_anime_episodes_menu(
)
if config.use_fzf:
current_episode_number = fzf.run(
choices, prompt="Select Episode:", header=anime_title, preview=preview
choices, prompt="Select Episode", header=anime_title, preview=preview
)
elif config.use_rofi:
current_episode_number = Rofi.run(choices, "Select Episode")
@@ -763,6 +793,39 @@ def fetch_anime_episode(
#
# ---- ANIME PROVIDER SEARCH RESULTS MENU ----
#
def set_prefered_progress_tracking(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState", update=False
):
if (
fastanime_runtime_state.progress_tracking == ""
or update
or fastanime_runtime_state.progress_tracking == "prompt"
):
if config.default_media_list_tracking == "track":
fastanime_runtime_state.progress_tracking = "track"
elif config.default_media_list_tracking == "disabled":
fastanime_runtime_state.progress_tracking = "disabled"
else:
options = ["disabled", "track"]
if config.use_fzf:
fastanime_runtime_state.progress_tracking = fzf.run(
options,
"Enter your preferred progress tracking for the current anime",
)
elif config.use_rofi:
fastanime_runtime_state.progress_tracking = Rofi.run(
options,
"Enter your preferred progress tracking for the current anime",
)
else:
fastanime_runtime_state.progress_tracking = fuzzy_inquirer(
options,
"Enter your preferred progress tracking for the current anime",
)
def anime_provider_search_results_menu(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
@@ -828,7 +891,7 @@ def anime_provider_search_results_menu(
if config.use_fzf:
provider_anime_title = fzf.run(
choices,
prompt="Select Search Result:",
prompt="Select Search Result",
header="Anime Search Results",
)
@@ -850,6 +913,11 @@ def anime_provider_search_results_menu(
fastanime_runtime_state.provider_anime_search_result = provider_search_results[
provider_anime_title
]
fastanime_runtime_state.progress_tracking = config.watch_history.get(
str(fastanime_runtime_state.selected_anime_id_anilist), {}
).get("progress_tracking", "prompt")
set_prefered_progress_tracking(config, fastanime_runtime_state)
fetch_anime_episode(config, fastanime_runtime_state)
@@ -893,11 +961,12 @@ def media_actions_menu(
run_mpv(
trailer_url,
ytdl_format=config.format,
player=config.player,
)
media_actions_menu(config, fastanime_runtime_state)
else:
if not config.use_rofi:
print("no trailer available :confused:")
print("no trailer available :confused")
input("Enter to continue...")
else:
if not Rofi.confirm("No trailler found!!Enter to continue"):
@@ -1030,7 +1099,7 @@ def media_actions_menu(
options = ["Sub", "Dub"]
if config.use_fzf:
translation_type = fzf.run(
options, prompt="Select Translation Type:", header="Language Options"
options, prompt="Select Translation Type", header="Language Options"
)
elif config.use_rofi:
translation_type = Rofi.run(options, "Select Translation Type")
@@ -1059,10 +1128,10 @@ def media_actions_menu(
if config.use_fzf:
player = fzf.run(
options,
prompt="Select Player:",
prompt="Select Player",
)
elif config.use_rofi:
player = Rofi.run(options, "Select Player: ")
player = Rofi.run(options, "Select Player")
else:
player = fuzzy_inquirer(
options,
@@ -1199,7 +1268,7 @@ def media_actions_menu(
options = list(anime_sources.keys())
if config.use_fzf:
provider = fzf.run(
options, prompt="Select Translation Type:", header="Language Options"
options, prompt="Select Translation Type", header="Language Options"
)
elif config.use_rofi:
provider = Rofi.run(options, "Select Translation Type")
@@ -1238,12 +1307,19 @@ def media_actions_menu(
config.continue_from_history = False
anime_provider_search_results_menu(config, fastanime_runtime_state)
def _set_progress_tracking(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
set_prefered_progress_tracking(config, fastanime_runtime_state, update=True)
media_actions_menu(config, fastanime_runtime_state)
icons = config.icons
options = {
f"{'📽️ ' if icons else ''}Stream ({progress}/{episodes_total})": _stream_anime,
f"{'📽️ ' if icons else ''}Episodes": _select_episode_to_stream,
f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer,
f"{'' if icons else ''}Score Anime": _score_anime,
f"{'' if icons else ''}Progress Tracking": _set_progress_tracking,
f"{'📥 ' if icons else ''}Add to List": _add_to_list,
f"{'📤 ' if icons else ''}Remove from List": _remove_from_list,
f"{'📖 ' if icons else ''}View Info": _view_info,
@@ -1258,7 +1334,7 @@ def media_actions_menu(
}
choices = list(options.keys())
if config.use_fzf:
action = fzf.run(choices, prompt="Select Action:", header="Anime Menu")
action = fzf.run(choices, prompt="Select Action", header="Anime Menu")
elif config.use_rofi:
action = Rofi.run(choices, "Select Action")
else:
@@ -1326,14 +1402,14 @@ def anilist_results_menu(
preview = get_fzf_anime_preview(search_results, anime_data.keys())
selected_anime_title = fzf.run(
choices,
prompt="Select Anime: ",
prompt="Select Anime",
header="Search Results",
preview=preview,
)
else:
selected_anime_title = fzf.run(
choices,
prompt="Select Anime: ",
prompt="Select Anime",
header="Search Results",
)
elif config.use_rofi:
@@ -1548,7 +1624,7 @@ def fastanime_main_menu(
if config.use_fzf:
action = fzf.run(
choices,
prompt="Select Action: ",
prompt="Select Action",
header="Anilist Menu",
)
elif config.use_rofi:

View File

@@ -213,7 +213,6 @@ def get_fzf_manga_preview(manga_results, workers=None, wait=False):
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
@@ -290,7 +289,6 @@ def get_fzf_episode_preview(
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
@@ -330,10 +328,10 @@ def get_fzf_anime_preview(
"""
# 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
@@ -354,10 +352,10 @@ def get_fzf_anime_preview(
fi
""" % (
fzf_preview,
IMAGES_CACHE_DIR.replace("\\","\\\\\\"),
IMAGES_CACHE_DIR.replace("\\","\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\","\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\","\\\\\\"),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
)
else:
preview = """

View File

@@ -50,75 +50,140 @@ def stream_video(MPV, url, mpv_args, custom_args):
def run_mpv(
link: str,
title: str | None = "",
title: str = "",
start_time: str = "0",
ytdl_format="",
custom_args=[],
headers={},
subtitles=[],
player="",
):
# Determine if mpv is available
MPV = shutil.which("mpv")
# If title is None, set a default value
# Regex to check if the link is a YouTube URL
youtube_regex = r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+"
if not MPV and not S_PLATFORM == "win32":
# Determine if the link is a YouTube URL
if re.match(youtube_regex, link):
# Android specific commands to launch mpv with a YouTube URL
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"com.google.android.youtube/.UrlActivity",
]
if link.endswith(".torrent"):
WEBTORRENT_CLI = shutil.which("webtorrent")
if not WEBTORRENT_CLI:
import time
print(
"webtorrent cli is not installed which is required for downloading and streaming from nyaa\nplease install it or use another provider"
)
time.sleep(120)
return "0", "0"
cmd = [WEBTORRENT_CLI, link, f"--{player}"]
subprocess.run(cmd)
return "0", "0"
if player == "vlc":
VLC = shutil.which("vlc")
if not VLC and not S_PLATFORM == "win32":
# Determine if the link is a YouTube URL
if re.match(youtube_regex, link):
# Android specific commands to launch mpv with a YouTube URL
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"com.google.android.youtube/.UrlActivity",
]
return "0", "0"
else:
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity",
"-e",
"title",
title,
]
subprocess.run(args)
return "0", "0"
else:
# Android specific commands to launch mpv with a regular URL
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"is.xyz.mpv/.MPVActivity",
]
subprocess.run(args)
return "0", "0"
args = ["vlc", link]
for subtitle in subtitles:
args.append("--sub-file")
args.append(subtitle["url"])
break
if title:
args.append("--video-title")
args.append(title)
subprocess.run(args)
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
# Determine if mpv is available
MPV = shutil.which("mpv")
if not MPV and not S_PLATFORM == "win32":
# Determine if the link is a YouTube URL
if re.match(youtube_regex, link):
# Android specific commands to launch mpv with a YouTube URL
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"com.google.android.youtube/.UrlActivity",
]
return "0", "0"
else:
# Android specific commands to launch mpv with a regular URL
args = [
"nohup",
"am",
"start",
"--user",
"0",
"-a",
"android.intent.action.VIEW",
"-d",
link,
"-n",
"is.xyz.mpv/.MPVActivity",
]
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

View File

@@ -68,7 +68,11 @@ class MpvPlayer(object):
current_episode_number = (
fastanime_runtime_state.provider_current_episode_number
)
config.update_watch_history(anime_id_anilist, str(current_episode_number))
config.media_list_track(
anime_id_anilist,
episode_no=str(current_episode_number),
progress_tracking=fastanime_runtime_state.progress_tracking,
)
elif type == "reload":
if current_episode_number not in total_episodes:
self.mpv_player.show_text("Episode not available")
@@ -84,7 +88,11 @@ class MpvPlayer(object):
self.mpv_player.show_text(f"Fetching episode {ep_no}")
current_episode_number = ep_no
config.update_watch_history(anime_id_anilist, str(ep_no))
config.media_list_track(
anime_id_anilist,
episode_no=str(ep_no),
progress_tracking=fastanime_runtime_state.progress_tracking,
)
fastanime_runtime_state.provider_current_episode_number = str(ep_no)
else:
self.mpv_player.show_text("Fetching previous episode...")
@@ -97,7 +105,11 @@ class MpvPlayer(object):
current_episode_number = (
fastanime_runtime_state.provider_current_episode_number
)
config.update_watch_history(anime_id_anilist, str(current_episode_number))
config.media_list_track(
anime_id_anilist,
episode_no=str(current_episode_number),
progress_tracking=fastanime_runtime_state.progress_tracking,
)
# update episode progress
if config.user and current_episode_number:
AniList.update_anime_list(
@@ -108,7 +120,7 @@ class MpvPlayer(object):
)
# get them juicy streams
episode_streams = anime_provider.get_episode_streams(
provider_anime,
provider_anime["id"],
current_episode_number,
translation_type,
)

View File

@@ -1,43 +1,13 @@
# this script was written by the fzf devs as an example on how to preview images
# its only here for convinience
fzf_preview = r"""
#
# The purpose of this script is to demonstrate how to preview a file or an
# image in the preview window of fzf.
# Adapted from the preview script in the fzf repo
#
# Dependencies:
# - https://github.com/sharkdp/bat
# - https://github.com/hpjansson/chafa
# - https://iterm2.com/utilities/imgcat
#
fzf-preview() {
if [[ $# -ne 1 ]]; then
>&2 echo "usage: $0 FILENAME"
exit 1
fi
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}')
@@ -57,22 +27,27 @@ fzf-preview() {
# 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
# 2. Use chafa with Sixel output
elif command -v chafa >/dev/null; then
chafa -f sixel -s "$dim" "$file"
case "$(uname -a)" in
# termux does not support sixel graphics
# and produces weird output
*ndroid*) chafa -s "$dim" "$file";;
*) chafa -f sixel -s "$dim" "$file";;
esac
# Add a new line character so that fzf can display multiple images in the preview window
echo
# 3. If chafa is not found but imgcat is available, use it on iTerm2
# 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
# 4. Cannot find any suitable method to preview the image
else
file "$file"
echo install chafa or imgcat or install kitty terminal so you can enjoy image previews
fi
}
"""

View File

@@ -19,6 +19,7 @@ class FastAnimeRuntimeState(object):
provider_anime_title: str
provider_anime: "Anime"
provider_anime_search_result: "SearchResult"
progress_tracking: str = ""
selected_anime_anilist: "AnilistBaseMediaDataSchema"
selected_anime_id_anilist: int
@@ -36,8 +37,13 @@ def exit_app(exit_code=0, *args):
console = Console()
if not console.is_terminal:
from plyer import notification
try:
from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
exit(1)
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,

View File

@@ -25,7 +25,7 @@ else:
# ----- user configs and data -----
S_PLATFORM = sys.platform
APP_DATA_DIR = click.get_app_dir(APP_NAME,roaming=False)
APP_DATA_DIR = click.get_app_dir(APP_NAME, roaming=False)
if S_PLATFORM == "win32":
# app data
# app_data_dir_base = os.getenv("LOCALAPPDATA")
@@ -78,6 +78,7 @@ Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
# useful paths
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
USER_WATCH_HISTORY_PATH = os.path.join(APP_DATA_DIR, "watch_history.json")
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log")

View File

@@ -1,11 +1,11 @@
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
from .animepahe.constants import SERVERS_AVAILABLE as ANIMEPAHE_SERVERS
from .hianime.constants import SERVERS_AVAILABLE as HIANIME_SERVERS
anime_sources = {
"allanime": "api.AllAnimeAPI",
"animepahe": "api.AnimePaheApi",
"aniwatch": "api.AniWatchApi",
"aniwave": "api.AniWaveApi",
"hianime": "api.HiAnimeApi",
"nyaa": "api.NyaaApi",
}
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHESERVERS, *ANIWATCHSERVERS]
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS]

View File

@@ -7,9 +7,8 @@ import json
import logging
from typing import TYPE_CHECKING
from requests.exceptions import Timeout
from ...anime_provider.base_provider import AnimeProvider
from ..decorators import debug_provider
from ..utils import give_random_quality, one_digit_symmetric_xor
from .constants import ALLANIME_API_ENDPOINT, ALLANIME_BASE, ALLANIME_REFERER
from .gql_queries import ALLANIME_EPISODES_GQL, ALLANIME_SEARCH_GQL, ALLANIME_SHOW_GQL
@@ -27,6 +26,7 @@ class AllAnimeAPI(AnimeProvider):
Provides a fast and effective interface to AllAnime site.
"""
PROVIDER = "allanime"
api_endpoint = ALLANIME_API_ENDPOINT
HEADERS = {
"Referer": ALLANIME_REFERER,
@@ -42,29 +42,21 @@ class AllAnimeAPI(AnimeProvider):
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}")
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 {}
@debug_provider(PROVIDER.upper())
def search_for_anime(
self,
user_query: str,
@@ -97,29 +89,25 @@ class AllAnimeAPI(AnimeProvider):
"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,
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"],
}
return normalized_search_results
results.append(normalized_result)
except Exception as e:
logger.error(f"[ALLANIME-ERROR]: {e}")
return {}
normalized_search_results = {
"pageInfo": page_info,
"results": results,
}
return normalized_search_results
@debug_provider(PROVIDER.upper())
def get_anime(self, allanime_show_id: str):
"""get an anime details given its id
@@ -130,25 +118,23 @@ class AllAnimeAPI(AnimeProvider):
[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 {}
anime = self._fetch_gql(ALLANIME_SHOW_GQL, variables)
id: str = anime["show"]["_id"]
title: str = anime["show"]["name"]
availableEpisodesDetail = anime["show"]["availableEpisodesDetail"]
self.store.set(allanime_show_id, "anime_info", {"title": title})
type = anime.get("__typename")
normalized_anime = {
"id": id,
"title": title,
"availableEpisodesDetail": availableEpisodesDetail,
"type": type,
}
return normalized_anime
@debug_provider(PROVIDER.upper())
def _get_anime_episode(
self, allanime_show_id: str, episode_string: str, translation_type: str = "sub"
self, allanime_show_id: str, episode, translation_type: str = "sub"
) -> "AllAnimeEpisode | dict":
"""get the episode details and sources info
@@ -163,16 +149,15 @@ class AllAnimeAPI(AnimeProvider):
variables = {
"showId": allanime_show_id,
"translationType": translation_type,
"episodeString": episode_string,
"episodeString": episode,
}
try:
episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables)
return episode["episode"]
except Exception as e:
logger.error(f"[ALLANIME-ERROR]: {e}")
return {}
episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables)
return episode["episode"]
def get_episode_streams(self, anime, episode_number: str, translation_type="sub"):
@debug_provider(PROVIDER.upper())
def get_episode_streams(
self, anime_id, episode_number: str, translation_type="sub"
):
"""get the streams of an episode
Args:
@@ -183,7 +168,10 @@ class AllAnimeAPI(AnimeProvider):
Yields:
[TODO:description]
"""
anime_id = anime["id"]
anime_title = (self.store.get(anime_id, "anime_info", "") or {"title": ""})[
"title"
]
allanime_episode = self._get_anime_episode(
anime_id, episode_number, translation_type
)
@@ -191,125 +179,117 @@ class AllAnimeAPI(AnimeProvider):
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",
}
],
@debug_provider(self.PROVIDER.upper())
def _get_server(embed):
# 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
):
return
url = embed.get("sourceUrl")
#
if not url:
return
if url.startswith("--"):
url = url[2:]
url = one_digit_symmetric_xor(56, url)
if "tools.fast4speed.rsvp" in url:
return {
"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,
)
# 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 []
if resp.ok:
match embed["sourceName"]:
case "Luf-mp4":
logger.debug("allanime:Found streams from gogoanime")
return {
"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")
return {
"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")
return {
"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")
return {
"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")
return {
"server": "wixmp",
"headers": {},
"subtitles": [],
"episode_title": (
allanime_episode["notes"] or f"{anime_title}"
)
+ f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]),
}
for embed in embeds:
if server := _get_server(embed):
yield server

View File

@@ -11,6 +11,7 @@ from yt_dlp.utils import (
)
from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from .constants import (
ANIMEPAHE_BASE,
ANIMEPAHE_ENDPOINT,
@@ -20,64 +21,63 @@ from .constants import (
from .utils import process_animepahe_embed_page
if TYPE_CHECKING:
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimeSearchResult
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimePaheSearchResult
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
PROVIDER = "animepahe"
@debug_provider(PROVIDER.upper())
def search_for_anime(self, user_query: str, *args):
try:
url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}"
response = self.session.get(
url,
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
for animepahe_search_result in data["data"]:
self.store.set(
str(animepahe_search_result["session"]),
"search_result",
animepahe_search_result,
)
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 {}
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"]
],
}
@debug_provider(PROVIDER.upper())
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]
if d := self.store.get(str(session_id), "search_result"):
anime_result: "AnimePaheSearchResult" = d
data: "AnimePaheAnimePage" = {} # pyright:ignore
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
@@ -122,7 +122,8 @@ class AnimePaheApi(AnimeProvider):
if not data:
return {}
self.anime = data # pyright:ignore
data["title"] = anime_result["title"] # pyright:ignore
self.store.set(str(session_id), "anime_info", data)
episodes = list(map(str, [episode["episode"] for episode in data["data"]]))
title = ""
return {
@@ -149,89 +150,87 @@ class AnimePaheApi(AnimeProvider):
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
@debug_provider(PROVIDER.upper())
def get_episode_streams(
self, anime_id, episode_number: str, translation_type, *args
):
anime_title = ""
episode = None
# extract episode details from memory
if d := self.store.get(str(anime_id), "anime_info"):
anime_title = d["title"]
episode = [
episode
for episode in self.anime["data"]
for episode in d["data"]
if float(episode["episode"]) == float(episode_number)
]
if not episode:
logger.error(
f"[ANIMEPAHE-ERROR]: episode {episode_number} doesn't exist"
if not episode:
logger.error(f"[ANIMEPAHE-ERROR]: episode {episode_number} doesn't exist")
return []
episode = episode[0]
# 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.warning(
"[ANIMEPAHE-WARN]: embed url not found please report to the developers"
)
return []
episode = episode[0]
anime_id = anime["id"]
# fetch the episode page
url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
response = self.session.get(url)
# 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 embed page
embed_response = self.session.get(
embed_url, headers={"User-Agent": self.USER_AGENT, **SERVER_HEADERS}
)
# get all links
streams = {
"server": "kwik",
"links": [],
"episode_title": episode_title,
"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 response.ok:
continue
embed_page = embed_response.text
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}")
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

View File

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

View File

@@ -1,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,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 +1,34 @@
import os
import requests
from yt_dlp.utils.networking import random_user_agent
from ...constants import APP_CACHE_DIR
from .providers_store import ProviderStore
class AnimeProvider:
session: requests.Session
PROVIDER = ""
USER_AGENT = random_user_agent()
HEADERS = {}
def __init__(self) -> None:
self.session = requests.session()
def __init__(self, cache_requests, use_persistent_provider_store) -> None:
if cache_requests.lower() == "true":
from ..common.requests_cacher import CachedRequestsSession
self.session = CachedRequestsSession(
os.path.join(APP_CACHE_DIR, "cached_requests.db")
)
else:
self.session = requests.session()
self.session.headers.update({"User-Agent": self.USER_AGENT, **self.HEADERS})
if use_persistent_provider_store.lower() == "true":
self.store = ProviderStore(
"persistent",
self.PROVIDER,
os.path.join(APP_CACHE_DIR, "anime_providers_store.db"),
)
else:
self.store = ProviderStore("memory")

View File

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

View File

@@ -0,0 +1,243 @@
import logging
import re
from html.parser import HTMLParser
from itertools import cycle
from urllib.parse import quote_plus
from yt_dlp.utils import (
clean_html,
extract_attributes,
get_element_by_class,
get_element_html_by_class,
get_elements_by_class,
get_elements_html_by_class,
)
from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from ..utils import give_random_quality
from .constants import SERVERS_AVAILABLE
from .types import HiAnimeStream
logger = logging.getLogger(__name__)
LINK_TO_STREAMS_REGEX = re.compile(r".*://(.*)/embed-(2|4|6)/e-([0-9])/(.*)\?.*")
IMAGE_HTML_ELEMENT_REGEX = re.compile(r"<img.*?>")
class ParseAnchorAndImgTag(HTMLParser):
def __init__(self):
super().__init__()
self.img_tag = None
self.a_tag = None
def handle_starttag(self, tag, attrs):
if tag == "img":
self.img_tag = {attr[0]: attr[1] for attr in attrs}
if tag == "a":
self.a_tag = {attr[0]: attr[1] for attr in attrs}
class HiAnimeApi(AnimeProvider):
# HEADERS = {"Referer": "https://hianime.to/home"}
PROVIDER = "hianime"
@debug_provider(PROVIDER.upper())
def search_for_anime(self, anime_title: str, *args):
query = quote_plus(anime_title)
url = f"https://hianime.to/search?keyword={query}"
response = self.session.get(url)
if not response.ok:
return
search_page = response.text
search_results_html_items = get_elements_by_class("flw-item", search_page)
results = []
for search_results_html_item in search_results_html_items:
film_poster_html = get_element_by_class(
"film-poster", search_results_html_item
)
if not film_poster_html:
continue
# get availableEpisodes
episodes_html = get_element_html_by_class("tick-sub", film_poster_html)
episodes = clean_html(episodes_html) or 12
# get anime id and poster image url
parser = ParseAnchorAndImgTag()
parser.feed(film_poster_html)
image_data = parser.img_tag
anime_link_data = parser.a_tag
if not image_data or not anime_link_data:
continue
episodes = int(episodes)
# finally!!
image_link = image_data["data-src"]
anime_id = anime_link_data["data-id"]
title = anime_link_data["title"]
result = {
"availableEpisodes": list(range(1, episodes)),
"id": anime_id,
"title": title,
"poster": image_link,
}
results.append(result)
self.store.set(result["id"], "search_result", result)
return {"pageInfo": {}, "results": results}
@debug_provider(PROVIDER.upper())
def get_anime(self, hianime_id, *args):
anime_result = {}
if d := self.store.get(str(hianime_id), "search_result"):
anime_result = d
anime_url = f"https://hianime.to/ajax/v2/episode/list/{hianime_id}"
response = self.session.get(anime_url, timeout=10)
if response.ok:
response_json = response.json()
hianime_anime_page = response_json["html"]
episodes_info_container_html = get_element_html_by_class(
"ss-list", hianime_anime_page
)
episodes_info_html_list = get_elements_html_by_class(
"ep-item", episodes_info_container_html
)
# keys: [ data-number: episode_number, data-id: episode_id, title: episode_title , href:episode_page_url]
episodes_info_dicts = [
extract_attributes(episode_dict)
for episode_dict in episodes_info_html_list
]
episodes = [episode["data-number"] for episode in episodes_info_dicts]
episodes_info = [
{
"id": episode["data-id"],
"title": (
(episode["title"] or "").replace(
f"Episode {episode['data-number']}", ""
)
or anime_result["title"]
)
+ f"; Episode {episode['data-number']}",
"episode": episode["data-number"],
}
for episode in episodes_info_dicts
]
self.store.set(
str(hianime_id),
"anime_info",
episodes_info,
)
return {
"id": hianime_id,
"availableEpisodesDetail": {
"dub": episodes,
"sub": episodes,
"raw": episodes,
},
"poster": anime_result["poster"],
"title": anime_result["title"],
"episodes_info": episodes_info,
}
@debug_provider(PROVIDER.upper())
def get_episode_streams(self, anime_id, episode, translation_type, *args):
if d := self.store.get(str(anime_id), "anime_info"):
episodes_info = d
episode_details = [
episode_details
for episode_details in episodes_info
if episode_details["episode"] == episode
]
if not episode_details:
return
episode_details = episode_details[0]
episode_url = f"https://hianime.to/ajax/v2/episode/servers?episodeId={episode_details['id']}"
response = self.session.get(episode_url)
if response.ok:
response_json = response.json()
episode_page_html = response_json["html"]
servers_containers_html = get_elements_html_by_class(
"ps__-list", episode_page_html
)
if not servers_containers_html:
return
# sub servers
try:
servers_html_sub = get_elements_html_by_class(
"server-item", servers_containers_html[0]
)
except Exception:
logger.warning("HiAnime: sub not found")
servers_html_sub = None
# dub servers
try:
servers_html_dub = get_elements_html_by_class(
"server-item", servers_containers_html[1]
)
except Exception:
logger.warning("HiAnime: dub not found")
servers_html_dub = None
if translation_type == "dub":
servers_html = servers_html_dub
else:
servers_html = servers_html_sub
if not servers_html:
return
@debug_provider(self.PROVIDER.upper())
def _get_server(server_name, server_html):
# keys: [ data-type: translation_type, data-id: embed_id, data-server-id: server_id ]
servers_info = extract_attributes(server_html)
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:
return
provider_domain = match.group(1)
embed_type = match.group(2)
episode_number = match.group(3)
source_id = match.group(4)
link_to_streams = f"https://{provider_domain}/embed-{embed_type}/ajax/e-{episode_number}/getSources?id={source_id}"
link_to_streams_response = self.session.get(link_to_streams)
if link_to_streams_response.ok:
juicy_streams_json: "HiAnimeStream" = (
link_to_streams_response.json()
)
# TODO: Hianime decided to fucking encrypt shit
# so got to fix it later
return {
"headers": {},
"subtitles": [
{
"url": track["file"],
"language": track["label"],
}
for track in juicy_streams_json["tracks"]
if track["kind"] == "captions"
],
"server": server_name,
"episode_title": episode_details["title"],
"links": give_random_quality(
[
{"link": link["file"]}
for link in juicy_streams_json["tracks"]
]
),
}
for server_name, server_html in zip(
cycle(SERVERS_AVAILABLE), servers_html
):
if server := _get_server(server_name, server_html):
yield server

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ class PageInfo(TypedDict):
class SearchResult(TypedDict):
id: str
title: str
otherTitles: list[str]
availableEpisodes: list[str]
type: str
score: int

View File

@@ -44,7 +44,7 @@ def search_for_manga_with_anilist(manga_title: str):
pageInfo {
currentPage
}
media(search: $query, type: MANGA) {
media(search: $query, type: MANGA,genre_not_in: ["hentai"]) {
id
idMal
title {
@@ -96,32 +96,36 @@ def search_for_manga_with_anilist(manga_title: str):
}
def search_for_anime_with_anilist(anime_title: str):
def search_for_anime_with_anilist(anime_title: str, prefer_eng_titles=False):
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
}
}
}
}
query ($query: String) {
Page(perPage: 50) {
pageInfo {
total
currentPage
hasNextPage
}
media(search: $query, type: ANIME, genre_not_in: ["hentai"]) {
id
idMal
title {
romaji
english
}
episodes
status
synonyms
nextAiringEpisode {
timeUntilAiring
airingAt
episode
}
coverImage {
large
}
}
}
}
"""
response = post(
ANILIST_ENDPOINT,
@@ -134,22 +138,55 @@ def search_for_anime_with_anilist(anime_title: str):
"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,
"id": str(anime_result["id"]),
"title": (
(
anime_result["title"]["english"]
or anime_result["title"]["romaji"]
)
if prefer_eng_titles
else (
anime_result["title"]["romaji"]
or anime_result["title"]["english"]
)
),
"otherTitles": [
(
(
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
anime_result["title"]["romaji"]
or anime_result["title"]["english"]
)
if prefer_eng_titles
else (
anime_result["title"]["english"]
or anime_result["title"]["romaji"]
)
),
*(anime_result["synonyms"] or []),
],
"type": "anime",
"poster": anime_result["coverImage"]["large"],
"availableEpisodes": list(
map(
str,
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
)
if not anime_result["episodes"]
else anime_result["episodes"]
)
)
+ 1,
),
)
),
@@ -228,7 +265,7 @@ def get_basic_anime_info_by_title(anime_title: str):
pageInfo {
total
}
media(search: $query, type: ANIME) {
media(search: $query, type: ANIME,genre_not_in: ["hentai"]) {
id
idMal
title {

View File

@@ -0,0 +1,214 @@
import json
import logging
import re
import time
from datetime import datetime
from urllib.parse import urlencode
import requests
from .sqlitedb_helper import SqliteDB
logger = logging.getLogger(__name__)
caching_mimetypes = {
"application": {
"json",
"xml",
"x-www-form-urlencoded",
"x-javascript",
"javascript",
},
"text": {"html", "css", "javascript", "plain", "xml", "xsl", "x-javascript"},
}
class CachedRequestsSession(requests.Session):
__request_functions__ = (
"get",
"options",
"head",
"post",
"put",
"patch",
"delete",
)
def __new__(cls, *args, **kwargs):
def caching_params(name: str):
def wrapper(self, *args, **kwargs):
return cls.request(self, name, *args, **kwargs)
return wrapper
for func in cls.__request_functions__:
setattr(cls, func, caching_params(func))
return super().__new__(cls)
def __init__(
self,
cache_db_path: str,
max_lifetime: int = 604800,
max_size: int = (1024**2) * 10,
table_name: str = "fastanime_requests_cache",
clean_db=False,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.cache_db_path = cache_db_path
self.max_lifetime = max_lifetime
self.max_size = max_size
self.table_name = table_name
self.sqlite_db_connection = SqliteDB(self.cache_db_path)
# Prepare the cache table if it doesn't exist
self._create_cache_table()
def _create_cache_table(self):
"""Create cache table if it doesn't exist."""
with self.sqlite_db_connection as conn:
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS {self.table_name} (
url TEXT,
status_code INTEGER,
request_headers TEXT,
response_headers TEXT,
data BLOB,
redirection_policy INT,
cache_expiry INTEGER
)"""
)
def request(
self,
method,
url,
params=None,
force_caching=False,
fresh=0,
*args,
**kwargs,
):
# TODO: improve the caching functionality and add a layer to auto delete
# expired requests
if fresh:
logger.debug("Executing fresh request")
return super().request(method, url, params=params, *args, **kwargs)
if params:
url += "?" + urlencode(params)
redirection_policy = int(kwargs.get("force_redirects", False))
with self.sqlite_db_connection as conn:
cursor = conn.cursor()
time_before_access_db = datetime.now()
logger.debug("Checking for existing request in cache")
cursor.execute(
f"""
SELECT
status_code,
request_headers,
response_headers,
data,
redirection_policy
FROM {self.table_name}
WHERE
url = ?
AND redirection_policy = ?
AND cache_expiry > ?
""",
(url, redirection_policy, int(time.time())),
)
cached_request = cursor.fetchone()
time_after_access_db = datetime.now()
if cached_request:
logger.debug("Found existing request in cache")
(
status_code,
request_headers,
response_headers,
data,
redirection_policy,
) = cached_request
response = requests.Response()
response.headers.update(json.loads(response_headers))
response.status_code = status_code
response._content = data
if "timeout" in kwargs:
kwargs.pop("timeout")
if "headers" in kwargs:
kwargs.pop("headers")
_request = requests.Request(
method, url, headers=json.loads(request_headers), *args, **kwargs
)
response.request = _request.prepare()
response.elapsed = time_after_access_db - time_before_access_db
return response
# Perform the request and cache it
response = super().request(method, url, *args, **kwargs)
if response.ok and (
force_caching
or self.is_content_type_cachable(
response.headers.get("content-type"), caching_mimetypes
)
and len(response.content) < self.max_size
):
logger.debug("Caching the current request")
cursor.execute(
f"""
INSERT INTO {self.table_name}
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
url,
response.status_code,
json.dumps(dict(response.request.headers)),
json.dumps(dict(response.headers)),
response.content,
redirection_policy,
int(time.time()) + self.max_lifetime,
),
)
return response
@staticmethod
def is_content_type_cachable(content_type, caching_mimetypes):
"""Checks whether the given encoding is supported by the cacher"""
if content_type is None:
return True
mime, contents = content_type.split("/")
contents = re.sub(r";.*$", "", contents)
return mime in caching_mimetypes and any(
content in caching_mimetypes[mime] for content in contents.split("+")
)
if __name__ == "__main__":
with CachedRequestsSession("cache.db") as session:
response = session.get(
"https://google.com",
)
response_b = session.get(
"https://google.com",
)
print("A: ", response.elapsed)
print("B: ", response_b.elapsed)
print(response_b.text[0:30])

View File

@@ -0,0 +1,34 @@
import logging
import sqlite3
import time
logger = logging.getLogger(__name__)
class SqliteDB:
def __init__(self, db_path: str) -> None:
self.db_path = db_path
self.connection = sqlite3.connect(self.db_path)
logger.debug("Enabling WAL mode for concurrent access")
self.connection.execute("PRAGMA journal_mode=WAL;")
self.connection.close()
self.connection = None
def __enter__(self):
logger.debug("Starting new connection...")
start_time = time.time()
self.connection = sqlite3.connect(self.db_path)
logger.debug(
"Successfully got a new connection in {} seconds".format(
time.time() - start_time
)
)
return self.connection
def __exit__(self, exc_type, exc_val, exc_tb):
if self.connection:
logger.debug("Closing connection to cache db")
self.connection.commit()
self.connection.close()
self.connection = None
logger.debug("Successfully closed connection to cache db")

View File

@@ -124,7 +124,7 @@ class FZF:
stdout=subprocess.PIPE,
universal_newlines=True,
text=True,
encoding="utf-8"
encoding="utf-8",
)
if not result or result.returncode != 0 or not result.stdout:
print("sth went wrong:confused:")
@@ -163,7 +163,7 @@ class FZF:
HEADER,
"--header-first",
"--prompt",
prompt.title(),
f"{prompt.title()}: ",
] # pyright:ignore
if preview:

View File

@@ -2,8 +2,6 @@ import subprocess
from shutil import which
from sys import exit
from plyer import notification
from fastanime import APP_NAME
from ...constants import ICON_PATH
@@ -25,7 +23,7 @@ class RofiApi:
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"])
args.extend(["-p", f"{prompt_text.title()}", "-i", "-show-icons", "-dmenu"])
result = subprocess.run(
args,
input=rofi_input,
@@ -35,6 +33,13 @@ class RofiApi:
choice = result.stdout.strip()
if not choice:
try:
from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
exit(1)
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,
@@ -64,6 +69,13 @@ class RofiApi:
choice = result.stdout.strip()
if not choice or choice not in options:
try:
from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
exit(1)
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,
@@ -91,6 +103,13 @@ class RofiApi:
choice = result.stdout.strip()
if not choice:
try:
from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
exit(1)
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,
@@ -120,6 +139,13 @@ class RofiApi:
user_input = result.stdout.strip()
if not user_input:
try:
from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
exit(1)
notification.notify(
app_name=APP_NAME,
app_icon=ICON_PATH,

736
poetry.lock generated
View File

@@ -205,89 +205,89 @@ files = [
[[package]]
name = "certifi"
version = "2024.7.4"
version = "2024.8.30"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
]
[[package]]
name = "cffi"
version = "1.17.0"
version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
files = [
{file = "cffi-1.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f9338cc05451f1942d0d8203ec2c346c830f8e86469903d5126c1f0a13a2bcbb"},
{file = "cffi-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0ce71725cacc9ebf839630772b07eeec220cbb5f03be1399e0457a1464f8e1a"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c815270206f983309915a6844fe994b2fa47e5d05c4c4cef267c3b30e34dbe42"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6bdcd415ba87846fd317bee0774e412e8792832e7805938987e4ede1d13046d"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a98748ed1a1df4ee1d6f927e151ed6c1a09d5ec21684de879c7ea6aa96f58f2"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a048d4f6630113e54bb4b77e315e1ba32a5a31512c31a273807d0027a7e69ab"},
{file = "cffi-1.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24aa705a5f5bd3a8bcfa4d123f03413de5d86e497435693b638cbffb7d5d8a1b"},
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:856bf0924d24e7f93b8aee12a3a1095c34085600aa805693fb7f5d1962393206"},
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:4304d4416ff032ed50ad6bb87416d802e67139e31c0bde4628f36a47a3164bfa"},
{file = "cffi-1.17.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:331ad15c39c9fe9186ceaf87203a9ecf5ae0ba2538c9e898e3a6967e8ad3db6f"},
{file = "cffi-1.17.0-cp310-cp310-win32.whl", hash = "sha256:669b29a9eca6146465cc574659058ed949748f0809a2582d1f1a324eb91054dc"},
{file = "cffi-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:48b389b1fd5144603d61d752afd7167dfd205973a43151ae5045b35793232aa2"},
{file = "cffi-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c5d97162c196ce54af6700949ddf9409e9833ef1003b4741c2b39ef46f1d9720"},
{file = "cffi-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ba5c243f4004c750836f81606a9fcb7841f8874ad8f3bf204ff5e56332b72b9"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb9333f58fc3a2296fb1d54576138d4cf5d496a2cc118422bd77835e6ae0b9cb"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:435a22d00ec7d7ea533db494da8581b05977f9c37338c80bc86314bec2619424"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1df34588123fcc88c872f5acb6f74ae59e9d182a2707097f9e28275ec26a12d"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df8bb0010fdd0a743b7542589223a2816bdde4d94bb5ad67884348fa2c1c67e8"},
{file = "cffi-1.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8b5b9712783415695663bd463990e2f00c6750562e6ad1d28e072a611c5f2a6"},
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ffef8fd58a36fb5f1196919638f73dd3ae0db1a878982b27a9a5a176ede4ba91"},
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e67d26532bfd8b7f7c05d5a766d6f437b362c1bf203a3a5ce3593a645e870b8"},
{file = "cffi-1.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:45f7cd36186db767d803b1473b3c659d57a23b5fa491ad83c6d40f2af58e4dbb"},
{file = "cffi-1.17.0-cp311-cp311-win32.whl", hash = "sha256:a9015f5b8af1bb6837a3fcb0cdf3b874fe3385ff6274e8b7925d81ccaec3c5c9"},
{file = "cffi-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:b50aaac7d05c2c26dfd50c3321199f019ba76bb650e346a6ef3616306eed67b0"},
{file = "cffi-1.17.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aec510255ce690d240f7cb23d7114f6b351c733a74c279a84def763660a2c3bc"},
{file = "cffi-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2770bb0d5e3cc0e31e7318db06efcbcdb7b31bcb1a70086d3177692a02256f59"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db9a30ec064129d605d0f1aedc93e00894b9334ec74ba9c6bdd08147434b33eb"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a47eef975d2b8b721775a0fa286f50eab535b9d56c70a6e62842134cf7841195"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3e0992f23bbb0be00a921eae5363329253c3b86287db27092461c887b791e5e"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6107e445faf057c118d5050560695e46d272e5301feffda3c41849641222a828"},
{file = "cffi-1.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb862356ee9391dc5a0b3cbc00f416b48c1b9a52d252d898e5b7696a5f9fe150"},
{file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c1c13185b90bbd3f8b5963cd8ce7ad4ff441924c31e23c975cb150e27c2bf67a"},
{file = "cffi-1.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:17c6d6d3260c7f2d94f657e6872591fe8733872a86ed1345bda872cfc8c74885"},
{file = "cffi-1.17.0-cp312-cp312-win32.whl", hash = "sha256:c3b8bd3133cd50f6b637bb4322822c94c5ce4bf0d724ed5ae70afce62187c492"},
{file = "cffi-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:dca802c8db0720ce1c49cce1149ff7b06e91ba15fa84b1d59144fef1a1bc7ac2"},
{file = "cffi-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce01337d23884b21c03869d2f68c5523d43174d4fc405490eb0091057943118"},
{file = "cffi-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cab2eba3830bf4f6d91e2d6718e0e1c14a2f5ad1af68a89d24ace0c6b17cced7"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14b9cbc8f7ac98a739558eb86fabc283d4d564dafed50216e7f7ee62d0d25377"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b00e7bcd71caa0282cbe3c90966f738e2db91e64092a877c3ff7f19a1628fdcb"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41f4915e09218744d8bae14759f983e466ab69b178de38066f7579892ff2a555"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4760a68cab57bfaa628938e9c2971137e05ce48e762a9cb53b76c9b569f1204"},
{file = "cffi-1.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:011aff3524d578a9412c8b3cfaa50f2c0bd78e03eb7af7aa5e0df59b158efb2f"},
{file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a003ac9edc22d99ae1286b0875c460351f4e101f8c9d9d2576e78d7e048f64e0"},
{file = "cffi-1.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef9528915df81b8f4c7612b19b8628214c65c9b7f74db2e34a646a0a2a0da2d4"},
{file = "cffi-1.17.0-cp313-cp313-win32.whl", hash = "sha256:70d2aa9fb00cf52034feac4b913181a6e10356019b18ef89bc7c12a283bf5f5a"},
{file = "cffi-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:b7b6ea9e36d32582cda3465f54c4b454f62f23cb083ebc7a94e2ca6ef011c3a7"},
{file = "cffi-1.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:964823b2fc77b55355999ade496c54dde161c621cb1f6eac61dc30ed1b63cd4c"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:516a405f174fd3b88829eabfe4bb296ac602d6a0f68e0d64d5ac9456194a5b7e"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dec6b307ce928e8e112a6bb9921a1cb00a0e14979bf28b98e084a4b8a742bd9b"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4094c7b464cf0a858e75cd14b03509e84789abf7b79f8537e6a72152109c76e"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2404f3de742f47cb62d023f0ba7c5a916c9c653d5b368cc966382ae4e57da401"},
{file = "cffi-1.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa9d43b02a0c681f0bfbc12d476d47b2b2b6a3f9287f11ee42989a268a1833c"},
{file = "cffi-1.17.0-cp38-cp38-win32.whl", hash = "sha256:0bb15e7acf8ab35ca8b24b90af52c8b391690ef5c4aec3d31f38f0d37d2cc499"},
{file = "cffi-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:93a7350f6706b31f457c1457d3a3259ff9071a66f312ae64dc024f049055f72c"},
{file = "cffi-1.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a2ddbac59dc3716bc79f27906c010406155031a1c801410f1bafff17ea304d2"},
{file = "cffi-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6327b572f5770293fc062a7ec04160e89741e8552bf1c358d1a23eba68166759"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbc183e7bef690c9abe5ea67b7b60fdbca81aa8da43468287dae7b5c046107d4"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bdc0f1f610d067c70aa3737ed06e2726fd9d6f7bfee4a351f4c40b6831f4e82"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6d872186c1617d143969defeadac5a904e6e374183e07977eedef9c07c8953bf"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d46ee4764b88b91f16661a8befc6bfb24806d885e27436fdc292ed7e6f6d058"},
{file = "cffi-1.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f76a90c345796c01d85e6332e81cab6d70de83b829cf1d9762d0a3da59c7932"},
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e60821d312f99d3e1569202518dddf10ae547e799d75aef3bca3a2d9e8ee693"},
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:eb09b82377233b902d4c3fbeeb7ad731cdab579c6c6fda1f763cd779139e47c3"},
{file = "cffi-1.17.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24658baf6224d8f280e827f0a50c46ad819ec8ba380a42448e24459daf809cf4"},
{file = "cffi-1.17.0-cp39-cp39-win32.whl", hash = "sha256:0fdacad9e0d9fc23e519efd5ea24a70348305e8d7d85ecbb1a5fa66dc834e7fb"},
{file = "cffi-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:7cbc78dc018596315d4e7841c8c3a7ae31cc4d638c9b627f87d52e8abaaf2d29"},
{file = "cffi-1.17.0.tar.gz", hash = "sha256:f3157624b7558b914cb039fd1af735e5e8049a87c817cc215109ad1c8779df76"},
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
{file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
{file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
{file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
{file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
{file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
{file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
{file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
{file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
{file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
{file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
{file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
{file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
{file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
{file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
{file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
{file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
{file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
{file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
{file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
{file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
]
[package.dependencies]
@@ -466,29 +466,29 @@ test = ["pytest (>=6)"]
[[package]]
name = "filelock"
version = "3.15.4"
version = "3.16.1"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.8"
files = [
{file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"},
{file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"},
{file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
{file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"]
typing = ["typing-extensions (>=4.8)"]
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"]
typing = ["typing-extensions (>=4.12.2)"]
[[package]]
name = "identify"
version = "2.6.0"
version = "2.6.1"
description = "File identification library for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"},
{file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"},
{file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"},
{file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"},
]
[package.extras]
@@ -496,15 +496,18 @@ license = ["ukkonen"]
[[package]]
name = "idna"
version = "3.7"
version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.5"
python-versions = ">=3.6"
files = [
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.0.0"
@@ -587,7 +590,7 @@ files = [
name = "mpv"
version = "1.0.7"
description = "A python interface to the mpv media player"
optional = false
optional = true
python-versions = ">=3.9"
files = [
{file = "mpv-1.0.7-py3-none-any.whl", hash = "sha256:520fb134c18185b69c7fce4aa3514f14371028022d92eb193818e9fefb1e9fe8"},
@@ -669,19 +672,19 @@ docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst
[[package]]
name = "platformdirs"
version = "4.2.2"
version = "4.3.6"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
{file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
{file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
type = ["mypy (>=1.8)"]
docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"]
type = ["mypy (>=1.11.2)"]
[[package]]
name = "pluggy"
@@ -702,7 +705,7 @@ testing = ["pytest", "pytest-benchmark"]
name = "plyer"
version = "2.1.0"
description = "Platform-independent wrapper for platform-dependent APIs"
optional = false
optional = true
python-versions = "*"
files = [
{file = "plyer-2.1.0-py2.py3-none-any.whl", hash = "sha256:1b1772060df8b3045ed4f08231690ec8f7de30f5a004aa1724665a9074eed113"},
@@ -735,13 +738,13 @@ virtualenv = ">=20.10.0"
[[package]]
name = "prompt-toolkit"
version = "3.0.47"
version = "3.0.48"
description = "Library for building powerful interactive command lines in Python"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"},
{file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"},
{file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"},
{file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"},
]
[package.dependencies]
@@ -760,43 +763,43 @@ files = [
[[package]]
name = "pycryptodomex"
version = "3.20.0"
version = "3.21.0"
description = "Cryptographic library for Python"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
files = [
{file = "pycryptodomex-3.20.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:645bd4ca6f543685d643dadf6a856cc382b654cc923460e3a10a49c1b3832aeb"},
{file = "pycryptodomex-3.20.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ff5c9a67f8a4fba4aed887216e32cbc48f2a6fb2673bb10a99e43be463e15913"},
{file = "pycryptodomex-3.20.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8ee606964553c1a0bc74057dd8782a37d1c2bc0f01b83193b6f8bb14523b877b"},
{file = "pycryptodomex-3.20.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7805830e0c56d88f4d491fa5ac640dfc894c5ec570d1ece6ed1546e9df2e98d6"},
{file = "pycryptodomex-3.20.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:bc3ee1b4d97081260d92ae813a83de4d2653206967c4a0a017580f8b9548ddbc"},
{file = "pycryptodomex-3.20.0-cp27-cp27m-win32.whl", hash = "sha256:8af1a451ff9e123d0d8bd5d5e60f8e3315c3a64f3cdd6bc853e26090e195cdc8"},
{file = "pycryptodomex-3.20.0-cp27-cp27m-win_amd64.whl", hash = "sha256:cbe71b6712429650e3883dc81286edb94c328ffcd24849accac0a4dbcc76958a"},
{file = "pycryptodomex-3.20.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:76bd15bb65c14900d98835fcd10f59e5e0435077431d3a394b60b15864fddd64"},
{file = "pycryptodomex-3.20.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:653b29b0819605fe0898829c8ad6400a6ccde096146730c2da54eede9b7b8baa"},
{file = "pycryptodomex-3.20.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a5ec91388984909bb5398ea49ee61b68ecb579123694bffa172c3b0a107079"},
{file = "pycryptodomex-3.20.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:108e5f1c1cd70ffce0b68739c75734437c919d2eaec8e85bffc2c8b4d2794305"},
{file = "pycryptodomex-3.20.0-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:59af01efb011b0e8b686ba7758d59cf4a8263f9ad35911bfe3f416cee4f5c08c"},
{file = "pycryptodomex-3.20.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:82ee7696ed8eb9a82c7037f32ba9b7c59e51dda6f105b39f043b6ef293989cb3"},
{file = "pycryptodomex-3.20.0-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91852d4480a4537d169c29a9d104dda44094c78f1f5b67bca76c29a91042b623"},
{file = "pycryptodomex-3.20.0-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca649483d5ed251d06daf25957f802e44e6bb6df2e8f218ae71968ff8f8edc4"},
{file = "pycryptodomex-3.20.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e186342cfcc3aafaad565cbd496060e5a614b441cacc3995ef0091115c1f6c5"},
{file = "pycryptodomex-3.20.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:25cd61e846aaab76d5791d006497134602a9e451e954833018161befc3b5b9ed"},
{file = "pycryptodomex-3.20.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:9c682436c359b5ada67e882fec34689726a09c461efd75b6ea77b2403d5665b7"},
{file = "pycryptodomex-3.20.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:7a7a8f33a1f1fb762ede6cc9cbab8f2a9ba13b196bfaf7bc6f0b39d2ba315a43"},
{file = "pycryptodomex-3.20.0-cp35-abi3-win32.whl", hash = "sha256:c39778fd0548d78917b61f03c1fa8bfda6cfcf98c767decf360945fe6f97461e"},
{file = "pycryptodomex-3.20.0-cp35-abi3-win_amd64.whl", hash = "sha256:2a47bcc478741b71273b917232f521fd5704ab4b25d301669879e7273d3586cc"},
{file = "pycryptodomex-3.20.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:1be97461c439a6af4fe1cf8bf6ca5936d3db252737d2f379cc6b2e394e12a458"},
{file = "pycryptodomex-3.20.0-pp27-pypy_73-win32.whl", hash = "sha256:19764605feea0df966445d46533729b645033f134baeb3ea26ad518c9fdf212c"},
{file = "pycryptodomex-3.20.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f2e497413560e03421484189a6b65e33fe800d3bd75590e6d78d4dfdb7accf3b"},
{file = "pycryptodomex-3.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e48217c7901edd95f9f097feaa0388da215ed14ce2ece803d3f300b4e694abea"},
{file = "pycryptodomex-3.20.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d00fe8596e1cc46b44bf3907354e9377aa030ec4cd04afbbf6e899fc1e2a7781"},
{file = "pycryptodomex-3.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:88afd7a3af7ddddd42c2deda43d53d3dfc016c11327d0915f90ca34ebda91499"},
{file = "pycryptodomex-3.20.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d3584623e68a5064a04748fb6d76117a21a7cb5eaba20608a41c7d0c61721794"},
{file = "pycryptodomex-3.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0daad007b685db36d977f9de73f61f8da2a7104e20aca3effd30752fd56f73e1"},
{file = "pycryptodomex-3.20.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dcac11031a71348faaed1f403a0debd56bf5404232284cf8c761ff918886ebc"},
{file = "pycryptodomex-3.20.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:69138068268127cd605e03438312d8f271135a33140e2742b417d027a0539427"},
{file = "pycryptodomex-3.20.0.tar.gz", hash = "sha256:7a710b79baddd65b806402e14766c721aee8fb83381769c27920f26476276c1e"},
{file = "pycryptodomex-3.21.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dbeb84a399373df84a69e0919c1d733b89e049752426041deeb30d68e9867822"},
{file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:a192fb46c95489beba9c3f002ed7d93979423d1b2a53eab8771dbb1339eb3ddd"},
{file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:1233443f19d278c72c4daae749872a4af3787a813e05c3561c73ab0c153c7b0f"},
{file = "pycryptodomex-3.21.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbb07f88e277162b8bfca7134b34f18b400d84eac7375ce73117f865e3c80d4c"},
{file = "pycryptodomex-3.21.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:e859e53d983b7fe18cb8f1b0e29d991a5c93be2c8dd25db7db1fe3bd3617f6f9"},
{file = "pycryptodomex-3.21.0-cp27-cp27m-win32.whl", hash = "sha256:ef046b2e6c425647971b51424f0f88d8a2e0a2a63d3531817968c42078895c00"},
{file = "pycryptodomex-3.21.0-cp27-cp27m-win_amd64.whl", hash = "sha256:da76ebf6650323eae7236b54b1b1f0e57c16483be6e3c1ebf901d4ada47563b6"},
{file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:c07e64867a54f7e93186a55bec08a18b7302e7bee1b02fd84c6089ec215e723a"},
{file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:56435c7124dd0ce0c8bdd99c52e5d183a0ca7fdcd06c5d5509423843f487dd0b"},
{file = "pycryptodomex-3.21.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65d275e3f866cf6fe891411be9c1454fb58809ccc5de6d3770654c47197acd65"},
{file = "pycryptodomex-3.21.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:5241bdb53bcf32a9568770a6584774b1b8109342bd033398e4ff2da052123832"},
{file = "pycryptodomex-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:34325b84c8b380675fd2320d0649cdcbc9cf1e0d1526edbe8fce43ed858cdc7e"},
{file = "pycryptodomex-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:103c133d6cd832ae7266feb0a65b69e3a5e4dbbd6f3a3ae3211a557fd653f516"},
{file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77ac2ea80bcb4b4e1c6a596734c775a1615d23e31794967416afc14852a639d3"},
{file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9aa0cf13a1a1128b3e964dc667e5fe5c6235f7d7cfb0277213f0e2a783837cc2"},
{file = "pycryptodomex-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46eb1f0c8d309da63a2064c28de54e5e614ad17b7e2f88df0faef58ce192fc7b"},
{file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:cc7e111e66c274b0df5f4efa679eb31e23c7545d702333dfd2df10ab02c2a2ce"},
{file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:770d630a5c46605ec83393feaa73a9635a60e55b112e1fb0c3cea84c2897aa0a"},
{file = "pycryptodomex-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:52e23a0a6e61691134aa8c8beba89de420602541afaae70f66e16060fdcd677e"},
{file = "pycryptodomex-3.21.0-cp36-abi3-win32.whl", hash = "sha256:a3d77919e6ff56d89aada1bd009b727b874d464cb0e2e3f00a49f7d2e709d76e"},
{file = "pycryptodomex-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b0e9765f93fe4890f39875e6c90c96cb341767833cfa767f41b490b506fa9ec0"},
{file = "pycryptodomex-3.21.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:feaecdce4e5c0045e7a287de0c4351284391fe170729aa9182f6bd967631b3a8"},
{file = "pycryptodomex-3.21.0-pp27-pypy_73-win32.whl", hash = "sha256:365aa5a66d52fd1f9e0530ea97f392c48c409c2f01ff8b9a39c73ed6f527d36c"},
{file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3efddfc50ac0ca143364042324046800c126a1d63816d532f2e19e6f2d8c0c31"},
{file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df2608682db8279a9ebbaf05a72f62a321433522ed0e499bc486a6889b96bf3"},
{file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5823d03e904ea3e53aebd6799d6b8ec63b7675b5d2f4a4bd5e3adcb512d03b37"},
{file = "pycryptodomex-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:27e84eeff24250ffec32722334749ac2a57a5fd60332cd6a0680090e7c42877e"},
{file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ef436cdeea794015263853311f84c1ff0341b98fc7908e8a70595a68cefd971"},
{file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a1058e6dfe827f4209c5cae466e67610bcd0d66f2f037465daa2a29d92d952b"},
{file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba09a5b407cbb3bcb325221e346a140605714b5e880741dc9a1e9ecf1688d42"},
{file = "pycryptodomex-3.21.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8a9d8342cf22b74a746e3c6c9453cb0cfbb55943410e3a2619bd9164b48dc9d9"},
{file = "pycryptodomex-3.21.0.tar.gz", hash = "sha256:222d0bd05381dd25c32dd6065c071ebf084212ab79bab4599ba9e6a3e0009e6c"},
]
[[package]]
@@ -826,13 +829,13 @@ windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyproject-api"
version = "1.7.1"
version = "1.8.0"
description = "API to interact with the python pyproject.toml based projects"
optional = false
python-versions = ">=3.8"
files = [
{file = "pyproject_api-1.7.1-py3-none-any.whl", hash = "sha256:2dc1654062c2b27733d8fd4cdda672b22fe8741ef1dde8e3a998a9547b071eeb"},
{file = "pyproject_api-1.7.1.tar.gz", hash = "sha256:7ebc6cd10710f89f4cf2a2731710a98abce37ebff19427116ff2174c9236a827"},
{file = "pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228"},
{file = "pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496"},
]
[package.dependencies]
@@ -840,36 +843,38 @@ packaging = ">=24.1"
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
[package.extras]
docs = ["furo (>=2024.5.6)", "sphinx-autodoc-typehints (>=2.2.1)"]
testing = ["covdefaults (>=2.3)", "pytest (>=8.2.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=70.1)"]
docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=2.4.1)"]
testing = ["covdefaults (>=2.3)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"]
[[package]]
name = "pyright"
version = "1.1.377"
version = "1.1.383"
description = "Command line wrapper for pyright"
optional = false
python-versions = ">=3.7"
files = [
{file = "pyright-1.1.377-py3-none-any.whl", hash = "sha256:af0dd2b6b636c383a6569a083f8c5a8748ae4dcde5df7914b3f3f267e14dd162"},
{file = "pyright-1.1.377.tar.gz", hash = "sha256:aabc30fedce0ded34baa0c49b24f10e68f4bfc8f68ae7f3d175c4b0f256b4fcf"},
{file = "pyright-1.1.383-py3-none-any.whl", hash = "sha256:d864d1182a313f45aaf99e9bfc7d2668eeabc99b29a556b5344894fd73cb1959"},
{file = "pyright-1.1.383.tar.gz", hash = "sha256:1df7f12407f3710c9c6df938d98ec53f70053e6c6bbf71ce7bcb038d42f10070"},
]
[package.dependencies]
nodeenv = ">=1.6.0"
typing-extensions = ">=4.1"
[package.extras]
all = ["twine (>=3.4.1)"]
all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"]
dev = ["twine (>=3.4.1)"]
nodejs = ["nodejs-wheel-binaries"]
[[package]]
name = "pytest"
version = "8.3.2"
version = "8.3.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
{file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
{file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
]
[package.dependencies]
@@ -947,123 +952,103 @@ files = [
[[package]]
name = "rapidfuzz"
version = "3.9.6"
version = "3.10.0"
description = "rapid fuzzy string matching"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "rapidfuzz-3.9.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a7ed0d0b9c85720f0ae33ac5efc8dc3f60c1489dad5c29d735fbdf2f66f0431f"},
{file = "rapidfuzz-3.9.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f3deff6ab7017ed21b9aec5874a07ad13e6b2a688af055837f88b743c7bfd947"},
{file = "rapidfuzz-3.9.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3f9fc060160507b2704f7d1491bd58453d69689b580cbc85289335b14fe8ca"},
{file = "rapidfuzz-3.9.6-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e86c2b3827fa6169ad6e7d4b790ce02a20acefb8b78d92fa4249589bbc7a2c"},
{file = "rapidfuzz-3.9.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f982e1aafb4bd8207a5e073b1efef9e68a984e91330e1bbf364f9ed157ed83f0"},
{file = "rapidfuzz-3.9.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9196a51d0ec5eaaaf5bca54a85b7b1e666fc944c332f68e6427503af9fb8c49e"},
{file = "rapidfuzz-3.9.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb5a514064e02585b1cc09da2fe406a6dc1a7e5f3e92dd4f27c53e5f1465ec81"},
{file = "rapidfuzz-3.9.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e3a4244f65dbc3580b1275480118c3763f9dc29fc3dd96610560cb5e140a4d4a"},
{file = "rapidfuzz-3.9.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6ebb910a702e41641e1e1dada3843bc11ba9107a33c98daef6945a885a40a07"},
{file = "rapidfuzz-3.9.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:624fbe96115fb39addafa288d583b5493bc76dab1d34d0ebba9987d6871afdf9"},
{file = "rapidfuzz-3.9.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1c59f1c1507b7a557cf3c410c76e91f097460da7d97e51c985343798e9df7a3c"},
{file = "rapidfuzz-3.9.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6f0256cb27b6a0fb2e1918477d1b56473cd04acfa245376a342e7c15806a396"},
{file = "rapidfuzz-3.9.6-cp310-cp310-win32.whl", hash = "sha256:24d473d00d23a30a85802b502b417a7f5126019c3beec91a6739fe7b95388b24"},
{file = "rapidfuzz-3.9.6-cp310-cp310-win_amd64.whl", hash = "sha256:248f6d2612e661e2b5f9a22bbd5862a1600e720da7bb6ad8a55bb1548cdfa423"},
{file = "rapidfuzz-3.9.6-cp310-cp310-win_arm64.whl", hash = "sha256:e03fdf0e74f346ed7e798135df5f2a0fb8d6b96582b00ebef202dcf2171e1d1d"},
{file = "rapidfuzz-3.9.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52e4675f642fbc85632f691b67115a243cd4d2a47bdcc4a3d9a79e784518ff97"},
{file = "rapidfuzz-3.9.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1f93a2f13038700bd245b927c46a2017db3dcd4d4ff94687d74b5123689b873b"},
{file = "rapidfuzz-3.9.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b70500bca460264b8141d8040caee22e9cf0418c5388104ff0c73fb69ee28f"},
{file = "rapidfuzz-3.9.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1e037fb89f714a220f68f902fc6300ab7a33349f3ce8ffae668c3b3a40b0b06"},
{file = "rapidfuzz-3.9.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6792f66d59b86ccfad5e247f2912e255c85c575789acdbad8e7f561412ffed8a"},
{file = "rapidfuzz-3.9.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68d9cffe710b67f1969cf996983608cee4490521d96ea91d16bd7ea5dc80ea98"},
{file = "rapidfuzz-3.9.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63daaeeea76da17fa0bbe7fb05cba8ed8064bb1a0edf8360636557f8b6511961"},
{file = "rapidfuzz-3.9.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d214e063bffa13e3b771520b74f674b22d309b5720d4df9918ff3e0c0f037720"},
{file = "rapidfuzz-3.9.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ed443a2062460f44c0346cb9d269b586496b808c2419bbd6057f54061c9b9c75"},
{file = "rapidfuzz-3.9.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5b0c9b227ee0076fb2d58301c505bb837a290ae99ee628beacdb719f0626d749"},
{file = "rapidfuzz-3.9.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:82c9722b7dfaa71e8b61f8c89fed0482567fb69178e139fe4151fc71ed7df782"},
{file = "rapidfuzz-3.9.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c18897c95c0a288347e29537b63608a8f63a5c3cb6da258ac46fcf89155e723e"},
{file = "rapidfuzz-3.9.6-cp311-cp311-win32.whl", hash = "sha256:3e910cf08944da381159587709daaad9e59d8ff7bca1f788d15928f3c3d49c2a"},
{file = "rapidfuzz-3.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:59c4a61fab676d37329fc3a671618a461bfeef53a4d0b8b12e3bc24a14e166f8"},
{file = "rapidfuzz-3.9.6-cp311-cp311-win_arm64.whl", hash = "sha256:8b4afea244102332973377fddbe54ce844d0916e1c67a5123432291717f32ffa"},
{file = "rapidfuzz-3.9.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:70591b28b218fff351b88cdd7f2359a01a71f9f7f5a2e465ce3715ed4b3c422b"},
{file = "rapidfuzz-3.9.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee2d8355c7343c631a03e57540ea06e8717c19ecf5ff64ea07e0498f7f161457"},
{file = "rapidfuzz-3.9.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:708fb675de0f47b9635d1cc6fbbf80d52cb710d0a1abbfae5c84c46e3abbddc3"},
{file = "rapidfuzz-3.9.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d66c247c2d3bb7a9b60567c395a15a929d0ebcc5f4ceedb55bfa202c38c6e0c"},
{file = "rapidfuzz-3.9.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15146301b32e6e3d2b7e8146db1a26747919d8b13690c7f83a4cb5dc111b3a08"},
{file = "rapidfuzz-3.9.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7a03da59b6c7c97e657dd5cd4bcaab5fe4a2affd8193958d6f4d938bee36679"},
{file = "rapidfuzz-3.9.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d2c2fe19e392dbc22695b6c3b2510527e2b774647e79936bbde49db7742d6f1"},
{file = "rapidfuzz-3.9.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:91aaee4c94cb45930684f583ffc4e7c01a52b46610971cede33586cf8a04a12e"},
{file = "rapidfuzz-3.9.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3f5702828c10768f9281180a7ff8597da1e5002803e1304e9519dd0f06d79a85"},
{file = "rapidfuzz-3.9.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ccd1763b608fb4629a0b08f00b3c099d6395e67c14e619f6341b2c8429c2f310"},
{file = "rapidfuzz-3.9.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc7a0d4b2cb166bc46d02c8c9f7551cde8e2f3c9789df3827309433ee9771163"},
{file = "rapidfuzz-3.9.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7496f53d40560a58964207b52586783633f371683834a8f719d6d965d223a2eb"},
{file = "rapidfuzz-3.9.6-cp312-cp312-win32.whl", hash = "sha256:5eb1a9272ca71bc72be5415c2fa8448a6302ea4578e181bb7da9db855b367df0"},
{file = "rapidfuzz-3.9.6-cp312-cp312-win_amd64.whl", hash = "sha256:0d21fc3c0ca507a1180152a6dbd129ebaef48facde3f943db5c1055b6e6be56a"},
{file = "rapidfuzz-3.9.6-cp312-cp312-win_arm64.whl", hash = "sha256:43bb27a57c29dc5fa754496ba6a1a508480d21ae99ac0d19597646c16407e9f3"},
{file = "rapidfuzz-3.9.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:83a5ac6547a9d6eedaa212975cb8f2ce2aa07e6e30833b40e54a52b9f9999aa4"},
{file = "rapidfuzz-3.9.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:10f06139142ecde67078ebc9a745965446132b998f9feebffd71acdf218acfcc"},
{file = "rapidfuzz-3.9.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74720c3f24597f76c7c3e2c4abdff55f1664f4766ff5b28aeaa689f8ffba5fab"},
{file = "rapidfuzz-3.9.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce2bce52b5c150878e558a0418c2b637fb3dbb6eb38e4eb27d24aa839920483e"},
{file = "rapidfuzz-3.9.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1611199f178793ca9a060c99b284e11f6d7d124998191f1cace9a0245334d219"},
{file = "rapidfuzz-3.9.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0308b2ad161daf502908a6e21a57c78ded0258eba9a8f5e2545e2dafca312507"},
{file = "rapidfuzz-3.9.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eda91832201b86e3b70835f91522587725bec329ec68f2f7faf5124091e5ca7"},
{file = "rapidfuzz-3.9.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ece873c093aedd87fc07c2a7e333d52e458dc177016afa1edaf157e82b6914d8"},
{file = "rapidfuzz-3.9.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d97d3c9d209d5c30172baea5966f2129e8a198fec4a1aeb2f92abb6e82a2edb1"},
{file = "rapidfuzz-3.9.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6c4550d0db4931f5ebe9f0678916d1b06f06f5a99ba0b8a48b9457fd8959a7d4"},
{file = "rapidfuzz-3.9.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b6b8dd4af6324fc325d9483bec75ecf9be33e590928c9202d408e4eafff6a0a6"},
{file = "rapidfuzz-3.9.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16122ae448bc89e2bea9d81ce6cb0f751e4e07da39bd1e70b95cae2493857853"},
{file = "rapidfuzz-3.9.6-cp313-cp313-win32.whl", hash = "sha256:71cc168c305a4445109cd0d4925406f6e66bcb48fde99a1835387c58af4ecfe9"},
{file = "rapidfuzz-3.9.6-cp313-cp313-win_amd64.whl", hash = "sha256:59ee78f2ecd53fef8454909cda7400fe2cfcd820f62b8a5d4dfe930102268054"},
{file = "rapidfuzz-3.9.6-cp313-cp313-win_arm64.whl", hash = "sha256:58b4ce83f223605c358ae37e7a2d19a41b96aa65b1fede99cc664c9053af89ac"},
{file = "rapidfuzz-3.9.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9f469dbc9c4aeaac7dd005992af74b7dff94aa56a3ea063ce64e4b3e6736dd2f"},
{file = "rapidfuzz-3.9.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a9ed7ad9adb68d0fe63a156fe752bbf5f1403ed66961551e749641af2874da92"},
{file = "rapidfuzz-3.9.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39ffe48ffbeedf78d120ddfb9d583f2ca906712159a4e9c3c743c9f33e7b1775"},
{file = "rapidfuzz-3.9.6-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8502ccdea9084d54b6f737d96a3b60a84e3afed9d016686dc979b49cdac71613"},
{file = "rapidfuzz-3.9.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6a4bec4956e06b170ca896ba055d08d4c457dac745548172443982956a80e118"},
{file = "rapidfuzz-3.9.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c0488b1c273be39e109ff885ccac0448b2fa74dea4c4dc676bcf756c15f16d6"},
{file = "rapidfuzz-3.9.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0542c036cb6acf24edd2c9e0411a67d7ba71e29e4d3001a082466b86fc34ff30"},
{file = "rapidfuzz-3.9.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0a96b52c9f26857bf009e270dcd829381e7a634f7ddd585fa29b87d4c82146d9"},
{file = "rapidfuzz-3.9.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:6edd3cd7c4aa8c68c716d349f531bd5011f2ca49ddade216bb4429460151559f"},
{file = "rapidfuzz-3.9.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:50b2fb55d7ed58c66d49c9f954acd8fc4a3f0e9fd0ff708299bd8abb68238d0e"},
{file = "rapidfuzz-3.9.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:32848dfe54391636b84cda1823fd23e5a6b1dbb8be0e9a1d80e4ee9903820994"},
{file = "rapidfuzz-3.9.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:29146cb7a1bf69c87e928b31bffa54f066cb65639d073b36e1425f98cccdebc6"},
{file = "rapidfuzz-3.9.6-cp38-cp38-win32.whl", hash = "sha256:aed13e5edacb0ecadcc304cc66e93e7e77ff24f059c9792ee602c0381808e10c"},
{file = "rapidfuzz-3.9.6-cp38-cp38-win_amd64.whl", hash = "sha256:af440e36b828922256d0b4d79443bf2cbe5515fc4b0e9e96017ec789b36bb9fc"},
{file = "rapidfuzz-3.9.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:efa674b407424553024522159296690d99d6e6b1192cafe99ca84592faff16b4"},
{file = "rapidfuzz-3.9.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0b40ff76ee19b03ebf10a0a87938f86814996a822786c41c3312d251b7927849"},
{file = "rapidfuzz-3.9.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16a6c7997cb5927ced6f617122eb116ba514ec6b6f60f4803e7925ef55158891"},
{file = "rapidfuzz-3.9.6-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3f42504bdc8d770987fc3d99964766d42b2a03e4d5b0f891decdd256236bae0"},
{file = "rapidfuzz-3.9.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9462aa2be9f60b540c19a083471fdf28e7cf6434f068b631525b5e6251b35e"},
{file = "rapidfuzz-3.9.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1629698e68f47609a73bf9e73a6da3a4cac20bc710529215cbdf111ab603665b"},
{file = "rapidfuzz-3.9.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68bc7621843d8e9a7fd1b1a32729465bf94b47b6fb307d906da168413331f8d6"},
{file = "rapidfuzz-3.9.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c6254c50f15bc2fcc33cb93a95a81b702d9e6590f432a7f7822b8c7aba9ae288"},
{file = "rapidfuzz-3.9.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7e535a114fa575bc143e175e4ca386a467ec8c42909eff500f5f0f13dc84e3e0"},
{file = "rapidfuzz-3.9.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d50acc0e9d67e4ba7a004a14c42d1b1e8b6ca1c515692746f4f8e7948c673167"},
{file = "rapidfuzz-3.9.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fa742ec60bec53c5a211632cf1d31b9eb5a3c80f1371a46a23ac25a1fa2ab209"},
{file = "rapidfuzz-3.9.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c256fa95d29cbe5aa717db790b231a9a5b49e5983d50dc9df29d364a1db5e35b"},
{file = "rapidfuzz-3.9.6-cp39-cp39-win32.whl", hash = "sha256:89acbf728b764421036c173a10ada436ecca22999851cdc01d0aa904c70d362d"},
{file = "rapidfuzz-3.9.6-cp39-cp39-win_amd64.whl", hash = "sha256:c608fcba8b14d86c04cb56b203fed31a96e8a1ebb4ce99e7b70313c5bf8cf497"},
{file = "rapidfuzz-3.9.6-cp39-cp39-win_arm64.whl", hash = "sha256:d41c00ded0e22e9dba88ff23ebe0dc9d2a5f21ba2f88e185ea7374461e61daa9"},
{file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a65c2f63218ea2dedd56fc56361035e189ca123bd9c9ce63a9bef6f99540d681"},
{file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:680dc78a5f889d3b89f74824b89fe357f49f88ad10d2c121e9c3ad37bac1e4eb"},
{file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8ca862927a0b05bd825e46ddf82d0724ea44b07d898ef639386530bf9b40f15"},
{file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2116fa1fbff21fa52cd46f3cfcb1e193ba1d65d81f8b6e123193451cd3d6c15e"},
{file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dcb7d9afd740370a897c15da61d3d57a8d54738d7c764a99cedb5f746d6a003"},
{file = "rapidfuzz-3.9.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1a5bd6401bb489e14cbb5981c378d53ede850b7cc84b2464cad606149cc4e17d"},
{file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:29fda70b9d03e29df6fc45cc27cbcc235534b1b0b2900e0a3ae0b43022aaeef5"},
{file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:88144f5f52ae977df9352029488326afadd7a7f42c6779d486d1f82d43b2b1f2"},
{file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:715aeaabafba2709b9dd91acb2a44bad59d60b4616ef90c08f4d4402a3bbca60"},
{file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af26ebd3714224fbf9bebbc27bdbac14f334c15f5d7043699cd694635050d6ca"},
{file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101bd2df438861a005ed47c032631b7857dfcdb17b82beeeb410307983aac61d"},
{file = "rapidfuzz-3.9.6-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:2185e8e29809b97ad22a7f99281d1669a89bdf5fa1ef4ef1feca36924e675367"},
{file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9e53c72d08f0e9c6e4a369e52df5971f311305b4487690c62e8dd0846770260c"},
{file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a0cb157162f0cdd62e538c7bd298ff669847fc43a96422811d5ab933f4c16c3a"},
{file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bb5ff2bd48132ed5e7fbb8f619885facb2e023759f2519a448b2c18afe07e5d"},
{file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dc37f601865e8407e3a8037ffbc3afe0b0f837b2146f7632bd29d087385babe"},
{file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a657eee4b94668faf1fa2703bdd803654303f7e468eb9ba10a664d867ed9e779"},
{file = "rapidfuzz-3.9.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:51be6ab5b1d5bb32abd39718f2a5e3835502e026a8272d139ead295c224a6f5e"},
{file = "rapidfuzz-3.9.6.tar.gz", hash = "sha256:5cf2a7d621e4515fee84722e93563bf77ff2cbe832a77a48b81f88f9e23b9e8d"},
{file = "rapidfuzz-3.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:884453860de029380dded8f3c1918af2d8eb5adf8010261645c7e5c88c2b5428"},
{file = "rapidfuzz-3.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:718c9bd369288aca5fa929df6dbf66fdbe9768d90940a940c0b5cdc96ade4309"},
{file = "rapidfuzz-3.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a68e3724b7dab761c01816aaa64b0903734d999d5589daf97c14ef5cc0629a8e"},
{file = "rapidfuzz-3.10.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1af60988d47534246d9525f77288fdd9de652608a4842815d9018570b959acc6"},
{file = "rapidfuzz-3.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3084161fc3e963056232ef8d937449a2943852e07101f5a136c8f3cfa4119217"},
{file = "rapidfuzz-3.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cd67d3d017296d98ff505529104299f78433e4b8af31b55003d901a62bbebe9"},
{file = "rapidfuzz-3.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b11a127ac590fc991e8a02c2d7e1ac86e8141c92f78546f18b5c904064a0552c"},
{file = "rapidfuzz-3.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aadce42147fc09dcef1afa892485311e824c050352e1aa6e47f56b9b27af4cf0"},
{file = "rapidfuzz-3.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b54853c2371bf0e38d67da379519deb6fbe70055efb32f6607081641af3dc752"},
{file = "rapidfuzz-3.10.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ce19887268e90ee81a3957eef5e46a70ecc000713796639f83828b950343f49e"},
{file = "rapidfuzz-3.10.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:f39a2a5ded23b9b9194ec45740dce57177b80f86c6d8eba953d3ff1a25c97766"},
{file = "rapidfuzz-3.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ec338d5f4ad8d9339a88a08db5c23e7f7a52c2b2a10510c48a0cef1fb3f0ddc"},
{file = "rapidfuzz-3.10.0-cp310-cp310-win32.whl", hash = "sha256:56fd15ea8f4c948864fa5ebd9261c67cf7b89a1c517a0caef4df75446a7af18c"},
{file = "rapidfuzz-3.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:43dfc5e733808962a822ff6d9c29f3039a3cfb3620706f5953e17cfe4496724c"},
{file = "rapidfuzz-3.10.0-cp310-cp310-win_arm64.whl", hash = "sha256:ae7966f205b5a7fde93b44ca8fed37c1c8539328d7f179b1197de34eceaceb5f"},
{file = "rapidfuzz-3.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bb0013795b40db5cf361e6f21ee7cda09627cf294977149b50e217d7fe9a2f03"},
{file = "rapidfuzz-3.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:69ef5b363afff7150a1fbe788007e307b9802a2eb6ad92ed51ab94e6ad2674c6"},
{file = "rapidfuzz-3.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c582c46b1bb0b19f1a5f4c1312f1b640c21d78c371a6615c34025b16ee56369b"},
{file = "rapidfuzz-3.10.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:288f6f6e7410cacb115fb851f3f18bf0e4231eb3f6cb5bd1cec0e7b25c4d039d"},
{file = "rapidfuzz-3.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9e29a13d2fd9be3e7d8c26c7ef4ba60b5bc7efbc9dbdf24454c7e9ebba31768"},
{file = "rapidfuzz-3.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea2da0459b951ee461bd4e02b8904890bd1c4263999d291c5cd01e6620177ad4"},
{file = "rapidfuzz-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:457827ba82261aa2ae6ac06a46d0043ab12ba7216b82d87ae1434ec0f29736d6"},
{file = "rapidfuzz-3.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d350864269d56f51ab81ab750c9259ae5cad3152c0680baef143dcec92206a1"},
{file = "rapidfuzz-3.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a9b8f51e08c3f983d857c3889930af9ddecc768453822076683664772d87e374"},
{file = "rapidfuzz-3.10.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f3a6aa6e70fc27e4ff5c479f13cc9fc26a56347610f5f8b50396a0d344c5f55"},
{file = "rapidfuzz-3.10.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:803f255f10d63420979b1909ef976e7d30dec42025c9b067fc1d2040cc365a7e"},
{file = "rapidfuzz-3.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2026651761bf83a0f31495cc0f70840d5c0d54388f41316e3f9cb51bd85e49a5"},
{file = "rapidfuzz-3.10.0-cp311-cp311-win32.whl", hash = "sha256:4df75b3ebbb8cfdb9bf8b213b168620b88fd92d0c16a8bc9f9234630b282db59"},
{file = "rapidfuzz-3.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:f9f0bbfb6787b97c51516f3ccf97737d504db5d239ad44527673b81f598b84ab"},
{file = "rapidfuzz-3.10.0-cp311-cp311-win_arm64.whl", hash = "sha256:10fdad800441b9c97d471a937ba7d42625f1b530db05e572f1cb7d401d95c893"},
{file = "rapidfuzz-3.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dc87073ba3a40dd65591a2100aa71602107443bf10770579ff9c8a3242edb94"},
{file = "rapidfuzz-3.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a425a0a868cf8e9c6e93e1cda4b758cdfd314bb9a4fc916c5742c934e3613480"},
{file = "rapidfuzz-3.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a86d5d1d75e61df060c1e56596b6b0a4422a929dff19cc3dbfd5eee762c86b61"},
{file = "rapidfuzz-3.10.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34f213d59219a9c3ca14e94a825f585811a68ac56b4118b4dc388b5b14afc108"},
{file = "rapidfuzz-3.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96ad46f5f56f70fab2be9e5f3165a21be58d633b90bf6e67fc52a856695e4bcf"},
{file = "rapidfuzz-3.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9178277f72d144a6c7704d7ae7fa15b7b86f0f0796f0e1049c7b4ef748a662ef"},
{file = "rapidfuzz-3.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76a35e9e19a7c883c422ffa378e9a04bc98cb3b29648c5831596401298ee51e6"},
{file = "rapidfuzz-3.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a6405d34c394c65e4f73a1d300c001f304f08e529d2ed6413b46ee3037956eb"},
{file = "rapidfuzz-3.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bd393683129f446a75d8634306aed7e377627098a1286ff3af2a4f1736742820"},
{file = "rapidfuzz-3.10.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b0445fa9880ead81f5a7d0efc0b9c977a947d8052c43519aceeaf56eabaf6843"},
{file = "rapidfuzz-3.10.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c50bc308fa29767ed8f53a8d33b7633a9e14718ced038ed89d41b886e301da32"},
{file = "rapidfuzz-3.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e89605afebbd2d4b045bccfdc12a14b16fe8ccbae05f64b4b4c64a97dad1c891"},
{file = "rapidfuzz-3.10.0-cp312-cp312-win32.whl", hash = "sha256:2db9187f3acf3cd33424ecdbaad75414c298ecd1513470df7bda885dcb68cc15"},
{file = "rapidfuzz-3.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:50e3d0c72ea15391ba9531ead7f2068a67c5b18a6a365fef3127583aaadd1725"},
{file = "rapidfuzz-3.10.0-cp312-cp312-win_arm64.whl", hash = "sha256:9eac95b4278bd53115903d89118a2c908398ee8bdfd977ae844f1bd2b02b917c"},
{file = "rapidfuzz-3.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fe5231e8afd069c742ac5b4f96344a0fe4aff52df8e53ef87faebf77f827822c"},
{file = "rapidfuzz-3.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:886882367dbc985f5736356105798f2ae6e794e671fc605476cbe2e73838a9bb"},
{file = "rapidfuzz-3.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b33e13e537e3afd1627d421a142a12bbbe601543558a391a6fae593356842f6e"},
{file = "rapidfuzz-3.10.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:094c26116d55bf9c53abd840d08422f20da78ec4c4723e5024322321caedca48"},
{file = "rapidfuzz-3.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:545fc04f2d592e4350f59deb0818886c1b444ffba3bec535b4fbb97191aaf769"},
{file = "rapidfuzz-3.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:916a6abf3632e592b937c3d04c00a6efadd8fd30539cdcd4e6e4d92be7ca5d90"},
{file = "rapidfuzz-3.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6ec40cef63b1922083d33bfef2f91fc0b0bc07b5b09bfee0b0f1717d558292"},
{file = "rapidfuzz-3.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c77a7330dd15c7eb5fd3631dc646fc96327f98db8181138766bd14d3e905f0ba"},
{file = "rapidfuzz-3.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:949b5e9eeaa4ecb4c7e9c2a4689dddce60929dd1ff9c76a889cdbabe8bbf2171"},
{file = "rapidfuzz-3.10.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5363932a5aab67010ae1a6205c567d1ef256fb333bc23c27582481606be480c"},
{file = "rapidfuzz-3.10.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5dd6eec15b13329abe66cc241b484002ecb0e17d694491c944a22410a6a9e5e2"},
{file = "rapidfuzz-3.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e7f98525b60b3c14524e0a4e1fedf7654657b6e02eb25f1be897ab097706f3"},
{file = "rapidfuzz-3.10.0-cp313-cp313-win32.whl", hash = "sha256:d29d1b9857c65f8cb3a29270732e1591b9bacf89de9d13fa764f79f07d8f1fd2"},
{file = "rapidfuzz-3.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:fa9720e56663cc3649d62b4b5f3145e94b8f5611e8a8e1b46507777249d46aad"},
{file = "rapidfuzz-3.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:eda4c661e68dddd56c8fbfe1ca35e40dd2afd973f7ebb1605f4d151edc63dff8"},
{file = "rapidfuzz-3.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cffbc50e0767396ed483900900dd58ce4351bc0d40e64bced8694bd41864cc71"},
{file = "rapidfuzz-3.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c038b9939da3035afb6cb2f465f18163e8f070aba0482923ecff9443def67178"},
{file = "rapidfuzz-3.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca366c2e2a54e2f663f4529b189fdeb6e14d419b1c78b754ec1744f3c01070d4"},
{file = "rapidfuzz-3.10.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c4c82b1689b23b1b5e6a603164ed2be41b6f6de292a698b98ba2381e889eb9d"},
{file = "rapidfuzz-3.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98f6ebe28831a482981ecfeedc8237047878424ad0c1add2c7f366ba44a20452"},
{file = "rapidfuzz-3.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd1a7676ee2a4c8e2f7f2550bece994f9f89e58afb96088964145a83af7408b"},
{file = "rapidfuzz-3.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec9139baa3f85b65adc700eafa03ed04995ca8533dd56c924f0e458ffec044ab"},
{file = "rapidfuzz-3.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:26de93e6495078b6af4c4d93a42ca067b16cc0e95699526c82ab7d1025b4d3bf"},
{file = "rapidfuzz-3.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f3a0bda83c18195c361b5500377d0767749f128564ca95b42c8849fd475bb327"},
{file = "rapidfuzz-3.10.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:63e4c175cbce8c3adc22dca5e6154588ae673f6c55374d156f3dac732c88d7de"},
{file = "rapidfuzz-3.10.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4dd3d8443970eaa02ab5ae45ce584b061f2799cd9f7e875190e2617440c1f9d4"},
{file = "rapidfuzz-3.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e5ddb2388610799fc46abe389600625058f2a73867e63e20107c5ad5ffa57c47"},
{file = "rapidfuzz-3.10.0-cp39-cp39-win32.whl", hash = "sha256:2e9be5d05cd960914024412b5406fb75a82f8562f45912ff86255acbfdbfb78e"},
{file = "rapidfuzz-3.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:47aca565a39c9a6067927871973ca827023e8b65ba6c5747f4c228c8d7ddc04f"},
{file = "rapidfuzz-3.10.0-cp39-cp39-win_arm64.whl", hash = "sha256:b0732343cdc4273b5921268026dd7266f75466eb21873cb7635a200d9d9c3fac"},
{file = "rapidfuzz-3.10.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f744b5eb1469bf92dd143d36570d2bdbbdc88fe5cb0b5405e53dd34f479cbd8a"},
{file = "rapidfuzz-3.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b67cc21a14327a0eb0f47bc3d7e59ec08031c7c55220ece672f9476e7a8068d3"},
{file = "rapidfuzz-3.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fe5783676f0afba4a522c80b15e99dbf4e393c149ab610308a8ef1f04c6bcc8"},
{file = "rapidfuzz-3.10.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4688862f957c8629d557d084f20b2d803f8738b6c4066802a0b1cc472e088d9"},
{file = "rapidfuzz-3.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20bd153aacc244e4c907d772c703fea82754c4db14f8aa64d75ff81b7b8ab92d"},
{file = "rapidfuzz-3.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:50484d563f8bfa723c74c944b0bb15b9e054db9c889348c8c307abcbee75ab92"},
{file = "rapidfuzz-3.10.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5897242d455461f2c5b82d7397b29341fd11e85bf3608a522177071044784ee8"},
{file = "rapidfuzz-3.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:116c71a81e046ba56551d8ab68067ca7034d94b617545316d460a452c5c3c289"},
{file = "rapidfuzz-3.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0a547e4350d1fa32624d3eab51eff8cf329f4cae110b4ea0402486b1da8be40"},
{file = "rapidfuzz-3.10.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:399b9b79ccfcf50ca3bad7692bc098bb8eade88d7d5e15773b7f866c91156d0c"},
{file = "rapidfuzz-3.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7947a425d1be3e744707ee58c6cb318b93a56e08f080722dcc0347e0b7a1bb9a"},
{file = "rapidfuzz-3.10.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:94c48b4a2a4b1d22246f48e2b11cae01ec7d23f0c9123f8bb822839ad79d0a88"},
{file = "rapidfuzz-3.10.0.tar.gz", hash = "sha256:6b62af27e65bb39276a66533655a2fa3c60a487b03935721c45b7809527979be"},
]
[package.extras]
full = ["numpy"]
all = ["numpy"]
[[package]]
name = "requests"
@@ -1088,18 +1073,19 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rich"
version = "13.7.1"
version = "13.9.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.7.0"
python-versions = ">=3.8.0"
files = [
{file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"},
{file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"},
{file = "rich-13.9.1-py3-none-any.whl", hash = "sha256:b340e739f30aa58921dc477b8adaa9ecdb7cecc217be01d93730ee1bc8aa83be"},
{file = "rich-13.9.1.tar.gz", hash = "sha256:097cffdf85db1babe30cc7deba5ab3a29e1b9885047dab24c57e9a7f8a9c1466"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""}
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
@@ -1146,41 +1132,42 @@ rapidfuzz = ">=3.0.0,<4.0.0"
[[package]]
name = "tomli"
version = "2.0.1"
version = "2.0.2"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
{file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"},
{file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"},
]
[[package]]
name = "tox"
version = "4.18.0"
version = "4.21.0"
description = "tox is a generic virtualenv management and test command line tool"
optional = false
python-versions = ">=3.8"
files = [
{file = "tox-4.18.0-py3-none-any.whl", hash = "sha256:0a457400cf70615dc0627eb70d293e80cd95d8ce174bb40ac011011f0c03a249"},
{file = "tox-4.18.0.tar.gz", hash = "sha256:5dfa1cab9f146becd6e351333a82f9e0ade374451630ba65ee54584624c27b58"},
{file = "tox-4.21.0-py3-none-any.whl", hash = "sha256:693ac51378255d34ad7aab6dd2ce9ab6a1cf1924eb930183fde850ad503b681d"},
{file = "tox-4.21.0.tar.gz", hash = "sha256:e64dd9847ff3a7ec90368be412d7efe61a39caf043222ffbe9ad638ea435f6f6"},
]
[package.dependencies]
cachetools = ">=5.4"
cachetools = ">=5.5"
chardet = ">=5.2"
colorama = ">=0.4.6"
filelock = ">=3.15.4"
filelock = ">=3.16.1"
packaging = ">=24.1"
platformdirs = ">=4.2.2"
platformdirs = ">=4.3.6"
pluggy = ">=1.5"
pyproject-api = ">=1.7.1"
pyproject-api = ">=1.8"
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
virtualenv = ">=20.26.3"
typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""}
virtualenv = ">=20.26.6"
[package.extras]
docs = ["furo (>=2024.7.18)", "sphinx (>=7.4.7)", "sphinx-argparse-cli (>=1.16)", "sphinx-autodoc-typehints (>=2.2.3)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.11)"]
testing = ["build[virtualenv] (>=1.2.1)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1)", "diff-cover (>=9.1.1)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=70.3)", "time-machine (>=2.14.2)", "wheel (>=0.43)"]
docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-argparse-cli (>=1.18.2)", "sphinx-autodoc-typehints (>=2.4.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=24.8)"]
testing = ["build[virtualenv] (>=1.2.2)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.2)", "devpi-process (>=1.0.2)", "diff-cover (>=9.2)", "distlib (>=0.3.8)", "flaky (>=3.8.1)", "hatch-vcs (>=0.4)", "hatchling (>=1.25)", "psutil (>=6)", "pytest (>=8.3.3)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-xdist (>=3.6.1)", "re-assert (>=1.1)", "setuptools (>=75.1)", "time-machine (>=2.15)", "wheel (>=0.44)"]
[[package]]
name = "typing-extensions"
@@ -1195,13 +1182,13 @@ files = [
[[package]]
name = "urllib3"
version = "2.2.2"
version = "2.2.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.8"
files = [
{file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"},
{file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"},
{file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"},
{file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"},
]
[package.extras]
@@ -1212,13 +1199,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "virtualenv"
version = "20.26.3"
version = "20.26.6"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.7"
files = [
{file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"},
{file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"},
{file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"},
{file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"},
]
[package.dependencies]
@@ -1243,108 +1230,108 @@ files = [
[[package]]
name = "websockets"
version = "13.0"
version = "13.1"
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
optional = false
python-versions = ">=3.8"
files = [
{file = "websockets-13.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad4fa707ff9e2ffee019e946257b5300a45137a58f41fbd9a4db8e684ab61528"},
{file = "websockets-13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6fd757f313c13c34dae9f126d3ba4cf97175859c719e57c6a614b781c86b617e"},
{file = "websockets-13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cbac2eb7ce0fac755fb983c9247c4a60c4019bcde4c0e4d167aeb17520cc7ef1"},
{file = "websockets-13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4b83cf7354cbbc058e97b3e545dceb75b8d9cf17fd5a19db419c319ddbaaf7a"},
{file = "websockets-13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9202c0010c78fad1041e1c5285232b6508d3633f92825687549540a70e9e5901"},
{file = "websockets-13.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6566e79c8c7cbea75ec450f6e1828945fc5c9a4769ceb1c7b6e22470539712"},
{file = "websockets-13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e7fcad070dcd9ad37a09d89a4cbc2a5e3e45080b88977c0da87b3090f9f55ead"},
{file = "websockets-13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a8f7d65358a25172db00c69bcc7df834155ee24229f560d035758fd6613111a"},
{file = "websockets-13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:63b702fb31e3f058f946ccdfa551f4d57a06f7729c369e8815eb18643099db37"},
{file = "websockets-13.0-cp310-cp310-win32.whl", hash = "sha256:3a20cf14ba7b482c4a1924b5e061729afb89c890ca9ed44ac4127c6c5986e424"},
{file = "websockets-13.0-cp310-cp310-win_amd64.whl", hash = "sha256:587245f0704d0bb675f919898d7473e8827a6d578e5a122a21756ca44b811ec8"},
{file = "websockets-13.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06df8306c241c235075d2ae77367038e701e53bc8c1bb4f6644f4f53aa6dedd0"},
{file = "websockets-13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85a1f92a02f0b8c1bf02699731a70a8a74402bb3f82bee36e7768b19a8ed9709"},
{file = "websockets-13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9ed02c604349068d46d87ef4c2012c112c791f2bec08671903a6bb2bd9c06784"},
{file = "websockets-13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b89849171b590107f6724a7b0790736daead40926ddf47eadf998b4ff51d6414"},
{file = "websockets-13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:939a16849d71203628157a5e4a495da63967c744e1e32018e9b9e2689aca64d4"},
{file = "websockets-13.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad818cdac37c0ad4c58e51cb4964eae4f18b43c4a83cb37170b0d90c31bd80cf"},
{file = "websockets-13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cbfe82a07596a044de78bb7a62519e71690c5812c26c5f1d4b877e64e4f46309"},
{file = "websockets-13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e07e76c49f39c5b45cbd7362b94f001ae209a3ea4905ae9a09cfd53b3c76373d"},
{file = "websockets-13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:372f46a0096cfda23c88f7e42349a33f8375e10912f712e6b496d3a9a557290f"},
{file = "websockets-13.0-cp311-cp311-win32.whl", hash = "sha256:376a43a4fd96725f13450d3d2e98f4f36c3525c562ab53d9a98dd2950dca9a8a"},
{file = "websockets-13.0-cp311-cp311-win_amd64.whl", hash = "sha256:2be1382a4daa61e2f3e2be3b3c86932a8db9d1f85297feb6e9df22f391f94452"},
{file = "websockets-13.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5407c34776b9b77bd89a5f95eb0a34aaf91889e3f911c63f13035220eb50107"},
{file = "websockets-13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4782ec789f059f888c1e8fdf94383d0e64b531cffebbf26dd55afd53ab487ca4"},
{file = "websockets-13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c8feb8e19ef65c9994e652c5b0324abd657bedd0abeb946fb4f5163012c1e730"},
{file = "websockets-13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f3d2e20c442b58dbac593cb1e02bc02d149a86056cc4126d977ad902472e3b"},
{file = "websockets-13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e39d393e0ab5b8bd01717cc26f2922026050188947ff54fe6a49dc489f7750b7"},
{file = "websockets-13.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f661a4205741bdc88ac9c2b2ec003c72cee97e4acd156eb733662ff004ba429"},
{file = "websockets-13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:384129ad0490e06bab2b98c1da9b488acb35bb11e2464c728376c6f55f0d45f3"},
{file = "websockets-13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:df5c0eff91f61b8205a6c9f7b255ff390cdb77b61c7b41f79ca10afcbb22b6cb"},
{file = "websockets-13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:02cc9bb1a887dac0e08bf657c5d00aa3fac0d03215d35a599130c2034ae6663a"},
{file = "websockets-13.0-cp312-cp312-win32.whl", hash = "sha256:d9726d2c9bd6aed8cb994d89b3910ca0079406edce3670886ec828a73e7bdd53"},
{file = "websockets-13.0-cp312-cp312-win_amd64.whl", hash = "sha256:fa0839f35322f7b038d8adcf679e2698c3a483688cc92e3bd15ee4fb06669e9a"},
{file = "websockets-13.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:da7e501e59857e8e3e9d10586139dc196b80445a591451ca9998aafba1af5278"},
{file = "websockets-13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a00e1e587c655749afb5b135d8d3edcfe84ec6db864201e40a882e64168610b3"},
{file = "websockets-13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a7fbf2a8fe7556a8f4e68cb3e736884af7bf93653e79f6219f17ebb75e97d8f0"},
{file = "websockets-13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ea9c9c7443a97ea4d84d3e4d42d0e8c4235834edae652993abcd2aff94affd7"},
{file = "websockets-13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35c2221b539b360203f3f9ad168e527bf16d903e385068ae842c186efb13d0ea"},
{file = "websockets-13.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:358d37c5c431dd050ffb06b4b075505aae3f4f795d7fff9794e5ed96ce99b998"},
{file = "websockets-13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:038e7a0f1bfafc7bf52915ab3506b7a03d1e06381e9f60440c856e8918138151"},
{file = "websockets-13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fd038bc9e2c134847f1e0ce3191797fad110756e690c2fdd9702ed34e7a43abb"},
{file = "websockets-13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93b8c2008f372379fb6e5d2b3f7c9ec32f7b80316543fd3a5ace6610c5cde1b0"},
{file = "websockets-13.0-cp313-cp313-win32.whl", hash = "sha256:851fd0afb3bc0b73f7c5b5858975d42769a5fdde5314f4ef2c106aec63100687"},
{file = "websockets-13.0-cp313-cp313-win_amd64.whl", hash = "sha256:7d14901fdcf212804970c30ab9ee8f3f0212e620c7ea93079d6534863444fb4e"},
{file = "websockets-13.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ae7a519a56a714f64c3445cabde9fc2fc927e7eae44f413eae187cddd9e54178"},
{file = "websockets-13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5575031472ca87302aeb2ce2c2349f4c6ea978c86a9d1289bc5d16058ad4c10a"},
{file = "websockets-13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9895df6cd0bfe79d09bcd1dbdc03862846f26fbd93797153de954306620c1d00"},
{file = "websockets-13.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4de299c947a54fca9ce1c5fd4a08eb92ffce91961becb13bd9195f7c6e71b47"},
{file = "websockets-13.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05c25f7b849702950b6fd0e233989bb73a0d2bc83faa3b7233313ca395205f6d"},
{file = "websockets-13.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ede95125a30602b1691a4b1da88946bf27dae283cf30f22cd2cb8ca4b2e0d119"},
{file = "websockets-13.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:addf0a16e4983280efed272d8cb3b2e05f0051755372461e7d966b80a6554e16"},
{file = "websockets-13.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:06b3186e97bf9a33921fa60734d5ed90f2a9b407cce8d23c7333a0984049ef61"},
{file = "websockets-13.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:eae368cac85adc4c7dc3b0d5f84ffcca609d658db6447387300478e44db70796"},
{file = "websockets-13.0-cp38-cp38-win32.whl", hash = "sha256:337837ac788d955728b1ab01876d72b73da59819a3388e1c5e8e05c3999f1afa"},
{file = "websockets-13.0-cp38-cp38-win_amd64.whl", hash = "sha256:f66e00e42f25ca7e91076366303e11c82572ca87cc5aae51e6e9c094f315ab41"},
{file = "websockets-13.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:94c1c02721139fe9940b38d28fb15b4b782981d800d5f40f9966264fbf23dcc8"},
{file = "websockets-13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bd4ba86513430513e2aa25a441bb538f6f83734dc368a2c5d18afdd39097aa33"},
{file = "websockets-13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a1ab8f0e0cadc5be5f3f9fa11a663957fecbf483d434762c8dfb8aa44948944a"},
{file = "websockets-13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3670def5d3dfd5af6f6e2b3b243ea8f1f72d8da1ef927322f0703f85c90d9603"},
{file = "websockets-13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6058b6be92743358885ad6dcdecb378fde4a4c74d4dd16a089d07580c75a0e80"},
{file = "websockets-13.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:516062a0a8ef5ecbfa4acbaec14b199fc070577834f9fe3d40800a99f92523ca"},
{file = "websockets-13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:da7e918d82e7bdfc6f66d31febe1b2e28a1ca3387315f918de26f5e367f61572"},
{file = "websockets-13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9cc7f35dcb49a4e32db82a849fcc0714c4d4acc9d2273aded2d61f87d7f660b7"},
{file = "websockets-13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f5737c53eb2c8ed8f64b50d3dafd3c1dae739f78aa495a288421ac1b3de82717"},
{file = "websockets-13.0-cp39-cp39-win32.whl", hash = "sha256:265e1f0d3f788ce8ef99dca591a1aec5263b26083ca0934467ad9a1d1181067c"},
{file = "websockets-13.0-cp39-cp39-win_amd64.whl", hash = "sha256:4d70c89e3d3b347a7c4d3c33f8d323f0584c9ceb69b82c2ef8a174ca84ea3d4a"},
{file = "websockets-13.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:602cbd010d8c21c8475f1798b705bb18567eb189c533ab5ef568bc3033fdf417"},
{file = "websockets-13.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:bf8eb5dca4f484a60f5327b044e842e0d7f7cdbf02ea6dc4a4f811259f1f1f0b"},
{file = "websockets-13.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d795c1802d99a643bf689b277e8604c14b5af1bc0a31dade2cd7a678087212"},
{file = "websockets-13.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:788bc841d250beccff67a20a5a53a15657a60111ef9c0c0a97fbdd614fae0fe2"},
{file = "websockets-13.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7334752052532c156d28b8eaf3558137e115c7871ea82adff69b6d94a7bee273"},
{file = "websockets-13.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7a1963302947332c3039e3f66209ec73b1626f8a0191649e0713c391e9f5b0d"},
{file = "websockets-13.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2e1cf4e1eb84b4fd74a47688e8b0940c89a04ad9f6937afa43d468e71128cd68"},
{file = "websockets-13.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:c026ee729c4ce55708a14b839ba35086dfae265fc12813b62d34ce33f4980c1c"},
{file = "websockets-13.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5f9d23fbbf96eefde836d9692670bfc89e2d159f456d499c5efcf6a6281c1af"},
{file = "websockets-13.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ad684cb7efce227d756bae3e8484f2e56aa128398753b54245efdfbd1108f2c"},
{file = "websockets-13.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1e10b3fbed7be4a59831d3a939900e50fcd34d93716e433d4193a4d0d1d335d"},
{file = "websockets-13.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d42a818e634f789350cd8fb413a3f5eec1cf0400a53d02062534c41519f5125c"},
{file = "websockets-13.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e5ba5e9b332267d0f2c33ede390061850f1ac3ee6cd1bdcf4c5ea33ead971966"},
{file = "websockets-13.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:f9af457ed593e35f467140d8b61d425495b127744a9d65d45a366f8678449a23"},
{file = "websockets-13.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcea3eb58c09c3a31cc83b45c06d5907f02ddaf10920aaa6443975310f699b95"},
{file = "websockets-13.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c210d1460dc8d326ffdef9703c2f83269b7539a1690ad11ae04162bc1878d33d"},
{file = "websockets-13.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b32f38bc81170fd56d0482d505b556e52bf9078b36819a8ba52624bd6667e39e"},
{file = "websockets-13.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:81a11a1ddd5320429db47c04d35119c3e674d215173d87aaeb06ae80f6e9031f"},
{file = "websockets-13.0-py3-none-any.whl", hash = "sha256:dbbac01e80aee253d44c4f098ab3cc17c822518519e869b284cfbb8cd16cc9de"},
{file = "websockets-13.0.tar.gz", hash = "sha256:b7bf950234a482b7461afdb2ec99eee3548ec4d53f418c7990bb79c620476602"},
{file = "websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee"},
{file = "websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7"},
{file = "websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6"},
{file = "websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b"},
{file = "websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa"},
{file = "websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700"},
{file = "websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c"},
{file = "websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0"},
{file = "websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f"},
{file = "websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe"},
{file = "websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a"},
{file = "websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19"},
{file = "websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5"},
{file = "websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd"},
{file = "websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02"},
{file = "websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7"},
{file = "websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096"},
{file = "websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084"},
{file = "websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3"},
{file = "websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9"},
{file = "websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f"},
{file = "websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557"},
{file = "websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc"},
{file = "websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49"},
{file = "websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd"},
{file = "websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0"},
{file = "websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6"},
{file = "websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9"},
{file = "websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68"},
{file = "websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14"},
{file = "websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf"},
{file = "websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c"},
{file = "websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3"},
{file = "websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6"},
{file = "websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708"},
{file = "websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418"},
{file = "websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a"},
{file = "websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f"},
{file = "websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5"},
{file = "websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135"},
{file = "websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2"},
{file = "websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6"},
{file = "websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d"},
{file = "websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2"},
{file = "websockets-13.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d"},
{file = "websockets-13.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23"},
{file = "websockets-13.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c"},
{file = "websockets-13.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea"},
{file = "websockets-13.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7"},
{file = "websockets-13.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54"},
{file = "websockets-13.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db"},
{file = "websockets-13.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295"},
{file = "websockets-13.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96"},
{file = "websockets-13.1-cp38-cp38-win32.whl", hash = "sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf"},
{file = "websockets-13.1-cp38-cp38-win_amd64.whl", hash = "sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6"},
{file = "websockets-13.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d"},
{file = "websockets-13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7"},
{file = "websockets-13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a"},
{file = "websockets-13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa"},
{file = "websockets-13.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa"},
{file = "websockets-13.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79"},
{file = "websockets-13.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17"},
{file = "websockets-13.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6"},
{file = "websockets-13.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5"},
{file = "websockets-13.1-cp39-cp39-win32.whl", hash = "sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c"},
{file = "websockets-13.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d"},
{file = "websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238"},
{file = "websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5"},
{file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9"},
{file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6"},
{file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a"},
{file = "websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23"},
{file = "websockets-13.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b"},
{file = "websockets-13.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51"},
{file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7"},
{file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d"},
{file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027"},
{file = "websockets-13.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978"},
{file = "websockets-13.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e"},
{file = "websockets-13.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09"},
{file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842"},
{file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb"},
{file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20"},
{file = "websockets-13.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678"},
{file = "websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f"},
{file = "websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878"},
]
[[package]]
name = "yt-dlp"
version = "2024.8.6"
version = "2024.9.27"
description = "A feature-rich command-line audio/video downloader"
optional = false
python-versions = ">=3.8"
files = [
{file = "yt_dlp-2024.8.6-py3-none-any.whl", hash = "sha256:ab507ff600bd9269ad4d654e309646976778f0e243eaa2f6c3c3214278bb2922"},
{file = "yt_dlp-2024.8.6.tar.gz", hash = "sha256:e8551f26bc8bf67b99c12373cc87ed2073436c3437e53290878d0f4b4bb1f663"},
{file = "yt_dlp-2024.9.27-py3-none-any.whl", hash = "sha256:2717468dd697fcfcf9a89f493ba30a3830cdfb276c09750e5b561b08b9ef5f69"},
{file = "yt_dlp-2024.9.27.tar.gz", hash = "sha256:86605542e17e2e23ad23145b637ec308133762a15a5dedac4ae50b7973237026"},
]
[package.dependencies]
@@ -1355,19 +1342,24 @@ mutagen = "*"
pycryptodomex = "*"
requests = ">=2.32.2,<3"
urllib3 = ">=1.26.17,<3"
websockets = ">=12.0"
websockets = ">=13.0"
[package.extras]
build = ["build", "hatchling", "pip", "setuptools (>=71.0.2)", "wheel"]
curl-cffi = ["curl-cffi (==0.5.10)", "curl-cffi (>=0.5.10,<0.6.dev0 || ==0.7.*)"]
dev = ["autopep8 (>=2.0,<3.0)", "pre-commit", "pytest (>=8.1,<9.0)", "ruff (>=0.5.0,<0.6.0)"]
curl-cffi = ["curl-cffi (==0.5.10)", "curl-cffi (>=0.5.10,!=0.6.*,<0.7.2)"]
dev = ["autopep8 (>=2.0,<3.0)", "pre-commit", "pytest (>=8.1,<9.0)", "ruff (>=0.6.0,<0.7.0)"]
py2exe = ["py2exe (>=0.12)"]
pyinstaller = ["pyinstaller (>=6.7.0)"]
pyinstaller = ["pyinstaller (>=6.10.0)"]
secretstorage = ["cffi", "secretstorage"]
static-analysis = ["autopep8 (>=2.0,<3.0)", "ruff (>=0.5.0,<0.6.0)"]
static-analysis = ["autopep8 (>=2.0,<3.0)", "ruff (>=0.6.0,<0.7.0)"]
test = ["pytest (>=8.1,<9.0)"]
[extras]
full = ["mpv", "plyer"]
mpv = ["mpv"]
notifications = ["plyer"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "7d20e2d0c0c3c8f3a48d9160a2b4a11a5f353d23bb5d7a06ec527fe08e425b91"
content-hash = "9599f141ccb5c3e9fb45b5521de500f860f4efd7f1d64cd7a05f28969666d42d"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "fastanime"
version = "2.5.0"
version = "2.6.0"
description = "A browser anime site experience from the terminal"
authors = ["Benextempest <benextempest@gmail.com>"]
license = "UNLICENSE"
@@ -9,14 +9,20 @@ readme = "README.md"
[tool.poetry.dependencies]
python = "^3.10"
yt-dlp = "^2024.5.27"
rich = "^13.7.1"
click = "^8.1.7"
inquirerpy = "^0.3.4"
thefuzz = "^0.22.1"
requests = "^2.32.3"
plyer = "^2.1.0"
rich = { version = "^13.7.1", optional = false }
click = { version = "^8.1.7", optional = false }
inquirerpy = { version = "^0.3.4", optional = false }
mpv = { version = "^1.0.7", optional = true }
plyer = { version = "^2.1.0", optional = true }
[tool.poetry.extras]
full = ["plyer", "mpv"]
# cli = ["rich", "click", "inquirerpy"]
mpv = ["mpv"]
notifications = ["plyer"]
mpv = "^1.0.7"
[tool.poetry.group.dev.dependencies]
black = "^24.4.2"
isort = "^5.13.2"

View File

@@ -6,7 +6,7 @@ from fastanime.cli import run_cli
@pytest.fixture
def runner():
return CliRunner()
return CliRunner(env={"FASTANIME_CACHE_REQUESTS": "false"})
def test_main_help(runner: CliRunner):

View File

@@ -7,7 +7,7 @@ env_list = lint, pyright, py{310,311}
description = run unit tests
deps =poetry
commands =
poetry install
poetry install --all-extras
poetry run pytest
[testenv:lint]
@@ -15,7 +15,7 @@ description = run linters
skip_install = true
deps =poetry
commands =
poetry install
poetry install --all-extras
poetry run black .
[testenv:pyright]
@@ -23,5 +23,5 @@ description = run type checking
skip_install = true
deps =poetry
commands =
poetry install --no-root
poetry install --no-root --all-extras
poetry run pyright