diff --git a/backend/program/__init__.py b/backend/program/__init__.py index 4a4ae483..0c906bc9 100644 --- a/backend/program/__init__.py +++ b/backend/program/__init__.py @@ -58,13 +58,9 @@ def validate(self): return all(service.initialized for service in self.core_manager.services) def stop(self): - try: - for service in self.core_manager.services: - if getattr(service, "running", False): - service.stop() - self.pickly.stop() - settings.save() - self.running = False - except Exception as e: - logger.error("Iceberg stopping with exception: %s", e) - pass \ No newline at end of file + for service in self.core_manager.services: + if getattr(service, "running", False): + service.stop() + self.pickly.stop() + settings.save() + self.running = False \ No newline at end of file diff --git a/backend/program/realdebrid.py b/backend/program/realdebrid.py index 60617dde..61fd13ff 100644 --- a/backend/program/realdebrid.py +++ b/backend/program/realdebrid.py @@ -130,9 +130,9 @@ def chunks(lst, n): "active_stream", {"hash": stream_hash, "files": wanted_files, "id": None}, ) - all_filenames = [file_info["filename"] for file_info in wanted_files.values()] - for file in all_filenames: - logger.debug(f"Found cached file {file} for {item.log_string}") + # all_filenames = [file_info["filename"] for file_info in wanted_files.values()] + # for file in all_filenames: + # logger.debug(f"Found cached file {file} for {item.log_string}") return True item.streams[stream_hash] = None return False diff --git a/backend/program/scrapers/jackett.py b/backend/program/scrapers/jackett.py index 1962d651..2ce2f6ad 100644 --- a/backend/program/scrapers/jackett.py +++ b/backend/program/scrapers/jackett.py @@ -25,7 +25,7 @@ def __init__(self, _): self.initialized = self.validate_settings() if not self.initialized and not self.api_key: return - self.minute_limiter = RateLimiter(max_calls=60, period=60, raise_on_limit=True) + self.minute_limiter = RateLimiter(max_calls=1000, period=3600, raise_on_limit=True) self.second_limiter = RateLimiter(max_calls=1, period=5) self.parse_logging = False logger.info("Jackett initialized!") @@ -45,7 +45,7 @@ def validate_settings(self) -> bool: except ReadTimeout: return True except Exception as e: - logger.exception("Jackett failed to initialize with API Key: %s", e) + logger.error("Jackett failed to initialize with API Key: %s", e) return False if self.settings.url: try: @@ -57,9 +57,10 @@ def validate_settings(self) -> bool: if not response.is_ok: return False except ReadTimeout: + logger.warn("Jackett connection timeout.") return True except Exception as e: - logger.exception("Jackett failed to initialize: %s", e) + logger.error("Jackett failed to initialize: %s", e) return False logger.info("Jackett is not configured and will not be used.") return False @@ -75,13 +76,11 @@ def run(self, item): logger.warn("Jackett rate limit hit for item: %s", item.log_string) return except RequestException as e: - self.minute_limiter.limit_hit() - logger.exception("Jackett request exception: %s", e, exc_info=True) + logger.debug("Jackett request exception: %s", e, exc_info=True) return except Exception as e: - self.minute_limiter.limit_hit() - # logger.debug("Jackett exception for item: %s - Exception: %s", item.log_string, e.args[0], exc_info=True) - # logger.debug("Exception details: %s", traceback.format_exc()) + logger.debug("Jackett exception for item: %s - Exception: %s", item.log_string, e.args[0], exc_info=True) + logger.debug("Exception details: %s", traceback.format_exc()) return def _scrape_item(self, item): diff --git a/backend/program/scrapers/orionoid.py b/backend/program/scrapers/orionoid.py index af858e1b..41665246 100644 --- a/backend/program/scrapers/orionoid.py +++ b/backend/program/scrapers/orionoid.py @@ -32,8 +32,8 @@ def __init__(self, _): self.orionoid_limit = 0 self.orionoid_remaining = 0 self.parse_logging = False - self.max_calls = 100 if not self.is_premium else 60 - self.period = 86400 if not self.is_premium else 60 + self.max_calls = 100 if not self.is_premium else 1000 + self.period = 86400 if not self.is_premium else 3600 self.minute_limiter = RateLimiter(max_calls=self.max_calls, period=self.period, raise_on_limit=True) self.second_limiter = RateLimiter(max_calls=1, period=5) logger.info("Orionoid initialized!") @@ -43,19 +43,20 @@ def validate_settings(self) -> bool: if not self.settings.enabled: logger.debug("Orionoid is set to disabled.") return False - if self.settings.api_key: - return True + if len(self.settings.api_key) != 32 or self.settings.api_key == "": + logger.error("Orionoid API Key is not valid or not set. Please check your settings.") + return False try: url = f"https://api.orionoid.com?keyapp={KEY_APP}&keyuser={self.settings.api_key}&mode=user&action=retrieve" response = get(url, retry_if_failed=False) - if response.is_ok: - return True - if not response.data.result.status == "success": - logger.error(f"Orionoid API Key is invalid. Status: {response.data.result.status}") - return False - if not response.is_ok: - logger.error(f"Orionoid Status Code: {response.status_code}, Reason: {response.reason}") - return False + if response.is_ok and hasattr(response.data, "result"): + if not response.data.result.status == "success": + logger.error(f"Orionoid API Key is invalid. Status: {response.data.result.status}") + return False + if not response.is_ok: + logger.error(f"Orionoid Status Code: {response.status_code}, Reason: {response.reason}") + return False + return True except Exception as e: logger.exception("Orionoid failed to initialize: %s", e) return False @@ -67,7 +68,7 @@ def check_premium(self) -> bool: """ url = f"https://api.orionoid.com?keyapp={KEY_APP}&keyuser={self.settings.api_key}&mode=user&action=retrieve" response = get(url, retry_if_failed=False) - if response.is_ok: + if response.is_ok and hasattr(response.data, "data"): active = True if response.data.data.status == "active" else False premium = response.data.data.subscription.package.premium debrid = response.data.data.service.realdebrid diff --git a/backend/program/scrapers/torrentio.py b/backend/program/scrapers/torrentio.py index 34d25b25..a47308f2 100644 --- a/backend/program/scrapers/torrentio.py +++ b/backend/program/scrapers/torrentio.py @@ -1,5 +1,4 @@ """ Torrentio scraper module """ -import os from typing import Optional from pydantic import BaseModel from requests import ConnectTimeout, ReadTimeout @@ -22,7 +21,7 @@ class Torrentio: def __init__(self, _): self.key = "torrentio" self.settings = TorrentioConfig(**settings_manager.get(f"scraping.{self.key}")) - self.minute_limiter = RateLimiter(max_calls=60, period=60, raise_on_limit=True) + 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() if not self.initialized: @@ -57,26 +56,18 @@ def run(self, item): self._scrape_item(item) except RateLimitExceeded: self.minute_limiter.limit_hit() - logger.warn("Torrentio rate limit hit for item: %s", item.log_string) return except ConnectTimeout: - self.minute_limiter.limit_hit() logger.warn("Torrentio connection timeout for item: %s", item.log_string) return except ReadTimeout: - self.minute_limiter.limit_hit() + logger.warn("Torrentio read timeout for item: %s", item.log_string) return except RequestException as e: - self.minute_limiter.limit_hit() logger.warn("Torrentio request exception: %s", e) return - except AttributeError: - # TODO: will fix later - self.minute_limiter.limit_hit() - return except Exception as e: - self.minute_limiter.limit_hit() - logger.warn("Torrentio failed to scrape item: %s", e) + logger.warn("Torrentio exception thrown: %s", e) return def _scrape_item(self, item): diff --git a/backend/program/symlink.py b/backend/program/symlink.py index 96fc14c2..53cd153f 100644 --- a/backend/program/symlink.py +++ b/backend/program/symlink.py @@ -1,6 +1,7 @@ """Symlinking module""" import os from pathlib import Path +from typing import NamedTuple from pydantic import BaseModel from utils.settings import settings_manager as settings from utils.logger import logger @@ -10,6 +11,9 @@ class SymlinkConfig(BaseModel): host_path: Path container_path: Path +class Setting(NamedTuple): + key: str + value: str class Symlinker(): """ @@ -26,60 +30,74 @@ class Symlinker(): def __init__(self, _): self.key = "symlink" self.settings = SymlinkConfig(**settings.get(self.key)) - self.initialized = False - - if (self.settings.host_path / "__all__").exists(): - logger.debug("Detected Zurg host path. Using __all__ folder for host path.") - settings.set(self.key, self.settings.host_path) - self.settings.host_path = Path(self.settings.host_path) / "__all__" - elif (self.settings.host_path / "torrents").exists(): - logger.debug("Detected standard rclone host path. Using torrents folder for host path.") - settings.set(self.key, self.settings.host_path) - self.settings.host_path = Path(self.settings.host_path) / "torrents" - - self.library_path = self.settings.host_path.parent / "library" - - if not self.validate(): - logger.error("Symlink configuration is invalid. Please check the host and container paths.") - return - - self.initialize_library_paths() - - if not self.create_initial_folders(): - logger.error("Failed to create initial library folders.") + self.initialized = self.validate() + if not self.initialized: + logger.error("Symlink initialization failed due to invalid configuration.") return - - logger.info("Found rclone mount path: %s", self.settings.host_path) - logger.info("Symlinks will be placed in library path: %s", self.library_path) - logger.info("Plex will see the symlinks in: %s", self.settings.container_path.parent / "library") + 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("Symlink initialized!") self.initialized = True def validate(self): - if not self.settings.host_path or not self.settings.container_path: + """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 + 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 - host_path = Path(self.settings.host_path) - if not host_path.exists() or not host_path.is_dir(): - logger.error(f"Invalid host path: {self.settings.host_path}") + if not host_path.is_absolute(): + logger.error(f"Host path is not an absolute path: {host_path}") return False - return True - - def initialize_library_paths(self): - 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" - self.library_path_anime_shows = self.library_path / "anime_shows" + if not container_path.is_absolute(): + logger.error(f"Container path is not an absolute path: {container_path}") + return False + try: + if not host_path.is_dir(): + logger.error(f"Host path is not a directory or does not exist: {host_path}") + return False + if not container_path.is_dir(): + logger.error(f"Container path is not a directory or does not exist: {container_path}") + return False + if Path(self.settings.host_path / "__all__").exists() and Path(self.settings.host_path / "__all__").is_dir(): + logger.debug("Detected Zurg host path. Using __all__ folder for host path.") + self.settings.host_path = self.settings.host_path / "__all__" + elif Path(self.settings.host_path / "torrents").exists() and Path(self.settings.host_path / "torrents").is_dir(): + logger.debug("Detected standard rclone host path. Using torrents folder for host path.") + self.settings.host_path = self.settings.host_path / "torrents" + if not self.create_initial_folders(): + logger.error("Failed to create initial library folders.") + return False + return True + except FileNotFoundError as e: + logger.error(f"Path not found: {e}") + except PermissionError as e: + logger.error(f"Permission denied when accessing path: {e}") + except OSError as e: + logger.error(f"OS error when validating paths: {e}") + return False def create_initial_folders(self): - for library in [self.library_path_movies, - self.library_path_shows, - self.library_path_anime_movies, - self.library_path_anime_shows]: - try: - library.mkdir(parents=True, exist_ok=True) - except Exception as e: - logger.error("Failed to create directory %s: %s", library, e) - return False + """Create the initial library folders.""" + try: + self.library_path = self.settings.container_path.parent / "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" + self.library_path_anime_shows = self.library_path / "anime_shows" + folders = [self.library_path_movies, + self.library_path_shows, + self.library_path_anime_movies, + self.library_path_anime_shows] + for folder in folders: + if not folder.exists(): + folder.mkdir(parents=True, exist_ok=True) + except PermissionError as e: + logger.error(f"Permission denied when creating directory: {e}") + return False + except OSError as e: + logger.error(f"OS error when creating directory: {e}") + return False return True def run(self, item): diff --git a/frontend/src/lib/forms/helpers.ts b/frontend/src/lib/forms/helpers.ts index c089b4c4..492a18c3 100644 --- a/frontend/src/lib/forms/helpers.ts +++ b/frontend/src/lib/forms/helpers.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; // General Settings ----------------------------------------------------------------------------------- export const generalSettingsToGet: string[] = ['debug', 'log', 'symlink', 'real_debrid']; +export const generalSettingsServices: string[] = ['symlink', 'real_debrid']; export const generalSettingsSchema = z.object({ debug: z.boolean().default(true), @@ -51,6 +52,7 @@ export function generalSettingsToSet(form: SuperValidated // Content Settings ----------------------------------------------------------------------------------- export const contentSettingsToGet: string[] = ['content']; +export const contentSettingsServices: string[] = ['content']; export const contentSettingsSchema = z.object({ overseerr_enabled: z.boolean().default(false), @@ -126,6 +128,7 @@ export function contentSettingsToSet(form: SuperValidated // Media Server Settings ----------------------------------------------------------------------------------- export const mediaServerSettingsToGet: string[] = ['plex']; +export const mediaServerSettingsServices: string[] = ['plex']; export const mediaServerSettingsSchema = z.object({ plex_token: z.string().optional().default(''), @@ -154,6 +157,7 @@ export function mediaServerSettingsToSet(form: SuperValidated @@ -49,6 +67,31 @@

About

Know what you're running.

+
+

+ {formatWords('Version')} +

+
+

+ {version} +

+ +
+
{#each Object.keys(aboutData) as key}
diff --git a/frontend/src/routes/settings/content/+page.server.ts b/frontend/src/routes/settings/content/+page.server.ts index b3bf7981..8ab7dbb2 100644 --- a/frontend/src/routes/settings/content/+page.server.ts +++ b/frontend/src/routes/settings/content/+page.server.ts @@ -1,10 +1,11 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, error, redirect } from '@sveltejs/kit'; import { message, superValidate } from 'sveltekit-superforms/server'; -import { saveSettings } from '$lib/helpers'; +import { saveSettings, formatWords } from '$lib/helpers'; import { contentSettingsSchema, contentSettingsToGet, + contentSettingsServices, contentSettingsToPass, contentSettingsToSet } from '$lib/forms/helpers'; @@ -48,6 +49,21 @@ export const actions: Actions = { }); } + const data = await event.fetch('http://127.0.0.1:8080/services'); + const services = await data.json(); + const allServicesTrue: boolean = contentSettingsServices.every( + (service) => services.data[service] === true + ); + if (!allServicesTrue) { + return message( + form, + `${contentSettingsServices.map(formatWords).join(', ')} service(s) failed to initialize. Please check your settings.`, + { + status: 400 + } + ); + } + if (event.url.searchParams.get('onboarding') === 'true') { redirect(302, '/onboarding/4'); } diff --git a/frontend/src/routes/settings/general/+page.server.ts b/frontend/src/routes/settings/general/+page.server.ts index e4d81722..6e420686 100644 --- a/frontend/src/routes/settings/general/+page.server.ts +++ b/frontend/src/routes/settings/general/+page.server.ts @@ -1,10 +1,11 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, error, redirect } from '@sveltejs/kit'; import { message, superValidate } from 'sveltekit-superforms/server'; -import { saveSettings } from '$lib/helpers'; +import { saveSettings, formatWords } from '$lib/helpers'; import { generalSettingsSchema, generalSettingsToGet, + generalSettingsServices, generalSettingsToPass, generalSettingsToSet } from '$lib/forms/helpers'; @@ -50,6 +51,21 @@ export const actions: Actions = { }); } + const data = await event.fetch('http://127.0.0.1:8080/services'); + const services = await data.json(); + const allServicesTrue: boolean = generalSettingsServices.every( + (service) => services.data[service] === true + ); + if (!allServicesTrue) { + return message( + form, + `${generalSettingsServices.map(formatWords).join(', ')} service(s) failed to initialize. Please check your settings.`, + { + status: 400 + } + ); + } + if (event.url.searchParams.get('onboarding') === 'true') { redirect(302, '/onboarding/2'); } diff --git a/frontend/src/routes/settings/mediaserver/+page.server.ts b/frontend/src/routes/settings/mediaserver/+page.server.ts index 16fbb396..a563079e 100644 --- a/frontend/src/routes/settings/mediaserver/+page.server.ts +++ b/frontend/src/routes/settings/mediaserver/+page.server.ts @@ -1,10 +1,11 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, error, redirect } from '@sveltejs/kit'; import { message, superValidate } from 'sveltekit-superforms/server'; -import { saveSettings } from '$lib/helpers'; +import { saveSettings, formatWords } from '$lib/helpers'; import { mediaServerSettingsSchema, mediaServerSettingsToGet, + mediaServerSettingsServices, mediaServerSettingsToPass, mediaServerSettingsToSet } from '$lib/forms/helpers'; @@ -48,6 +49,21 @@ export const actions: Actions = { }); } + const data = await event.fetch('http://127.0.0.1:8080/services'); + const services = await data.json(); + const allServicesTrue: boolean = mediaServerSettingsServices.every( + (service) => services.data[service] === true + ); + if (!allServicesTrue) { + return message( + form, + `${mediaServerSettingsServices.map(formatWords).join(', ')} service(s) failed to initialize. Please check your settings.`, + { + status: 400 + } + ); + } + if (event.url.searchParams.get('onboarding') === 'true') { redirect(302, '/onboarding/3'); } diff --git a/frontend/src/routes/settings/scrapers/+page.server.ts b/frontend/src/routes/settings/scrapers/+page.server.ts index 216d8577..d3d73054 100644 --- a/frontend/src/routes/settings/scrapers/+page.server.ts +++ b/frontend/src/routes/settings/scrapers/+page.server.ts @@ -1,10 +1,11 @@ import type { PageServerLoad, Actions } from './$types'; import { fail, error, redirect } from '@sveltejs/kit'; import { message, superValidate } from 'sveltekit-superforms/server'; -import { saveSettings } from '$lib/helpers'; +import { saveSettings, formatWords } from '$lib/helpers'; import { scrapersSettingsSchema, scrapersSettingsToGet, + scrapersSettingsServices, scrapersSettingsToPass, scrapersSettingsToSet } from '$lib/forms/helpers'; @@ -48,6 +49,21 @@ export const actions: Actions = { }); } + const data = await event.fetch('http://127.0.0.1:8080/services'); + const services = await data.json(); + const allServicesTrue: boolean = scrapersSettingsServices.every( + (service) => services.data[service] === true + ); + if (!allServicesTrue) { + return message( + form, + `${scrapersSettingsServices.map(formatWords).join(', ')} service(s) failed to initialize. Please check your settings.`, + { + status: 400 + } + ); + } + if (event.url.searchParams.get('onboarding') === 'true') { redirect(302, '/?onboarding=true'); }