diff --git a/src/perun/get_episode.py b/src/perun/get_episode.py index ce9f304..55a0ef3 100644 --- a/src/perun/get_episode.py +++ b/src/perun/get_episode.py @@ -1,12 +1,15 @@ -import requests -import yt_dlp import os import time + +import requests +import yt_dlp from dotenv import load_dotenv -from ssh_helper import upload_via_sftp, send_notification_via_ssh -from youtube_handler import return_download_options, check_for_sponsorblock_segments from simple_logger_handler import setup_logger -from rss_feed_handler import grab_latest_chapter_information, EpisodeData + +from rss_feed_handler import EpisodeData, FeedError, grab_latest_chapter_information +from ssh_helper import send_notification_via_ssh, upload_via_sftp +from youtube_handler import check_for_sponsorblock_segments, return_download_options + logger = setup_logger(__name__) load_dotenv() @@ -84,8 +87,8 @@ def check_until_new_episode_gets_released() -> tuple[EpisodeData | None, str | N try: episode_data = grab_latest_chapter_information("UCC3ehuUksTyQ7bbjGntmx3Q") - except Exception as e: - logger.warning(f"[EpisodeCheck] Failed to fetch latest video data: {e}, retrying in 1 hour.") + except FeedError as e: + logger.warning(f"[EpisodeCheck] Failed to fetch latest video data: {e!r}, retrying in 1 hour.") time.sleep(CHECK_INTERVAL_HOURS * 3600) continue @@ -144,8 +147,8 @@ def download_episode() -> None: return logger.info("[App] New episode found") - except Exception as e: - logger.error(f"[App] Failed to fetch new episode info: {e}", exc_info=True) + except Exception: + logger.exception("[App] Failed to fetch new episode info") return try: @@ -165,8 +168,8 @@ def download_episode() -> None: try: options = return_download_options(episode_data) - except Exception as e: - logger.error(f"[App] Failed to generate download options: {e}", exc_info=True) + except Exception: + logger.exception("[App] Failed to generate download options") return logger.info("[App] Downloading new episode") @@ -174,8 +177,8 @@ def download_episode() -> None: with yt_dlp.YoutubeDL(options) as episode: episode.download(episode_data.episode_link) logger.debug("[App] Download completed successfully") - except Exception as e: - logger.error(f"[App] Failed to download episode: {e}", exc_info=True) + except Exception: + logger.exception("[App] Failed to download episode") return logger.info("[App] Uploading episode via SFTP") diff --git a/src/perun/helper.py b/src/perun/helper.py index 9917f46..67f95e1 100644 --- a/src/perun/helper.py +++ b/src/perun/helper.py @@ -1,5 +1,6 @@ import re + def return_string_as_html(input_text): string_without_ads="" for line in input_text.splitlines(): diff --git a/src/perun/requirements.txt b/src/perun/requirements.txt index 209f245..dd2db1e 100644 --- a/src/perun/requirements.txt +++ b/src/perun/requirements.txt @@ -1,23 +1,25 @@ --extra-index-url https://git.gansejunge.com/api/packages/notifier/pypi/simple/ bcrypt==5.0.0 -Brotli==1.1.0 +brotli==1.2.0 certifi==2026.10.19 -cffi==2.0.0 -charset-normalizer==3.4.3 -cryptography==46.0.2 +cffi==2.1.1 +charset-normalizer==3.5.2 +cryptography==50.0.2 +deno==2.9.7 dotenv==0.9.9 -feedparser==6.0.12 -idna==3.10 -invoke==2.2.0 -mutagen==1.47.0 -paramiko==4.0.0 -pycparser==2.23 -pycryptodomex==3.23.0 -PyNaCl==1.6.0 -python-dotenv==1.1.1 -requests==2.32.5 -sgmllib3k==1.0.0 -simple-logger-handler==0.1.0 -sponsorblock.py==0.2.3 -urllib3==2.5.0 -websockets==15.0.1 +feedparser==6.0.14 +feedparser-sgmllib==2.1.0 +idna==3.20 +invoke==3.0.3 +mutagen==1.48.1 +paramiko==5.1.0 +pycparser==3.11 +pycryptodomex==3.24.1 +PyNaCl==1.6.2 +python-dotenv==1.2.4 +python-json-logger==4.2.0 +requests==2.34.2 +simple-logger-handler==0.2.1 +sponsorblock.py==1.0.0 +urllib3==2.8.0 +websockets==17.3 \ No newline at end of file diff --git a/src/perun/rss_feed_handler.py b/src/perun/rss_feed_handler.py index cca78fb..9062fed 100644 --- a/src/perun/rss_feed_handler.py +++ b/src/perun/rss_feed_handler.py @@ -1,11 +1,22 @@ -import feedparser -from simple_logger_handler import setup_logger import time -from urllib.error import URLError -from typing import Optional from dataclasses import dataclass from datetime import datetime +import feedparser +from simple_logger_handler import setup_logger + + +class FeedError(Exception): + """Base class for all errors raised while fetching or reading the feed.""" + + +class FeedUnavailableError(FeedError): + """The feed could not be fetched (network failure or HTTP error status).""" + + +class FeedParseError(FeedError): + """The feed was fetched but its content is empty, malformed or unexpected.""" + @dataclass class EpisodeData: episode_date: str @@ -17,12 +28,24 @@ class EpisodeData: logger = setup_logger(__name__) -def grab_latest_chapter_information(id: str, max_retries: int = 3) -> Optional[EpisodeData]: +def fetch_feed(url: str) -> feedparser.FeedParserDict: + feed = feedparser.parse(url) + status = feed.get("status") + + if status is None: + raise FeedUnavailableError(f"No HTTP response: {feed.get('bozo_exception')}") + if status >= 400: + raise FeedUnavailableError(f"HTTP {status}") + if not feed.entries: + raise FeedParseError(f"No entries in feed (bozo={feed.get('bozo_exception')})") + return feed + +def grab_latest_chapter_information(channel_id: str, max_retries: int = 3) -> EpisodeData: """ Fetches the latest episodes information from a Youtube RSS feed, with retries on network-related errors. Parameters: - id: Youtube channel ID as a string. + channel_id: Youtube channel ID as a string. max_retries: Number of retry attempts if fetching the feed fails due to network issues. Returns: @@ -36,50 +59,45 @@ def grab_latest_chapter_information(id: str, max_retries: int = 3) -> Optional[E Returns None if the feed has no entries or all retries are exhausted. Raises: - ValueError: If the feed has no entries. - Other network-related exceptions: If fetching fails after retries. + FeedUnavailableError: Feed could not be fetched after all retries. + FeedParseError: Feed was fetched but is empty or has unexpected content. """ - rss_feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={id}" + rss_feed_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}" attempt = 1 while attempt <= max_retries: logger.debug(f"[Feed] Parsing feed URL: {rss_feed_url} (attempt {attempt}/{max_retries})") try: - feed = feedparser.parse(rss_feed_url) - - if not feed.entries: - logger.warning(f"[Feed] No entries found for feed {id}") - return None - - latest_chapter_data = feed["entries"][0] - episode_link = latest_chapter_data["link"] - episode_title = latest_chapter_data["title"] - episode_description = latest_chapter_data["summary"] - episode_date = latest_chapter_data["published"] - episode_date = datetime.fromisoformat(episode_date).date().isoformat() - episode_ytid = latest_chapter_data["yt_videoid"] - - logger.info(f"[Feed] Latest episode '{episode_title}': {episode_link}") - logger.debug(f"[Feed] Latest episode '{episode_title}' (YouTubeId {episode_ytid}): {episode_link} -> {episode_description}") - return EpisodeData( - episode_date=episode_date, - episode_description=episode_description, - episode_link=episode_link, - episode_number="", - episode_title=episode_title, - episode_ytid=episode_ytid - ) - - except (URLError, OSError) as e: - logger.warning(f"[Feed] Network error on attempt {attempt} for feed {id}: {e}") + feed = fetch_feed(rss_feed_url) + break + except FeedUnavailableError as e: + logger.warning(f"[Feed] Attempt {attempt}/{max_retries} failed for {channel_id}: {e}") if attempt == max_retries: - logger.error(f"[Feed] All {max_retries} attempts failed for feed {id}") - return None + logger.error(f"[Feed] All {max_retries} attempts failed for feed {channel_id}") + raise backoff = 2 ** (attempt - 1) logger.debug(f"[Feed] Retrying in {backoff} seconds...") time.sleep(backoff) attempt += 1 + latest = feed.entries[0] + try: + episode_date = datetime.fromisoformat(latest["published"]).date().isoformat() + episode = EpisodeData( + episode_date=episode_date, + episode_description=latest["summary"], + episode_link=latest["link"], + episode_number="", + episode_title=latest["title"], + episode_ytid=latest["yt_videoid"], + ) + except (KeyError, ValueError) as e: + raise FeedParseError(f"Unexpected entry format for {channel_id}: {e!r}") from e + + logger.info(f"[Feed] Latest episode '{episode.episode_title}': {episode.episode_link}") + logger.debug(f"[Feed] Latest episode '{episode.episode_title}' (YouTubeId {episode.episode_ytid}): {episode.episode_link} -> {episode.episode_description}") + return episode + if __name__ == "__main__": print(grab_latest_chapter_information("UCC3ehuUksTyQ7bbjGntmx3Q")) \ No newline at end of file diff --git a/src/perun/ssh_helper.py b/src/perun/ssh_helper.py index 3c24daf..94ba13f 100644 --- a/src/perun/ssh_helper.py +++ b/src/perun/ssh_helper.py @@ -1,10 +1,11 @@ -import paramiko import os -from dotenv import load_dotenv -from json import dumps -from simple_logger_handler import setup_logger -import time import shlex +import time +from json import dumps + +import paramiko +from dotenv import load_dotenv +from simple_logger_handler import setup_logger logger = setup_logger(__name__) load_dotenv() @@ -78,8 +79,8 @@ def create_ssh_client(hostname: str, port: int, username: str, keyfile: str)-> p ssh.connect(hostname=hostname, username=username, port=port, pkey=pkey) logger.debug("[SSH] SSH connection established successfully") return ssh - except Exception as e: - logger.error(f"[SSH] SSH connection failed: {e}", exc_info=True) + except Exception: + logger.exception("[SSH] SSH connection failed") raise @@ -110,8 +111,8 @@ def upload_via_sftp(filename) -> None: sftp.close() transport.close() logger.info(f"[SFTP] File '{filename}' uploaded successfully") - except Exception as e: - logger.error(f"[SFTP] SFTP upload failed for '{filename}': {e}", exc_info=True) + except Exception: + logger.exception(f"[SFTP] SFTP upload failed for '{filename}'") raise @@ -171,8 +172,8 @@ def send_notification_via_ssh(notification_title, notification_info) -> None: logger.warning(f"[Notification] Notification command exited with {exit_status}") logger.warning(f"[Notification] Error: {error_output}") logger.warning(f"[Notification] Response: {response_output}") - except Exception as e: - logger.error(f"[Notification] Failed to send SSH notification: {e}", exc_info=True) + except Exception: + logger.exception("[Notification] Failed to send SSH notification") raise finally: if ssh: diff --git a/src/perun/youtube_handler.py b/src/perun/youtube_handler.py index 54864aa..31f41b7 100644 --- a/src/perun/youtube_handler.py +++ b/src/perun/youtube_handler.py @@ -1,13 +1,11 @@ -import yt_dlp -import datetime -import contextlib -from dotenv import load_dotenv -import os -from helper import return_string_as_html -from simple_logger_handler import setup_logger import json -import sponsorblock as sb +import os +import sponsorblock as sb +from dotenv import load_dotenv +from simple_logger_handler import setup_logger + +from helper import return_string_as_html logger = setup_logger(__name__) load_dotenv()