diff --git a/.gitignore b/.gitignore index 6096d90b..3c994c93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ data/ logs/ settings.json -.vscode +.vscode/* +!.vscode/launch.json __pycache__ .git docker-compose.yml diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..7c69a6e7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Iceberg", + "type": "python", + "request": "launch", + "program": "backend/main.py", + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 48acfa88..acf66baf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ COPY --from=frontend --chown=node:node /app/node_modules /iceberg/frontend/node_ COPY --from=frontend --chown=node:node /app/package.json /iceberg/frontend/package.json # Backend +COPY VERSION /iceberg/VERSION COPY backend/ /iceberg/backend RUN python3 -m venv /venv COPY requirements.txt /iceberg/requirements.txt diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..c0a1ac19 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.4.6 \ No newline at end of file diff --git a/backend/controllers/default.py b/backend/controllers/default.py index 95e59871..4c539065 100644 --- a/backend/controllers/default.py +++ b/backend/controllers/default.py @@ -13,6 +13,7 @@ async def root(): return { "success": True, "message": "Iceburg is running!", + "version": settings_manager.settings.version } diff --git a/backend/program/__init__.py b/backend/program/__init__.py index 8f8e2d39..5c202381 100644 --- a/backend/program/__init__.py +++ b/backend/program/__init__.py @@ -32,12 +32,11 @@ def start(self): logger.info("Iceberg v%s starting!", settings_manager.settings.version) self.initialized = False self.media_items = MediaItemContainer(items=[]) - self.data_path = data_dir_path - os.makedirs(self.data_path, exist_ok=True) + os.makedirs(data_dir_path, exist_ok=True) if not self.startup_args.dev: - self.pickly = Pickly(self.media_items, self.data_path) + self.pickly = Pickly(self.media_items, data_dir_path) self.pickly.start() - self.core_manager = ServiceManager(self.media_items, True, Content, Plex, Scraping, Debrid, Symlinker) + self.core_manager = ServiceManager(self.media_items, True, Plex, Content, Scraping, Debrid, Symlinker) if self.validate(): logger.info("Iceberg started!") else: diff --git a/backend/program/content/base.py b/backend/program/content/base.py new file mode 100644 index 00000000..bb702382 --- /dev/null +++ b/backend/program/content/base.py @@ -0,0 +1,37 @@ +from program.media.container import MediaItemContainer +from program.updaters.trakt import Updater as Trakt + + +class ContentServiceBase: + """Base class for content providers""" + + def __init__(self, media_items: MediaItemContainer): + self.media_items = media_items + self.updater = Trakt() + self.not_found_ids = [] + self.next_run_time = 0 + + def validate(self): + """Validate the content provider settings.""" + raise NotImplementedError("The 'validate' method must be implemented by subclasses.") + + def run(self): + """Fetch new media from the content provider.""" + raise NotImplementedError("The 'run' method must be implemented by subclasses.") + + def process_items(self, items, requested_by): + """Process fetched media items and log the results.""" + new_items = [item for item in items if self.is_valid_item(item)] + if not new_items: + return + container = self.updater.create_items(new_items) + added_items = self.media_items.extend(container) + for item in added_items: + if hasattr(item, "set"): + item.set("requested_by", requested_by) + return added_items + + def is_valid_item(self, item) -> bool: + """Check if the item is valid for processing and not already in media_items""" + is_unique = item not in self.media_items + return item is not None and is_unique diff --git a/backend/program/content/listrr.py b/backend/program/content/listrr.py index c0884f47..096ab02e 100644 --- a/backend/program/content/listrr.py +++ b/backend/program/content/listrr.py @@ -1,14 +1,15 @@ """Listrr content module""" from time import time -from requests.exceptions import HTTPError -from program.settings.manager import settings_manager from utils.logger import logger from utils.request import get, ping +from requests.exceptions import HTTPError +from program.settings.manager import settings_manager from program.media.container import MediaItemContainer -from program.updaters.trakt import Updater as Trakt, get_imdbid_from_tmdb +from program.updaters.trakt import get_imdbid_from_tmdb +from program.content.base import ContentServiceBase -class Listrr: +class Listrr(ContentServiceBase): """Content class for Listrr""" def __init__(self, media_items: MediaItemContainer): @@ -19,10 +20,7 @@ def __init__(self, media_items: MediaItemContainer): self.initialized = self.validate() if not self.initialized: return - self.media_items = media_items - self.updater = Trakt() - self.not_found_ids = [] - self.next_run_time = 0 + super().__init__(media_items) logger.info("Listrr initialized!") def validate(self) -> bool: @@ -62,14 +60,10 @@ def run(self): self.next_run_time = time() + self.settings.update_interval movie_items = self._get_items_from_Listrr("Movies", self.settings.movie_lists) show_items = self._get_items_from_Listrr("Shows", self.settings.show_lists) - items = set(movie_items + show_items) - new_items = [item for item in items if item not in self.media_items and item is not None] - if not new_items: + items = movie_items.extend(show_items) + added_items = self.process_items(items, "Listrr") + if not added_items: return - container = self.updater.create_items(new_items) - for item in container: - item.set("requested_by", "Listrr") - added_items = self.media_items.extend(container) length = len(added_items) if length >= 1 and length <= 5: for item in added_items: @@ -77,9 +71,9 @@ def run(self): elif length > 5: logger.info("Added %s items", length) if self.not_found_ids: - logger.warn("Failed to process %s items, skipping.", len(self.not_found_ids)) + logger.debug("Failed to process %s items, skipping.", len(self.not_found_ids)) - def _get_items_from_Listrr(self, content_type, content_lists): + def _get_items_from_Listrr(self, content_type, content_lists) -> MediaItemContainer: """Fetch unique IMDb IDs from Listrr for a given type and list of content.""" unique_ids = set() if not content_lists: @@ -87,7 +81,7 @@ def _get_items_from_Listrr(self, content_type, content_lists): for list_id in content_lists: if not list_id or len(list_id) != 24: - continue # Skip invalid list IDs + continue page, total_pages = 1, 1 while page <= total_pages: diff --git a/backend/program/content/mdblist.py b/backend/program/content/mdblist.py index 291d0684..60fd9d96 100644 --- a/backend/program/content/mdblist.py +++ b/backend/program/content/mdblist.py @@ -1,13 +1,13 @@ """Mdblist content module""" from time import time -from program.settings.manager import settings_manager from utils.logger import logger -from utils.request import RateLimitExceeded, RateLimiter, get, ping +from program.settings.manager import settings_manager from program.media.container import MediaItemContainer -from program.updaters.trakt import Updater as Trakt +from utils.request import RateLimitExceeded, RateLimiter, get, ping +from program.content.base import ContentServiceBase -class Mdblist: +class Mdblist(ContentServiceBase): """Content class for mdblist""" def __init__(self, media_items: MediaItemContainer): @@ -16,11 +16,9 @@ def __init__(self, media_items: MediaItemContainer): self.initialized = self.validate() if not self.initialized: return - self.media_items = media_items - self.updater = Trakt() - self.next_run_time = 0 self.requests_per_2_minutes = self._calculate_request_time() self.rate_limiter = RateLimiter(self.requests_per_2_minutes, 120, True) + super().__init__(media_items) logger.info("mdblist initialized") def validate(self): @@ -47,17 +45,13 @@ def run(self): self.next_run_time = time() + self.settings.update_interval try: with self.rate_limiter: - items = [] + items = MediaItemContainer() for list_id in self.settings.lists: if list_id: - items += self._get_items_from_list( - list_id, self.settings.api_key - ) - new_items = [item for item in items if item not in self.media_items and item is not None] - container = self.updater.create_items(new_items) - for item in container: - item.set("requested_by", "Mdblist") - added_items = self.media_items.extend(container) + items.extend(self._get_items_from_list(list_id, self.settings.api_key)) + added_items = self.process_items(items, "Mdblist") + if not added_items: + return length = len(added_items) if length >= 1 and length <= 5: for item in added_items: diff --git a/backend/program/content/overseerr.py b/backend/program/content/overseerr.py index fb1c557b..b7b1e0a8 100644 --- a/backend/program/content/overseerr.py +++ b/backend/program/content/overseerr.py @@ -1,13 +1,14 @@ """Mdblist content module""" from time import time -from program.settings.manager import settings_manager from utils.logger import logger from utils.request import get, ping +from program.settings.manager import settings_manager from program.media.container import MediaItemContainer -from program.updaters.trakt import Updater as Trakt, get_imdbid_from_tmdb +from program.updaters.trakt import get_imdbid_from_tmdb +from program.content.base import ContentServiceBase -class Overseerr: +class Overseerr(ContentServiceBase): """Content class for overseerr""" def __init__(self, media_items: MediaItemContainer): @@ -17,10 +18,7 @@ def __init__(self, media_items: MediaItemContainer): self.initialized = self.validate() if not self.initialized: return - self.media_items = media_items - self.updater = Trakt() - self.not_found_ids = [] - self.next_run_time = 0 + super().__init__(media_items) logger.info("Overseerr initialized!") def validate(self) -> bool: @@ -53,13 +51,9 @@ def run(self): self.not_found_ids.clear() self.next_run_time = time() + self.settings.update_interval items = self._get_items_from_overseerr(10000) - new_items = [item for item in items if item not in self.media_items and item is not None] - if not new_items: + added_items = self.process_items(items, "Overseerr") + if not added_items: return - container = self.updater.create_items(new_items) - for item in container: - item.set("requested_by", "Overseerr") - added_items = self.media_items.extend(container) length = len(added_items) if length >= 1 and length <= 5: for item in added_items: @@ -67,9 +61,9 @@ def run(self): elif length > 5: logger.info("Added %s items", length) if self.not_found_ids: - logger.warn("Failed to process %s items, skipping.", len(self.not_found_ids)) + logger.debug("Failed to process %s items, skipping.", len(self.not_found_ids)) - def _get_items_from_overseerr(self, amount: int) -> list[str]: + def _get_items_from_overseerr(self, amount: int) -> MediaItemContainer: """Fetch media from overseerr""" response = get( self.settings.url + f"/api/v1/request?take={amount}", diff --git a/backend/program/content/plex_watchlist.py b/backend/program/content/plex_watchlist.py index 28a01589..001aec36 100644 --- a/backend/program/content/plex_watchlist.py +++ b/backend/program/content/plex_watchlist.py @@ -5,10 +5,10 @@ from utils.logger import logger from program.settings.manager import settings_manager from program.media.container import MediaItemContainer -from program.updaters.trakt import Updater as Trakt +from program.content.base import ContentServiceBase -class PlexWatchlist: +class PlexWatchlist(ContentServiceBase): """Class for managing Plex Watchlists""" def __init__(self, media_items: MediaItemContainer): @@ -19,10 +19,7 @@ def __init__(self, media_items: MediaItemContainer): if not self.initialized: return self.token = settings_manager.settings.plex.token - self.media_items = media_items - self.updater = Trakt() - self.not_found_ids = [] - self.next_run_time = 0 + super().__init__(media_items) logger.info("Plex Watchlist initialized!") def validate(self): @@ -53,13 +50,9 @@ def run(self): self.not_found_ids.clear() self.next_run_time = time() + self.settings.update_interval items = self._create_unique_list() - new_items = [item for item in items if item not in self.media_items and item is not None] - if not new_items: + added_items = self.process_items(items, "Plex Watchlist") + if not added_items: return - container = self.updater.create_items(new_items) - for item in container: - item.set("requested_by", "Plex Watchlist") - added_items = self.media_items.extend(container) length = len(added_items) if length >= 1 and length <= 5: for item in added_items: @@ -67,9 +60,9 @@ def run(self): elif length > 5: logger.info("Added %s items", length) if self.not_found_ids: - logger.warn("Failed to process %s items, skipping.", len(self.not_found_ids)) - - def _create_unique_list(self): + logger.debug("Failed to process %s items, skipping.", len(self.not_found_ids)) + + def _create_unique_list(self) -> MediaItemContainer: """Create a unique list of items from Plex RSS and Watchlist.""" if not self.rss_enabled: return self._get_items_from_watchlist() diff --git a/backend/program/settings/manager.py b/backend/program/settings/manager.py index 5dd31c5d..9b6a865d 100644 --- a/backend/program/settings/manager.py +++ b/backend/program/settings/manager.py @@ -1,12 +1,8 @@ import json import os -import shutil -from pathlib import Path - from pydantic import ValidationError - -from utils import data_dir_path from program.settings.models import AppModel, NotifyingBaseModel +from utils import data_dir_path from utils.logger import logger from utils.observable import Observable @@ -21,7 +17,7 @@ def __init__(self): NotifyingBaseModel.set_notify_observers(self.notify_observers) - if not os.path.exists(self.settings_file): + if not self.settings_file.exists(): self.settings = AppModel() self.notify_observers() else: diff --git a/backend/program/settings/models.py b/backend/program/settings/models.py index b40aa177..ed5cb0bc 100644 --- a/backend/program/settings/models.py +++ b/backend/program/settings/models.py @@ -1,6 +1,7 @@ """Iceberg settings models""" from pathlib import Path from pydantic import BaseModel +from utils import version_file_path class NotifyingBaseModel(BaseModel): @@ -96,8 +97,12 @@ class ParserModel(NotifyingBaseModel): # Application Settings +def get_version() -> str: + with open(version_file_path.resolve()) as file: + return file.read() + class AppModel(NotifyingBaseModel): - version: str = "0.4.6" + version: str = get_version() debug: bool = True log: bool = True plex: PlexModel = PlexModel() diff --git a/backend/program/updaters/trakt.py b/backend/program/updaters/trakt.py index 9f4cbb5f..e3bbe34c 100644 --- a/backend/program/updaters/trakt.py +++ b/backend/program/updaters/trakt.py @@ -164,7 +164,7 @@ def create_item_from_imdb_id(imdb_id: str): except UnboundLocalError: logger.error("Unknown item %s with response %s", imdb_id, response) return None - logger.error("Unable to create item from IMDb ID %s", imdb_id) + logger.error("Unable to create item from IMDb ID %s, skipping..", imdb_id) return None def get_imdbid_from_tvdb(tvdb_id: str) -> str: diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py index 2e53478f..212c535b 100644 --- a/backend/utils/__init__.py +++ b/backend/utils/__init__.py @@ -1,4 +1,7 @@ from pathlib import Path -import os -data_dir_path = Path(os.path.abspath(__file__)).parent.parent.parent / "data" \ No newline at end of file + +root_dir = Path(__file__).resolve().parents[2] + +data_dir_path = root_dir / "data" +version_file_path = root_dir / "VERSION" diff --git a/backend/utils/logger.py b/backend/utils/logger.py index 9cf77ee3..41a25644 100644 --- a/backend/utils/logger.py +++ b/backend/utils/logger.py @@ -3,7 +3,6 @@ import logging import os import re -import sys from utils import data_dir_path