From 7b0aa01be1a7393c2a086c84635cfdb10d196de5 Mon Sep 17 00:00:00 2001 From: Hank Bond <3474285+omnunum@users.noreply.github.com> Date: Mon, 5 Feb 2024 22:44:28 -0500 Subject: [PATCH] Use Pydantic for settings validation data model (#204) * Use Pydantic data models for settings * Rename Scraping to Scraper to be consistent across platform * Revert "Rename Scraping to Scraper to be consistent across platform" This reverts commit db29f42d608dce33659b5b72805b1275dbfb3d54. * fix data model * Fix data model and references * Add url field to torrentio model * Correct docstring --- backend/controllers/default.py | 4 +- backend/controllers/settings.py | 56 +++++++++-- backend/program/__init__.py | 16 ++-- backend/program/content/listrr.py | 19 ++-- backend/program/content/mdblist.py | 11 +-- backend/program/content/overseerr.py | 13 +-- backend/program/content/plex_watchlist.py | 15 ++- backend/program/content/trakt.py | 14 +-- backend/program/plex.py | 18 ++-- backend/program/realdebrid.py | 9 +- backend/program/scrapers/__init__.py | 17 ++-- backend/program/scrapers/jackett.py | 13 +-- backend/program/scrapers/orionoid.py | 12 +-- backend/program/scrapers/torrentio.py | 13 +-- backend/program/settings/__init__.py | 0 backend/program/settings/manager.py | 62 +++++++++++++ backend/program/settings/models.py | 107 ++++++++++++++++++++++ backend/program/symlink.py | 23 ++--- backend/program/updaters/trakt.py | 5 +- backend/utils/__init__.py | 4 + backend/utils/default_settings.json | 66 ------------- backend/utils/logger.py | 60 ++++++------ backend/utils/parser.py | 4 +- backend/utils/service_manager.py | 8 +- backend/utils/settings.py | 87 ------------------ 25 files changed, 326 insertions(+), 330 deletions(-) create mode 100644 backend/program/settings/__init__.py create mode 100644 backend/program/settings/manager.py create mode 100644 backend/program/settings/models.py delete mode 100644 backend/utils/default_settings.json delete mode 100644 backend/utils/settings.py diff --git a/backend/controllers/default.py b/backend/controllers/default.py index 34e86682..95e59871 100644 --- a/backend/controllers/default.py +++ b/backend/controllers/default.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Request import requests -from utils.settings import settings_manager +from program.settings.manager import settings_manager router = APIRouter( @@ -26,7 +26,7 @@ async def health(request: Request): @router.get("/user") async def get_rd_user(): - api_key = settings_manager.get("real_debrid.api_key") + api_key = settings_manager.settings.real_debrid.api_key headers = {"Authorization": f"Bearer {api_key}"} response = requests.get( "https://api.real-debrid.com/rest/1.0/user", headers=headers diff --git a/backend/controllers/settings.py b/backend/controllers/settings.py index 7c1a7327..75b84790 100644 --- a/backend/controllers/settings.py +++ b/backend/controllers/settings.py @@ -1,6 +1,6 @@ from copy import copy -from fastapi import APIRouter -from utils.settings import settings_manager +from fastapi import APIRouter, HTTPException +from program.settings.manager import settings_manager from pydantic import BaseModel from typing import Any, List @@ -39,14 +39,25 @@ async def save_settings(): async def get_all_settings(): return { "success": True, - "data": copy(settings_manager.get_all()), + "data": copy(settings_manager.settings), } -@router.get("/get/{keys}") -async def get_settings(keys: str): - keys = keys.split(",") - data = {key: settings_manager.get(key) for key in keys} +@router.get("/get/{paths}") +async def get_settings(paths: str): + current_settings = settings_manager.settings.dict() + data = {} + for path in paths.split(","): + keys = path.split('.') + current_obj = current_settings + + for k in keys: + if k not in current_obj: + return None + current_obj = current_obj[k] + + data[path] = current_obj + return { "success": True, "data": data, @@ -55,8 +66,33 @@ async def get_settings(keys: str): @router.post("/set") async def set_settings(settings: List[SetSettings]): - settings_manager.set(settings) + current_settings = settings_manager.settings.dict() + + for setting in settings: + keys = setting.key.split('.') + current_obj = current_settings + + # Navigate to the last key's parent object, similar to the getter. + for k in keys[:-1]: + if k not in current_obj: + # If a key in the path does not exist, raise an exception or optionally create a new dict. + raise HTTPException(status_code=400, detail=f"Path '{'.'.join(keys[:-1])}' does not exist.") + current_obj = current_obj[k] + + # Set the value at the final key. + if keys[-1] in current_obj: + current_obj[keys[-1]] = setting.value + else: + # If the final key does not exist, raise an exception. + raise HTTPException(status_code=400, detail=f"Key '{keys[-1]}' does not exist in path '{'.'.join(keys[:-1])}'.") + + + settings_manager.load(settings_dict=current_settings) + + # Notify observers about the update. + settings_manager.notify_observers() + return { "success": True, - "message": "Settings saved!", - } + "message": "Settings updated successfully." + } \ No newline at end of file diff --git a/backend/program/__init__.py b/backend/program/__init__.py index 0c906bc9..7a2d5691 100644 --- a/backend/program/__init__.py +++ b/backend/program/__init__.py @@ -7,11 +7,12 @@ from program.realdebrid import Debrid from program.symlink import Symlinker from program.media.container import MediaItemContainer -from utils.logger import logger, get_data_path +from utils.logger import logger from program.plex import Plex from program.content import Content from utils.utils import Pickly -from utils.settings import settings_manager as settings +from utils import data_dir_path +from program.settings.manager import settings_manager from utils.service_manager import ServiceManager @@ -22,14 +23,17 @@ def __init__(self, args): super().__init__(name="Iceberg") self.running = False self.startup_args = args + logger.configure_logger( + debug=settings_manager.settings.debug, + log=settings_manager.settings.log + ) def start(self): - logger.info("Iceberg v%s starting!", settings.get("version")) + logger.info("Iceberg v%s starting!", settings_manager.settings.version) self.initialized = False self.media_items = MediaItemContainer(items=[]) - self.data_path = get_data_path() - if not os.path.exists(self.data_path): - os.mkdir(self.data_path) + self.data_path = data_dir_path + os.makedirs(self.data_path, exist_ok=True) if not self.startup_args.dev: self.pickly = Pickly(self.media_items, self.data_path) self.pickly.start() diff --git a/backend/program/content/listrr.py b/backend/program/content/listrr.py index 6104c128..94890b1d 100644 --- a/backend/program/content/listrr.py +++ b/backend/program/content/listrr.py @@ -1,30 +1,23 @@ -"""Mdblist content module""" +"""Listrr content module""" from time import time from typing import Optional -from pydantic import BaseModel -from utils.settings import settings_manager + +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.media.container import MediaItemContainer from program.updaters.trakt import Updater as Trakt, get_imdbid_from_tmdb, get_imdbid_from_tvdb -class ListrrConfig(BaseModel): - enabled: bool - movie_lists: Optional[list] - show_lists: Optional[list] - api_key: Optional[str] - update_interval: int # in seconds - - class Listrr: """Content class for Listrr""" def __init__(self, media_items: MediaItemContainer): self.key = "listrr" self.url = "https://listrr.pro/api" - self.settings = ListrrConfig(**settings_manager.get(f"content.{self.key}")) + self.settings = settings_manager.settings.content.listrr self.headers = {"X-Api-Key": self.settings.api_key} self.initialized = self.validate_settings() if not self.initialized: diff --git a/backend/program/content/mdblist.py b/backend/program/content/mdblist.py index d6896429..4112e7b6 100644 --- a/backend/program/content/mdblist.py +++ b/backend/program/content/mdblist.py @@ -1,24 +1,19 @@ """Mdblist content module""" from typing import Optional -from pydantic import BaseModel -from utils.settings import settings_manager + +from program.settings.manager import settings_manager from utils.logger import logger from utils.request import RateLimitExceeded, RateLimiter, get, ping from program.media.container import MediaItemContainer from program.updaters.trakt import Updater as Trakt -class MdblistConfig(BaseModel): - enabled: bool - api_key: Optional[str] - lists: Optional[list] - class Mdblist: """Content class for mdblist""" def __init__(self, media_items: MediaItemContainer): self.key = "mdblist" - self.settings = MdblistConfig(**settings_manager.get(f"content.{self.key}")) + self.settings = settings_manager.settings.content.mdblist self.initialized = self.validate_settings() if not self.initialized: return diff --git a/backend/program/content/overseerr.py b/backend/program/content/overseerr.py index 4ba3924e..bfc7e40f 100644 --- a/backend/program/content/overseerr.py +++ b/backend/program/content/overseerr.py @@ -1,25 +1,20 @@ """Mdblist content module""" from typing import Optional -from pydantic import BaseModel -from utils.settings import settings_manager + + +from program.settings.manager import settings_manager from utils.logger import logger from utils.request import get, ping from program.media.container import MediaItemContainer from program.updaters.trakt import Updater as Trakt, get_imdbid_from_tmdb, get_imdbid_from_tvdb -class OverseerrConfig(BaseModel): - enabled: bool - url: Optional[str] - api_key: Optional[str] - - class Overseerr: """Content class for overseerr""" def __init__(self, media_items: MediaItemContainer): self.key = "overseerr" - self.settings = OverseerrConfig(**settings_manager.get(f"content.{self.key}")) + self.settings = settings_manager.settings.content.overseerr self.headers = {"X-Api-Key": self.settings.api_key} self.initialized = self.validate_settings() if not self.initialized: diff --git a/backend/program/content/plex_watchlist.py b/backend/program/content/plex_watchlist.py index 81134f4b..abb3188e 100644 --- a/backend/program/content/plex_watchlist.py +++ b/backend/program/content/plex_watchlist.py @@ -1,18 +1,15 @@ """Plex Watchlist Module""" +import json from typing import Optional -from pydantic import BaseModel + from requests import ConnectTimeout, HTTPError + from utils.request import get, ping from utils.logger import logger -from utils.settings import settings_manager +from program.settings.manager import settings_manager from program.media.container import MediaItemContainer from program.updaters.trakt import Updater as Trakt -import json - -class PlexWatchlistConfig(BaseModel): - enabled: bool - rss: Optional[str] class PlexWatchlist: @@ -21,11 +18,11 @@ class PlexWatchlist: def __init__(self, media_items: MediaItemContainer): self.key = "plex_watchlist" self.rss_enabled = False - self.settings = PlexWatchlistConfig(**settings_manager.get(f"content.{self.key}")) + self.settings = settings_manager.settings.content.plex_watchlist self.initialized = self.validate_settings() if not self.initialized: return - self.token = settings_manager.get("plex.token") + self.token = settings_manager.settings.plex.token self.media_items = media_items self.prev_count = 0 self.updater = Trakt() diff --git a/backend/program/content/trakt.py b/backend/program/content/trakt.py index 86d057cd..cb36bff8 100644 --- a/backend/program/content/trakt.py +++ b/backend/program/content/trakt.py @@ -1,22 +1,14 @@ """Mdblist content module""" from time import time from typing import Optional -from pydantic import BaseModel -from utils.settings import settings_manager + +from program.settings.manager import settings_manager from utils.logger import logger from utils.request import get, ping from program.media.container import MediaItemContainer from program.updaters.trakt import Updater as Trakt, CLIENT_ID -class TraktConfig(BaseModel): - enabled: bool - watchlist: Optional[list] - collection: Optional[list] - user_lists: Optional[list] - api_key: Optional[str] - update_interval: int # in seconds - class Trakt: """Content class for Trakt""" @@ -24,7 +16,7 @@ class Trakt: def __init__(self, media_items: MediaItemContainer): self.key = "trakt" self.url = None - self.settings = TraktConfig(**settings_manager.get(f"content.{self.key}")) + self.settings = settings_manager.settings.content.trakt self.headers = {"X-Api-Key": self.settings.api_key} self.initialized = self.validate_settings() if not self.initialized: diff --git a/backend/program/plex.py b/backend/program/plex.py index 496beb9f..127120fc 100644 --- a/backend/program/plex.py +++ b/backend/program/plex.py @@ -6,12 +6,12 @@ import uuid from datetime import datetime from typing import Optional + from plexapi.server import PlexServer from plexapi.exceptions import BadRequest, Unauthorized -from pydantic import BaseModel -# from program.updaters.trakt import get_imdbid_from_tvdb + from utils.logger import logger -from utils.settings import settings_manager as settings +from program.settings.manager import settings_manager from program.media.container import MediaItemContainer from program.media.state import Symlink, Library from utils.request import get, post @@ -23,12 +23,6 @@ ) -class PlexConfig(BaseModel): - user: Optional[str] = None - token: Optional[str] = None - url: Optional[str] = None - - class Plex(threading.Thread): """Plex library class""" @@ -37,12 +31,12 @@ def __init__(self, media_items: MediaItemContainer): self.key = "plex" self.initialized = False self.library_path = os.path.abspath( - os.path.dirname(settings.get("symlink.container_path")) + os.path.dirname(settings_manager.settings.symlink.container_path) ) self.last_fetch_times = {} try: - self.settings = PlexConfig(**settings.get(self.key)) + self.settings = settings_manager.settings.plex self.plex = PlexServer( self.settings.url, self.settings.token, timeout=60 ) @@ -185,7 +179,7 @@ def _oauth(self): additional_headers={ "X-Plex-Product": "Iceberg", "X-Plex-Client-Identifier": random_uuid, - "X-Plex-Token": settings.get("plex.token"), + "X-Plex-Token": settings_manager.settings.plex.token, }, ) if not response.ok: diff --git a/backend/program/realdebrid.py b/backend/program/realdebrid.py index 163cb7f1..ea18f3ed 100644 --- a/backend/program/realdebrid.py +++ b/backend/program/realdebrid.py @@ -3,11 +3,10 @@ from pathlib import Path import time from typing import Optional -from pydantic import BaseModel from requests import ConnectTimeout from utils.logger import logger from utils.request import get, post, ping -from utils.settings import settings_manager +from program.settings.manager import settings_manager from utils.parser import parser @@ -15,10 +14,6 @@ RD_BASE_URL = "https://api.real-debrid.com/rest/1.0" -class DebridConfig(BaseModel): - api_key: Optional[str] - - class Debrid: """Real-Debrid API Wrapper""" @@ -26,7 +21,7 @@ def __init__(self, _): # Realdebrid class library is a necessity self.initialized = False self.key = "real_debrid" - self.settings = DebridConfig(**settings_manager.get(self.key)) + self.settings = settings_manager.settings.real_debrid self.auth_headers = {"Authorization": f"Bearer {self.settings.api_key}"} self.running = False if not self._validate_settings(): diff --git a/backend/program/scrapers/__init__.py b/backend/program/scrapers/__init__.py index b9fb3f42..e8b90433 100644 --- a/backend/program/scrapers/__init__.py +++ b/backend/program/scrapers/__init__.py @@ -1,24 +1,19 @@ from datetime import datetime -from pydantic import BaseModel + from utils.service_manager import ServiceManager -from utils.settings import settings_manager as settings -# from utils.parser import parser, sort_streams +from program.settings.manager import settings_manager from utils.logger import logger -from .torrentio import Torrentio -from .orionoid import Orionoid -from .jackett import Jackett +from program.scrapers.torrentio import Torrentio +from program.scrapers.orionoid import Orionoid +from program.scrapers.jackett import Jackett -class ScrapingConfig(BaseModel): - after_2: float - after_5: float - after_10: float class Scraping: def __init__(self, _): self.key = "scraping" self.initialized = False - self.settings = ScrapingConfig(**settings.get(self.key)) + self.settings = settings_manager.settings.scraping self.sm = ServiceManager(None, False, Orionoid, Torrentio, Jackett) if not any(service.initialized for service in self.sm.services): logger.error( diff --git a/backend/program/scrapers/jackett.py b/backend/program/scrapers/jackett.py index 2ce2f6ad..bcd2c145 100644 --- a/backend/program/scrapers/jackett.py +++ b/backend/program/scrapers/jackett.py @@ -1,27 +1,22 @@ """ Jackett scraper module """ import traceback from typing import Optional -from pydantic import BaseModel + from requests import ReadTimeout, RequestException + from utils.logger import logger -from utils.settings import settings_manager +from program.settings.manager import settings_manager from utils.parser import parser from utils.request import RateLimitExceeded, get, RateLimiter, ping -class JackettConfig(BaseModel): - enabled: bool - url: Optional[str] - api_key: Optional[str] - - class Jackett: """Scraper for `Jackett`""" def __init__(self, _): self.key = "jackett" self.api_key = None - self.settings = JackettConfig(**settings_manager.get(f"scraping.{self.key}")) + self.settings = settings_manager.settings.scraping.jackett self.initialized = self.validate_settings() if not self.initialized and not self.api_key: return diff --git a/backend/program/scrapers/orionoid.py b/backend/program/scrapers/orionoid.py index 41665246..5c2991ec 100644 --- a/backend/program/scrapers/orionoid.py +++ b/backend/program/scrapers/orionoid.py @@ -1,27 +1,23 @@ """ Orionoid scraper module """ from typing import Optional -from pydantic import BaseModel + from requests import ConnectTimeout from requests.exceptions import RequestException + from utils.logger import logger from utils.request import RateLimitExceeded, RateLimiter, get -from utils.settings import settings_manager +from program.settings.manager import settings_manager from utils.parser import parser KEY_APP = "D3CH6HMX9KD9EMD68RXRCDUNBDJV5HRR" -class OrionoidConfig(BaseModel): - enabled: bool - api_key: Optional[str] - - class Orionoid: """Scraper for `Orionoid`""" def __init__(self, _): self.key = "orionoid" - self.settings = OrionoidConfig(**settings_manager.get(f"scraping.{self.key}")) + self.settings = settings_manager.settings.scraping.orionoid self.is_premium = False self.initialized = False if self.validate_settings(): diff --git a/backend/program/scrapers/torrentio.py b/backend/program/scrapers/torrentio.py index a47308f2..de3c35f7 100644 --- a/backend/program/scrapers/torrentio.py +++ b/backend/program/scrapers/torrentio.py @@ -1,26 +1,21 @@ """ Torrentio scraper module """ from typing import Optional -from pydantic import BaseModel + from requests import ConnectTimeout, ReadTimeout from requests.exceptions import RequestException + from utils.logger import logger from utils.request import RateLimitExceeded, get, RateLimiter, ping -from utils.settings import settings_manager +from program.settings.manager import settings_manager from utils.parser import parser -class TorrentioConfig(BaseModel): - enabled: bool - url: Optional[str] - filter: Optional[str] - - class Torrentio: """Scraper for `Torrentio`""" def __init__(self, _): self.key = "torrentio" - self.settings = TorrentioConfig(**settings_manager.get(f"scraping.{self.key}")) + self.settings = settings_manager.settings.scraping.torrentio self.minute_limiter = RateLimiter(max_calls=300, period=3600, raise_on_limit=True) self.second_limiter = RateLimiter(max_calls=1, period=5) self.initialized = self.validate_settings() diff --git a/backend/program/settings/__init__.py b/backend/program/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/program/settings/manager.py b/backend/program/settings/manager.py new file mode 100644 index 00000000..5dd31c5d --- /dev/null +++ b/backend/program/settings/manager.py @@ -0,0 +1,62 @@ +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.logger import logger +from utils.observable import Observable + + +class SettingsManager(Observable): + """Class that handles settings, ensuring they are validated against a Pydantic schema.""" + + def __init__(self): + self.observers = [] + self.filename = "settings.json" + self.settings_file = data_dir_path / self.filename + + NotifyingBaseModel.set_notify_observers(self.notify_observers) + + if not os.path.exists(self.settings_file): + self.settings = AppModel() + self.notify_observers() + else: + self.load() + + def register_observer(self, observer): + self.observers.append(observer) + + def notify_observers(self): + for observer in self.observers: + observer.notify() + + def load(self, settings_dict: dict = None): + """Load settings from file, validating against the AppModel schema.""" + try: + if not settings_dict: + with open(self.settings_file, "r", encoding="utf-8") as file: + settings_dict = json.loads(file.read()) + self.settings = AppModel.model_validate(settings_dict) + except ValidationError as e: + logger.error(f"Error loading settings: {e}, initializing with default settings") + raise + except json.JSONDecodeError as e: + logger.error(f"Error parsing settings file: {e}, initializing with default settings") + raise + except FileNotFoundError as e: + logger.error(f"Error loading settings: {self.settings_file} does not exist") + raise + self.notify_observers() + + def save(self): + """Save settings to file, using Pydantic model for JSON serialization.""" + with open(self.settings_file, "w", encoding="utf-8") as file: + file.write(self.settings.model_dump_json(indent=4)) + + + +settings_manager = SettingsManager() \ No newline at end of file diff --git a/backend/program/settings/models.py b/backend/program/settings/models.py new file mode 100644 index 00000000..ecaf7670 --- /dev/null +++ b/backend/program/settings/models.py @@ -0,0 +1,107 @@ +"""Iceberg settings models""" +from pathlib import Path +from typing import Optional + +from pydantic import BaseModel, root_validator + + + +class NotifyingBaseModel(BaseModel): + class Config: + arbitrary_types_allowed = True + + # Assuming _notify_observers is a static method or class-level attribute + _notify_observers = None + + # This method sets the change notifier on the class, not an instance + @classmethod + def set_notify_observers(cls, notify_observers_callable): + cls._notify_observers = notify_observers_callable + + def __setattr__(self, name, value): + super().__setattr__(name, value) + if self.__class__._notify_observers: + self.__class__._notify_observers() + +class PlexModel(NotifyingBaseModel): + user: str = "" + token: str = "" + url: str = "http://localhost:32400" + +class DebridModel(NotifyingBaseModel): + api_key: str = "" + +class SymlinkModel(NotifyingBaseModel): + host_path: Path = Path() + container_path: Path = Path() + +# Content Services +class ContentNotifyingBaseModel(NotifyingBaseModel): + update_interval: int = 80 + +class ListrrModel(ContentNotifyingBaseModel): + enabled: bool = False + movie_lists: list[str] = [""] + show_lists: list[str] = [""] + api_key: str = "" + +class MdblistModel(ContentNotifyingBaseModel): + enabled: bool = False + api_key: str = "" + lists: list[str] = [""] + +class OverseerrModel(ContentNotifyingBaseModel): + enabled: bool = False + url: str = "http://localhost:5055" + api_key: str = "" + +class PlexWatchlistModel(ContentNotifyingBaseModel): + enabled: bool = False + rss: str = "" + +class ContentModel(NotifyingBaseModel): + listrr: ListrrModel = ListrrModel() + mdblist: MdblistModel = MdblistModel() + overseerr: OverseerrModel = OverseerrModel() + plex_watchlist: PlexWatchlistModel = PlexWatchlistModel() + +# Scraper Services + +class JackettConfig(NotifyingBaseModel): + enabled: bool = False + url: str = "http://localhost:9117" + +class OrionoidConfig(NotifyingBaseModel): + enabled: bool = False + api_key: str = "" + +class TorrentioConfig(NotifyingBaseModel): + enabled: bool = False + filter: str = "sort=qualitysize%7Cqualityfilter=480p,scr,cam,unknown" + url: str = "https://torrentio.strem.fun" + +class ScraperModel(NotifyingBaseModel): + after_2: float = 0.5 + after_5: int = 2 + after_10: int = 24 + jackett: JackettConfig = JackettConfig() + orionoid: OrionoidConfig = OrionoidConfig() + torrentio: TorrentioConfig = TorrentioConfig() + +class ParserModel(NotifyingBaseModel): + highest_quality: bool = False + include_4k: bool = False + repack_proper: bool = True + language: list[str] = ["English"] + + +class AppModel(NotifyingBaseModel): + version: str = "0.4.3" + debug: bool = True + log: bool = True + plex: PlexModel = PlexModel() + real_debrid: DebridModel = DebridModel() + symlink: SymlinkModel = SymlinkModel() + content: ContentModel = ContentModel() + scraping: ScraperModel = ScraperModel() + parser: ParserModel = ParserModel() \ No newline at end of file diff --git a/backend/program/symlink.py b/backend/program/symlink.py index c5d16cd4..399967cf 100644 --- a/backend/program/symlink.py +++ b/backend/program/symlink.py @@ -2,19 +2,10 @@ import os from pathlib import Path from typing import NamedTuple -from pydantic import BaseModel -from utils.settings import settings_manager as settings +from program.settings.manager import settings_manager from utils.logger import logger -class SymlinkConfig(BaseModel): - host_path: Path - container_path: Path - -class Setting(NamedTuple): - key: str - value: str - class Symlinker(): """ A class that represents a symlinker thread. @@ -29,20 +20,19 @@ class Symlinker(): """ def __init__(self, _): self.key = "symlink" - self.settings = SymlinkConfig(**settings.get(self.key)) + self.settings = settings_manager.settings.symlink self.initialized = self.validate() if not self.initialized: logger.error("Symlink initialization failed due to invalid configuration.") return - logger.info("Rclone path symlinks are pointed to: %s", self.settings.host_path) - logger.info("Symlinks will be placed in: %s", self.library_path) + logger.info(f"Rclone path symlinks are pointed to: {self.settings.host_path}") logger.info("Symlink initialized!") self.initialized = True def validate(self): """Validate paths and create the initial folders.""" - host_path = Path(self.settings.host_path) if self.settings.host_path else None - container_path = Path(self.settings.container_path) if self.settings.container_path else None + host_path = self.settings.host_path + container_path = self.settings.container_path if not host_path or not container_path or host_path == Path('.') or container_path == Path('.'): logger.error("Host or container path not provided, is empty, or is set to the current directory.") return False @@ -80,7 +70,7 @@ def validate(self): def create_initial_folders(self): """Create the initial library folders.""" try: - self.library_path = self.settings.host_path.parent / "library" + self.library_path = self.settings.container_path / "library" self.library_path_movies = self.library_path / "movies" self.library_path_shows = self.library_path / "shows" self.library_path_anime_movies = self.library_path / "anime_movies" @@ -92,6 +82,7 @@ def create_initial_folders(self): for folder in folders: if not folder.exists(): folder.mkdir(parents=True, exist_ok=True) + logger.info(f"Symlinks will be placed in: {self.library_path}") except PermissionError as e: logger.error(f"Permission denied when creating directory: {e}") return False diff --git a/backend/program/updaters/trakt.py b/backend/program/updaters/trakt.py index 8009fa60..c22d3788 100644 --- a/backend/program/updaters/trakt.py +++ b/backend/program/updaters/trakt.py @@ -3,8 +3,9 @@ import concurrent.futures from datetime import datetime from os import path -from utils.logger import get_data_path, logger +from utils.logger import logger from utils.request import get +from utils import data_dir_path from program.media.container import MediaItemContainer from program.media.item import Movie, Show, Season, Episode @@ -16,7 +17,7 @@ class Updater: def __init__(self): self.trakt_data = MediaItemContainer() - self.pkl_file = path.join(get_data_path(), "trakt_data.pkl") + self.pkl_file = data_dir_path / "trakt_data.pkl" self.ids = [] def create_items(self, imdb_ids): diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py index e69de29b..2e53478f 100644 --- a/backend/utils/__init__.py +++ b/backend/utils/__init__.py @@ -0,0 +1,4 @@ +from pathlib import Path +import os + +data_dir_path = Path(os.path.abspath(__file__)).parent.parent.parent / "data" \ No newline at end of file diff --git a/backend/utils/default_settings.json b/backend/utils/default_settings.json deleted file mode 100644 index 49551b00..00000000 --- a/backend/utils/default_settings.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "version": "0.4.5", - "debug": true, - "log": true, - "symlink": { - "host_path": "", - "container_path": "" - }, - "real_debrid": { - "api_key": "" - }, - "plex": { - "token": "", - "url": "http://localhost:32400" - }, - "content": { - "plex_watchlist": { - "enabled": false, - "rss": "", - "update_interval": 80 - }, - "mdblist": { - "enabled": false, - "lists": [""], - "api_key": "", - "update_interval": 80 - }, - "listrr": { - "enabled": false, - "movie_lists": [""], - "show_lists": [""], - "api_key": "", - "update_interval": 80 - }, - "overseerr": { - "enabled": false, - "url": "http://localhost:5055", - "api_key": "" - } - }, - "scraping": { - "after_2": 0.5, - "after_5": 2, - "after_10": 24, - "torrentio": { - "enabled": false, - "url": "https://torrentio.strem.fun", - "filter": "sort=qualitysize%7Cqualityfilter=480p,scr,cam" - }, - "orionoid": { - "enabled": false, - "api_key": "" - }, - "jackett": { - "enabled": false, - "url": "http://localhost:9117", - "api_key": "" - } - }, - "parser": { - "language": ["English"], - "include_4k": false, - "highest_quality": false, - "repack_proper": true - } -} diff --git a/backend/utils/logger.py b/backend/utils/logger.py index 14cd23a3..9cf77ee3 100644 --- a/backend/utils/logger.py +++ b/backend/utils/logger.py @@ -4,13 +4,8 @@ import os import re import sys -from .settings import settings_manager as settings - - -def get_data_path(): - main_dir = os.path.dirname(os.path.abspath(sys.modules["__main__"].__file__)) - return os.path.join(os.path.dirname(main_dir), "data") +from utils import data_dir_path class RedactSensitiveInfo(logging.Filter): """logging filter to redact sensitive info""" @@ -62,40 +57,47 @@ class Logger(logging.Logger): """Logging class""" def __init__(self): - timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S") - file_name = f"iceberg-{timestamp}.log" - data_path = get_data_path() - - super().__init__(file_name) - formatter = logging.Formatter( + self.timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + self.filename = f"iceberg-{self.timestamp}.log" + super().__init__(self.filename) + self.formatter = logging.Formatter( "[%(asctime)s | %(levelname)s] <%(module)s.%(funcName)s> - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) + self.logs_dir_path = data_dir_path / "logs" + os.makedirs(self.logs_dir_path, exist_ok=True) - if not os.path.exists(data_path): - os.mkdir(data_path) + self.addFilter(RedactSensitiveInfo()) + + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(self.formatter) + self.addHandler(console_handler) + self.console_handler = console_handler + self.file_handler = None - if not os.path.exists(os.path.join(data_path, "logs")): - os.mkdir(os.path.join(data_path, "logs")) + def configure_logger(self, debug=False, log=False): + log_level = logging.DEBUG if debug else logging.INFO + self.setLevel(log_level) - self.addFilter(RedactSensitiveInfo()) + # Update console handler level + for handler in self.handlers: + handler.setLevel(log_level) - log_level = logging.INFO - if settings.get("debug"): - log_level = logging.DEBUG + # Configure file handler + if log and not self.file_handler: - if settings.get("log"): + # Only add a new file handler if it hasn't been added before file_handler = logging.FileHandler( - os.path.join(get_data_path(), "logs", file_name), encoding="utf-8" + self.logs_dir_path / self.filename, encoding="utf-8" ) file_handler.setLevel(log_level) - file_handler.setFormatter(formatter) + file_handler.setFormatter(self.formatter) self.addHandler(file_handler) - - console_handler = logging.StreamHandler() - console_handler.setLevel(log_level) - console_handler.setFormatter(formatter) - self.addHandler(console_handler) - + self.file_handler = file_handler # Keep a reference to avoid adding it again + elif not log and self.file_handler: + # If logging to file is disabled but the handler exists, remove it + self.removeHandler(self.file_handler) + self.file_handler = None logger = Logger() diff --git a/backend/utils/parser.py b/backend/utils/parser.py index 6a8088ee..8bd8a7b8 100644 --- a/backend/utils/parser.py +++ b/backend/utils/parser.py @@ -2,7 +2,7 @@ import PTN from typing import List from pydantic import BaseModel -from utils.settings import settings_manager +from program.settings.manager import settings_manager from thefuzz import fuzz @@ -16,7 +16,7 @@ class ParserConfig(BaseModel): class Parser: def __init__(self): - self.settings = ParserConfig(**settings_manager.get("parser")) + self.settings = settings_manager.settings.parser self.language = self.settings.language self.resolution = self.determine_resolution() diff --git a/backend/utils/service_manager.py b/backend/utils/service_manager.py index 6b15c008..616fc430 100644 --- a/backend/utils/service_manager.py +++ b/backend/utils/service_manager.py @@ -1,6 +1,6 @@ from copy import deepcopy from threading import Thread -from utils.settings import settings_manager +from program.settings.manager import settings_manager class ServiceManager: @@ -8,7 +8,7 @@ def __init__(self, media_items=None, register_observer=False, *services): self.media_items = media_items self.services = [] self.initialize_services(services) - self.settings = deepcopy(settings_manager.get_all()) + self.settings = settings_manager.settings if register_observer: settings_manager.register_observer(self) @@ -36,8 +36,8 @@ def initialize_services(self, modules=None): def update_settings(self, new_settings): modules_to_update = [] - for module, values in self.settings.items(): - for new_module, new_values in new_settings.items(): + for module, values in self.settings.dict().items(): + for new_module, new_values in new_settings.dict().items(): if module == new_module: if values != new_values: modules_to_update.append(module) diff --git a/backend/utils/settings.py b/backend/utils/settings.py deleted file mode 100644 index 6fcb5175..00000000 --- a/backend/utils/settings.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Settings manager""" -from utils.observable import Observable -import json -import os -import shutil - - -class SettingsManager(Observable): - """Class that handles settings""" - - def __init__(self): - self.filename = "data/settings.json" - self.config_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), os.pardir, os.pardir) - ) - self.settings_file = os.path.join(self.config_dir, self.filename) - self.settings = {} - self.observers = [] - self.load() - - def register_observer(self, observer): - self.observers.append(observer) - - def notify_observers(self): - for observer in self.observers: - observer.notify() - - def load(self): - """Load settings from file""" - if not os.path.exists(self.settings_file): - default_settings_path = os.path.join( - os.path.dirname(__file__), "default_settings.json" - ) - shutil.copy(default_settings_path, self.settings_file) - with open(self.settings_file, "r", encoding="utf-8") as file: - self.settings = json.loads(file.read()) - self.notify_observers() - - def save(self): - """Save settings to file""" - with open(self.settings_file, "w", encoding="utf-8") as file: - json.dump(self.settings, file, indent=4) - - def get(self, key): - """Get setting with key""" - return _get_nested_attr(self.settings, key) - - def set(self, data): - """Set setting value with key""" - for setting in data: - _set_nested_attr(self.settings, setting.key, setting.value) - self.notify_observers() - - def get_all(self): - """Return all settings""" - return self.settings - -def _get_nested_attr(obj, key): - if "." in key: - parts = key.split(".", 1) - current_key, rest_of_keys = parts[0], parts[1] - - if not obj.get(current_key, None): - return None - - current_obj = obj.get(current_key) - return _get_nested_attr(current_obj, rest_of_keys) - else: - return obj.get(key, None) - - -def _set_nested_attr(obj, key, value): - if "." in key: - parts = key.split(".", 1) - current_key, rest_of_keys = parts[0], parts[1] - - if not obj.get(current_key): - return False - - current_obj = obj.get(current_key) - return _set_nested_attr(current_obj, rest_of_keys, value) - else: - obj[key] = value - return True - - -settings_manager = SettingsManager()