diff --git a/media_manager/auth/users.py b/media_manager/auth/users.py index cb42ee8..c9f37f3 100644 --- a/media_manager/auth/users.py +++ b/media_manager/auth/users.py @@ -17,14 +17,11 @@ from fastapi.responses import RedirectResponse, Response from starlette import status import media_manager.notification.utils -from media_manager.auth.config import AuthConfig, OpenIdConfig, EmailConfig +from media_manager.auth.config import AuthConfig, OpenIdConfig from media_manager.auth.db import User, get_user_db from media_manager.auth.schemas import UserUpdate from media_manager.config import BasicConfig -import smtplib -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart log = logging.getLogger(__name__) @@ -84,7 +81,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): """ - media_manager.notification.utils.send_email(subject=subject, html=html, addressee=user.email) + media_manager.notification.utils.send_email( + subject=subject, html=html, addressee=user.email + ) log.info(f"Sent password reset email to {user.email}") async def on_after_reset_password( diff --git a/media_manager/indexer/service.py b/media_manager/indexer/service.py index 7a00569..99ff8ee 100644 --- a/media_manager/indexer/service.py +++ b/media_manager/indexer/service.py @@ -27,23 +27,27 @@ class IndexerService: try: indexer_results = indexer.search(query) results.extend(indexer_results) - log.debug(f"Indexer {indexer.__class__.__name__} returned {len(indexer_results)} results for query: {query}") + log.debug( + f"Indexer {indexer.__class__.__name__} returned {len(indexer_results)} results for query: {query}" + ) except Exception as e: failed_indexers.append(indexer.__class__.__name__) - log.error(f"Indexer {indexer.__class__.__name__} failed for query '{query}': {e}") + log.error( + f"Indexer {indexer.__class__.__name__} failed for query '{query}': {e}" + ) # Send notification if indexers failed if failed_indexers and notification_manager.is_configured(): notification_manager.send_notification( title="Indexer Failure", - message=f"The following indexers failed for query '{query}': {', '.join(failed_indexers)}. Check indexer configuration and connectivity." + message=f"The following indexers failed for query '{query}': {', '.join(failed_indexers)}. Check indexer configuration and connectivity.", ) # Send notification if no results found from any indexer if not results and notification_manager.is_configured(): notification_manager.send_notification( title="No Search Results", - message=f"No torrents found for query '{query}' from any configured indexer. Consider checking the search terms or indexer availability." + message=f"No torrents found for query '{query}' from any configured indexer. Consider checking the search terms or indexer availability.", ) for result in results: diff --git a/media_manager/movies/service.py b/media_manager/movies/service.py index c65a638..dd276d7 100644 --- a/media_manager/movies/service.py +++ b/media_manager/movies/service.py @@ -444,14 +444,14 @@ class MovieService: """ video_files, subtitle_files, all_files = import_torrent(torrent=torrent) - success: bool = False # determines if the import was successful, if true, the Imported flag will be set to True after the import + success: bool = False # determines if the import was successful, if true, the Imported flag will be set to True after the import if len(video_files) != 0: # Send notification about multiple video files found if self.notification_service: self.notification_service.send_notification_to_all_providers( title="Multiple Video Files Found", - message=f"Found {len(video_files)} video files in movie torrent '{torrent.title}' for {movie.name} ({movie.year}). Only the first will be imported. Manual intervention recommended." + message=f"Found {len(video_files)} video files in movie torrent '{torrent.title}' for {movie.name} ({movie.year}). Only the first will be imported. Manual intervention recommended.", ) log.error( "Found multiple video files in movie torrent, only the first will be imported. Manual intervention is recommended.." @@ -516,7 +516,7 @@ class MovieService: if self.notification_service: self.notification_service.send_notification_to_all_providers( title="Movie Downloaded", - message=f"Successfully downloaded: {movie.name} ({movie.year}) from torrent {torrent.title}." + message=f"Successfully downloaded: {movie.name} ({movie.year}) from torrent {torrent.title}.", ) log.info(f"Finished organizing files for torrent {torrent.title}") diff --git a/media_manager/notification/config.py b/media_manager/notification/config.py index cda9ee4..c7acd2e 100644 --- a/media_manager/notification/config.py +++ b/media_manager/notification/config.py @@ -10,15 +10,20 @@ class EmailConfig(BaseSettings): from_email: str use_tls: bool = False + class NotificationConfig(BaseSettings): model_config = SettingsConfigDict(env_prefix="NOTIFICATION_") - email: str|None = None # the email address to send notifications to + email: str | None = None # the email address to send notifications to - ntfy_url: str|None = None # e.g. https://ntfy.sh/your-topic (note lack of trailing slash) + ntfy_url: str | None = ( + None # e.g. https://ntfy.sh/your-topic (note lack of trailing slash) + ) - pushover_api_key : str|None = None - pushover_user: str|None = None + pushover_api_key: str | None = None + pushover_user: str | None = None - gotify_api_key: str|None = None - gotify_url: str|None = None # e.g. https://gotify.example.com (note lack of trailing slash) \ No newline at end of file + gotify_api_key: str | None = None + gotify_url: str | None = ( + None # e.g. https://gotify.example.com (note lack of trailing slash) + ) diff --git a/media_manager/notification/dependencies.py b/media_manager/notification/dependencies.py index d56f2b9..8492610 100644 --- a/media_manager/notification/dependencies.py +++ b/media_manager/notification/dependencies.py @@ -29,4 +29,3 @@ def get_notification_service( notification_service_dep = Annotated[ NotificationService, Depends(get_notification_service) ] - diff --git a/media_manager/notification/manager.py b/media_manager/notification/manager.py index da8ef7e..c9badae 100644 --- a/media_manager/notification/manager.py +++ b/media_manager/notification/manager.py @@ -1,14 +1,25 @@ """ Notification Manager - Orchestrates sending notifications through all configured service providers """ + import logging -from typing import List, Dict, Any +from typing import List from media_manager.notification.schemas import MessageNotification -from media_manager.notification.service_providers.abstractNotificationServiceProvider import AbstractNotificationServiceProvider -from media_manager.notification.service_providers.email import EmailNotificationServiceProvider -from media_manager.notification.service_providers.gotify import GotifyNotificationServiceProvider -from media_manager.notification.service_providers.ntfy import NtfyNotificationServiceProvider -from media_manager.notification.service_providers.pushover import PushoverNotificationServiceProvider +from media_manager.notification.service_providers.abstractNotificationServiceProvider import ( + AbstractNotificationServiceProvider, +) +from media_manager.notification.service_providers.email import ( + EmailNotificationServiceProvider, +) +from media_manager.notification.service_providers.gotify import ( + GotifyNotificationServiceProvider, +) +from media_manager.notification.service_providers.ntfy import ( + NtfyNotificationServiceProvider, +) +from media_manager.notification.service_providers.pushover import ( + PushoverNotificationServiceProvider, +) from media_manager.notification.config import NotificationConfig logger = logging.getLogger(__name__) @@ -77,7 +88,6 @@ class NotificationManager: except Exception as e: logger.error(f"Error sending notification via {provider_name}: {e}") - def get_configured_providers(self) -> List[str]: return [provider.__class__.__name__ for provider in self.providers] diff --git a/media_manager/notification/models.py b/media_manager/notification/models.py index fd25c45..031d12b 100644 --- a/media_manager/notification/models.py +++ b/media_manager/notification/models.py @@ -1,12 +1,9 @@ -from datetime import datetime from uuid import UUID -from sqlalchemy import ForeignKey, PrimaryKeyConstraint, UniqueConstraint, DateTime -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import DateTime +from sqlalchemy.orm import Mapped, mapped_column -from media_manager.auth.db import User from media_manager.database import Base -from media_manager.torrent.models import Quality class Notification(Base): @@ -16,4 +13,3 @@ class Notification(Base): message: Mapped[str] read: Mapped[bool] timestamp = mapped_column(type=DateTime) - diff --git a/media_manager/notification/repository.py b/media_manager/notification/repository.py index 899feb7..0790997 100644 --- a/media_manager/notification/repository.py +++ b/media_manager/notification/repository.py @@ -3,12 +3,15 @@ from sqlalchemy.exc import ( IntegrityError, SQLAlchemyError, ) -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session import logging from media_manager.exceptions import NotFoundError, MediaAlreadyExists from media_manager.notification.models import Notification -from media_manager.notification.schemas import NotificationId, Notification as NotificationSchema +from media_manager.notification.schemas import ( + NotificationId, + Notification as NotificationSchema, +) log = logging.getLogger(__name__) @@ -18,7 +21,7 @@ class NotificationRepository: self.db = db def get_notification(self, id: NotificationId) -> NotificationSchema: - result= self.db.get(Notification, id) + result = self.db.get(Notification, id) if not result: raise NotFoundError @@ -27,10 +30,17 @@ class NotificationRepository: def get_unread_notifications(self) -> list[NotificationSchema]: try: - stmt = select(Notification).where(Notification.read == False).order_by(Notification.timestamp.desc()) # noqa: E712 + stmt = ( + select(Notification) + .where(Notification.read == False) + .order_by(Notification.timestamp.desc()) + ) # noqa: E712 results = self.db.execute(stmt).scalars().all() log.info(f"Successfully retrieved {len(results)} unread notifications.") - return [NotificationSchema.model_validate(notification) for notification in results] + return [ + NotificationSchema.model_validate(notification) + for notification in results + ] except SQLAlchemyError as e: log.error(f"Database error while retrieving unread notifications: {e}") raise @@ -40,7 +50,10 @@ class NotificationRepository: stmt = select(Notification).order_by(Notification.timestamp.desc()) results = self.db.execute(stmt).scalars().all() log.info(f"Successfully retrieved {len(results)} notifications.") - return [NotificationSchema.model_validate(notification) for notification in results] + return [ + NotificationSchema.model_validate(notification) + for notification in results + ] except SQLAlchemyError as e: log.error(f"Database error while retrieving notifications: {e}") raise @@ -51,7 +64,9 @@ class NotificationRepository: self.db.commit() except IntegrityError as e: log.error(f"Could not save notification, Error: {e}") - raise MediaAlreadyExists(f"Notification with id {notification.id} already exists.") + raise MediaAlreadyExists( + f"Notification with id {notification.id} already exists." + ) return def mark_notification_as_read(self, id: NotificationId) -> None: diff --git a/media_manager/notification/schemas.py b/media_manager/notification/schemas.py index 09ec913..c082bd2 100644 --- a/media_manager/notification/schemas.py +++ b/media_manager/notification/schemas.py @@ -3,28 +3,32 @@ import uuid from datetime import datetime from uuid import UUID -from pydantic import BaseModel, Field, ConfigDict, model_validator +from pydantic import BaseModel, Field, ConfigDict -from media_manager.auth.schemas import UserRead -from media_manager.torrent.models import Quality -from media_manager.torrent.schemas import TorrentId, TorrentStatus MovieId = typing.NewType("MovieId", UUID) MovieRequestId = typing.NewType("MovieRequestId", UUID) NotificationId = typing.NewType("NotificationId", UUID) + class Notification(BaseModel): model_config = ConfigDict(from_attributes=True) - id: NotificationId = Field(default_factory=uuid.uuid4, description="Unique identifier for the notification") + id: NotificationId = Field( + default_factory=uuid.uuid4, description="Unique identifier for the notification" + ) read: bool = Field(False, description="Whether the notification has been read") message: str = Field(description="The content of the notification") - timestamp: datetime = Field(default_factory=datetime.now, description="The timestamp of the notification") + timestamp: datetime = Field( + default_factory=datetime.now, description="The timestamp of the notification" + ) + class MessageNotification(BaseModel): """ Notification type for messages. """ + message: str - title: str \ No newline at end of file + title: str diff --git a/media_manager/notification/service.py b/media_manager/notification/service.py index d173f3c..211b4af 100644 --- a/media_manager/notification/service.py +++ b/media_manager/notification/service.py @@ -1,44 +1,6 @@ -import re - -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session - -from media_manager.exceptions import InvalidConfigError -from media_manager.indexer.repository import IndexerRepository -from media_manager.database import SessionLocal -from media_manager.indexer.schemas import IndexerQueryResult -from media_manager.indexer.schemas import IndexerQueryResultId -from media_manager.metadataProvider.schemas import MetaDataProviderSearchResult from media_manager.notification.repository import NotificationRepository from media_manager.notification.schemas import NotificationId, Notification from media_manager.notification.manager import notification_manager -from media_manager.torrent.schemas import Torrent, TorrentStatus -from media_manager.torrent.service import TorrentService -from media_manager.movies import log -from media_manager.movies.schemas import ( - Movie, - MovieId, - MovieRequest, - MovieFile, - RichMovieTorrent, - PublicMovie, - PublicMovieFile, - MovieRequestId, - RichMovieRequest, -) -from media_manager.torrent.schemas import QualityStrings -from media_manager.movies.repository import MovieRepository -from media_manager.exceptions import NotFoundError -import pprint -from media_manager.config import BasicConfig -from media_manager.torrent.repository import TorrentRepository -from media_manager.torrent.utils import import_file, import_torrent -from media_manager.indexer.service import IndexerService -from media_manager.metadataProvider.abstractMetaDataProvider import ( - AbstractMetadataProvider, -) -from media_manager.metadataProvider.tmdb import TmdbMetadataProvider -from media_manager.metadataProvider.tvdb import TvdbMetadataProvider class NotificationService: @@ -75,4 +37,4 @@ class NotificationService: internal_notification = Notification(message=f"{title}: {message}", read=False) self.save_notification(internal_notification) - return \ No newline at end of file + return diff --git a/media_manager/notification/service_providers/abstractNotificationServiceProvider.py b/media_manager/notification/service_providers/abstractNotificationServiceProvider.py index 878b044..1bc3ec9 100644 --- a/media_manager/notification/service_providers/abstractNotificationServiceProvider.py +++ b/media_manager/notification/service_providers/abstractNotificationServiceProvider.py @@ -1,6 +1,7 @@ import abc from media_manager.notification.schemas import MessageNotification + class AbstractNotificationServiceProvider(abc.ABC): @abc.abstractmethod def send_notification(self, message: MessageNotification) -> bool: @@ -11,4 +12,3 @@ class AbstractNotificationServiceProvider(abc.ABC): :return: True if the notification was sent successfully, False otherwise. """ pass - diff --git a/media_manager/notification/service_providers/email.py b/media_manager/notification/service_providers/email.py index 85ca133..89f3c43 100644 --- a/media_manager/notification/service_providers/email.py +++ b/media_manager/notification/service_providers/email.py @@ -1,14 +1,17 @@ import media_manager.notification.utils from media_manager.notification.schemas import MessageNotification -from media_manager.notification.service_providers.abstractNotificationServiceProvider import \ - AbstractNotificationServiceProvider +from media_manager.notification.service_providers.abstractNotificationServiceProvider import ( + AbstractNotificationServiceProvider, +) from media_manager.notification.config import NotificationConfig + class EmailNotificationServiceProvider(AbstractNotificationServiceProvider): def __init__(self): self.config = NotificationConfig() + def send_notification(self, message: MessageNotification) -> bool: - subject = "MediaManager - "+message.title + subject = "MediaManager - " + message.title html = f"""\ @@ -20,5 +23,7 @@ class EmailNotificationServiceProvider(AbstractNotificationServiceProvider): """ - media_manager.notification.utils.send_email(subject=subject, html=html,addressee=self.config.email) - return True \ No newline at end of file + media_manager.notification.utils.send_email( + subject=subject, html=html, addressee=self.config.email + ) + return True diff --git a/media_manager/notification/service_providers/gotify.py b/media_manager/notification/service_providers/gotify.py index 52d5354..427d9e5 100644 --- a/media_manager/notification/service_providers/gotify.py +++ b/media_manager/notification/service_providers/gotify.py @@ -1,10 +1,10 @@ import requests -from pydantic import HttpUrl from media_manager.notification.config import NotificationConfig from media_manager.notification.schemas import MessageNotification -from media_manager.notification.service_providers.abstractNotificationServiceProvider import \ - AbstractNotificationServiceProvider +from media_manager.notification.service_providers.abstractNotificationServiceProvider import ( + AbstractNotificationServiceProvider, +) class GotifyNotificationServiceProvider(AbstractNotificationServiceProvider): @@ -23,6 +23,6 @@ class GotifyNotificationServiceProvider(AbstractNotificationServiceProvider): "title": message.title, }, ) - if response.status_code not in range(200,300): + if response.status_code not in range(200, 300): return False return True diff --git a/media_manager/notification/service_providers/ntfy.py b/media_manager/notification/service_providers/ntfy.py index d94c90d..3398783 100644 --- a/media_manager/notification/service_providers/ntfy.py +++ b/media_manager/notification/service_providers/ntfy.py @@ -1,10 +1,10 @@ import requests -from pydantic import HttpUrl from media_manager.notification.config import NotificationConfig from media_manager.notification.schemas import MessageNotification -from media_manager.notification.service_providers.abstractNotificationServiceProvider import \ - AbstractNotificationServiceProvider +from media_manager.notification.service_providers.abstractNotificationServiceProvider import ( + AbstractNotificationServiceProvider, +) class NtfyNotificationServiceProvider(AbstractNotificationServiceProvider): @@ -15,15 +15,14 @@ class NtfyNotificationServiceProvider(AbstractNotificationServiceProvider): def __init__(self): self.config = NotificationConfig() - def send_notification(self, message: MessageNotification) -> bool: response = requests.post( - url = self.config.ntfy_url, + url=self.config.ntfy_url, data=message.message.encode(encoding="utf-8"), headers={ - "Title": "MediaManager - "+ message.title, - } + "Title": "MediaManager - " + message.title, + }, ) - if response.status_code not in range(200,300): + if response.status_code not in range(200, 300): return False return True diff --git a/media_manager/notification/service_providers/pushover.py b/media_manager/notification/service_providers/pushover.py index 680f299..3bbc224 100644 --- a/media_manager/notification/service_providers/pushover.py +++ b/media_manager/notification/service_providers/pushover.py @@ -2,25 +2,25 @@ import requests from media_manager.notification.config import NotificationConfig from media_manager.notification.schemas import MessageNotification -from media_manager.notification.service_providers.abstractNotificationServiceProvider import \ - AbstractNotificationServiceProvider +from media_manager.notification.service_providers.abstractNotificationServiceProvider import ( + AbstractNotificationServiceProvider, +) class PushoverNotificationServiceProvider(AbstractNotificationServiceProvider): def __init__(self): self.config = NotificationConfig() - def send_notification(self, message: MessageNotification) -> bool: response = requests.post( - url = "https://api.pushover.net/1/messages.json", + url="https://api.pushover.net/1/messages.json", params={ "token": self.config.pushover_api_key, "user": self.config.pushover_user, "message": message.message, - "title": "MediaManager - "+ message.title, - } + "title": "MediaManager - " + message.title, + }, ) - if response.status_code not in range(200,300): + if response.status_code not in range(200, 300): return False return True diff --git a/media_manager/notification/utils.py b/media_manager/notification/utils.py index ca5a406..45b2b63 100644 --- a/media_manager/notification/utils.py +++ b/media_manager/notification/utils.py @@ -7,7 +7,8 @@ from media_manager.notification.config import EmailConfig log = logging.getLogger(__name__) -def send_email(html: str, addressee: str, subject: str|list[str]) -> None: + +def send_email(html: str, addressee: str, subject: str | list[str]) -> None: email_conf = EmailConfig() message = MIMEMultipart() message["From"] = email_conf.from_email @@ -19,8 +20,6 @@ def send_email(html: str, addressee: str, subject: str|list[str]) -> None: if email_conf.use_tls: server.starttls() server.login(email_conf.smtp_user, email_conf.smtp_password) - server.sendmail(email_conf.from_email,addressee, message.as_string()) + server.sendmail(email_conf.from_email, addressee, message.as_string()) log.info(f"Successfully sent email to {addressee} with subject: {subject}") - - diff --git a/media_manager/torrent/utils.py b/media_manager/torrent/utils.py index 7d0a6df..afb16d5 100644 --- a/media_manager/torrent/utils.py +++ b/media_manager/torrent/utils.py @@ -49,9 +49,7 @@ def extract_archives(files): try: patoolib.extract_archive(str(file), outdir=str(file.parent)) except patoolib.util.PatoolError as e: - log.error( - f"Failed to extract archive {file}. Error: {e}" - ) + log.error(f"Failed to extract archive {file}. Error: {e}") def get_torrent_filepath(torrent: Torrent): diff --git a/media_manager/tv/service.py b/media_manager/tv/service.py index e8ada0d..12f96fe 100644 --- a/media_manager/tv/service.py +++ b/media_manager/tv/service.py @@ -494,7 +494,7 @@ class TvService: video_files, subtitle_files, all_files = import_torrent(torrent=torrent) - success: bool = True # determines if the import was successful, if true, the Imported flag will be set to True after the import + success: bool = True # determines if the import was successful, if true, the Imported flag will be set to True after the import log.info( f"Importing these {len(video_files)} files:\n" + pprint.pformat(video_files) @@ -574,7 +574,7 @@ class TvService: if self.notification_service: self.notification_service.send_notification_to_all_providers( title="Missing Episode File", - message=f"No video file found for S{season.number:02d}E{episode.number:02d} in torrent '{torrent.title}' for show {show.name}. Manual intervention may be required." + message=f"No video file found for S{season.number:02d}E{episode.number:02d} in torrent '{torrent.title}' for show {show.name}. Manual intervention may be required.", ) success = False log.warning( @@ -586,10 +586,12 @@ class TvService: # Send successful season download notification if self.notification_service: - season_info = ", ".join([f"Season {season_file.season_id}" for season_file in season_files]) + season_info = ", ".join( + [f"Season {season_file.season_id}" for season_file in season_files] + ) self.notification_service.send_notification_to_all_providers( title="TV Season Downloaded", - message=f"Successfully downloaded {show.name} ({show.year}) - {season_info}" + message=f"Successfully downloaded {show.name} ({show.year}) - {season_info}", ) log.info(f"Finished organizing files for torrent {torrent.title}")