From 24d4d7a293d300632da93b3e73a56150607e640a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Sat, 7 Mar 2026 05:50:13 +0100 Subject: [PATCH] Add support for changing the update interval for feeds --- README.md | 1 + discord_rss_bot/feeds.py | 8 +- discord_rss_bot/git_backup.py | 16 +- discord_rss_bot/hoyolab_api.py | 6 +- discord_rss_bot/main.py | 243 +++++++++++++++++++++++- discord_rss_bot/settings.py | 10 +- discord_rss_bot/static/styles.css | 4 + discord_rss_bot/templates/feed.html | 219 +++++++++++++-------- discord_rss_bot/templates/nav.html | 4 + discord_rss_bot/templates/settings.html | 122 ++++++++++++ pyproject.toml | 8 - tests/test_blacklist.py | 8 +- tests/test_custom_filter.py | 4 +- tests/test_feeds.py | 21 +- tests/test_main.py | 150 +++++++++++++++ tests/test_search.py | 4 +- tests/test_settings.py | 4 +- tests/test_update_interval.py | 88 +++++++++ 18 files changed, 802 insertions(+), 118 deletions(-) create mode 100644 discord_rss_bot/templates/settings.html create mode 100644 tests/test_update_interval.py diff --git a/README.md b/README.md index 26a8ad8..09b6bbc 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Discord: TheLovinator#9276 - Choose between Discord embed or plain text. - Regex filters for RSS feeds. - Blacklist/whitelist words in the title/description/author/etc. +- Set different update frequencies for each feed or use a global default. - Gets extra information from APIs if available, currently for: - [https://feeds.c3kay.de/](https://feeds.c3kay.de/) - Genshin Impact News diff --git a/discord_rss_bot/feeds.py b/discord_rss_bot/feeds.py index c01cecb..8e6ca63 100644 --- a/discord_rss_bot/feeds.py +++ b/discord_rss_bot/feeds.py @@ -98,7 +98,7 @@ def extract_domain(url: str) -> str: # noqa: PLR0911 return "Other" -def send_entry_to_discord(entry: Entry, custom_reader: Reader | None = None) -> str | None: # noqa: PLR0912 +def send_entry_to_discord(entry: Entry, custom_reader: Reader | None = None) -> str | None: # noqa: C901, PLR0912 """Send a single entry to Discord. Args: @@ -240,7 +240,7 @@ def set_title(custom_embed: CustomEmbed, discord_embed: DiscordEmbed) -> None: discord_embed.set_title(embed_title) if embed_title else None -def create_embed_webhook(webhook_url: str, entry: Entry) -> DiscordWebhook: +def create_embed_webhook(webhook_url: str, entry: Entry) -> DiscordWebhook: # noqa: C901 """Create a webhook with an embed. Args: @@ -341,7 +341,7 @@ def set_entry_as_read(reader: Reader, entry: Entry) -> None: logger.exception("Error setting entry to read: %s", entry.id) -def send_to_discord(custom_reader: Reader | None = None, feed: Feed | None = None, *, do_once: bool = False) -> None: # noqa: PLR0912 +def send_to_discord(custom_reader: Reader | None = None, feed: Feed | None = None, *, do_once: bool = False) -> None: # noqa: C901, PLR0912 """Send entries to Discord. If response was not ok, we will log the error and mark the entry as unread, so it will be sent again next time. @@ -520,7 +520,7 @@ def truncate_webhook_message(webhook_message: str) -> str: return webhook_message -def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: +def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: # noqa: C901 """Add a new feed, update it and mark every entry as read. Args: diff --git a/discord_rss_bot/git_backup.py b/discord_rss_bot/git_backup.py index 0277dca..4106d21 100644 --- a/discord_rss_bot/git_backup.py +++ b/discord_rss_bot/git_backup.py @@ -30,6 +30,8 @@ from pathlib import Path from typing import TYPE_CHECKING from typing import Any +from reader import TagNotFoundError + if TYPE_CHECKING: from reader import Reader @@ -66,6 +68,7 @@ _FEED_TAGS: tuple[str, ...] = ( "regex_whitelist_summary", "regex_whitelist_content", "regex_whitelist_author", + ".reader.update", ) @@ -178,10 +181,21 @@ def export_state(reader: Reader, backup_path: Path) -> None: webhooks: list[str | int | float | bool | dict[str, Any] | list[Any] | None] = list( reader.get_tag((), "webhooks", []) ) - except Exception: # noqa: BLE001 + except TagNotFoundError: webhooks = [] + # Export global update interval if set + global_update_interval: dict[str, Any] | None = None + try: + global_update_config = reader.get_tag((), ".reader.update", None) + if isinstance(global_update_config, dict): + global_update_interval = global_update_config + except TagNotFoundError: + pass + state: dict = {"feeds": feeds_state, "webhooks": webhooks} + if global_update_interval is not None: + state["global_update_interval"] = global_update_interval state_file: Path = backup_path / "state.json" state_file.write_text(json.dumps(state, indent=2, default=str), encoding="utf-8") diff --git a/discord_rss_bot/hoyolab_api.py b/discord_rss_bot/hoyolab_api.py index cb1ed71..227a413 100644 --- a/discord_rss_bot/hoyolab_api.py +++ b/discord_rss_bot/hoyolab_api.py @@ -4,10 +4,12 @@ import contextlib import json import logging import re -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING +from typing import Any import requests -from discord_webhook import DiscordEmbed, DiscordWebhook +from discord_webhook import DiscordEmbed +from discord_webhook import DiscordWebhook if TYPE_CHECKING: from reader import Entry diff --git a/discord_rss_bot/main.py b/discord_rss_bot/main.py index 0e884bf..2e7af0f 100644 --- a/discord_rss_bot/main.py +++ b/discord_rss_bot/main.py @@ -100,6 +100,46 @@ logging.config.dictConfig(LOGGING_CONFIG) logger: logging.Logger = logging.getLogger(__name__) reader: Reader = get_reader() +# Time constants for relative time formatting +SECONDS_PER_MINUTE = 60 +SECONDS_PER_HOUR = 3600 +SECONDS_PER_DAY = 86400 + + +def relative_time(dt: datetime | None) -> str: + """Convert a datetime to a relative time string (e.g., '2 hours ago', 'in 5 minutes'). + + Args: + dt: The datetime to convert (should be timezone-aware). + + Returns: + A human-readable relative time string. + """ + if dt is None: + return "Never" + + now = datetime.now(tz=UTC) + diff = dt - now + seconds = int(abs(diff.total_seconds())) + is_future = diff.total_seconds() > 0 + + # Determine the appropriate unit and value + if seconds < SECONDS_PER_MINUTE: + value = seconds + unit = "s" + elif seconds < SECONDS_PER_HOUR: + value = seconds // SECONDS_PER_MINUTE + unit = "m" + elif seconds < SECONDS_PER_DAY: + value = seconds // SECONDS_PER_HOUR + unit = "h" + else: + value = seconds // SECONDS_PER_DAY + unit = "d" + + # Format based on future or past + return f"in {value}{unit}" if is_future else f"{value}{unit} ago" + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None]: @@ -131,6 +171,7 @@ templates.env.filters["encode_url"] = lambda url: urllib.parse.quote(url) if url templates.env.filters["entry_is_whitelisted"] = entry_is_whitelisted templates.env.filters["entry_is_blacklisted"] = entry_is_blacklisted templates.env.filters["discord_markdown"] = markdownify +templates.env.filters["relative_time"] = relative_time templates.env.globals["get_backup_path"] = get_backup_path @@ -613,6 +654,102 @@ async def post_use_text(feed_url: Annotated[str, Form()]) -> RedirectResponse: return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) +@app.post("/set_update_interval") +async def post_set_update_interval( + feed_url: Annotated[str, Form()], + interval_minutes: Annotated[int | None, Form()] = None, + redirect_to: Annotated[str, Form()] = "", +) -> RedirectResponse: + """Set the update interval for a feed. + + Args: + feed_url: The feed to change. + interval_minutes: The update interval in minutes (None to reset to global default). + redirect_to: Optional redirect URL (defaults to feed page). + + Returns: + RedirectResponse: Redirect to the specified page or feed page. + """ + clean_feed_url: str = feed_url.strip() + + # If no interval specified, reset to global default + if interval_minutes is None: + try: + reader.delete_tag(clean_feed_url, ".reader.update") + commit_state_change(reader, f"Reset update interval to default for {clean_feed_url}") + except TagNotFoundError: + pass + else: + # Validate interval (minimum 1 minute, no maximum) + interval_minutes = max(interval_minutes, 1) + reader.set_tag(clean_feed_url, ".reader.update", {"interval": interval_minutes}) # pyright: ignore[reportArgumentType] + commit_state_change(reader, f"Set update interval to {interval_minutes} minutes for {clean_feed_url}") + + # Update the feed immediately to recalculate update_after with the new interval + try: + reader.update_feed(clean_feed_url) + logger.info("Updated feed after interval change: %s", clean_feed_url) + except Exception: + logger.exception("Failed to update feed after interval change: %s", clean_feed_url) + + if redirect_to: + return RedirectResponse(url=redirect_to, status_code=303) + return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) + + +@app.post("/reset_update_interval") +async def post_reset_update_interval( + feed_url: Annotated[str, Form()], + redirect_to: Annotated[str, Form()] = "", +) -> RedirectResponse: + """Reset the update interval for a feed to use the global default. + + Args: + feed_url: The feed to change. + redirect_to: Optional redirect URL (defaults to feed page). + + Returns: + RedirectResponse: Redirect to the specified page or feed page. + """ + clean_feed_url: str = feed_url.strip() + + try: + reader.delete_tag(clean_feed_url, ".reader.update") + commit_state_change(reader, f"Reset update interval to default for {clean_feed_url}") + except TagNotFoundError: + # Tag doesn't exist, which is fine + pass + + # Update the feed immediately to recalculate update_after with the new interval + try: + reader.update_feed(clean_feed_url) + logger.info("Updated feed after interval reset: %s", clean_feed_url) + except Exception: + logger.exception("Failed to update feed after interval reset: %s", clean_feed_url) + + if redirect_to: + return RedirectResponse(url=redirect_to, status_code=303) + return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) + + +@app.post("/set_global_update_interval") +async def post_set_global_update_interval(interval_minutes: Annotated[int, Form()]) -> RedirectResponse: + """Set the global default update interval. + + Args: + interval_minutes: The update interval in minutes. + + Returns: + RedirectResponse: Redirect to the settings page. + """ + # Validate interval (minimum 1 minute, no maximum) + interval_minutes = max(interval_minutes, 1) + + reader.set_tag((), ".reader.update", {"interval": interval_minutes}) # pyright: ignore[reportArgumentType] + commit_state_change(reader, f"Set global update interval to {interval_minutes} minutes") + return RedirectResponse(url="/settings", status_code=303) + + @app.get("/add", response_class=HTMLResponse) def get_add(request: Request): """Page for adding a new feed. @@ -631,7 +768,7 @@ def get_add(request: Request): @app.get("/feed", response_class=HTMLResponse) -async def get_feed(feed_url: str, request: Request, starting_after: str = ""): +async def get_feed(feed_url: str, request: Request, starting_after: str = ""): # noqa: C901, PLR0912, PLR0914, PLR0915 """Get a feed by URL. Args: @@ -656,7 +793,7 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): # Only show button if more than 10 entries. total_entries: int = reader.get_entry_counts(feed=feed).total or 0 - show_more_entires_button: bool = total_entries > entries_per_page + is_show_more_entries_button_visible: bool = total_entries > entries_per_page # Get entries from the feed. if starting_after: @@ -669,6 +806,27 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): msg: str = f"{e}\n\n{[entry.id for entry in current_entries]}" html: str = create_html_for_feed(current_entries) + # Get feed and global intervals for error case too + feed_interval: int | None = None + try: + feed_update_config = reader.get_tag(feed, ".reader.update") + if isinstance(feed_update_config, dict) and "interval" in feed_update_config: + interval_value = feed_update_config["interval"] + if isinstance(interval_value, int): + feed_interval = interval_value + except TagNotFoundError: + pass + + global_interval: int = 60 + try: + global_update_config = reader.get_tag((), ".reader.update") + if isinstance(global_update_config, dict) and "interval" in global_update_config: + interval_value = global_update_config["interval"] + if isinstance(interval_value, int): + global_interval = interval_value + except TagNotFoundError: + pass + context = { "request": request, "feed": feed, @@ -678,8 +836,10 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): "should_send_embed": False, "last_entry": None, "messages": msg, - "show_more_entires_button": show_more_entires_button, + "is_show_more_entries_button_visible": is_show_more_entries_button_visible, "total_entries": total_entries, + "feed_interval": feed_interval, + "global_interval": global_interval, } return templates.TemplateResponse(request=request, name="feed.html", context=context) @@ -708,6 +868,29 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): add_missing_tags(reader) should_send_embed: bool = bool(reader.get_tag(feed, "should_send_embed")) + # Get the update interval for this feed + feed_interval: int | None = None + try: + feed_update_config = reader.get_tag(feed, ".reader.update") + if isinstance(feed_update_config, dict) and "interval" in feed_update_config: + interval_value = feed_update_config["interval"] + if isinstance(interval_value, int): + feed_interval = interval_value + except TagNotFoundError: + # No custom interval set for this feed, will use global default + pass + + # Get the global default update interval + global_interval: int = 60 # Default to 60 minutes if not set + try: + global_update_config = reader.get_tag((), ".reader.update") + if isinstance(global_update_config, dict) and "interval" in global_update_config: + interval_value = global_update_config["interval"] + if isinstance(interval_value, int): + global_interval = interval_value + except TagNotFoundError: + pass + context = { "request": request, "feed": feed, @@ -716,8 +899,10 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): "html": html, "should_send_embed": should_send_embed, "last_entry": last_entry, - "show_more_entires_button": show_more_entires_button, + "is_show_more_entries_button_visible": is_show_more_entries_button_visible, "total_entries": total_entries, + "feed_interval": feed_interval, + "global_interval": global_interval, } return templates.TemplateResponse(request=request, name="feed.html", context=context) @@ -847,6 +1032,56 @@ def get_data_from_hook_url(hook_name: str, hook_url: str) -> WebhookInfo: return our_hook +@app.get("/settings", response_class=HTMLResponse) +async def get_settings(request: Request): + """Settings page. + + Args: + request: The request object. + + Returns: + HTMLResponse: The settings page. + """ + # Get the global default update interval + global_interval: int = 60 # Default to 60 minutes if not set + try: + global_update_config = reader.get_tag((), ".reader.update") + if isinstance(global_update_config, dict) and "interval" in global_update_config: + interval_value = global_update_config["interval"] + if isinstance(interval_value, int): + global_interval = interval_value + except TagNotFoundError: + pass + + # Get all feeds with their intervals + feeds: Iterable[Feed] = reader.get_feeds() + feed_intervals = [] + for feed in feeds: + feed_interval: int | None = None + try: + feed_update_config = reader.get_tag(feed, ".reader.update") + if isinstance(feed_update_config, dict) and "interval" in feed_update_config: + interval_value = feed_update_config["interval"] + if isinstance(interval_value, int): + feed_interval = interval_value + except TagNotFoundError: + pass + + feed_intervals.append({ + "feed": feed, + "interval": feed_interval, + "effective_interval": feed_interval or global_interval, + "domain": extract_domain(feed.url), + }) + + context = { + "request": request, + "global_interval": global_interval, + "feed_intervals": feed_intervals, + } + return templates.TemplateResponse(request=request, name="settings.html", context=context) + + @app.get("/webhooks", response_class=HTMLResponse) async def get_webhooks(request: Request): """Page for adding a new webhook. diff --git a/discord_rss_bot/settings.py b/discord_rss_bot/settings.py index 3ef7b1a..676a185 100644 --- a/discord_rss_bot/settings.py +++ b/discord_rss_bot/settings.py @@ -6,6 +6,7 @@ from pathlib import Path from platformdirs import user_data_dir from reader import Reader +from reader import TagNotFoundError from reader import make_reader if typing.TYPE_CHECKING: @@ -39,7 +40,12 @@ def get_reader(custom_location: Path | None = None) -> Reader: reader: Reader = make_reader(url=str(db_location)) # https://reader.readthedocs.io/en/latest/api.html#reader.types.UpdateConfig - # Set the update interval to 15 minutes - reader.set_tag((), ".reader.update", {"interval": 15}) + # Set the default update interval to 15 minutes if not already configured + # Users can change this via the Settings page or per-feed in the feed page + try: + reader.get_tag((), ".reader.update") + except TagNotFoundError: + # Set default + reader.set_tag((), ".reader.update", {"interval": 15}) return reader diff --git a/discord_rss_bot/static/styles.css b/discord_rss_bot/static/styles.css index db0cfba..5758237 100644 --- a/discord_rss_bot/static/styles.css +++ b/discord_rss_bot/static/styles.css @@ -13,3 +13,7 @@ body { .form-text { color: #acabab; } + +.interval-input { + max-width: 120px; +} \ No newline at end of file diff --git a/discord_rss_bot/templates/feed.html b/discord_rss_bot/templates/feed.html index 340a8a3..d58c714 100644 --- a/discord_rss_bot/templates/feed.html +++ b/discord_rss_bot/templates/feed.html @@ -1,90 +1,145 @@ {% extends "base.html" %} {% block title %} -| {{ feed.title }} + | {{ feed.title }} {% endblock title %} {% block content %} -
- -

- {{ feed.title }} ({{ total_entries }} entries) -

- {% if not feed.updates_enabled %} - Disabled - {% endif %} - - {% if feed.last_exception %} -
-
{{ feed.last_exception.type_name }}:
- {{ feed.last_exception.value_str }} - -
-
{{ feed.last_exception.traceback_str }}
+
+ +

+ {{ feed.title }} ({{ total_entries }} entries) +

+ {% if not feed.updates_enabled %}Disabled{% endif %} + {% if feed.last_exception %} +
+
{{ feed.last_exception.type_name }}:
+ {{ feed.last_exception.value_str }} + +
+
{{ feed.last_exception.traceback_str }}
+
+
+ {% endif %} + +
+ Update +
+ +
+ {% if not feed.updates_enabled %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% if not "youtube.com/feeds/videos.xml" in feed.url %} + {% if should_send_embed %} +
+ +
+ {% else %} +
+ +
+ {% endif %} + {% endif %} +
+ + + +
+
Feed Information
+
+
+ Added: {{ feed.added | relative_time }} +
+
+ Last Updated: {{ feed.last_updated | relative_time }} +
+
+ Last Retrieved: {{ feed.last_retrieved | relative_time }} +
+
+ Next Update: {{ feed.update_after | relative_time }} +
+
+ Updates: {{ 'Enabled' if feed.updates_enabled else 'Disabled' }} +
+
+
+ +
+
Update Interval
+ {% if feed_interval %} +

+ Current: {{ feed_interval }} minutes + {% if feed_interval >= 60 %}({{ (feed_interval / 60) | round(1) }} hours){% endif %} + Custom +

+ {% else %} +

+ Current: {{ global_interval }} minutes + {% if global_interval >= 60 %}({{ (global_interval / 60) | round(1) }} hours){% endif %} + Using global default +

+ {% endif %} +
+ +
+ + +
+
+ {% if feed_interval %} +
+ + +
+ {% endif %}
+ {# Rendered HTML content #} +
{{ html|safe }}
+ {% if is_show_more_entries_button_visible %} + + Show more entries + {% endif %} - - -
- Update - -
- -
- - {% if not feed.updates_enabled %} -
- -
- {% else %} -
- -
- {% endif %} - - {% if not "youtube.com/feeds/videos.xml" in feed.url %} - {% if should_send_embed %} -
- -
- {% else %} -
- -
- {% endif %} - {% endif %} -
- - - -
- -{# Rendered HTML content #} -
{{ html|safe }}
- -{% if show_more_entires_button %} - - Show more entries - -{% endif %} - {% endblock content %} diff --git a/discord_rss_bot/templates/nav.html b/discord_rss_bot/templates/nav.html index 93f3f93..7442554 100644 --- a/discord_rss_bot/templates/nav.html +++ b/discord_rss_bot/templates/nav.html @@ -19,6 +19,10 @@ + + {% if get_backup_path() %}