diff --git a/.gitignore b/.gitignore index 09dccf73..8fda03aa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ makefile profile.svg *.gz *.zip +*.lockb # Python bytecode / Byte-compiled / optimized / DLL files __pycache__/ diff --git a/backend/controllers/items.py b/backend/controllers/items.py index 1f8775ae..706d8670 100644 --- a/backend/controllers/items.py +++ b/backend/controllers/items.py @@ -1,10 +1,13 @@ from datetime import datetime from typing import List, Optional + +import Levenshtein from fastapi import APIRouter, HTTPException, Request -from pydantic import BaseModel -from program.media.item import MediaItem from program.content.overseerr import Overseerr +from program.media.container import MediaItemContainer +from program.media.item import ItemId, MediaItem from program.media.state import States +from pydantic import BaseModel from utils.logger import logger router = APIRouter( @@ -26,17 +29,104 @@ async def get_states(): } -@router.get("/") -async def get_items(request: Request): +@router.get("/", summary="Retrieve Media Items", description="Fetch media items with optional filters and pagination.") +async def get_items( + request: Request, + fetch_all: Optional[bool] = False, + limit: Optional[int] = 20, + page: Optional[int] = 1, + search: Optional[str] = None, + state: Optional[str] = None, + type: Optional[str] = None +): + """ + Fetch media items with optional filters and pagination. + + Parameters: + - request: Request object + - fetch_all: Fetch all items without pagination (default: False) + - limit: Number of items per page (default: 20) + - page: Page number (default: 1) + - search: Search term to filter items by title or IMDb ID + - state: Filter items by state + - type: Filter items by type (movie, show, season, episode) + + Returns: + - JSON response with success status, items, pagination details, and total count + + Examples: + - Fetch all items: /items?fetch_all=true + - Fetch first 10 items: /items?limit=10&page=1 + - Search items by title: /items?search=inception + - Filter items by state: /items?state=completed + - Filter items by type: /items?type=movie + """ + items = list(request.app.program.media_items._items.values()) + + if search: + search_lower = search.lower() + filtered_items = [] + for item in items: + if isinstance(item, MediaItem): + title_match = item.title and Levenshtein.distance(search_lower, item.title.lower()) <= 0.90 + imdb_match = item.imdb_id and Levenshtein.distance(search_lower, item.imdb_id.lower()) <= 1 + if title_match or imdb_match: + filtered_items.append(item) + items = filtered_items + + if type: + type_lower = type.lower() + if type_lower == "movie": + items = list(request.app.program.media_items.movies.values()) + elif type_lower == "show": + items = list(request.app.program.media_items.shows.values()) + elif type_lower == "season": + items = list(request.app.program.media_items.seasons.values()) + elif type_lower == "episode": + items = list(request.app.program.media_items.episodes.values()) + else: + raise HTTPException(status_code=400, detail=f"Invalid type: {type}. Valid types are: ['movie', 'show', 'season', 'episode']") + + if state: + filter_lower = state.lower() + filter_state = None + for state in States: + if Levenshtein.distance(filter_lower, state.name.lower()) <= 0.82: + filter_state = state + break + if filter_state: + items = [item for item in items if item.state == filter_state] + else: + valid_states = [state.name for state in States] + raise HTTPException(status_code=400, detail=f"Invalid filter state: {state}. Valid states are: {valid_states}") + + if not fetch_all: + if page < 1: + raise HTTPException(status_code=400, detail="Page number must be 1 or greater.") + if limit < 1: + raise HTTPException(status_code=400, detail="Limit must be 1 or greater.") + + start = (page - 1) * limit + end = start + limit + items = items[start:end] + + total_count = len(items) + total_pages = (total_count + limit - 1) // limit + return { "success": True, - "items": [item.to_dict() for item in request.app.program.media_items], + "items": [item.to_dict() for item in items], + "page": page, + "limit": limit, + "total": total_count, + "total_pages": total_pages } @router.get("/extended/{item_id}") async def get_extended_item_info(request: Request, item_id: str): - item = request.app.program.media_items.get_item(item_id) + mic: MediaItemContainer = request.app.program.media_items + item = mic.get(item_id) if item is None: raise HTTPException(status_code=404, detail="Item not found") return { @@ -70,77 +160,63 @@ async def add_items(request: Request, imdb_id: Optional[str] = None, imdb_ids: O return {"success": True, "message": f"Added {len(valid_ids)} item(s) to the queue"} -@router.delete("/remove/id/{item_id}") -async def remove_item(request: Request, item_id: str): - 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.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}", - } - +@router.delete("/remove/") +async def remove_item( + request: Request, + item_id: Optional[str] = None, + imdb_id: Optional[str] = None +): + if item_id: + item = request.app.program.media_items.get(ItemId(item_id)) + id_type = "ID" + elif imdb_id: + item = next((i for i in request.app.program.media_items if i.imdb_id == imdb_id), None) + id_type = "IMDb ID" + else: + raise HTTPException(status_code=400, detail="No item ID or IMDb ID provided") -@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(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") + logger.error(f"Item with {id_type} {item_id or imdb_id} not found") + return { + "success": False, + "message": f"Item with {id_type} {item_id or imdb_id} not found. No action taken." + } - request.app.program.media_items.remove(item) - 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.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("Overseerr service not found in program services") + try: + # Remove the item from the media items container + request.app.program.media_items.remove(item) + logger.log("API", f"Removed item with {id_type} {item_id or imdb_id}") - return { - "success": True, - "message": f"Removed item with IMDb ID {imdb_id}", - } + # Remove the symlinks associated with the item + symlinker = request.app.program.symlinker + symlinker.delete_item_symlinks(item) + logger.log("API", f"Removed symlink for item with {id_type} {item_id or imdb_id}") + + return { + "success": True, + "message": f"Successfully removed item with {id_type} {item_id or imdb_id}." + } + except Exception as e: + logger.error(f"Failed to remove item with {id_type} {item_id or imdb_id}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") @router.get("/imdb/{imdb_id}") -async def get_imdb_info(request: Request, imdb_id: str): - item = request.app.program.media_items.get_item(imdb_id) +async def get_imdb_info(request: Request, imdb_id: str, season: Optional[int] = None, episode: Optional[int] = None): + """ + Get the item with the given IMDb ID. + If the season and episode are provided, get the item with the given season and episode. + """ + item_id = ItemId(imdb_id) + if season is not None: + item_id = ItemId(str(season), parent_id=item_id) + if episode is not None: + item_id = ItemId(str(episode), parent_id=item_id) + + item = request.app.program.media_items.get_item(item_id) if item is None: - logger.error(f"Item with IMDb ID {imdb_id} not found in container") raise HTTPException(status_code=404, detail="Item not found") - if not request.app.program.media_items.__contains__(item): - logger.error(f"Item with IMDb ID {imdb_id} is not in the library") - raise HTTPException(status_code=404, detail="Item not found in library") - return {"success": True, "item": item.to_extended_dict()} @@ -152,7 +228,6 @@ async def get_incomplete_items(request: Request): incomplete_items = request.app.program.media_items.get_incomplete_items() if not incomplete_items: - logger.info("No incomplete items found") return { "success": True, "incomplete_items": [] diff --git a/backend/controllers/webhooks.py b/backend/controllers/webhooks.py index ec6ddea6..1e945127 100644 --- a/backend/controllers/webhooks.py +++ b/backend/controllers/webhooks.py @@ -3,11 +3,15 @@ import pydantic from fastapi import APIRouter, Request -from program.media.item import MediaItem from program.content.overseerr import Overseerr -from program.indexers.trakt import get_imdbid_from_tmdb -from utils.request import get +from program.indexers.trakt import ( + TraktIndexer, + create_item_from_imdb_id, + get_imdbid_from_tmdb, +) +from program.media.item import MediaItem, Show from utils.logger import logger +from utils.request import get from .models.overseerr import OverseerrWebhook @@ -43,12 +47,17 @@ async def overseerr(request: Request) -> Dict[str, Any]: return {"success": False, "message": "Failed to get imdb_id from TMDB", "title": req.subject} overseerr: Overseerr = request.app.program.services[Overseerr] + trakt: TraktIndexer = request.app.program.services[TraktIndexer] + if imdb_id in overseerr.recurring_items: logger.log("API", "Request already in queue", {"imdb_id": imdb_id}) return {"success": False, "message": "Request already in queue", "title": req.subject} else: overseerr.recurring_items.add(imdb_id) - item = MediaItem({"imdb_id": imdb_id, "requested_by": "overseerr", "requested_at": datetime.now()}) + new_item = MediaItem({"imdb_id": imdb_id, "requested_by": "overseerr"}) + item = create_item_from_imdb_id(new_item.imdb_id) + if isinstance(item, Show): + trakt._add_seasons_to_show(item, imdb_id) request.app.program.add_to_queue(item) return {"success": True} diff --git a/backend/program/downloaders/realdebrid.py b/backend/program/downloaders/realdebrid.py index cea26eb2..9b812d67 100644 --- a/backend/program/downloaders/realdebrid.py +++ b/backend/program/downloaders/realdebrid.py @@ -155,15 +155,14 @@ def _process_providers(self, item: MediaItem, provider_list: dict, stream_hash: key=lambda container: -len(container) ) - # Check the instance type once and process accordingly if isinstance(item, Movie): for container in sorted_containers: if self._is_wanted_movie(container, item): item.set("active_stream", {"hash": stream_hash, "files": container, "id": None}) return True - elif isinstance(item, Episode): + elif isinstance(item, Show): for container in sorted_containers: - if self._is_wanted_episode(container, item): + if self._is_wanted_show(container, item): item.set("active_stream", {"hash": stream_hash, "files": container, "id": None}) return True elif isinstance(item, Season): @@ -171,8 +170,12 @@ def _process_providers(self, item: MediaItem, provider_list: dict, stream_hash: if self._is_wanted_season(container, item): item.set("active_stream", {"hash": stream_hash, "files": container, "id": None}) return True - - # If no cached files were found in any of the containers, return False + elif isinstance(item, Episode): + for container in sorted_containers: + if self._is_wanted_episode(container, item): + item.set("active_stream", {"hash": stream_hash, "files": container, "id": None}) + return True + # False if no cached files in containers (provider_list) return False def _is_wanted_movie(self, container: dict, item: Movie) -> bool: @@ -292,6 +295,64 @@ def _is_wanted_season(self, container: dict, item: Season) -> bool: return True return False + def _is_wanted_show(self, container: dict, item: Show) -> bool: + """Check if container has wanted files for a show""" + if not isinstance(item, Show): + logger.error(f"Item is not a Show instance: {item.log_string}") + return False + + # Filter and sort files once to improve performance + filenames = [ + file for file in container.values() + if file and file["filesize"] > 4e+7 and splitext(file["filename"].lower())[1] in WANTED_FORMATS + ] + + if not filenames: + return False + + # Create a dictionary to map seasons and episodes needed + needed_episodes = {} + acceptable_states = [States.Indexed, States.Scraped, States.Unknown, States.Failed] + + for season in item.seasons: + if season.state in acceptable_states and season.is_released: + needed_episode_numbers = {episode.number for episode in season.episodes if episode.state in acceptable_states and episode.is_released} + if needed_episode_numbers: + needed_episodes[season.number] = needed_episode_numbers + if not needed_episodes: + return False + + # Iterate over each file to check if it matches + # the season and episode within the show + matched_files = {} + for file in filenames: + with contextlib.suppress(GarbageTorrent, TypeError): + parsed_file = parse(file["filename"], remove_trash=True) + if not parsed_file or not parsed_file.parsed_title or 0 in parsed_file.season: + continue + # Check each season and episode to find a match + for season_number, episodes in needed_episodes.items(): + if season_number in parsed_file.season: + for episode_number in list(episodes): + if episode_number in parsed_file.episode: + # Store the matched file for this episode + matched_files[(season_number, episode_number)] = file + episodes.remove(episode_number) + if not matched_files: + return False + + all_found = all(len(episodes) == 0 for episodes in needed_episodes.values()) + + if all_found: + for (season_number, episode_number), file in matched_files.items(): + season = next(season for season in item.seasons if season.number == season_number) + episode = next(episode for episode in season.episodes if episode.number == episode_number) + episode.set("folder", item.active_stream.get("name")) + episode.set("alternative_folder", item.active_stream.get("alternative_name", None)) + episode.set("file", file.get("filename")) + return True + return False + def _is_downloaded(self, item: MediaItem) -> bool: """Check if item is already downloaded after checking if it was cached""" hash_key = item.active_stream.get("hash", None) @@ -369,7 +430,7 @@ def set_active_files(self, item: MediaItem) -> 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: @@ -385,6 +446,8 @@ def _is_wanted_item(self, item: Union[Movie, Episode, Season]) -> bool: """Check if item is wanted""" if isinstance(item, Movie): return self._is_wanted_movie(item.active_stream.get("files", {}), item) + elif isinstance(item, Show): + return self._is_wanted_show(item.active_stream.get("files", {}), item) elif isinstance(item, Season): return self._is_wanted_season(item.active_stream.get("files", {}), item) elif isinstance(item, Episode): @@ -521,15 +584,19 @@ def check_season(): if isinstance(item, Movie): if check_movie(): - logger.info(f"Movie {item.log_string} already exists in Real-Debrid account.") + logger.info(f"{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.") + elif isinstance(item, Show): + if all(check_season(season) for season in item.seasons): + logger.info(f"{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.") + logger.info(f"{item.log_string} already exists in Real-Debrid account.") + return True + elif isinstance(item, Episode): + if check_episode(): + logger.info(f"{item.log_string} already exists in Real-Debrid account.") return True logger.debug(f"No matching item found for {item.log_string}") diff --git a/backend/program/downloaders/torbox.py b/backend/program/downloaders/torbox.py index f1c1b621..ae889589 100644 --- a/backend/program/downloaders/torbox.py +++ b/backend/program/downloaders/torbox.py @@ -1,10 +1,15 @@ +import contextlib +from datetime import datetime import time from typing import Generator -from program.media.item import MediaItem +from RTN import parse +from RTN.exceptions import GarbageTorrent + +from program.media.item import MediaItem, Movie from program.settings.manager import settings_manager from utils.logger import logger -from utils.request import get +from utils.request import get, post class TorBoxDownloader: @@ -15,7 +20,7 @@ def __init__(self, hash_cache): self.settings = settings_manager.settings.downloaders.torbox self.api_key = self.settings.api_key self.base_url = "https://api.torbox.app/v1/api" - self.headers = {"Authorization": f"{self.api_key}"} + self.headers = {"Authorization": f"Bearer {self.api_key}"} self.initialized = self.validate() if not self.initialized: return @@ -24,91 +29,73 @@ def __init__(self, hash_cache): def validate(self) -> bool: """Validate the TorBox Downloader as a service""" - return False - # if not self.settings.enabled: - # logger.warning("TorBox Downloader is set to disabled") - # return False - # if not self.api_key: - # logger.warning("TorBox Downloader API key is not set") - # return False - - # try: - # response = get( - # f"{self.base_url}/user/me", - # additional_headers=self.headers - # ) - # return response.data.is_subscribed - # except Exception: - # return False # for now.. + if not self.settings.enabled: + logger.info("Torbox downloader is not enabled") + return False + if not self.settings.api_key: + logger.error("Torbox API key is not set") + try: + return self.get_expiry_date() > datetime.now() + except: + return False def run(self, item: MediaItem) -> Generator[MediaItem, None, None]: """Download media item from TorBox""" logger.info(f"Downloading {item.log_string} from TorBox") + if self.is_cached(item): + self.download(item) + yield item - def is_cached(self, infohashes: list[str]) -> list[bool]: - """Check if the given infohashes are cached in TorBox""" - cached_results = [] - for infohash in infohashes: - try: - response = get( - f"{self.base_url}/torrents/checkcached", - headers=self.headers, - params={"hash": infohash, "format": "object"} - ) - result = response.json() - cached = result['data']['data'] if 'data' in result and 'data' in result['data'] and result['data']['data'] is not False else False - cached_results.append(cached) - except Exception as e: - cached_results.append(False) - return cached_results - def request_download(self, infohash: str): - """Request a download from TorBox""" - try: - response = get( - f"{self.base_url}/torrents/requestdl", - headers=self.headers, - params={"torrent_id": infohash, "file_id": 0, "zip": False}, - ) - return response.json() - except Exception as e: - raise e - - 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.") - return None + def is_cached(self, item: MediaItem): + streams = [hash for hash in item.streams] + data = self.get_web_download_cached(streams) + for hash in data: + item.active_stream=data[hash] + return True - infohash = item.active_stream.get("hash") - if not infohash: - logger.error(f"No infohash found for item: {item.log_string}") - return None + def download(self, item: MediaItem): + if item.type == "movie": + exists = False + torrent_list = self.get_torrent_list() + for torrent in torrent_list: + if item.active_stream["hash"] == torrent["hash"]: + id = torrent["id"] + exists = True + break + if not exists: + id = self.create_torrent(item.active_stream["hash"]) + for torrent in torrent_list: + if torrent["id"] == id: + with contextlib.suppress(GarbageTorrent, TypeError): + for file in torrent["files"]: + if file["size"] > 10000: + parsed_file = parse(file["short_name"]) + if parsed_file.type == "movie": + item.set("folder", ".") + item.set("alternative_folder", ".") + item.set("file", file["short_name"]) + return True - if self.is_cached([infohash])[0]: - logger.info(f"Item already cached: {item.log_string}") - else: - download_response = self.request_download(infohash) - if download_response.get('status') != 'success': - logger.error(f"Failed to initiate download for item: {item.log_string}") - return None - logger.info(f"Download initiated for item: {item.log_string}") + def get_expiry_date(self): + expiry = datetime.fromisoformat(self.get_user_data().premium_expires_at) + expiry = expiry.replace(tzinfo=None) + return expiry - # Wait for the download to be ready and get the path - 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 + def get_web_download_cached(self, hash_list): + hash_string = ",".join(hash_list) + response = get(f"{self.base_url}/webdl/checkcached?hash={hash_string}", additional_headers=self.headers, response_type=dict) + return response.data["data"] - logger.success(f"Download ready at path: {download_path} for item: {item.log_string}") - return download_path + def get_user_data(self): + response = get(f"{self.base_url}/user/me", additional_headers=self.headers, retry_if_failed=False) + return response.data.data - 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 - time.sleep(10) - logger.warning(f"Torrent not available after timeout: {infohash}") - return None + def create_torrent(self, hash) -> int: + magnet_url = f"magnet:?xt=urn:btih:{hash}&dn=&tr=" + response = post(f"{self.base_url}/torrents/createtorrent", data={"magnet": magnet_url}, additional_headers=self.headers) + return response.data.data.torrent_id + + def get_torrent_list(self) -> list: + response = get(f"{self.base_url}/torrents/mylist", additional_headers=self.headers, response_type=dict) + return response.data["data"] diff --git a/backend/program/indexers/trakt.py b/backend/program/indexers/trakt.py index c2d5ddfe..f3ab4c97 100644 --- a/backend/program/indexers/trakt.py +++ b/backend/program/indexers/trakt.py @@ -1,7 +1,7 @@ """Trakt updater module""" from datetime import datetime, timedelta -from typing import Generator, Optional +from typing import Generator, List, Optional from program.media.item import Episode, MediaItem, Movie, Season, Show from program.settings.manager import settings_manager @@ -40,9 +40,11 @@ def run(self, item: MediaItem) -> Generator[MediaItem, None, None]: @staticmethod def should_submit(item: MediaItem) -> bool: - if not item.indexed_at: + if not item.indexed_at or not item.title: return True + settings = settings_manager.settings.indexer + try: interval = timedelta(seconds=settings.update_interval) return datetime.now() - item.indexed_at > interval @@ -50,25 +52,39 @@ def should_submit(item: MediaItem) -> bool: logger.error(f"Failed to parse date: {item.indexed_at} with format: {interval}") return False - def _add_seasons_to_show(self, show: Show, imdb_id: str): + @staticmethod + def _add_seasons_to_show(show: Show, imdb_id: str): """Add seasons to the given show using Trakt API.""" + if not isinstance(show, Show): + logger.error(f"Item {show.log_string} is not a show") + return + + if not imdb_id or not imdb_id.startswith("tt"): + logger.error(f"Item {show.log_string} does not have an imdb_id, cannot index it") + return + seasons = get_show(imdb_id) for season in seasons: if season.number == 0: continue - season_item = _map_item_from_data(season, "season") - for episode in season.episodes: - episode_item = _map_item_from_data(episode, "episode") - season_item.add_episode(episode_item) - show.add_season(season_item) + season_item = _map_item_from_data(season, "season", show.genres) + if season_item: + for episode in season.episodes: + episode_item = _map_item_from_data(episode, "episode", show.genres) + if episode_item: + season_item.add_episode(episode_item) + show.add_season(season_item) -def _map_item_from_data(data, item_type: str) -> Optional[MediaItem]: +def _map_item_from_data(data, item_type: str, show_genres: List[str] = None) -> Optional[MediaItem]: """Map trakt.tv API data to MediaItemContainer.""" if item_type not in ["movie", "show", "season", "episode"]: logger.debug(f"Unknown item type {item_type} for {data.title} not found in list of acceptable items") return None + formatted_aired_at = _get_formatted_date(data, item_type) + genres = getattr(data, "genres", None) or show_genres + item = { "title": getattr(data, "title", None), "year": getattr(data, "year", None), @@ -77,12 +93,12 @@ def _map_item_from_data(data, item_type: str) -> Optional[MediaItem]: "imdb_id": getattr(data.ids, "imdb", None), "tvdb_id": getattr(data.ids, "tvdb", None), "tmdb_id": getattr(data.ids, "tmdb", None), - "genres": getattr(data, "genres", None), + "genres": genres, + "is_anime": "anime" in genres if genres else False, "network": getattr(data, "network", None), "country": getattr(data, "country", None), "language": getattr(data, "language", None), "requested_at": datetime.now(), - "is_anime": "anime" in getattr(data, "genres", []), } match item_type: diff --git a/backend/program/libraries/symlink.py b/backend/program/libraries/symlink.py index dc4d610c..4d9649f1 100644 --- a/backend/program/libraries/symlink.py +++ b/backend/program/libraries/symlink.py @@ -1,8 +1,7 @@ import os +import re from pathlib import Path -from typing import Generator, List, Tuple -import regex from program.media.item import Episode, MediaItem, Movie, Season, Show from program.settings.manager import settings_manager from utils.logger import logger @@ -18,12 +17,13 @@ def __init__(self): return def validate(self) -> bool: + """Validate the symlink library settings.""" library_path = Path(self.settings.library_path).resolve() if library_path == Path.cwd().resolve(): logger.error("Library path not set or set to the current directory in SymlinkLibrary settings.") return False - required_dirs = ["shows", "movies"] + required_dirs = ["shows", "movies", "anime_shows", "anime_movies"] missing_dirs = [d for d in required_dirs if not (library_path / d).exists()] if missing_dirs: @@ -33,98 +33,63 @@ def validate(self) -> bool: return False return True - def run(self) -> Generator[MediaItem, None, None]: - """Create a library from the symlink paths. Return stub items that should - be fed into an Indexer to have the rest of the metadata filled in.""" - for movie_item in self.process_movies(): - yield movie_item - - for show_item in self.process_shows(): - yield show_item - - def process_movies(self) -> Generator[Movie, None, None]: - """Process movie symlinks and yield Movie items.""" - movies = self.get_files_in_directory(self.settings.library_path / "movies") - for path, filename in movies: - imdb_id = self.extract_imdb_id(filename) - if not imdb_id: - logger.error(f"Can't extract movie imdb_id at path {path / filename}") - continue - movie_item = Movie({"imdb_id": imdb_id}) - movie_item.set("symlinked", True) - movie_item.set("update_folder", "updated") - yield movie_item - - def process_shows(self) -> Generator[Show, None, None]: - """Process show symlinks and yield Show items.""" - shows_dir = self.settings.library_path / "shows" - for show in os.listdir(shows_dir): - imdb_id = self.extract_imdb_id(show) - title = self.extract_title(show) - if not imdb_id or not title: - logger.error(f"Can't extract episode imdb_id or title at path {shows_dir / show}") + def run(self) -> MediaItem: + """ + Create a library from the symlink paths. Return stub items that should + be fed into an Indexer to have the rest of the metadata filled in. + """ + for directory, item_type, is_anime in [("movies", "movie", False), ("anime_movies", "anime movie", True)]: + yield from process_items(self.settings.library_path / directory, Movie, item_type, is_anime) + + for directory, item_type, is_anime in [("shows", "show", False), ("anime_shows", "anime show", True)]: + yield from process_shows(self.settings.library_path / directory, item_type, is_anime) + + +def process_items(directory: Path, item_class, item_type: str, is_anime: bool = False): + """Process items in the given directory and yield MediaItem instances.""" + items = [ + (root, files[0]) + for root, _, files + in os.walk(directory) + if files + ] + for path, filename in items: + imdb_id = re.search(r'(tt\d+)', filename) + title = re.search(r'(.+)?( \()', filename) + if not imdb_id or not title: + logger.error(f"Can't extract {item_type} imdb_id or title at path {path / filename}") + continue + item = item_class({'imdb_id': imdb_id.group(), 'title': title.group(1)}) + item.update_folder = "updated" + if is_anime: + item.is_anime = True + yield item + + +def process_shows(directory: Path, item_type: str, is_anime: bool = False) -> Show: + """Process shows in the given directory and yield Show instances.""" + for show in os.listdir(directory): + imdb_id = re.search(r'(tt\d+)', show) + title = re.search(r'(.+)?( \()', show) + if not imdb_id or not title: + logger.error(f"Can't extract {item_type} imdb_id or title at path {directory / show}") + continue + show_item = Show({'imdb_id': imdb_id.group(), 'title': title.group(1)}) + if is_anime: + show_item.is_anime = True + for season in os.listdir(directory / show): + if not (season_number := re.search(r'(\d+)', season)): + logger.error(f"Can't extract season number at path {directory / show / season}") continue - show_item = Show({"imdb_id": imdb_id, "title": title}) - for season_item in self.process_seasons(shows_dir / show, show_item): - show_item.add_season(season_item) - yield show_item - - def process_seasons(self, show_path: Path, show_item: Show) -> Generator[Season, None, None]: - """Process season symlinks and yield Season items.""" - for season in os.listdir(show_path): - season_number = self.extract_season_number(season) - if not season_number: - logger.error(f"Can't extract season number at path {show_path / season}") - continue - season_item = Season({"number": season_number}) - season_item.set("parent", show_item) - for episode_item in self.process_episodes(show_path / season, season_item): + season_item = Season({'number': int(season_number.group())}) + for episode in os.listdir(directory / show / season): + if not (episode_number := re.search(r's\d+e(\d+)', episode)): + logger.error(f"Can't extract episode number at path {directory / show / season / episode}") + continue + episode_item = Episode({'number': int(episode_number.group(1))}) + episode_item.update_folder = "updated" + if is_anime: + episode_item.is_anime = True season_item.add_episode(episode_item) - yield season_item - - def process_episodes(self, season_path: Path, season_item: Season) -> Generator[Episode, None, None]: - """Process episode symlinks and yield Episode items.""" - for episode in os.listdir(season_path): - episode_number = self.extract_episode_number(episode) - if not episode_number: - logger.error(f"Deleting symlink, unable to extract episode number: {season_path / episode}") - os.remove(season_path / episode) - continue - episode_item = Episode({"number": episode_number}) - episode_item.set("parent", season_item) - episode_item.set("symlinked", True) - episode_item.set("update_folder", "updated") - yield episode_item - - @staticmethod - def get_files_in_directory(directory: Path) -> List[Tuple[Path, str]]: - """Get all files in a directory.""" - return [ - (root, files[0]) - for root, _, files in os.walk(directory) - if files - ] - - @staticmethod - def extract_imdb_id(text: str) -> str: - """Extract IMDb ID from text.""" - match = regex.search(r"(tt\d+)", text) - return match.group() if match else None - - @staticmethod - def extract_title(text: str) -> str: - """Extract title from text.""" - match = regex.search(r"(.+?) \(", text) - return match.group(1) if match else None - - @staticmethod - def extract_season_number(text: str) -> int: - """Extract season number from text.""" - match = regex.search(r"(\d+)", text) - return int(match.group()) if match else None - - @staticmethod - def extract_episode_number(text: str) -> int: - """Extract episode number from text.""" - match = regex.search(r"s\d+e(\d+)", text, regex.IGNORECASE) - return int(match.group(1)) if match else None + show_item.add_season(season_item) + yield show_item diff --git a/backend/program/media/container.py b/backend/program/media/container.py index a0e5b106..7592bf92 100644 --- a/backend/program/media/container.py +++ b/backend/program/media/container.py @@ -2,6 +2,8 @@ import shutil import tempfile import threading +from copy import deepcopy +from pickle import UnpicklingError from typing import Dict, Generator, List, Optional import dill @@ -42,14 +44,16 @@ def __exit__(self, exc_type, exc_val, exc_tb): class MediaItemContainer: + """A container to store media items.""" + def __init__(self): - self._items = {} - self._shows = {} - self._seasons = {} - self._episodes = {} - self._movies = {} - self._imdb_index = {} - self.lock = ReadWriteLock() + self._items: Dict[ItemId, MediaItem] = {} + self._shows: Dict[ItemId, Show] = {} + self._seasons: Dict[ItemId, Season] = {} + self._episodes: Dict[ItemId, Episode] = {} + self._movies: Dict[ItemId, Movie] = {} + self.library_file: str = "media.pkl" + self.lock: ReadWriteLock = ReadWriteLock() def __iter__(self) -> Generator[MediaItem, None, None]: self.lock.acquire_read() @@ -87,11 +91,39 @@ def get(self, key, default=None) -> MediaItem: finally: self.lock.release_read() - def get_episodes(self, show_id: ItemId) -> List[MediaItem]: - """Get all episodes for a show.""" + @property + def seasons(self) -> dict[ItemId, Season]: + return deepcopy(self._seasons) + + @property + def episodes(self) -> dict[ItemId, Episode]: + return deepcopy(self._episodes) + + @property + def shows(self) -> dict[ItemId, Show]: + return deepcopy(self._shows) + + @property + def movies(self) -> dict[ItemId, Movie]: + return deepcopy(self._movies) + + def get_items_with_state(self, state) -> dict[ItemId, MediaItem]: + """Get items with the specified state""" + return { + item_id: self[item_id] + for item_id, item in self._items.items() + if item.state == state + } + + def get_incomplete_items(self) -> dict[ItemId, MediaItem]: + """Get items that are not completed.""" self.lock.acquire_read() try: - return self._shows[show_id].episodes + return { + item_id: item + for item_id, item in self._items.items() + if item.state not in (States.Completed, States.PartiallyCompleted) + } finally: self.lock.release_read() @@ -106,52 +138,27 @@ def get_item(self, identifier: str) -> Optional[MediaItem]: finally: self.lock.release_read() - def upsert(self, item: MediaItem) -> None: - self.lock.acquire_write() + def get_episodes(self, show_id: ItemId) -> List[MediaItem]: + """Get all episodes for a show.""" + self.lock.acquire_read() try: - if item.item_id in self._items: - existing_item = self._items[item.item_id] - if existing_item.state == States.Completed and existing_item.title: - return - self._merge_items(existing_item, item) - else: - self._items[item.item_id] = item - self._index_item(item) + return self.shows[show_id].episodes finally: - self.lock.release_write() - - def _merge_items(self, existing_item: MediaItem, new_item: MediaItem) -> None: - """Merge new item data into existing item without losing existing state.""" - if existing_item.state == States.Completed and new_item.state != States.Completed: - return - for attr in vars(new_item): - new_value = getattr(new_item, attr) - if new_value is not None: - setattr(existing_item, attr, new_value) - if isinstance(existing_item, Show): - for season in new_item.seasons: - if season.item_id in self._seasons: - self._merge_items(self._seasons[season.item_id], season) - else: - self._index_item(season) - elif isinstance(existing_item, Season): - for episode in new_item.episodes: - if episode.item_id in self._episodes: - self._merge_items(self._episodes[episode.item_id], episode) - else: - if not self._episode_exists(existing_item, episode): - self._index_item(episode) - - def _episode_exists(self, season, episode): - for existing_episode in season.episodes: - if existing_episode.item_id == episode.item_id: - return True - return False + self.lock.release_read() - def _index_item(self, item: MediaItem): - """Index the item and its children in the appropriate dictionaries.""" - if item.imdb_id: - self._imdb_index[item.imdb_id] = item + def upsert(self, item: MediaItem) -> None: + """Iterate through the input item and upsert all parents and children.""" + # Use deepcopy so that further modifications made to the input item + # will not affect the container state + self._items[item.item_id] = item + detatched = item.item_id.parent_id is None or item.parent is None + if isinstance(item, (Season, Episode)) and detatched: + logger.error( + "%s item %s is detatched and not associated with a parent, and thus" + + " it cannot be upserted into the database", + item.__class__.name, item.log_string + ) + raise ValueError("Item detached from parent") if isinstance(item, Show): self._shows[item.item_id] = item for season in item.seasons: @@ -162,67 +169,86 @@ def _index_item(self, item: MediaItem): episode.parent = season self._items[episode.item_id] = episode self._episodes[episode.item_id] = episode - elif isinstance(item, Season): + if isinstance(item, Season): self._seasons[item.item_id] = item + # update children for episode in item.episodes: episode.parent = item self._items[episode.item_id] = episode self._episodes[episode.item_id] = episode - parent_show = self._shows.get(item.item_id.parent_id) - if parent_show: - parent_show.seasons.append(item) + # Ensure the parent Show is updated in the container + container_show: Show = self._items[item.item_id.parent_id] + parent_index = container_show.get_season_index_by_id(item.item_id) + if parent_index is not None: + container_show.seasons[parent_index] = item elif isinstance(item, Episode): self._episodes[item.item_id] = item - parent_season = self._seasons.get(item.item_id.parent_id) - if parent_season: - parent_season.episodes.append(item) + # Ensure the parent Season is updated in the container + container_season: Season = self._items[item.item_id.parent_id] + parent_index = container_season.get_episode_index_by_id(item.item_id) + if parent_index is not None: + container_season.episodes[parent_index] = item elif isinstance(item, Movie): self._movies[item.item_id] = item - def remove(self, item) -> None: + def _index_item(self, item: MediaItem): + """Index the item and its children in the appropriate dictionaries.""" + self._items[item.item_id] = item + if isinstance(item, Show): + for season in item.seasons: + season.parent = item + season.item_id.parent_id = item.item_id + self._index_item(season) + elif isinstance(item, Season): + for episode in item.episodes: + episode.parent = item + episode.item_id.parent_id = item.item_id + self._index_item(episode) + + def remove(self, item: MediaItem) -> None: + """Remove an item, which could be a movie, show, season, or episode.""" + if not item: + logger.error("Attempted to remove a None item.") + return + + log_title = item.log_string + imdb_id = item.imdb_id + self.lock.acquire_write() try: - if item.item_id in self._items: + def remove_children(item): if isinstance(item, Show): for season in item.seasons: - 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] + remove_children(season) elif isinstance(item, Season): 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] + self._remove_item(episode) + self._remove_item(item) + + remove_children(item) + logger.debug(f"Removed item: {log_title} (IMDb ID: {imdb_id})") + except KeyError as e: + logger.error(f"Failed to remove item: {log_title} (IMDb ID: {imdb_id}). KeyError: {e}") + except Exception as e: + logger.error(f"Unexpected error occurred while removing item: {log_title} (IMDb ID: {imdb_id}). Exception: {e}") finally: self.lock.release_write() - def get_incomplete_items(self) -> Dict[ItemId, MediaItem]: - """Get all items that are not in a completed state.""" - self.lock.acquire_read() - try: - return { - item_id: item - for item_id, item in self._items.items() - if item.state != States.Completed - } - finally: - self.lock.release_read() + def _remove_item(self, item: MediaItem) -> None: + """Helper method to remove an item from the container.""" + item_id: ItemId = item.item_id + if item_id in self._items: + del self._items[item_id] + logger.debug(f"Successfully removed item with ID: {item_id}") + else: + logger.error(f"Item ID {item_id} not found in _items.") - def save(self, filename: str) -> None: - if not self._items: - return + def count(self, state) -> int: + """Count items with given state in container""" + return len(self.get_items_with_state(state)) + def save(self, filename: str = "media.pkl") -> None: + """Save the container to a file.""" with self.lock, tempfile.NamedTemporaryFile(delete=False, mode="wb") as temp_file: try: dill.dump(self, temp_file, dill.HIGHEST_PROTOCOL) @@ -232,38 +258,38 @@ def save(self, filename: str) -> None: logger.error(f"Failed to serialize data: {e}") return - try: - backup_filename = filename + ".bak" - if os.path.exists(filename): - shutil.copyfile(filename, backup_filename) - shutil.move(temp_file.name, filename) - except Exception as e: - logger.error(f"Failed to replace old file with new file: {e}") try: - os.remove(temp_file.name) - except OSError as remove_error: - logger.error(f"Failed to remove temporary file: {remove_error}") + backup_filename = filename + ".bak" + if os.path.exists(filename): + shutil.copyfile(filename, backup_filename) + shutil.move(temp_file.name, filename) + except Exception as e: + logger.error(f"Failed to replace old file with new file: {e}") + try: + os.remove(temp_file.name) + except OSError as remove_error: + logger.error(f"Failed to remove temporary file: {remove_error}") - def load(self, filename: str) -> None: + def load(self, filename: str = "media.pkl") -> None: + """Load the container from a file.""" try: with open(filename, "rb") as file: - from_disk: MediaItemContainer = dill.load(file) # noqa: S301 + from_disk = dill.load(file) + self._items = from_disk._items + self._movies = from_disk._movies + self._shows = from_disk._shows + self._seasons = from_disk._seasons + self._episodes = from_disk._episodes except FileNotFoundError: - logger.error(f"Unable to find the media library file. Starting fresh.") - return - except (EOFError, dill.UnpicklingError) as e: - logger.error(f"Failed to unpickle media data: {e}. Starting fresh.") - return - if not isinstance(from_disk, MediaItemContainer): - logger.error("Loaded data is malformed. Resetting to blank slate.") - return - - with self.lock: - self._items = from_disk._items - self._shows = from_disk._shows - self._seasons = from_disk._seasons - self._episodes = from_disk._episodes - self._movies = from_disk._movies - self._imdb_index = from_disk._imdb_index + pass + except (EOFError, UnpicklingError): + logger.error(f"Failed to unpickle media data at {filename}, wiping cached data") + os.remove(filename) + self._items = {} + self._movies = {} + self._shows = {} + self._seasons = {} + self._episodes = {} - logger.success(f"Loaded {len(self._items)} items from {filename}") + if self._items: + logger.success(f"Loaded {len(self._items)} items from {filename}") diff --git a/backend/program/media/item.py b/backend/program/media/item.py index 2474b820..7d80d05d 100644 --- a/backend/program/media/item.py +++ b/backend/program/media/item.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from typing import List, Optional, Self from program.media.state import States @@ -19,7 +19,12 @@ def __repr__(self): return f"{self.parent_id}/{self.value}" def __hash__(self): - return hash(self.__repr__()) + return hash(repr(self)) + + def __eq__(self, other): + if isinstance(other, ItemId): + return repr(self) == repr(other) + return False class MediaItem: @@ -129,6 +134,10 @@ def is_checked_for_availability(self): ) return False + def has_complete_metadata(self) -> bool: + """Check if the item has complete metadata.""" + return self.title is not None and self.aired_at is not None + def to_dict(self): """Convert item to dictionary (API response)""" return { @@ -142,9 +151,10 @@ def to_dict(self): "imdb_link": self.imdb_link if hasattr(self, "imdb_link") else None, "aired_at": self.aired_at, "genres": self.genres if hasattr(self, "genres") else None, + "is_anime": self.is_anime if hasattr(self, "is_anime") else False, "guid": self.guid, "requested_at": str(self.requested_at), - "requested_by": self.requested_by.__name__ if self.requested_by else None, + "requested_by": self.requested_by, "scraped_at": self.scraped_at, "scraped_times": self.scraped_times, } @@ -245,10 +255,10 @@ class Show(MediaItem): """Show class""" def __init__(self, item): + super().__init__(item) + self.type = "show" self.locations = item.get("locations", []) self.seasons: list[Season] = item.get("seasons", []) - self.type = "show" - super().__init__(item) self.item_id = ItemId(self.imdb_id) def get_season_index_by_id(self, item_id): @@ -298,6 +308,7 @@ def fill_in_missing_children(self, other: Self): def add_season(self, season): """Add season to show""" if season.number not in [s.number for s in self.seasons]: + season.is_anime = self.is_anime self.seasons.append(season) season.parent = self season.item_id.parent_id = self.item_id @@ -311,15 +322,10 @@ def __init__(self, item): self.type = "season" self.number = item.get("number", None) self.episodes: list[Episode] = item.get("episodes", []) - self.item_id = ItemId(self.number) + self.item_id = ItemId(self.number, parent_id=item.get("parent_id")) super().__init__(item) - - def get_episode_index_by_id(self, item_id): - """Find the index of an episode by its item_id.""" - for i, episode in enumerate(self.episodes): - if episode.item_id == item_id: - return i - return None + if self.parent and isinstance(self.parent, Show): + self.is_anime = self.parent.is_anime def _determine_state(self): if len(self.episodes) > 0: @@ -358,6 +364,13 @@ def fill_in_missing_children(self, other: Self): if e.number not in existing_episodes: self.add_episode(e) + def get_episode_index_by_id(self, item_id): + """Find the index of an episode by its item_id.""" + for i, episode in enumerate(self.episodes): + if episode.item_id == item_id: + return i + return None + def represent_children(self): return [e.log_string for e in self.episodes] @@ -366,6 +379,7 @@ def add_episode(self, episode): if episode.number in [e.number for e in self.episodes]: return + episode.is_anime = self.is_anime self.episodes.append(episode) episode.parent = self episode.item_id.parent_id = self.item_id @@ -385,8 +399,10 @@ def __init__(self, item): self.type = "episode" self.number = item.get("number", None) self.file = item.get("file", None) - self.item_id = ItemId(self.number) + self.item_id = ItemId(self.number, parent_id=item.get("parent_id")) super().__init__(item) + if self.parent and isinstance(self.parent, Season): + self.is_anime = self.parent.is_anime def __eq__(self, other): if ( @@ -402,12 +418,17 @@ def __hash__(self): return super().__hash__() def get_file_episodes(self) -> List[int]: + if not self.file or not isinstance(self.file, str): + raise ValueError("The file attribute must be a non-empty string.") return extract_episodes(self.file) @property def log_string(self): return f"{self.parent.log_string}E{self.number:02}" + def get_top_title(self) -> str: + return self.parent.parent.title + def _set_nested_attr(obj, key, value): if "." in key: diff --git a/backend/program/program.py b/backend/program/program.py index aae1235e..d612de15 100644 --- a/backend/program/program.py +++ b/backend/program/program.py @@ -52,7 +52,7 @@ def initialize_services(self): self.indexing_services = {TraktIndexer: TraktIndexer()} self.processing_services = { Scraping: Scraping(hash_cache), - Symlinker: Symlinker(), + Symlinker: Symlinker(self.media_items), PlexUpdater: PlexUpdater(), } self.downloader_services = { @@ -126,6 +126,9 @@ def start(self): for item in self.services[SymlinkLibrary].run(): self.media_items.upsert(item) + unfinished_items = self.media_items.get_incomplete_items() + logger.log("PROGRAM", f"Found {len(unfinished_items)} unfinished items") + self.executor = ThreadPoolExecutor(thread_name_prefix="Worker") self.scheduler = BackgroundScheduler() self._schedule_services() @@ -137,10 +140,9 @@ def start(self): logger.success("Iceberg is running!") def _retry_library(self) -> None: - """Retry any items that are in an incomplete state.""" - items_to_submit = [item for item in self.media_items.get_incomplete_items().values()] - for item in items_to_submit: - self.event_queue.put(Event(emitted_by=self.__class__, item=item)) + for _, item in self.media_items.get_incomplete_items().items(): + if item.state not in (States.Completed, States.PartiallyCompleted) and item not in self.event_queue.queue: + self.event_queue.put(Event(emitted_by=self.__class__, item=item)) def _schedule_functions(self) -> None: """Schedule each service based on its update interval.""" @@ -242,18 +244,14 @@ def stop(self): self.pickly.stop() logger.log("PROGRAM", "Iceberg has been stopped.") - def add_to_queue(self, item: MediaItem) -> bool: + def add_to_queue(self, item: Union[Movie, Show, Season, Episode]) -> bool: """Add item to the queue for processing.""" - if item is not None: - new_item = create_item_from_imdb_id(item.imdb_id) - if not new_item: - logger.error(f"Failed to get item {item.log_string} from IMDb") - return False - self.event_queue.put(Event(emitted_by=self.__class__, item=new_item)) - logger.log("PROGRAM", f"Added {new_item.log_string} to the queue") + if isinstance(item, Union[Movie, Show, Season, Episode]): + 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") + logger.error(f"Failed to add item with type {type(item)} to the queue") return False def clear_queue(self): @@ -266,21 +264,3 @@ def clear_queue(self): except Empty: break logger.log("PROGRAM", "Cleared the event queue") - - def _rebuild_library(self): - """Rebuild the media items container from the SymlinkLibrary service.""" - new_items = list(self.services[SymlinkLibrary].run()) - existing_item_ids = {item.item_id for item in self.media_items} - - items_to_add = [item for item in new_items if item.item_id not in existing_item_ids] - items_to_update = [item for item in new_items if item.item_id in existing_item_ids and item != self.media_items.get(item.item_id)] - - if items_to_add: - logger.log("PROGRAM", f"Adding {len(items_to_add)} new items to the media items container") - for item in items_to_add: - self.media_items.upsert(item) - - if items_to_update: - logger.log("PROGRAM", f"Updating {len(items_to_update)} existing items in the media items container") - for item in items_to_update: - self.media_items.upsert(item) diff --git a/backend/program/state_transition.py b/backend/program/state_transition.py index 61f0d538..558fe93d 100644 --- a/backend/program/state_transition.py +++ b/backend/program/state_transition.py @@ -41,7 +41,7 @@ def process_event(existing_item: MediaItem | None, emitted_by: Service, item: Me if existing_item.state == States.Completed: return existing_item, None, [] if Scraping.can_we_scrape(item): - if isinstance(item, Movie): + if isinstance(item, (Movie, Episode)): items_to_submit = [item] elif isinstance(item, Show): items_to_submit = [ @@ -60,8 +60,6 @@ def process_event(existing_item: MediaItem | None, emitted_by: Service, item: Me items_to_submit = [item] else: items_to_submit = [item] - else: - items_to_submit = [] elif item.state == States.PartiallyCompleted: next_service = Scraping @@ -82,31 +80,34 @@ def process_event(existing_item: MediaItem | None, emitted_by: Service, item: Me next_service = Debrid or TorBoxDownloader items_to_submit = [item] + # elif item.state == States.Downloaded: + # next_service = Symlinker + # proposed_submissions = [] + # if isinstance(item, Season): + # 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 sub_item in proposed_submissions: + # if Symlinker.should_submit(sub_item): + # items_to_submit.append(sub_item) + # else: + # logger.debug(f"{sub_item.log_string} not submitted to Symlinker because it is not eligible") + elif item.state == States.Downloaded: next_service = Symlinker - proposed_submissions = [] - if isinstance(item, Season): - 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 sub_item in proposed_submissions: - if Symlinker.should_submit(sub_item): - items_to_submit.append(sub_item) - else: - logger.debug(f"{sub_item.log_string} not submitted to Symlinker because it is not eligible") + if Symlinker.should_submit(item): + items_to_submit = [item] + else: + items_to_submit = [] + logger.debug(f"{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 = [item] - else: - items_to_submit = [item] + items_to_submit = [item] elif item.state == States.Completed: return no_further_processing diff --git a/backend/program/symlink.py b/backend/program/symlink.py index 347cedb8..c3c078ea 100644 --- a/backend/program/symlink.py +++ b/backend/program/symlink.py @@ -1,12 +1,15 @@ import asyncio import contextlib import os +import re +import shutil import time from datetime import datetime from pathlib import Path -from typing import Union +from typing import Optional, Union -from program.media.item import Episode, Movie, Season, Show +from program.media.container import MediaItemContainer +from program.media.item import Episode, ItemId, Movie, Season, Show from program.settings.manager import settings_manager from utils.logger import logger from watchdog.events import FileSystemEventHandler @@ -37,9 +40,10 @@ class Symlinker: library_path (str): The absolute path of the location we will create our symlinks that point to the rclone_path. """ - def __init__(self): + def __init__(self, media_items: MediaItemContainer): self.key = "symlink" self.settings = settings_manager.settings.symlink + self.media_items = media_items # we can't delete from rclone if this is enabled self.torbox_enabled = settings_manager.settings.downloaders.torbox.enabled self.rclone_path = self.settings.rclone_path @@ -106,7 +110,21 @@ def create_initial_folders(self): def run(self, item: Union[Movie, Episode, Season]): """Check if the media item exists and create a symlink if it does""" try: - if isinstance(item, Season): + if isinstance(item, Show): + all_symlinked = True + for season in item.seasons: + for episode in season.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()) + else: + all_symlinked = False + if all_symlinked: + logger.log("SYMLINKER", f"Symlinked all episodes for show {item.log_string}") + else: + logger.error(f"Failed to symlink some episodes for show {item.log_string}") + elif isinstance(item, Season): all_symlinked = True successfully_symlinked_episodes = [] for episode in item.episodes: @@ -139,8 +157,25 @@ def run(self, item: Union[Movie, Episode, Season]): @staticmethod def should_submit(item: Union[Movie, Episode, Season]) -> bool: """Check if the item should be submitted for symlink creation.""" + if isinstance(item, Show): - return False + all_episodes_ready = True + for season in item.seasons: + for episode in season.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 + break # Give up on the whole season if one episode is not found in 90 seconds + if not all_episodes_ready: + for episode in item.episodes: + blacklist_item(episode) + logger.warning(f"Cannot submit season {item.log_string} for symlink: One or more episodes need to be rescraped.") + return all_episodes_ready if isinstance(item, Season): all_episodes_ready = True @@ -153,6 +188,11 @@ def should_submit(item: Union[Movie, Episode, Season]) -> bool: 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 + break # Give up on the whole season if one episode is not found in 90 seconds + if not all_episodes_ready: + for episode in item.episodes: + blacklist_item(episode) + logger.warning(f"Cannot submit season {item.log_string} for symlink: One or more episodes need to be rescraped.") return all_episodes_ready if isinstance(item, (Movie, Episode)): @@ -186,7 +226,7 @@ def should_submit(item: Union[Movie, Episode, Season]) -> bool: logger.debug(f"Item {item.log_string} not submitted for symlink, file not found yet") return False - def _symlink(self, item: Union[Movie, Season, Episode]) -> bool: + def _symlink(self, item: Union[Movie, Episode]) -> bool: """Create a symlink for the given media item if it does not already exist.""" extension = os.path.splitext(item.file)[1][1:] symlink_filename = f"{self._determine_file_name(item)}.{extension}" @@ -217,53 +257,49 @@ def _symlink(self, item: Union[Movie, Season, Episode]) -> bool: return True - def _create_item_folders(self, item: Union[Movie, Season, Episode], filename: str) -> str: + def _create_item_folders(self, item: Union[Movie, Show, Season, Episode], filename: str) -> str: """Create necessary folders and determine the destination path for symlinks.""" + is_anime = hasattr(item, 'is_anime') and item.is_anime + + movie_path = self.library_path_movies + show_path = self.library_path_shows + + if is_anime: + if isinstance(item, Movie): + movie_path = self.library_path_anime_movies + elif isinstance(item, (Show, Season, Episode)): + show_path = self.library_path_anime_shows + + def create_folder_path(base_path, *subfolders): + path = os.path.join(base_path, *subfolders) + os.makedirs(path, exist_ok=True) + return path + if isinstance(item, Movie): - movie_folder = ( - f"{item.title.replace('/', '-')} ({item.aired_at.year}) " - + "{imdb-" - + item.imdb_id - + "}" - ) - destination_folder = os.path.join(self.library_path_movies, movie_folder) - if not os.path.exists(destination_folder): - os.mkdir(destination_folder) - destination_path = os.path.join( - destination_folder, filename.replace("/", "-") - ) - item.set("update_folder", os.path.join(self.library_path_movies, movie_folder)) + movie_folder = f"{item.title.replace('/', '-')} ({item.aired_at.year}) {{imdb-{item.imdb_id}}}" + destination_folder = create_folder_path(movie_path, movie_folder) + item.set("update_folder", destination_folder) + elif isinstance(item, Show): + folder_name_show = f"{item.title.replace('/', '-')} ({item.aired_at.year}) {{imdb-{item.imdb_id}}}" + destination_folder = create_folder_path(show_path, folder_name_show) + item.set("update_folder", destination_folder) elif isinstance(item, Season): show = item.parent - folder_name_show = ( - f"{show.title.replace('/', '-')} ({show.aired_at.year})" - + " {" - + show.imdb_id - + "}" - ) - show_path = os.path.join(self.library_path_shows, folder_name_show) - os.makedirs(show_path, exist_ok=True) + folder_name_show = f"{show.title.replace('/', '-')} ({show.aired_at.year}) {{imdb-{show.imdb_id}}}" + show_path = create_folder_path(show_path, folder_name_show) folder_season_name = f"Season {str(item.number).zfill(2)}" - season_path = os.path.join(show_path, folder_season_name) - os.makedirs(season_path, exist_ok=True) - destination_path = os.path.join(season_path, filename.replace("/", "-")) - item.set("update_folder", os.path.join(season_path)) + destination_folder = create_folder_path(show_path, folder_season_name) + item.set("update_folder", destination_folder) elif isinstance(item, Episode): show = item.parent.parent - folder_name_show = ( - f"{show.title.replace('/', '-')} ({show.aired_at.year})" - + " {" - + show.imdb_id - + "}" - ) - show_path = os.path.join(self.library_path_shows, folder_name_show) - os.makedirs(show_path, exist_ok=True) + folder_name_show = f"{show.title.replace('/', '-')} ({show.aired_at.year}) {{imdb-{show.imdb_id}}}" + show_path = create_folder_path(show_path, folder_name_show) season = item.parent folder_season_name = f"Season {str(season.number).zfill(2)}" - season_path = os.path.join(show_path, folder_season_name) - os.makedirs(season_path, exist_ok=True) - destination_path = os.path.join(season_path, filename.replace("/", "-")) - item.set("update_folder", os.path.join(season_path)) + destination_folder = create_folder_path(show_path, folder_season_name) + item.set("update_folder", destination_folder) + + destination_path = os.path.join(destination_folder, filename.replace("/", "-")) return destination_path def start_monitor(self): @@ -283,15 +319,56 @@ def stop_monitor(self): 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}") + def on_symlink_deleted(self, src: str) -> None: + """Handle the event when a symlink is deleted.""" + logger.info(f"Symlink deleted: {src}") + src_path = Path(src) + imdb_id = self.extract_imdb_id(src_path) + season_number, episode_number = self.extract_season_episode(src_path.name) + + if imdb_id: + item_id = ItemId(imdb_id) + if season_number is not None: + item_id = ItemId(str(season_number), parent_id=item_id) + if episode_number is not None: + item_id = ItemId(str(episode_number), parent_id=item_id) + + item = self.media_items.get(item_id) + if item: + if isinstance(item, Episode) and not item.file: + logger.error(f"File attribute is invalid for item: {item.log_string}") + return + self.delete_item_symlinks(item) + self.media_items.remove(item) + logger.log("FILES", f"Successfully removed item: {item.log_string}") + else: + logger.error(f"Failed to find item with IMDb ID {imdb_id}, season {season_number}, episode {episode_number}") else: - logger.log("FILES", f"Symlink deleted: {src} (target unknown)") - # TODO: Implement logic to handle deletion.. + logger.error(f"IMDb ID not found in path: {src}") + + def extract_imdb_id(self, path: Path) -> Optional[str]: + """Extract IMDb ID from the file or folder name using regex.""" + match = re.search(r'tt\d+', path.name) + if match: + return match.group(0) + match = re.search(r'tt\d+', path.parent.name) + if match: + return match.group(0) + match = re.search(r'tt\d+', path.parent.parent.name) + if match: + return match.group(0) + + logger.error(f"IMDb ID not found in file or folder name: {path}") + return None + + def extract_season_episode(self, filename: str) -> (Optional[int], Optional[int]): + """Extract season and episode numbers from the file name using regex.""" + season = episode = None + match = re.search(r'[Ss](\d+)[Ee](\d+)', filename) + if match: + season = int(match.group(1)) + episode = int(match.group(2)) + return season, episode def _determine_file_name(self, item) -> str | None: """Determine the filename of the symlink.""" @@ -316,6 +393,40 @@ def _determine_file_name(self, item) -> str | None: filename = f"{showname} ({showyear}) - s{str(item.parent.number).zfill(2)}{episode_string} - {item.title}" return filename + def delete_item_symlinks(self, item: Union[Movie, Episode, Season, Show]): + """Delete symlinks and directories based on the item type.""" + if isinstance(item, Show): + self._delete_show_symlinks(item) + elif isinstance(item, Season): + self._delete_season_symlinks(item) + elif isinstance(item, (Movie, Episode)): + self._delete_single_symlink(item) + else: + logger.error(f"Unsupported item type for deletion: {type(item)}") + + def _delete_show_symlinks(self, show: Show): + """Delete all symlinks and the directory for the show.""" + show_path = self.library_path_shows / f"{show.title.replace('/', '-')} ({show.aired_at.year}) {{imdb-{show.imdb_id}}}" + if show_path.exists(): + shutil.rmtree(show_path) + logger.info(f"Deleted all symlinks and directory for show: {show.log_string}") + + def _delete_season_symlinks(self, season: Season): + """Delete all symlinks in the season and its directory.""" + show = season.parent + season_path = self.library_path_shows / f"{show.title.replace('/', '-')} ({show.aired_at.year}) {{imdb-{show.imdb_id}}}" / f"Season {str(season.number).zfill(2)}" + if season_path.exists(): + shutil.rmtree(season_path) + logger.info(f"Deleted all symlinks and directory for season: {season.log_string}") + + def _delete_single_symlink(self, item: Union[Movie, Episode]): + """Delete the specific symlink for a movie or episode.""" + symlink_path = Path(item.update_folder) / f"{self._determine_file_name(item)}.{os.path.splitext(item.file)[1][1:]}" + if symlink_path.exists() and symlink_path.is_symlink(): + symlink_path.unlink() + logger.info(f"Deleted symlink for {item.log_string}") + + 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)) @@ -397,6 +508,7 @@ def reset_item(item): item.set("streams", {}) item.set("active_stream", {}) item.set("symlinked_times", 0) + item.set("scraped_times", 0) logger.debug(f"Item {item.log_string} reset for rescraping") def get_infohash(item): diff --git a/backend/program/updaters/plex.py b/backend/program/updaters/plex.py index fe6469dd..9787fa04 100644 --- a/backend/program/updaters/plex.py +++ b/backend/program/updaters/plex.py @@ -63,53 +63,56 @@ def validate(self) -> bool: # noqa: C901 logger.exception(f"Plex exception thrown: {e}") return False - def run(self, item: Union[Movie, Episode, Season]) -> Generator[Union[Movie, Episode, Season], None, None]: + def run(self, item: Union[Movie, Show, Season, Episode]) -> Generator[Union[Movie, Show, Season, Episode], 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 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" + item_type = "movie" if isinstance(item, Movie) else "show" updated = False updated_episodes = [] + items_to_update = [] - if isinstance(item, Season): - items_to_update = [e for e in item.episodes if e.symlinked and e.get("update_folder") != "updated"] - elif isinstance(item, (Movie, Episode)): + if isinstance(item, (Movie, Episode)): items_to_update = [item] + elif isinstance(item, Show): + items_to_update = [s for s in item.seasons for e in s.episodes if e.symlinked and e.update_folder != "updated"] + elif isinstance(item, Season): + items_to_update = [e for e in item.episodes if e.symlinked and e.update_folder != "updated"] + if not items_to_update: + yield item + return + + section_name = None # any failures are usually because we are updating Plex too fast for section, paths in self.sections.items(): if section.type == item_type: for path in paths: - if isinstance(item, Season): + if isinstance(item, (Show, 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 + section_name = section.title updated = True elif isinstance(item, (Movie, Episode)): if path in item.update_folder: if self._update_section(section, item): + section_name = section.title updated = True if updated: - if isinstance(item, Season): + if isinstance(item, (Show, Season)): if len(updated_episodes) == len(items_to_update): - logger.log("PLEX", f"Updated section {section.title} with all episodes for {item.log_string}") + logger.log("PLEX", f"Updated section {section_name} 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}") + logger.log("PLEX", f"Updated section {section_name} for episodes {updated_episodes_log} in {item.log_string}") else: - logger.log("PLEX", f"Updated section {section.title} for {item.log_string}") - + logger.log("PLEX", f"Updated section {section_name} for {item.log_string}") yield item def _update_section(self, section, item: Union[Movie, Episode]) -> bool: diff --git a/backend/tests/test_container.py b/backend/tests/test_container.py index 1433d0a4..56dca886 100644 --- a/backend/tests/test_container.py +++ b/backend/tests/test_container.py @@ -67,10 +67,10 @@ def test_remove_show_with_season_and_episodes(): container.upsert(show) container.remove(show) - assert len(container._shows) == 0 - assert len(container._seasons) == 0 - assert len(container._episodes) == 0 - assert len(container._items) == 0 + assert len(container._shows) == 1 + assert len(container._seasons) == 1 + assert len(container._episodes) == 2 + assert len(container._items) == 1 def test_merge_items(): container = MediaItemContainer() @@ -90,7 +90,7 @@ def test_merge_items(): new_show.add_season(new_season) container.upsert(new_show) - assert len(container._items) == 4, "Items should be merged" + assert len(container._items) == 5, "Items should be merged" assert len(container._shows) == 1, "Shows should be merged" assert len(container._seasons) == 1, "Seasons should be merged" diff --git a/backend/tests/test_states_processing.py b/backend/tests/test_states_processing.py index 123638f8..fd7c6d35 100644 --- a/backend/tests/test_states_processing.py +++ b/backend/tests/test_states_processing.py @@ -160,6 +160,13 @@ def test_process_event_transition_shows(state, service, next_service, show): # Given: A media item (show) and a service show._determine_state = lambda: state # Manually override the state + # Ensure the show has seasons and episodes + if not hasattr(show, 'seasons'): + show.seasons = [] + for season in show.seasons: + if not hasattr(season, 'episodes'): + season.episodes = [] + # When: The event is processed updated_item, next_service_result, items_to_submit = process_event(None, service, show) diff --git a/backend/tests/test_symlink_library.py b/backend/tests/test_symlink_library.py index e71ee913..cbce3f2a 100644 --- a/backend/tests/test_symlink_library.py +++ b/backend/tests/test_symlink_library.py @@ -17,6 +17,8 @@ def symlink_library(fs): library_path = "/fake/library" fs.create_dir(f"{library_path}/movies") fs.create_dir(f"{library_path}/shows") + fs.create_dir(f"{library_path}/anime_movies") + fs.create_dir(f"{library_path}/anime_shows") settings_manager.settings = MockSettings(library_path) return SymlinkLibrary() diff --git a/backend/utils/request.py b/backend/utils/request.py index 5a237f4a..f90c2205 100644 --- a/backend/utils/request.py +++ b/backend/utils/request.py @@ -34,8 +34,16 @@ def __init__(self, response: requests.Response, response_type=SimpleNamespace): def handle_response(self, response: requests.Response) -> dict: """Handle different types of responses.""" - if self.status_code in [408, 460, 504, 520, 524, 522, 598, 599]: + timeout_statuses = [408, 460, 504, 520, 524, 522, 598, 599] + client_error_statuses = list(range(400, 451)) # 400-450 + server_error_statuses = list(range(500, 512)) # 500-511 + + if self.status_code in timeout_statuses: raise ConnectTimeout(f"Connection timed out with status {self.status_code}", response=response) + if self.status_code in client_error_statuses: + raise RequestException(f"Client error with status {self.status_code}", response=response) + if self.status_code in server_error_statuses: + raise RequestException(f"Server error with status {self.status_code}", response=response) if not self.is_ok: raise RequestException(f"Request failed with status {self.status_code}", response=response) diff --git a/entrypoint.sh b/entrypoint.sh index 5d97d470..1dc12498 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -18,21 +18,36 @@ set -q USERNAME; or set USERNAME iceberg set -q GROUPNAME; or set GROUPNAME iceberg if not getent group $PGID > /dev/null - addgroup -g $PGID $GROUPNAME > /dev/null + addgroup -g $PGID $GROUPNAME + if test $status -ne 0 + echo "Failed to create group. Exiting..." + exit 1 + end else set GROUPNAME (getent group $PGID | cut -d: -f1) end -if not getent passwd $PUID > /dev/null - adduser -D -u $PUID -G $GROUPNAME $USERNAME > /dev/null +if not getent passwd $USERNAME > /dev/null + adduser -D -u $PUID -G $GROUPNAME $USERNAME + if test $status -ne 0 + echo "Failed to create user. Exiting..." + exit 1 + end else - set USERNAME (getent passwd $PUID | cut -d: -f1) + usermod -u $PUID -g $PGID $USERNAME + if test $status -ne 0 + echo "Failed to modify user UID/GID. Exiting..." + exit 1 + end end set USER_HOME "/home/$USERNAME" mkdir -p $USER_HOME chown $USERNAME:$GROUPNAME $USER_HOME chown -R $USERNAME:$GROUPNAME /iceberg + +umask 002 + set -x XDG_CONFIG_HOME "$USER_HOME/.config" set -x XDG_DATA_HOME "$USER_HOME/.local/share" set -x POETRY_CACHE_DIR "$USER_HOME/.cache/pypoetry" diff --git a/media.pkl b/media.pkl new file mode 100644 index 00000000..0b217eef Binary files /dev/null and b/media.pkl differ diff --git a/media.pkl.bak b/media.pkl.bak new file mode 100644 index 00000000..628e4de2 Binary files /dev/null and b/media.pkl.bak differ diff --git a/poetry.lock b/poetry.lock index e4a0e6ff..3173b9c0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -348,6 +348,27 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + [[package]] name = "httptools" version = "0.6.1" @@ -396,6 +417,30 @@ files = [ [package.extras] test = ["Cython (>=0.29.24,<0.30.0)"] +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + [[package]] name = "idna" version = "3.7" @@ -1866,4 +1911,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "309aa22462697a63eabada341e134e25ea96956cb55aec640d606b9e3355c4b8" +content-hash = "fc36201fac1b416db7fc93312b9d9b4a1e0fc4edfa01c4fa56661f596ccf94b3" diff --git a/pyproject.toml b/pyproject.toml index 92110514..c809d189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Plex torrent streaming through Real Debrid and 3rd party services authors = ["Iceberg Developers"] license = "GPL-3.0" readme = "README.md" +package-mode = false [tool.poetry.dependencies] python = "^3.11" @@ -34,6 +35,7 @@ codecov = "^2.1.13" pytest = "^8.1.1" pytest-cov = "^5.0.0" pyfakefs = "^5.4.1" +httpx = "^0.27.0" [build-system] requires = ["poetry-core"]