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.last_exception.value_str }}
-
- {{ feed.last_exception.traceback_str }}
+ {{ feed.last_exception.value_str }}
+
+ {{ feed.last_exception.traceback_str }}
+ + 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 %}{{ html|safe }}
+ {% if is_show_more_entries_button_visible %}
+
+ Show more entries
+
{% endif %}
-
-
- {{ 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 @@
+ Set a default interval for all feeds. Individual feeds can still override this value. +
++ Customize the update interval for individual feeds. Leave empty or reset to use the global default. +
+| Feed | +Domain | +Status | +Interval | +Last Updated | +Next Update | +Set Interval (min) | +Actions | +
|---|---|---|---|---|---|---|---|
| + {{ item.feed.title }} + | ++ {{ item.domain }} + | ++ + {{ 'Enabled' if item.feed.updates_enabled else 'Disabled' }} + + | ++ {{ item.effective_interval }} min + {% if item.interval %} + Custom + {% else %} + Global + {% endif %} + | ++ {{ item.feed.last_updated | relative_time }} + | ++ {{ item.feed.update_after | relative_time }} + | ++ + | ++ {% if item.interval %} + + {% endif %} + | +
No feeds added yet.
+ {% endif %} +