From 2c7083809954868c6d5500aad51b1fcf3f9b23ec Mon Sep 17 00:00:00 2001 From: Spoked <5782630+dreulavelle@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:24:45 -0400 Subject: [PATCH] Realdebrid and Symlink Changes (#351) * fix: extra validation on set_active_files * feat: several fixes. wip on symlinking * feat: symlinking working as it should now! * fix: minor tweaks to release dates and container * fix: symlink get_infohash issue * feat: updated logging for api. add symlink validation * fix: tidy api logging --------- Co-authored-by: Spoked --- .dockerignore | 2 +- .gitignore | 1 - backend/controllers/items.py | 61 ++-- backend/main.py | 30 +- backend/program/content/overseerr.py | 8 +- backend/program/content/plex_watchlist.py | 8 +- backend/program/content/trakt.py | 2 +- backend/program/downloaders/realdebrid.py | 168 ++++++--- backend/program/downloaders/torbox.py | 10 +- backend/program/libraries/plex.py | 9 +- backend/program/media/container.py | 42 ++- backend/program/media/item.py | 25 +- backend/program/program.py | 7 +- backend/program/scrapers/__init__.py | 5 +- backend/program/settings/models.py | 4 +- backend/program/state_transition.py | 44 ++- backend/program/symlink.py | 365 +++++++++++-------- backend/program/updaters/plex.py | 66 +++- backend/utils/logger.py | 54 +-- backend/utils/table_logger.py | 40 +++ docker-compose.yml | 16 + poetry.lock | 418 +++++++++++++++++++++- pyproject.toml | 1 + 23 files changed, 1018 insertions(+), 368 deletions(-) create mode 100644 backend/utils/table_logger.py create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore index fbe3249e..d39de150 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,7 @@ .git/ .gitignore .dockerignore -docker-compose.yml +docker-compose* Dockerfile makefile htmlcov/ diff --git a/.gitignore b/.gitignore index c632bebd..09dccf73 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ logs/ settings.json .vscode .git -docker-compose.yml makefile .ruff_cache/ *.dat diff --git a/backend/controllers/items.py b/backend/controllers/items.py index f6e2e8fb..11baee48 100644 --- a/backend/controllers/items.py +++ b/backend/controllers/items.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, HTTPException, Request +from program.content.overseerr import Overseerr from program.media.state import States from utils.logger import logger @@ -27,7 +28,7 @@ async def get_items(request: Request): @router.get("/extended/{item_id}") async def get_extended_item_info(request: Request, item_id: str): - item = request.app.program.media_items.get_item_by_id(item_id) + item = request.app.program.media_items.get_item(item_id) if item is None: raise HTTPException(status_code=404, detail="Item not found") return { @@ -37,22 +38,27 @@ async def get_extended_item_info(request: Request, item_id: str): @router.delete("/remove/id/{item_id}") async def remove_item(request: Request, item_id: str): - item = request.app.program.media_items.get_item_by_id(item_id) - if item is None: + item = request.app.program.media_items.get_item(item_id) + if not item: logger.error(f"Item with ID {item_id} not found") raise HTTPException(status_code=404, detail="Item not found") - + request.app.program.media_items.remove(item) if item.symlinked: request.app.program.media_items.remove_symlink(item) - logger.success(f"Removed symlink for item with ID {item_id}") - - overseerr_result = request.app.program.content.overseerr.delete_request(item_id) - if overseerr_result: - logger.success(f"Deleted Overseerr request for item with ID {item_id}") - else: - logger.error(f"Failed to delete Overseerr request for item with ID {item_id}") - + logger.log("API", f"Removed symlink for item with ID {item_id}") + + overseerr_service = request.app.program.services.get(Overseerr) + if overseerr_service and overseerr_service.initialized: + try: + overseerr_result = overseerr_service.delete_request(item_id) + if overseerr_result: + logger.log("API", f"Deleted Overseerr request for item with ID {item_id}") + else: + logger.log("API", f"Failed to delete Overseerr request for item with ID {item_id}") + except Exception as e: + logger.error(f"Exception occurred while deleting Overseerr request for item with ID {item_id}: {e}") + return { "success": True, "message": f"Removed {item_id}", @@ -60,22 +66,29 @@ async def remove_item(request: Request, item_id: str): @router.delete("/remove/imdb/{imdb_id}") async def remove_item_by_imdb(request: Request, imdb_id: str): - item = request.app.program.media_items.get_item_by_imdb_id(imdb_id) - if item is None: + item = request.app.program.media_items.get_item(imdb_id) + if not item: logger.error(f"Item with IMDb ID {imdb_id} not found") raise HTTPException(status_code=404, detail="Item not found") - + request.app.program.media_items.remove(item) - if item.symlinked: + if item.symlinked or (item.file and item.folder): # TODO: this needs to be checked later.. request.app.program.media_items.remove_symlink(item) - logger.success(f"Removed symlink for item with IMDb ID {imdb_id}") - - overseerr_result = request.app.program.content.overseerr.delete_request(imdb_id) - if overseerr_result: - logger.success(f"Deleted Overseerr request for item with IMDb ID {imdb_id}") + logger.log("API", f"Removed symlink for item with IMDb ID {imdb_id}") + + overseerr_service = request.app.program.services.get(Overseerr) + if overseerr_service and overseerr_service.initialized: + try: + overseerr_result = overseerr_service.delete_request(item.overseerr_id) + if overseerr_result: + logger.log("API", f"Deleted Overseerr request for item with IMDb ID {imdb_id}") + else: + logger.error(f"Failed to delete Overseerr request for item with IMDb ID {imdb_id}") + except Exception as e: + logger.error(f"Exception occurred while deleting Overseerr request for item with IMDb ID {imdb_id}: {e}") else: - logger.error(f"Failed to delete Overseerr request for item with IMDb ID {imdb_id}") - + logger.error("Overseerr service not found in program services") + return { "success": True, "message": f"Removed item with IMDb ID {imdb_id}", @@ -83,7 +96,7 @@ async def remove_item_by_imdb(request: Request, imdb_id: str): @router.get("/imdb/{imdb_id}") async def get_imdb_info(request: Request, imdb_id: str): - item = request.app.program.media_items.get_item_by_imdb_id(imdb_id) + item = request.app.program.media_items.get_item(imdb_id) if item is None: raise HTTPException(status_code=404, detail="Item not found") return {"success": True, "item": item.to_extended_dict()} diff --git a/backend/main.py b/backend/main.py index c2734a13..5cb76181 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,8 +12,26 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from program import Program +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request from utils.logger import logger + +class LoguruMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + start_time = time.time() + try: + response = await call_next(request) + except Exception as e: + logger.exception(f"Exception during request processing: {e}") + raise + finally: + process_time = time.time() - start_time + logger.log( + "API", f"{request.method} {request.url.path} - {response.status_code if 'response' in locals() else '500'} - {process_time:.2f}s" + ) + return response + parser = argparse.ArgumentParser() parser.add_argument( "--ignore_cache", @@ -34,6 +52,9 @@ allow_headers=["*"], ) +# Add the custom Loguru middleware +app.add_middleware(LoguruMiddleware) + app.include_router(default_router) app.include_router(settings_router) app.include_router(items_router) @@ -61,20 +82,17 @@ def run_in_thread(self): config = uvicorn.Config(app, host="0.0.0.0", port=8080, log_config=None) server = Server(config=config) - with server.run_in_thread(): try: app.program.start() app.program.run() - app.program.stop() except AttributeError as e: logger.error(f"Program failed to initialize: {e}") except KeyboardInterrupt: - app.program.stop() - sys.exit(0) + pass except Exception as e: logger.exception(f"Error in main thread: {e}") finally: + app.program.stop() logger.critical("Server has been stopped") - # need to terminate to give back control of terminal to user - sys.exit(0) + sys.exit(0) \ No newline at end of file diff --git a/backend/program/content/overseerr.py b/backend/program/content/overseerr.py index 59f2b63b..01d18160 100644 --- a/backend/program/content/overseerr.py +++ b/backend/program/content/overseerr.py @@ -1,14 +1,14 @@ """Overseerr content module""" from typing import Union + from program.indexers.trakt import get_imdbid_from_tmdb from program.media.item import MediaItem from program.settings.manager import settings_manager +from requests.exceptions import ConnectionError, RetryError +from urllib3.exceptions import MaxRetryError, NewConnectionError from utils.logger import logger from utils.request import delete, get, ping, post -from requests.exceptions import RetryError, ConnectionError -from urllib3.exceptions import MaxRetryError -from urllib3.exceptions import NewConnectionError class Overseerr: @@ -141,7 +141,7 @@ def delete_request(mediaId: int) -> bool: settings.url + f"/api/v1/request/{mediaId}", additional_headers=headers, ) - logger.info(f"Deleted request {mediaId} from overseerr") + logger.success(f"Deleted request {mediaId} from overseerr") return response.is_ok except Exception as e: logger.error(f"Failed to delete request from overseerr: {str(e)}") diff --git a/backend/program/content/plex_watchlist.py b/backend/program/content/plex_watchlist.py index 1eb99479..9d9e02a8 100644 --- a/backend/program/content/plex_watchlist.py +++ b/backend/program/content/plex_watchlist.py @@ -85,9 +85,13 @@ def _get_items_from_watchlist(self) -> Generator[MediaItem, None, None]: url = f"https://metadata.provider.plex.tv/library/sections/watchlist/all?X-Plex-Token={self.token}&{filter_params}" response = get(url) if not response.is_ok or not hasattr(response.data, "MediaContainer"): - yield + logger.error("Invalid response or missing MediaContainer in response data.") return - for item in response.data.MediaContainer.Metadata: + media_container = getattr(response.data, "MediaContainer", None) + if not media_container or not hasattr(media_container, "Metadata"): + logger.error("MediaContainer is missing Metadata attribute.") + return + for item in media_container.Metadata: if hasattr(item, "ratingKey") and item.ratingKey: imdb_id = self._ratingkey_to_imdbid(item.ratingKey) if imdb_id and imdb_id not in self.recurring_items: diff --git a/backend/program/content/trakt.py b/backend/program/content/trakt.py index c47982c0..92b05b6d 100644 --- a/backend/program/content/trakt.py +++ b/backend/program/content/trakt.py @@ -4,9 +4,9 @@ from urllib.parse import urlencode, urlparse import regex -from requests import RequestException from program.media.item import MediaItem, Movie, Show from program.settings.manager import settings_manager +from requests import RequestException from utils.logger import logger from utils.request import RateLimiter, get, post diff --git a/backend/program/downloaders/realdebrid.py b/backend/program/downloaders/realdebrid.py index 45570847..90360f3c 100644 --- a/backend/program/downloaders/realdebrid.py +++ b/backend/program/downloaders/realdebrid.py @@ -63,9 +63,6 @@ def run(self, item: MediaItem) -> Generator[MediaItem, None, None]: return if not self._is_downloaded(item): self._download_item(item) - # self.set_file_paths(item) - # Lets handle all the logging in one place - # for completed downloads self.log_item(item) yield item @@ -74,19 +71,33 @@ def log_item(item: MediaItem) -> None: """Log only the files downloaded for the item based on its type.""" if isinstance(item, Movie): if item.file and item.folder: - logger.log("DEBRID", f"Downloaded: {item.log_string} with file: {item.file}") + logger.log("DEBRID", f"Downloaded {item.log_string} with file: {item.file}") + else: + logger.debug(f"Movie item missing file or folder: {item.log_string}") elif isinstance(item, Episode): if item.file and item.folder: - logger.log("DEBRID", f"Downloaded: {item.log_string} with file: {item.file}") + logger.log("DEBRID", f"Downloaded {item.log_string} with file: {item.file}") + else: + logger.debug(f"Episode item missing file or folder: {item.log_string}") elif isinstance(item, Season): for episode in item.episodes: if episode.file and episode.folder: - logger.log("DEBRID", f"Downloaded: {episode.log_string} with file: {episode.file}") + logger.log("DEBRID", f"Downloaded {episode.log_string} with file: {episode.file}") + elif not episode.file: + logger.debug(f"Episode item missing file: {episode.log_string}") + elif not episode.folder: + logger.debug(f"Episode item missing folder: {episode.log_string}") elif isinstance(item, Show): for season in item.seasons: for episode in season.episodes: if episode.file and episode.folder: - logger.log("DEBRID", f"Downloaded: {episode.log_string} with file: {episode.file}") + logger.log("DEBRID", f"Downloaded {episode.log_string} with file: {episode.file}") + elif not episode.file: + logger.debug(f"Episode item missing file or folder: {episode.log_string}") + elif not episode.folder: + logger.debug(f"Episode item missing folder: {episode.log_string}") + else: + logger.debug(f"Unknown item type: {item.log_string}") def is_cached(self, item: MediaItem) -> bool: """Check if item is cached on real-debrid.com""" @@ -116,7 +127,7 @@ def _chunked(lst: List, n: int) -> Generator[List, None, None]: logger.exception("Error checking cache for streams") item.set("streams", {}) - logger.log("NOT_FOUND", f"No wanted cached streams found for {item.log_string}") + logger.log("NOT_FOUND", f"No wanted cached streams found for {item.log_string} out of {len(filtered_streams)}") return False def _evaluate_stream_response(self, data, processed_stream_hashes, item): @@ -175,10 +186,6 @@ def _is_wanted_movie(self, container: dict, item: Movie) -> bool: key=lambda file: file["filesize"], reverse=True ) - # lets create a regex pattern to remove deleted scenes and samples and trailers from the filenames list - # unwanted_regex = regex.compile(r"\b(?:deleted.scene|sample|trailer|featurette)\b", regex.IGNORECASE) - # filenames = [file for file in filenames if not unwanted_regex.search(file["filename"])] - if not filenames: return False @@ -300,50 +307,79 @@ def _is_downloaded(self, item: MediaItem) -> bool: logger.log("DEBRID", f"Item already downloaded for hash: {hash_key}") return True + logger.debug(f"Checking if torrent is already downloaded for item: {item.log_string}") torrents = self.get_torrents(1000) - sorted_torrents = sorted(torrents.items(), key=lambda x: x[0]) - - # Binary search for the hash_key in sorted list of torrents - left, right = 0, len(sorted_torrents) - 1 - while left <= right: - mid = (left + right) // 2 - if sorted_torrents[mid][0] < hash_key: - left = mid + 1 - elif sorted_torrents[mid][0] > hash_key: - right = mid - 1 - else: - torrent = sorted_torrents[mid][1] - if torrent.hash == hash_key: - if item.active_stream.get("id", None): - return True - info = self.get_torrent_info(torrent.id) - if _matches_item(info, item): - self.hash_cache.mark_downloaded(torrent.hash) - # Cache this as downloaded - item.set("active_stream.id", torrent.id) - self.set_active_files(item) - return True - else: - self.hash_cache.blacklist(torrent.hash) - return False + torrent = torrents.get(hash_key) + + if not torrent: + logger.debug(f"No matching torrent found for hash: {hash_key}") + return False + + if item.active_stream.get("id", None): + logger.debug(f"Item already has an active stream ID: {item.active_stream.get('id')}") + return True + + info = self.get_torrent_info(torrent.id) + if not info: + logger.debug(f"Failed to get torrent info for ID: {torrent.id}") + self.hash_cache.blacklist(torrent.hash) + return False + + if not _matches_item(info, item): + self.hash_cache.blacklist(torrent.hash) + return False + + # Cache this as downloaded + logger.debug(f"Marking torrent as downloaded for hash: {torrent.hash}") + self.hash_cache.mark_downloaded(torrent.hash) + item.set("active_stream.id", torrent.id) + self.set_active_files(item) + logger.debug(f"Set active files for item: {item.log_string} with {len(item.active_stream.get('files', {}))} total files") + return True def _download_item(self, item: MediaItem): """Download item from real-debrid.com""" + logger.debug(f"Starting download for item: {item.log_string}") request_id = self.add_magnet(item) # uses item.active_stream.hash + logger.debug(f"Magnet added to Real-Debrid, request ID: {request_id} for {item.log_string}") item.set("active_stream.id", request_id) self.set_active_files(item) + logger.debug(f"Active files set for item: {item.log_string} with {len(item.active_stream.get('files', {}))} total files") time.sleep(0.5) self.select_files(request_id, item) + logger.debug(f"Files selected for request ID: {request_id} for {item.log_string}") self.hash_cache.mark_downloaded(item.active_stream["hash"]) + logger.debug(f"Item marked as downloaded: {item.log_string}") def set_active_files(self, item: MediaItem) -> None: """Set active files for item from real-debrid.com""" - info = self.get_torrent_info(item.get("active_stream")["id"]) - item.active_stream["alternative_name"] = info.original_filename - item.active_stream["name"] = info.filename + active_stream = item.get("active_stream") + if not active_stream or "id" not in active_stream: + logger.error(f"Invalid active stream data for item: {item.log_string}") + return + + info = self.get_torrent_info(active_stream["id"]) + if not info: + logger.error(f"Failed to get torrent info for item: {item.log_string}") + return + + item.active_stream["alternative_name"] = getattr(info, "original_filename", None) + item.active_stream["name"] = getattr(info, "filename", None) + if not item.folder or not item.alternative_folder: item.set("folder", item.active_stream.get("name")) item.set("alternative_folder", item.active_stream.get("alternative_name")) + + # this is only for Movie and Episode instances + if isinstance(item, (Movie, Episode)): + if not item.folder or not item.alternative_folder or not item.file: + logger.error(f"Missing folder or alternative_folder or file for item: {item.log_string}") + return + + if isinstance(item, Season) and item.folder: + for episode in item.episodes: + if episode.file and not episode.folder: + episode.set("folder", item.folder) def _is_wanted_item(self, item: Union[Movie, Episode, Season]) -> bool: """Check if item is wanted""" @@ -439,22 +475,33 @@ def get_torrents(self, limit: int) -> dict[str, SimpleNamespace]: def _matches_item(torrent_info: SimpleNamespace, item: MediaItem) -> bool: """Check if the downloaded torrent matches the item specifics.""" - if isinstance(item, Movie): + logger.debug(f"Checking if torrent matches item: {item.log_string}") + + def check_movie(): for file in torrent_info.files: - # TODO: This can be improved further.. - if file.selected == 1 and file.bytes > 200_000_000: # 200,000,000 bytes is approximately 0.186 GB + if file.selected == 1 and file.bytes > 200_000_000: + file_size_mb = file.bytes / (1024 * 1024) + if file_size_mb >= 1024: + file_size_gb = file_size_mb / 1024 + logger.debug(f"Selected file: {Path(file.path).name} with size: {file_size_gb:.2f} GB") + else: + logger.debug(f"Selected file: {Path(file.path).name} with size: {file_size_mb:.2f} MB") return True - elif isinstance(item, Episode): + return False + + def check_episode(): one_season = len(item.parent.parent.seasons) == 1 - return any( - file.selected == 1 and ( - (item.number in extract_episodes(Path(file.path).name) and item.parent.number in extract_episodes(Path(file.path).name)) or - (one_season and item.number in extract_episodes(Path(file.path).name)) - ) - for file in torrent_info.files - ) - elif isinstance(item, Season): - # Check if all episodes of the season are present in the torrent + item_number = item.number + parent_number = item.parent.number + for file in torrent_info.files: + if file.selected == 1: + file_episodes = extract_episodes(Path(file.path).name) + if (item_number in file_episodes and parent_number in file_episodes) or (one_season and item_number in file_episodes): + logger.debug(f"File {Path(file.path).name} selected for episode {item_number} in season {parent_number}") + return True + return False + + def check_season(): season_number = item.number episodes_in_season = {episode.number for episode in item.episodes} matched_episodes = set() @@ -467,4 +514,19 @@ def _matches_item(torrent_info: SimpleNamespace, item: MediaItem) -> bool: elif one_season and file_episodes: matched_episodes.update(file_episodes) return len(matched_episodes) >= len(episodes_in_season) // 2 + + if isinstance(item, Movie): + if check_movie(): + logger.info(f"Movie {item.log_string} already exists in Real-Debrid account.") + return True + elif isinstance(item, Episode): + if check_episode(): + logger.info(f"Episode {item.log_string} already exists in Real-Debrid account.") + return True + elif isinstance(item, Season): + if check_season(): + logger.info(f"Season {item.log_string} already exists in Real-Debrid account.") + return True + + logger.debug(f"No matching item found for {item.log_string}") return False diff --git a/backend/program/downloaders/torbox.py b/backend/program/downloaders/torbox.py index e52f7fbf..f1c1b621 100644 --- a/backend/program/downloaders/torbox.py +++ b/backend/program/downloaders/torbox.py @@ -1,4 +1,4 @@ -import asyncio +import time from typing import Generator from program.media.item import MediaItem @@ -74,7 +74,7 @@ def request_download(self, infohash: str): except Exception as e: raise e - async def download_media(self, item: MediaItem): + def download_media(self, item: MediaItem): """Initiate the download of a media item using TorBox.""" if not item: logger.error("No media item provided for download.") @@ -95,7 +95,7 @@ async def download_media(self, item: MediaItem): logger.info(f"Download initiated for item: {item.log_string}") # Wait for the download to be ready and get the path - download_path = await self.get_torrent_path(infohash) + download_path = self.get_torrent_path(infohash) if not download_path: logger.error(f"Failed to get download path for item: {item.log_string}") return None @@ -103,12 +103,12 @@ async def download_media(self, item: MediaItem): logger.success(f"Download ready at path: {download_path} for item: {item.log_string}") return download_path - async def get_torrent_path(self, infohash: str): + def get_torrent_path(self, infohash: str): """Check and wait until the torrent is fully downloaded and return the path.""" for _ in range(30): # Check for 5 minutes max if self.is_cached([infohash])[0]: logger.info(f"Torrent cached: {infohash}") return self.mount_torrents_path + infohash # Assuming the path to be mounted torrents path + infohash - await asyncio.sleep(10) + time.sleep(10) logger.warning(f"Torrent not available after timeout: {infohash}") return None diff --git a/backend/program/libraries/plex.py b/backend/program/libraries/plex.py index 481b0fda..cfdb0639 100644 --- a/backend/program/libraries/plex.py +++ b/backend/program/libraries/plex.py @@ -95,15 +95,10 @@ def run(self): try: future = executor.submit(self._process_chunk, chunk) futures.append(future) - except RuntimeError as e: - if 'cannot schedule new futures after shutdown' in str(e): - logger.warning("Executor has been shut down, stopping chunk processing.") - break - else: - logger.exception(f"Failed to process chunk: {e}") + except (RuntimeError, KeyboardInterrupt): + break except Exception as e: logger.exception(f"Failed to process chunk: {e}") - continue if len(futures) % rate_limit == 0: # Rate limit: process 5 chunks per minute diff --git a/backend/program/media/container.py b/backend/program/media/container.py index feb8fe63..e263034a 100644 --- a/backend/program/media/container.py +++ b/backend/program/media/container.py @@ -2,7 +2,7 @@ import shutil import tempfile import threading -from typing import Dict, Generator, Optional +from typing import Dict, Generator, List, Optional import dill from program.media.item import Episode, ItemId, MediaItem, Movie, Season, Show @@ -87,17 +87,21 @@ def get(self, key, default=None) -> MediaItem: finally: self.lock.release_read() - def get_imdbid(self, imdb_id: str) -> Optional[MediaItem]: + def get_episodes(self, show_id: ItemId) -> List[MediaItem]: + """Get all episodes for a show.""" self.lock.acquire_read() try: - return self._imdb_index.get(imdb_id) + return self._shows[show_id].episodes finally: self.lock.release_read() - def get_item_by_id(self, item_id: ItemId) -> Optional[MediaItem]: - """Retrieve an item by its ID from the container.""" + def get_item(self, identifier: str) -> Optional[MediaItem]: + """Retrieve an item by its IMDb ID or item ID from the container.""" self.lock.acquire_read() try: + if identifier.startswith("tt"): + return self._imdb_index.get(identifier) + item_id = ItemId(identifier) return self._items.get(item_id) finally: self.lock.release_read() @@ -164,14 +168,14 @@ def _index_item(self, item: MediaItem): episode.parent = item self._items[episode.item_id] = episode self._episodes[episode.item_id] = episode - if item.item_id.parent_id in self._shows: - show = self._shows[item.item_id.parent_id] - show.seasons.append(item) + parent_show = self._shows.get(item.item_id.parent_id) + if parent_show: + parent_show.seasons.append(item) elif isinstance(item, Episode): self._episodes[item.item_id] = item - if item.item_id.parent_id in self._seasons: - season = self._seasons[item.item_id.parent_id] - season.episodes.append(item) + parent_season = self._seasons.get(item.item_id.parent_id) + if parent_season: + parent_season.episodes.append(item) elif isinstance(item, Movie): self._movies[item.item_id] = item @@ -179,26 +183,27 @@ def remove(self, item) -> None: self.lock.acquire_write() try: if item.item_id in self._items: - del self._items[item.item_id] - if item.imdb_id in self._imdb_index: - del self._imdb_index[item.imdb_id] if isinstance(item, Show): - del self._shows[item.item_id] for season in item.seasons: - del self._items[season.item_id] - del self._seasons[season.item_id] for episode in season.episodes: del self._items[episode.item_id] del self._episodes[episode.item_id] + del self._items[season.item_id] + del self._seasons[season.item_id] + del self._shows[item.item_id] elif isinstance(item, Season): - del self._seasons[item.item_id] for episode in item.episodes: del self._items[episode.item_id] del self._episodes[episode.item_id] + del self._seasons[item.item_id] elif isinstance(item, Episode): del self._episodes[item.item_id] elif isinstance(item, Movie): del self._movies[item.item_id] + + del self._items[item.item_id] + if item.imdb_id in self._imdb_index: + del self._imdb_index[item.imdb_id] finally: self.lock.release_write() @@ -233,7 +238,6 @@ def save(self, filename: str) -> None: if os.path.exists(filename): shutil.copyfile(filename, backup_filename) shutil.move(temp_file.name, filename) - # logger.success("Successfully saved %d items.", len(self._items)) except Exception as e: logger.error(f"Failed to replace old file with new file: {e}") try: diff --git a/backend/program/media/item.py b/backend/program/media/item.py index 50545ea2..2474b820 100644 --- a/backend/program/media/item.py +++ b/backend/program/media/item.py @@ -73,22 +73,14 @@ def is_released(self) -> bool: """Check if an item has been released.""" if not self.aired_at: return False - elif self.aired_at > datetime.now(): - time_until_release = self.aired_at - datetime.now() - months_until_release = time_until_release.days // 30 - days_until_release = time_until_release.days % 30 - hours_until_release = time_until_release.seconds // 3600 - minutes_until_release = (time_until_release.seconds % 3600) // 60 - - time_message = f"{self.log_string} will be released in" - if months_until_release > 0: - time_message += f" {months_until_release} months" - if days_until_release > 0: - time_message += f" {days_until_release} days" - if hours_until_release > 0: - time_message += f" {hours_until_release} hours" - if minutes_until_release > 0: - time_message += f" {minutes_until_release} minutes" + now = datetime.now() + if self.aired_at > now: + time_until_release = self.aired_at - now + days, seconds = time_until_release.days, time_until_release.seconds + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + seconds = seconds % 60 + time_message = f"{self.log_string} will be released in {days} days, {hours:02}:{minutes:02}:{seconds:02}" logger.log("ITEM", time_message) return False return True @@ -331,7 +323,6 @@ def get_episode_index_by_id(self, item_id): def _determine_state(self): if len(self.episodes) > 0: - # TODO: Cleanup how many times state is accessed if all(episode.state == States.Completed for episode in self.episodes): return States.Completed if any(episode.state == States.Completed for episode in self.episodes): diff --git a/backend/program/program.py b/backend/program/program.py index 5c491fb6..ac484dba 100644 --- a/backend/program/program.py +++ b/backend/program/program.py @@ -5,6 +5,7 @@ from concurrent.futures import Future, ThreadPoolExecutor from datetime import datetime from queue import Empty, Queue +from typing import Union from apscheduler.schedulers.background import BackgroundScheduler from program.content import Listrr, Mdblist, Overseerr, PlexWatchlist, TraktContent @@ -13,7 +14,7 @@ from program.indexers.trakt import TraktIndexer from program.libraries import PlexLibrary, SymlinkLibrary from program.media.container import MediaItemContainer -from program.media.item import MediaItem, Movie, Season, Show +from program.media.item import Episode, MediaItem, Movie, Season, Show from program.media.state import States from program.scrapers import Scraping from program.settings.manager import settings_manager @@ -243,10 +244,12 @@ def stop(self): def add_to_queue(self, item: MediaItem) -> bool: """Add item to the queue for processing.""" - if item is not None and item not in self.media_items: + if item is not None: self.event_queue.put(Event(emitted_by=self.__class__, item=item)) logger.log("PROGRAM", f"Added {item.log_string} to the queue") return True + else: + logger.error("Attempted to add a None item to the queue") return False def clear_queue(self): diff --git a/backend/program/scrapers/__init__.py b/backend/program/scrapers/__init__.py index 77abfba1..9b056067 100644 --- a/backend/program/scrapers/__init__.py +++ b/backend/program/scrapers/__init__.py @@ -31,9 +31,6 @@ def validate(self): return any(service.initialized for service in self.services.values()) def run(self, item: MediaItem): - if not self.can_we_scrape(item): - return - for service_name, service in self.services.items(): if service.initialized: try: @@ -55,7 +52,7 @@ def can_we_scrape(cls, item: MediaItem) -> bool: def should_submit(item: MediaItem) -> bool: """Check if an item should be submitted for scraping.""" settings = settings_manager.settings.scraping - scrape_time = 5 # 5 seconds by default + scrape_time = 5 * 60 # 5 minutes by default if item.scraped_times >= 2 and item.scraped_times <= 5: scrape_time = settings.after_2 * 60 * 60 diff --git a/backend/program/settings/models.py b/backend/program/settings/models.py index 34398c49..c89eb2f8 100644 --- a/backend/program/settings/models.py +++ b/backend/program/settings/models.py @@ -42,8 +42,8 @@ class DownloadersModel(Observable): class SymlinkModel(Observable): - rclone_path: Path = Path("/mnt/zurg/__all__") - library_path: Path = Path("/mnt/library") + rclone_path: Path = Path() + library_path: Path = Path() # Content Services diff --git a/backend/program/state_transition.py b/backend/program/state_transition.py index b458e908..61f0d538 100644 --- a/backend/program/state_transition.py +++ b/backend/program/state_transition.py @@ -40,21 +40,22 @@ def process_event(existing_item: MediaItem | None, emitted_by: Service, item: Me updated_item = item = existing_item if existing_item.state == States.Completed: return existing_item, None, [] - if Scraping.should_submit(item): - if isinstance(item, Movie) and item.is_released: + if Scraping.can_we_scrape(item): + if isinstance(item, Movie): items_to_submit = [item] - elif isinstance(item, Show) and item.is_released: + elif isinstance(item, Show): items_to_submit = [ s for s in item.seasons - if s.state not in (States.Completed, States.Downloaded, States.Scraped) and s.scraped_times < 3 + if s.state not in (States.Completed, States.Downloaded, States.Scraped) + and Scraping.can_we_scrape(s) ] elif isinstance(item, Season): if item.scraped_times >= 4: - if item.is_released: - items_to_submit = [ - e for e in item.episodes - if e.state not in (States.Completed, States.Downloaded, States.Scraped) - ] + items_to_submit = [ + e for e in item.episodes + if e.state not in (States.Completed, States.Downloaded, States.Scraped) + and Scraping.can_we_scrape(e) + ] else: items_to_submit = [item] else: @@ -68,39 +69,46 @@ def process_event(existing_item: MediaItem | None, emitted_by: Service, item: Me items_to_submit = [ s for s in item.seasons if s.state not in (States.Completed, States.PartiallyCompleted) + and Scraping.can_we_scrape(s) ] elif isinstance(item, Season): items_to_submit = [ e for e in item.episodes - if e.state not in (States.Completed, States.PartiallyCompleted) + if e.state == States.Indexed + and Scraping.can_we_scrape(e) ] elif item.state == States.Scraped: next_service = Debrid or TorBoxDownloader items_to_submit = [item] - elif item.state == States.Downloaded and Symlinker.should_submit(item): + elif item.state == States.Downloaded: next_service = Symlinker proposed_submissions = [] if isinstance(item, Season): - proposed_submissions = [e for e in item.episodes] + if all(e.file and e.folder for e in item.episodes if not e.symlinked): + proposed_submissions = [item] + else: + proposed_submissions = [e for e in item.episodes if not e.symlinked and e.file and e.folder] elif isinstance(item, (Movie, Episode)): proposed_submissions = [item] items_to_submit = [] - for item in proposed_submissions: - if not Symlinker.should_submit(item): - logger.error(f"Item {item.log_string} rejected by Symlinker, skipping submit") + for sub_item in proposed_submissions: + if Symlinker.should_submit(sub_item): + items_to_submit.append(sub_item) else: - items_to_submit.append(item) + logger.debug(f"{sub_item.log_string} not submitted to Symlinker because it is not eligible") + elif item.state == States.Symlinked: next_service = PlexUpdater if isinstance(item, Show): items_to_submit = [s for s in item.seasons] elif isinstance(item, Season): - items_to_submit = [e for e in item.episodes] + items_to_submit = [item] else: items_to_submit = [item] + elif item.state == States.Completed: return no_further_processing - return updated_item, next_service, items_to_submit + return updated_item, next_service, items_to_submit \ No newline at end of file diff --git a/backend/program/symlink.py b/backend/program/symlink.py index 94031ffd..347cedb8 100644 --- a/backend/program/symlink.py +++ b/backend/program/symlink.py @@ -1,9 +1,12 @@ +import asyncio +import contextlib import os import time from datetime import datetime from pathlib import Path +from typing import Union -from program.media.item import Episode, MediaItem, Movie, Season +from program.media.item import Episode, Movie, Season, Show from program.settings.manager import settings_manager from utils.logger import logger from watchdog.events import FileSystemEventHandler @@ -73,33 +76,6 @@ def validate(self): return False return self.create_initial_folders() - def start_monitor(self): - """Starts monitoring the library path for symlink deletions.""" - self.event_handler = DeleteHandler(self) - self.observer = Observer() - self.observer.schedule( - self.event_handler, self.settings.library_path, recursive=True - ) - self.observer.start() - logger.log("FILES", "Symlink deletion monitoring started") - - def stop_monitor(self): - """Stops the directory monitoring.""" - if hasattr(self, "observer"): - self.observer.stop() - self.observer.join() - logger.log("FILES", "Stopped monitoring for symlink deletions") - - def on_symlink_deleted(self, symlink_path): - """Handle a symlink deletion event.""" - src = Path(symlink_path) - if src.is_symlink(): - dst = src.resolve() - logger.log("FILES", f"Symlink deleted: {src} -> {dst}") - else: - logger.log("FILES", f"Symlink deleted: {src} (target unknown)") - # TODO: Implement logic to handle deletion.. - def create_initial_folders(self): """Create the initial library folders.""" try: @@ -127,12 +103,33 @@ def create_initial_folders(self): return False return True - def run(self, item): + def run(self, item: Union[Movie, Episode, Season]): """Check if the media item exists and create a symlink if it does""" try: - if self._symlink(item): - item.set("symlinked", True) - item.set("symlinked_at", datetime.now()) + if isinstance(item, Season): + all_symlinked = True + successfully_symlinked_episodes = [] + for episode in item.episodes: + if not episode.symlinked and episode.file and episode.folder: + if self._symlink(episode): + episode.set("symlinked", True) + episode.set("symlinked_at", datetime.now()) + successfully_symlinked_episodes.append(episode) + else: + all_symlinked = False + if all_symlinked: + logger.log("SYMLINKER", f"Symlinked all episodes for {item.log_string}") + else: + for episode in successfully_symlinked_episodes: + logger.log("SYMLINKER", f"Symlink created for {episode.log_string}") + elif isinstance(item, (Movie, Episode)): + if not item.symlinked and item.file and item.folder: + if self._symlink(item): + logger.log("SYMLINKER", f"Symlink created for {item.log_string}") + else: + logger.error(f"Failed to create symlink for {item.log_string}") + item.set("symlinked", True) + item.set("symlinked_at", datetime.now()) except Exception as e: logger.exception(f"Exception thrown when creating symlink for {item.log_string}: {e}") @@ -140,144 +137,87 @@ def run(self, item): yield item @staticmethod - def should_submit(item) -> bool: + def should_submit(item: Union[Movie, Episode, Season]) -> bool: """Check if the item should be submitted for symlink creation.""" - if isinstance(item, (Movie, Episode)): - if Symlinker.file_check(item): - return True - - # If we've tried 3 times to symlink the file, give up - if item.symlinked_times >= 3: - if isinstance(item, (Movie, Episode)): - item.set("file", None) - item.set("folder", None) - item.set("streams", {}) # Ensure rescraping - item.set("symlinked_times", 0) - infohash = item.active_stream.get("hash") - if infohash: - hash_cache.blacklist(infohash) - else: - logger.error(f"Failed to retrieve hash for {item.log_string}, unable to blacklist") - return False - - # If the file doesn't exist, we should wait a bit and try again - logger.log("NOT_FOUND", f"Retrying file check in 10 seconds: {item.log_string}") - time.sleep(10) - return True - - @staticmethod - def file_check(item: MediaItem) -> bool: - """Check if the file exists in the rclone path.""" - if not item.file or item.file == "None.mkv": - logger.error(f"Invalid file for {item.log_string}: {item.file}. Needs to be rescraped.") + if isinstance(item, Show): return False - try: - if item.folder and item.alternative_folder and item.folder == item.alternative_folder: - alternative_folder = os.path.splitext(item.file)[0] - item.set("alternative_folder", alternative_folder) - except Exception as e: - logger.error(f"Exception occurred while processing file for {item.log_string}: {e}") - raise - - rclone_path = Path(settings_manager.settings.symlink.rclone_path) - - std_file_path = rclone_path / item.folder / item.file if item.folder else None - alt_file_path = rclone_path / item.alternative_folder / item.file if item.alternative_folder else None - thd_file_path = rclone_path / item.file / item.file + if isinstance(item, Season): + all_episodes_ready = True + for episode in item.episodes: + if not episode.file or not episode.folder or episode.file == "None.mkv": + logger.warning(f"Cannot submit {episode.log_string} for symlink: Invalid file or folder. Needs to be rescraped.") + blacklist_item(episode) + all_episodes_ready = False + elif not quick_file_check(episode): + logger.debug(f"File not found for {episode.log_string} at the moment, waiting for it to become available") + if not _wait_for_file(episode): + all_episodes_ready = False + return all_episodes_ready - for attempt in range(2): - if std_file_path and std_file_path.exists(): - return True - elif alt_file_path and alt_file_path.exists(): - item.set("folder", item.alternative_folder) - return True - elif thd_file_path.exists(): - item.set("folder", item.file) + if isinstance(item, (Movie, Episode)): + if not item.file or not item.folder or item.file == "None.mkv": + logger.warning(f"Cannot submit {item.log_string} for symlink: Invalid file or folder. Needs to be rescraped.") + blacklist_item(item) + return False + + if item.symlinked_times < 3: + if quick_file_check(item): + logger.log("SYMLINKER", f"File found for {item.log_string}, submitting to be symlinked") return True - - if attempt < 1: - logger.log("FILES", f"File not found for {item.log_string} with file: {item.file}. Retrying in 10 seconds...") - time.sleep(10) else: - logger.log("FILES", f"File not found for {item.log_string} after 1 attempt. Searching entire rclone_path...") + logger.debug(f"File not found for {item.log_string} at the moment, waiting for it to become available") + if _wait_for_file(item): + return True + return False - # On the 2nd attempt, search the entire rclone_path - for file_path in rclone_path.rglob(item.file): - if file_path.exists(): - item.set("folder", str(file_path.parent.relative_to(rclone_path))) - logger.log("FILES", f"File found for {item.log_string} by searching rclone_path: {file_path}") + item.set("symlinked_times", item.symlinked_times + 1) + + if item.symlinked_times >= 3: + rclone_path = Path(settings_manager.settings.symlink.rclone_path) + if search_file(rclone_path, item): + logger.log("SYMLINKER", f"File found for {item.log_string}, creating symlink") return True + else: + logger.log("SYMLINKER", f"File not found for {item.log_string} after 3 attempts, blacklisting") + blacklist_item(item) + return False - logger.log("FILES", f"File not found for {item.log_string} with file: {item.file} after searching rclone_path.") + logger.debug(f"Item {item.log_string} not submitted for symlink, file not found yet") return False - def _determine_file_name(self, item) -> str | None: - """Determine the filename of the symlink.""" - filename = None - if isinstance(item, Movie): - filename = f"{item.title} ({item.aired_at.year}) " + "{imdb-" + item.imdb_id + "}" - elif isinstance(item, Season): - showname = item.parent.title - showyear = item.parent.aired_at.year - filename = f"{showname} ({showyear}) - Season {str(item.number).zfill(2)}" - elif isinstance(item, Episode): - episode_string = "" - episode_number = item.get_file_episodes() - if episode_number[0] == item.number: - if len(episode_number) > 1: - episode_string = f"e{str(episode_number[0]).zfill(2)}-e{str(episode_number[-1]).zfill(2)}" - else: - episode_string = f"e{str(item.number).zfill(2)}" - if episode_string != "": - showname = item.parent.parent.title - showyear = item.parent.parent.aired_at.year - filename = f"{showname} ({showyear}) - s{str(item.parent.number).zfill(2)}{episode_string} - {item.title}" - return filename - - def _symlink(self, item) -> bool: + def _symlink(self, item: Union[Movie, Season, Episode]) -> bool: """Create a symlink for the given media item if it does not already exist.""" - if isinstance(item, Season) and all(ep.file and ep.folder for ep in item.episodes): - success = True - for episode in item.episodes: - if not self._symlink_episode(episode): - success = False - return success - - return self._symlink_single(item) - - def _symlink_single(self, item) -> bool: - """Create a symlink for a single media item.""" - if not item.file or item.file == "None.mkv": - logger.error(f"Cannot create symlink for {item.log_string}: Invalid file {item.file}. Needs to be rescraped.") - return False - extension = os.path.splitext(item.file)[1][1:] symlink_filename = f"{self._determine_file_name(item)}.{extension}" destination = self._create_item_folders(item, symlink_filename) source = os.path.join(self.rclone_path, item.folder, item.file) if not os.path.exists(source): + logger.error(f"Source file does not exist: {source}") return False try: - os.symlink(source, destination) - logger.log("SYMLINKER", f"Created symlink for {item.log_string}") + with contextlib.suppress(FileExistsError): + os.symlink(source, destination) item.set("symlinked", True) item.set("symlinked_at", datetime.now()) item.set("symlinked_times", item.symlinked_times + 1) - return True - except FileExistsError: - return True + except PermissionError as e: + logger.error(f"Permission denied when creating symlink for {item.log_string}: {e}") + return False except OSError as e: - logger.error(f"Failed to create symlink for {item.log_string}: {e}") + logger.error(f"OS error when creating symlink for {item.log_string}: {e}") return False - def _symlink_episode(self, episode) -> bool: - """Create a symlink for an individual episode.""" - return self._symlink_single(episode) + # Validate the symlink + if not os.path.islink(destination) or not os.path.exists(destination): + logger.error(f"Symlink validation failed for {item.log_string}: {destination}") + return False - def _create_item_folders(self, item, filename) -> str: + return True + + def _create_item_folders(self, item: Union[Movie, Season, Episode], filename: str) -> str: """Create necessary folders and determine the destination path for symlinks.""" if isinstance(item, Movie): movie_folder = ( @@ -326,3 +266,144 @@ def _create_item_folders(self, item, filename) -> str: item.set("update_folder", os.path.join(season_path)) return destination_path + def start_monitor(self): + """Starts monitoring the library path for symlink deletions.""" + self.event_handler = DeleteHandler(self) + self.observer = Observer() + self.observer.schedule( + self.event_handler, self.settings.library_path, recursive=True + ) + self.observer.start() + logger.log("FILES", "Symlink deletion monitoring started") + + def stop_monitor(self): + """Stops the directory monitoring.""" + if hasattr(self, "observer"): + self.observer.stop() + self.observer.join() + logger.log("FILES", "Stopped monitoring for symlink deletions") + + def on_symlink_deleted(self, symlink_path): + """Handle a symlink deletion event.""" + src = Path(symlink_path) + if src.is_symlink(): + dst = src.resolve() + logger.log("FILES", f"Symlink deleted: {src} -> {dst}") + else: + logger.log("FILES", f"Symlink deleted: {src} (target unknown)") + # TODO: Implement logic to handle deletion.. + + def _determine_file_name(self, item) -> str | None: + """Determine the filename of the symlink.""" + filename = None + if isinstance(item, Movie): + filename = f"{item.title} ({item.aired_at.year}) " + "{imdb-" + item.imdb_id + "}" + elif isinstance(item, Season): + showname = item.parent.title + showyear = item.parent.aired_at.year + filename = f"{showname} ({showyear}) - Season {str(item.number).zfill(2)}" + elif isinstance(item, Episode): + episode_string = "" + episode_number = item.get_file_episodes() + if episode_number[0] == item.number: + if len(episode_number) > 1: + episode_string = f"e{str(episode_number[0]).zfill(2)}-e{str(episode_number[-1]).zfill(2)}" + else: + episode_string = f"e{str(item.number).zfill(2)}" + if episode_string != "": + showname = item.parent.parent.title + showyear = item.parent.parent.aired_at.year + filename = f"{showname} ({showyear}) - s{str(item.parent.number).zfill(2)}{episode_string} - {item.title}" + return filename + +def _wait_for_file(item: Union[Movie, Episode], timeout: int = 90) -> bool: + """Wrapper function to run the asynchronous wait_for_file function.""" + return asyncio.run(wait_for_file(item, timeout)) + +async def wait_for_file(item: Union[Movie, Episode], timeout: int = 90) -> bool: + """Asynchronously wait for the file to become available within the given timeout.""" + start_time = time.monotonic() + while time.monotonic() - start_time < timeout: + # keep trying to find the file until timeout duration is hit + if quick_file_check(item): + logger.log("SYMLINKER", f"File found for {item.log_string}") + return True + await asyncio.sleep(5) + # If 30 seconds have passed, try searching for the file + if time.monotonic() - start_time >= 30: + rclone_path = Path(settings_manager.settings.symlink.rclone_path) + if search_file(rclone_path, item): + logger.log("SYMLINKER", f"File found for {item.log_string} after searching") + return True + logger.log("SYMLINKER", f"File not found for {item.log_string} after waiting for {timeout} seconds, blacklisting") + blacklist_item(item) + return False + +def quick_file_check(item: Union[Movie, Episode]) -> bool: + """Quickly check if the file exists in the rclone path.""" + if not isinstance(item, (Movie, Episode)): + logger.debug(f"Cannot create symlink for {item.log_string}: Not a movie or episode") + return False + + if not item.file or item.file == "None.mkv": + logger.log("NOT_FOUND", f"Invalid file for {item.log_string}: {item.file}. Needs to be rescraped.") + return False + + rclone_path = Path(settings_manager.settings.symlink.rclone_path) + possible_folders = [item.folder, item.file, item.alternative_folder] + + for folder in possible_folders: + if folder: + file_path = rclone_path / folder / item.file + if file_path.exists(): + item.set("folder", folder) + return True + return False + +def search_file(rclone_path: Path, item: Union[Movie, Episode]) -> bool: + """Search for the file in the rclone path.""" + if not isinstance(item, (Movie, Episode)): + logger.debug(f"Cannot search for file for {item.log_string}: Not a movie or episode") + return False + + filename = item.file + if not filename: + return False + logger.debug(f"Searching for file {filename} in {rclone_path}") + try: + for root, _, files in os.walk(rclone_path): + if filename in files: + relative_root = Path(root).relative_to(rclone_path).as_posix() + item.set("folder", relative_root) + return True + logger.debug(f"File {filename} not found in {rclone_path}") + except Exception as e: + logger.error(f"Error occurred while searching for file {filename} in {rclone_path}: {e}") + return False + +def blacklist_item(item): + """Blacklist the item and reset its attributes to be rescraped.""" + infohash = get_infohash(item) + reset_item(item) + if infohash: + hash_cache.blacklist(infohash) + else: + logger.error(f"Failed to retrieve hash for {item.log_string}, unable to blacklist") + +def reset_item(item): + """Reset item attributes for rescraping.""" + item.set("file", None) + item.set("folder", None) + item.set("streams", {}) + item.set("active_stream", {}) + item.set("symlinked_times", 0) + logger.debug(f"Item {item.log_string} reset for rescraping") + +def get_infohash(item): + """Retrieve the infohash from the item or its parent.""" + infohash = item.active_stream.get("hash") + if isinstance(item, Episode) and not infohash: + infohash = item.parent.active_stream.get("hash") + if isinstance(item, Movie) and not infohash: + logger.error(f"Failed to retrieve hash for {item.log_string}, unable to blacklist") + return infohash diff --git a/backend/program/updaters/plex.py b/backend/program/updaters/plex.py index 3bccf8d1..0f2c3c93 100644 --- a/backend/program/updaters/plex.py +++ b/backend/program/updaters/plex.py @@ -4,7 +4,7 @@ from plexapi.exceptions import BadRequest, Unauthorized from plexapi.server import PlexServer -from program.media.item import Episode, Movie +from program.media.item import Episode, Movie, Season, Show from program.settings.manager import settings_manager from requests.exceptions import ConnectionError as RequestsConnectionError from urllib3.exceptions import MaxRetryError, NewConnectionError, RequestError @@ -51,38 +51,74 @@ def validate(self): # noqa: C901 except BadRequest: logger.error("Plex is not configured correctly!") except MaxRetryError: - logger.error("Plex max retries exceeded!") + logger.error("Plex max retries exceeded") except NewConnectionError: - logger.error("Plex new connection error!") + logger.error("Plex new connection error") except RequestsConnectionError: - logger.error("Plex requests connection error!") - except RequestError: - logger.error("Plex request error!") + logger.error("Plex requests connection error") + except RequestError as e: + logger.error(f"Plex request error: {e}") except Exception as e: logger.exception(f"Plex exception thrown: {e}") return False - def run(self, item: Union[Movie, Episode]) -> Generator[Union[Movie, Episode], None, None]: - """Update Plex library section for a single item""" - if not item or not item.update_folder: - logger.error(f"Item {item.log_string} is missing update folder: {item.update_folder}") + def run(self, item: Union[Movie, Episode, Season]) -> Generator[Union[Movie, Episode, Season], None, None]: + """Update Plex library section for a single item or a season with its episodes""" + if not item: + logger.error(f"Item type not supported, skipping {item}") yield item - item_type = "show" if isinstance(item, Episode) else "movie" + return + + if isinstance(item, Show): + logger.error(f"Plex Updater does not support shows, skipping {item}") + yield item + return + + item_type = "show" if isinstance(item, (Episode, Season)) else "movie" + updated = False + updated_episodes = [] + + if isinstance(item, Season): + items_to_update = [e for e in item.episodes if e.symlinked and e.get("update_folder") != "updated"] + else: + items_to_update = [item] + for section, paths in self.sections.items(): if section.type == item_type: for path in paths: - if path in item.update_folder and self._update_section(section, item): - logger.log("PLEX", f"Updated section {section.title} for {item.log_string}") + if isinstance(item, Season): + for episode in items_to_update: + if path in episode.update_folder: + if self._update_section(section, episode): + updated_episodes.append(episode) + episode.set("update_folder", "updated") # Mark the episode as updated + updated = True + elif isinstance(item, (Movie, Episode)): + if path in item.update_folder: + if self._update_section(section, item): + updated = True + + if updated: + if isinstance(item, Season): + if len(updated_episodes) == len(items_to_update): + logger.log("PLEX", f"Updated section {section.title} with all episodes for {item.log_string}") + else: + updated_episodes_log = ', '.join([str(ep.number) for ep in updated_episodes]) + logger.log("PLEX", f"Updated section {section.title} for episodes {updated_episodes_log} in {item.log_string}") + else: + logger.log("PLEX", f"Updated section {section.title} for {item.log_string}") + else: + logger.error(f"Failed to update section {section.title} for {item.log_string}") + yield item - def _update_section(self, section, item) : + def _update_section(self, section, item: Union[Movie, Episode]) -> bool: """Update the Plex section for the given item""" if item.symlinked and item.get("update_folder") != "updated": update_folder = item.update_folder section.update(str(update_folder)) item.set("update_folder", "updated") return True - logger.error(f"Failed to update section {section.title} for {item.log_string}") return False def map_sections_with_paths(self): diff --git a/backend/utils/logger.py b/backend/utils/logger.py index 03ad6cdf..d36a7b85 100644 --- a/backend/utils/logger.py +++ b/backend/utils/logger.py @@ -1,5 +1,6 @@ """Logging utils""" +import logging import os import sys from datetime import datetime @@ -7,45 +8,9 @@ from loguru import logger from program.settings.manager import settings_manager from rich.console import Console -from rich.table import Table from utils import data_dir_path -class FileLogger: - """A logger for rich tables.""" - - def __init__(self, title, show_header=False, header_style=None): - self.title = title - self.show_header = show_header - self.header_style = header_style - self.create_new_table() - - def create_new_table(self): - """Create a new table with the initial configuration.""" - self.table = Table(title=self.title, header_style=self.header_style or "bold white", row_styles=["bold green", "bold white", "bold green"]) - - def add_column(self, column_name, style="bold green"): - """Add a column to the table.""" - self.table.add_column(column_name, style=style) - - def add_row(self, *args): - """Add a row to the table.""" - self.table.add_row(*args) - - def log_table(self): - """Log the table to the console.""" - console.print(self.table) - self.clear_table() - - def clear_table(self): - """Clear the table by reinitializing it.""" - self.create_new_table() - - def progress_bar(self, *args): - """Add a progress bar to the table.""" - self.table.add_row(*args) - - def setup_logger(level): """Setup the logger""" logs_dir_path = data_dir_path / "logs" @@ -64,7 +29,10 @@ def setup_logger(level): logger.level("NEW", no=44, color="", icon="✨") logger.level("FILES", no=45, color="", icon="🗃️ ") logger.level("ITEM", no=46, color="", icon="🗃️ ") - + + # API Logging + logger.level("API", no=47, color="", icon="👾") + # Extras logger.level("PLEX", no=47, color="", icon="📽️ ") logger.level("TRAKT", no=48, color="", icon="🎵") @@ -101,15 +69,9 @@ def setup_logger(level): "backtrace": False, "diagnose": True, "enqueue": True, - }, + } ]) -console = Console() -table = FileLogger("Downloaded Files") - -log_level = "DEBUG" if settings_manager.settings.debug else "INFO" -setup_logger(log_level) - def scrub_logs(): """Remove old log files based on retention settings.""" @@ -123,3 +85,7 @@ def scrub_logs(): except Exception as e: logger.log("ERROR", f"Failed to clean old logs: {e}") + +console = Console() +log_level = "DEBUG" if settings_manager.settings.debug else "INFO" +setup_logger(log_level) \ No newline at end of file diff --git a/backend/utils/table_logger.py b/backend/utils/table_logger.py new file mode 100644 index 00000000..a33d9f1d --- /dev/null +++ b/backend/utils/table_logger.py @@ -0,0 +1,40 @@ +from rich.table import Table + +from .logger import console + + +class FileLogger: + """A logger for rich tables.""" + + def __init__(self, title, show_header=False, header_style=None): + self.title = title + self.show_header = show_header + self.header_style = header_style + self.create_new_table() + + def create_new_table(self): + """Create a new table with the initial configuration.""" + self.table = Table(title=self.title, header_style=self.header_style or "bold white", row_styles=["bold green", "bold white", "bold green"]) + + def add_column(self, column_name, style="bold green"): + """Add a column to the table.""" + self.table.add_column(column_name, style=style) + + def add_row(self, *args): + """Add a row to the table.""" + self.table.add_row(*args) + + def log_table(self): + """Log the table to the console.""" + console.print(self.table) + self.clear_table() + + def clear_table(self): + """Clear the table by reinitializing it.""" + self.create_new_table() + + def progress_bar(self, *args): + """Add a progress bar to the table.""" + self.table.add_row(*args) + +table = FileLogger("Downloaded Files", show_header=True) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..f673682e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + iceberg: + image: spoked/iceberg:latest + container_name: iceberg + network_mode: host + tty: true + environment: + - PUID=1000 + - PGID=1000 + - ORIGIN=http://localhost:3000 + - TZ=America/New_York + volumes: + - ./data:/iceberg/data + - /mnt:/mnt + # ports: # disabled when network_mode set to `host` + # - "3000:3000" diff --git a/poetry.lock b/poetry.lock index 2c695344..ed1e924d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,114 @@ # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +[[package]] +name = "aiohttp" +version = "3.9.5" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fcde4c397f673fdec23e6b05ebf8d4751314fa7c24f93334bf1f1364c1c69ac7"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d6b3f1fabe465e819aed2c421a6743d8debbde79b6a8600739300630a01bf2c"}, + {file = "aiohttp-3.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae79c1bc12c34082d92bf9422764f799aee4746fd7a392db46b7fd357d4a17a"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d3ebb9e1316ec74277d19c5f482f98cc65a73ccd5430540d6d11682cd857430"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84dabd95154f43a2ea80deffec9cb44d2e301e38a0c9d331cc4aa0166fe28ae3"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a02fbeca6f63cb1f0475c799679057fc9268b77075ab7cf3f1c600e81dd46b"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"}, + {file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:714d4e5231fed4ba2762ed489b4aec07b2b9953cf4ee31e9871caac895a839c0"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7a6a8354f1b62e15d48e04350f13e726fa08b62c3d7b8401c0a1314f02e3558"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c413016880e03e69d166efb5a1a95d40f83d5a3a648d16486592c49ffb76d0db"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ff84aeb864e0fac81f676be9f4685f0527b660f1efdc40dcede3c251ef1e867f"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ad7f2919d7dac062f24d6f5fe95d401597fbb015a25771f85e692d043c9d7832"}, + {file = "aiohttp-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:702e2c7c187c1a498a4e2b03155d52658fdd6fda882d3d7fbb891a5cf108bb10"}, + {file = "aiohttp-3.9.5-cp310-cp310-win32.whl", hash = "sha256:67c3119f5ddc7261d47163ed86d760ddf0e625cd6246b4ed852e82159617b5fb"}, + {file = "aiohttp-3.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:471f0ef53ccedec9995287f02caf0c068732f026455f07db3f01a46e49d76bbb"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"}, + {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"}, + {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"}, + {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"}, + {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"}, + {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:c7a4b7a6cf5b6eb11e109a9755fd4fda7d57395f8c575e166d363b9fc3ec4678"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0a158704edf0abcac8ac371fbb54044f3270bdbc93e254a82b6c82be1ef08f3c"}, + {file = "aiohttp-3.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d153f652a687a8e95ad367a86a61e8d53d528b0530ef382ec5aaf533140ed00f"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82a6a97d9771cb48ae16979c3a3a9a18b600a8505b1115cfe354dfb2054468b4"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60cdbd56f4cad9f69c35eaac0fbbdf1f77b0ff9456cebd4902f3dd1cf096464c"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8676e8fd73141ded15ea586de0b7cda1542960a7b9ad89b2b06428e97125d4fa"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da00da442a0e31f1c69d26d224e1efd3a1ca5bcbf210978a2ca7426dfcae9f58"}, + {file = "aiohttp-3.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18f634d540dd099c262e9f887c8bbacc959847cfe5da7a0e2e1cf3f14dbf2daf"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:320e8618eda64e19d11bdb3bd04ccc0a816c17eaecb7e4945d01deee2a22f95f"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:2faa61a904b83142747fc6a6d7ad8fccff898c849123030f8e75d5d967fd4a81"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:8c64a6dc3fe5db7b1b4d2b5cb84c4f677768bdc340611eca673afb7cf416ef5a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:393c7aba2b55559ef7ab791c94b44f7482a07bf7640d17b341b79081f5e5cd1a"}, + {file = "aiohttp-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c671dc117c2c21a1ca10c116cfcd6e3e44da7fcde37bf83b2be485ab377b25da"}, + {file = "aiohttp-3.9.5-cp312-cp312-win32.whl", hash = "sha256:5a7ee16aab26e76add4afc45e8f8206c95d1d75540f1039b84a03c3b3800dd59"}, + {file = "aiohttp-3.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:5ca51eadbd67045396bc92a4345d1790b7301c14d1848feaac1d6a6c9289e888"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:694d828b5c41255e54bc2dddb51a9f5150b4eefa9886e38b52605a05d96566e8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0605cc2c0088fcaae79f01c913a38611ad09ba68ff482402d3410bf59039bfb8"}, + {file = "aiohttp-3.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4558e5012ee03d2638c681e156461d37b7a113fe13970d438d95d10173d25f78"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbc053ac75ccc63dc3a3cc547b98c7258ec35a215a92bd9f983e0aac95d3d5b"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4109adee842b90671f1b689901b948f347325045c15f46b39797ae1bf17019de"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6ea1a5b409a85477fd8e5ee6ad8f0e40bf2844c270955e09360418cfd09abac"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3c2890ca8c59ee683fd09adf32321a40fe1cf164e3387799efb2acebf090c11"}, + {file = "aiohttp-3.9.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3916c8692dbd9d55c523374a3b8213e628424d19116ac4308e434dbf6d95bbdd"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8d1964eb7617907c792ca00b341b5ec3e01ae8c280825deadbbd678447b127e1"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d5ab8e1f6bee051a4bf6195e38a5c13e5e161cb7bad83d8854524798bd9fcd6e"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52c27110f3862a1afbcb2af4281fc9fdc40327fa286c4625dfee247c3ba90156"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7f64cbd44443e80094309875d4f9c71d0401e966d191c3d469cde4642bc2e031"}, + {file = "aiohttp-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8b4f72fbb66279624bfe83fd5eb6aea0022dad8eec62b71e7bf63ee1caadeafe"}, + {file = "aiohttp-3.9.5-cp38-cp38-win32.whl", hash = "sha256:6380c039ec52866c06d69b5c7aad5478b24ed11696f0e72f6b807cfb261453da"}, + {file = "aiohttp-3.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:da22dab31d7180f8c3ac7c7635f3bcd53808f374f6aa333fe0b0b9e14b01f91a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1732102949ff6087589408d76cd6dea656b93c896b011ecafff418c9661dc4ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c6021d296318cb6f9414b48e6a439a7f5d1f665464da507e8ff640848ee2a58a"}, + {file = "aiohttp-3.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:239f975589a944eeb1bad26b8b140a59a3a320067fb3cd10b75c3092405a1372"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b7b30258348082826d274504fbc7c849959f1989d86c29bc355107accec6cfb"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd2adf5c87ff6d8b277814a28a535b59e20bfea40a101db6b3bdca7e9926bc24"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9a3d838441bebcf5cf442700e3963f58b5c33f015341f9ea86dcd7d503c07e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e3a1ae66e3d0c17cf65c08968a5ee3180c5a95920ec2731f53343fac9bad106"}, + {file = "aiohttp-3.9.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9c69e77370cce2d6df5d12b4e12bdcca60c47ba13d1cbbc8645dd005a20b738b"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf56238f4bbf49dab8c2dc2e6b1b68502b1e88d335bea59b3f5b9f4c001475"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d1469f228cd9ffddd396d9948b8c9cd8022b6d1bf1e40c6f25b0fb90b4f893ed"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:45731330e754f5811c314901cebdf19dd776a44b31927fa4b4dbecab9e457b0c"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3fcb4046d2904378e3aeea1df51f697b0467f2aac55d232c87ba162709478c46"}, + {file = "aiohttp-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8cf142aa6c1a751fcb364158fd710b8a9be874b81889c2bd13aa8893197455e2"}, + {file = "aiohttp-3.9.5-cp39-cp39-win32.whl", hash = "sha256:7b179eea70833c8dee51ec42f3b4097bd6370892fa93f510f76762105568cf09"}, + {file = "aiohttp-3.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:38d80498e2e169bc61418ff36170e0aad0cd268da8b38a17c4cf29d254a8b3f1"}, + {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"}, +] + +[package.dependencies] +aiosignal = ">=1.1.2" +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["Brotli", "aiodns", "brotlicffi"] + +[[package]] +name = "aiosignal" +version = "1.3.1" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.7" +files = [ + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" + [[package]] name = "annotated-types" version = "0.7.0" @@ -78,6 +187,25 @@ types-python-dateutil = ">=2.8.10" doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"] test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2021.1)", "simplejson (==3.*)"] +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + [[package]] name = "cachetools" version = "5.3.3" @@ -355,6 +483,92 @@ typing-extensions = ">=4.8.0" [package.extras] all = ["email_validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "frozenlist" +version = "1.4.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.8" +files = [ + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, + {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, + {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, + {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, + {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, + {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, + {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, + {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, + {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, + {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, + {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, + {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, + {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, + {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, + {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, + {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, + {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, + {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, + {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, + {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, + {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, + {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, + {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, + {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, + {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, + {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, + {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, + {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, +] + [[package]] name = "h11" version = "0.14.0" @@ -808,6 +1022,105 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "multidict" +version = "6.0.5" +description = "multidict implementation" +optional = false +python-versions = ">=3.7" +files = [ + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, + {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, + {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, + {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, + {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, + {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, + {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, + {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, + {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, + {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, + {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, + {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, + {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, + {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, + {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, + {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, +] + [[package]] name = "nodeenv" version = "1.9.0" @@ -1940,7 +2253,110 @@ files = [ {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"}, ] +[[package]] +name = "yarl" +version = "1.9.4" +description = "Yet another URL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, + {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, + {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, + {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, + {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, + {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, + {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, + {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, + {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, + {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, + {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, + {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, + {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, + {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, + {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, + {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, + {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, + {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, + {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, + {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, + {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, + {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, + {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, + {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, + {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, + {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, + {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, + {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, + {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, + {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, + {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, + {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, + {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "44ae00058c4ba10e5cb24aa8d55d5f55bc7892f28a1a317752f2024bdc1dfc90" +content-hash = "963b46bfc5c1ff7cefde5d30623470ccdf948ca824f1ad4961b18bc39cd47c15" diff --git a/pyproject.toml b/pyproject.toml index 99bc8195..3c187777 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ tzlocal = "^5.2" cachetools = "^5.3.3" loguru = "^0.7.2" rich = "^13.7.1" +aiohttp = "^3.9.5" [tool.poetry.group.dev.dependencies] pyright = "^1.1.352"