diff --git a/.env.example b/.env.example deleted file mode 100644 index 90e4cce..0000000 --- a/.env.example +++ /dev/null @@ -1,19 +0,0 @@ -# You can optionally store backups of your bot's configuration in a git repository. -# This allows you to track changes by subscribing to the repository or using a RSS feed. -# Local path for the backup git repository (e.g., /data/backup or /home/user/backups/discord-rss-bot) -# When set, the bot will initialize a git repo here and commit state.json after every configuration change -# GIT_BACKUP_PATH= - -# Remote URL for pushing backup commits (e.g., git@github.com:username/private-config.git) -# Optional - only set if you want automatic pushes to a remote repository -# Leave empty to keep git history local only -# GIT_BACKUP_REMOTE= - -# Sentry Configuration (Optional) -# Sentry DSN for error tracking and monitoring -# Leave empty to disable Sentry integration -# SENTRY_DSN= - -# Testing Configuration -# Discord webhook URL used for testing (optional, only needed when running tests) -# TEST_WEBHOOK_URL= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d68331..6ebdf7e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: # GitHub Container Registry - - uses: docker/login-action@v4 + - uses: docker/login-action@v3 if: github.event_name != 'pull_request' with: registry: ghcr.io @@ -25,18 +25,18 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} # Download the latest commit from the master branch - - uses: actions/checkout@v6 + - uses: actions/checkout@v5 # Set up QEMU - id: qemu - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@v3 with: image: tonistiigi/binfmt:master platforms: linux/amd64,linux/arm64 cache-image: false # Set up Buildx so we can build multi-arch images - - uses: docker/setup-buildx-action@v4 + - uses: docker/setup-buildx-action@v3 # Install the latest version of ruff - uses: astral-sh/ruff-action@v3 @@ -68,7 +68,7 @@ jobs: # Extract metadata (tags, labels) from Git reference and GitHub events for Docker - id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@v5 env: DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index with: @@ -79,7 +79,7 @@ jobs: type=raw,value=master,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} # Build and push the Docker image - - uses: docker/build-push-action@v7 + - uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/README.md b/README.md index 09b6bbc..440058a 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,6 @@ Subscribe to RSS feeds and get updates to a Discord webhook. -Email: [tlovinator@gmail.com](mailto:tlovinator@gmail.com) - -Discord: TheLovinator#9276 - ## Features - Subscribe to RSS feeds and get updates to a Discord webhook. @@ -14,7 +10,6 @@ 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 @@ -45,7 +40,7 @@ or [install directly on your computer](#install-directly-on-your-computer). ### Install directly on your computer - Install the latest of [uv](https://docs.astral.sh/uv/#installation): - - `powershell -ExecutionPolicy ByPass -c "irm | iex"` + - `powershell -ExecutionPolicy ByPass -c "irm | iex"` - Download the project from GitHub with Git or download the [ZIP](https://github.com/TheLovinator1/discord-rss-bot/archive/refs/heads/master.zip). - If you want to update the bot, you can run `git pull` in the project folder or download the ZIP again. @@ -63,49 +58,8 @@ or [install directly on your computer](#install-directly-on-your-computer). - Use [Windows Task Scheduler](https://en.wikipedia.org/wiki/Windows_Task_Scheduler). - Or add a shortcut to `%userprofile%\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup`. -## Git Backup (State Version Control) +## Contact -The bot can commit every configuration change (adding/removing feeds, webhook -changes, blacklist/whitelist updates) to a separate private Git repository so -you get a full, auditable history of state changes — similar to `etckeeper`. +Email: [tlovinator@gmail.com](mailto:tlovinator@gmail.com) -### Configuration - -Set the following environment variables (e.g. in `docker-compose.yml` or a -`.env` file): - -| Variable | Required | Description | -| ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| `GIT_BACKUP_PATH` | Yes | Local path where the backup git repository is stored. The bot will initialise it automatically if it does not yet exist. | -| `GIT_BACKUP_REMOTE` | No | Remote URL to push to after each commit (e.g. `git@github.com:you/private-config.git`). Leave unset to keep the history local only. | - -### What is backed up - -After every relevant change a `state.json` file is written and committed. -The file contains: - -- All feed URLs together with their webhook URL, custom message, embed - settings, and any blacklist/whitelist filters. -- The global list of Discord webhooks. - -### Docker example - -```yaml -services: - discord-rss-bot: - image: ghcr.io/thelovinator1/discord-rss-bot:latest - volumes: - - ./data:/data - environment: - - GIT_BACKUP_PATH=/data/backup - - GIT_BACKUP_REMOTE=git@github.com:you/private-config.git -``` - -For SSH-based remotes mount your SSH key into the container and make sure the -host key is trusted, e.g.: - -```yaml - volumes: - - ./data:/data - - ~/.ssh:/root/.ssh:ro -``` +Discord: TheLovinator#9276 diff --git a/discord_rss_bot/custom_filters.py b/discord_rss_bot/custom_filters.py index 7d8fe83..99fe77d 100644 --- a/discord_rss_bot/custom_filters.py +++ b/discord_rss_bot/custom_filters.py @@ -4,15 +4,12 @@ import urllib.parse from functools import lru_cache from typing import TYPE_CHECKING -from discord_rss_bot.filter.blacklist import entry_should_be_skipped -from discord_rss_bot.filter.blacklist import feed_has_blacklist_tags -from discord_rss_bot.filter.whitelist import has_white_tags -from discord_rss_bot.filter.whitelist import should_be_sent +from discord_rss_bot.filter.blacklist import entry_should_be_skipped, feed_has_blacklist_tags +from discord_rss_bot.filter.whitelist import has_white_tags, should_be_sent from discord_rss_bot.settings import get_reader if TYPE_CHECKING: - from reader import Entry - from reader import Reader + from reader import Entry, Reader # Our reader reader: Reader = get_reader() diff --git a/discord_rss_bot/custom_message.py b/discord_rss_bot/custom_message.py index 058b275..99a7e11 100644 --- a/discord_rss_bot/custom_message.py +++ b/discord_rss_bot/custom_message.py @@ -1,17 +1,12 @@ from __future__ import annotations -import html import json import logging from dataclasses import dataclass -from bs4 import BeautifulSoup -from bs4 import Tag +from bs4 import BeautifulSoup, Tag from markdownify import markdownify -from reader import Entry -from reader import Feed -from reader import Reader -from reader import TagNotFoundError +from reader import Entry, Feed, Reader, TagNotFoundError from discord_rss_bot.is_url_valid import is_url_valid from discord_rss_bot.settings import get_reader @@ -73,10 +68,6 @@ def replace_tags_in_text_message(entry: Entry) -> str: first_image: str = get_first_image(summary, content) - # Unescape HTML entities (e.g., <h1> becomes

) before converting to markdown - summary = html.unescape(summary) - content = html.unescape(content) - summary = markdownify( html=summary, strip=["img", "table", "td", "tr", "tbody", "thead"], @@ -208,10 +199,6 @@ def replace_tags_in_embed(feed: Feed, entry: Entry) -> CustomEmbed: first_image: str = get_first_image(summary, content) - # Unescape HTML entities (e.g., <h1> becomes

) before converting to markdown - summary = html.unescape(summary) - content = html.unescape(content) - summary = markdownify( html=summary, strip=["img", "table", "td", "tr", "tbody", "thead"], diff --git a/discord_rss_bot/feeds.py b/discord_rss_bot/feeds.py index 8e6ca63..ff38a6c 100644 --- a/discord_rss_bot/feeds.py +++ b/discord_rss_bot/feeds.py @@ -5,41 +5,42 @@ import logging import os import pprint import re -from typing import TYPE_CHECKING -from typing import Any -from urllib.parse import ParseResult -from urllib.parse import urlparse +from typing import TYPE_CHECKING, Any +from urllib.parse import ParseResult, urlparse import tldextract -from discord_webhook import DiscordEmbed -from discord_webhook import DiscordWebhook +from discord_webhook import DiscordEmbed, DiscordWebhook from fastapi import HTTPException from markdownify import markdownify -from reader import Entry -from reader import EntryNotFoundError -from reader import Feed -from reader import FeedExistsError -from reader import FeedNotFoundError -from reader import Reader -from reader import ReaderError -from reader import StorageError -from reader import TagNotFoundError +from reader import ( + Entry, + EntryNotFoundError, + Feed, + FeedExistsError, + FeedNotFoundError, + Reader, + ReaderError, + StorageError, + TagNotFoundError, +) -from discord_rss_bot.custom_message import CustomEmbed -from discord_rss_bot.custom_message import get_custom_message -from discord_rss_bot.custom_message import replace_tags_in_embed -from discord_rss_bot.custom_message import replace_tags_in_text_message +from discord_rss_bot.custom_message import ( + CustomEmbed, + get_custom_message, + replace_tags_in_embed, + replace_tags_in_text_message, +) from discord_rss_bot.filter.blacklist import entry_should_be_skipped -from discord_rss_bot.filter.whitelist import has_white_tags -from discord_rss_bot.filter.whitelist import should_be_sent -from discord_rss_bot.hoyolab_api import create_hoyolab_webhook -from discord_rss_bot.hoyolab_api import extract_post_id_from_hoyolab_url -from discord_rss_bot.hoyolab_api import fetch_hoyolab_post -from discord_rss_bot.hoyolab_api import is_c3kay_feed +from discord_rss_bot.filter.whitelist import has_white_tags, should_be_sent +from discord_rss_bot.hoyolab_api import ( + create_hoyolab_webhook, + extract_post_id_from_hoyolab_url, + fetch_hoyolab_post, + is_c3kay_feed, +) from discord_rss_bot.is_url_valid import is_url_valid from discord_rss_bot.missing_tags import add_missing_tags -from discord_rss_bot.settings import default_custom_message -from discord_rss_bot.settings import get_reader +from discord_rss_bot.settings import default_custom_message, get_reader if TYPE_CHECKING: from collections.abc import Iterable @@ -98,7 +99,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: C901, PLR0912 +def send_entry_to_discord(entry: Entry, custom_reader: Reader | None = None) -> str | None: # noqa: PLR0912 """Send a single entry to Discord. Args: @@ -240,7 +241,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: # noqa: C901 +def create_embed_webhook(webhook_url: str, entry: Entry) -> DiscordWebhook: """Create a webhook with an embed. Args: @@ -341,7 +342,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: C901, PLR0912 +def send_to_discord(custom_reader: Reader | None = None, feed: Feed | None = None, *, do_once: bool = False) -> None: # noqa: 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 +521,7 @@ def truncate_webhook_message(webhook_message: str) -> str: return webhook_message -def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: # noqa: C901 +def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: """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 deleted file mode 100644 index 4106d21..0000000 --- a/discord_rss_bot/git_backup.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Git backup module for committing bot state changes to a private repository. - -Configure the backup by setting these environment variables: -- ``GIT_BACKUP_PATH``: Local filesystem path for the backup git repository. - When set, the bot will initialise a git repo there (if one doesn't exist) - and commit an export of its state after every relevant change. -- ``GIT_BACKUP_REMOTE``: Optional remote URL (e.g. ``git@github.com:you/private-repo.git``). - When set, every commit is followed by a ``git push`` to this remote. - -The exported state is written as ``state.json`` inside the backup repo. It -contains the list of feeds together with their webhook URL, filter settings -(blacklist / whitelist, regex variants), custom messages and embed settings. -Global webhooks are also included. - -Example docker-compose snippet:: - - environment: - - GIT_BACKUP_PATH=/data/backup - - GIT_BACKUP_REMOTE=git@github.com:you/private-config.git -""" - -from __future__ import annotations - -import json -import logging -import os -import shutil -import subprocess # noqa: S404 -from pathlib import Path -from typing import TYPE_CHECKING -from typing import Any - -from reader import TagNotFoundError - -if TYPE_CHECKING: - from reader import Reader - -logger: logging.Logger = logging.getLogger(__name__) -GIT_EXECUTABLE: str = shutil.which("git") or "git" - - -type TAG_VALUE = ( - dict[str, str | int | float | bool | dict[str, Any] | list[Any] | None] - | list[str | int | float | bool | dict[str, Any] | list[Any] | None] - | None -) -"""Type alias for the value of a feed tag, which can be a nested structure of dicts and lists, or None.""" - -# Tags that are exported per-feed (empty values are omitted). -_FEED_TAGS: tuple[str, ...] = ( - "webhook", - "custom_message", - "should_send_embed", - "embed", - "blacklist_title", - "blacklist_summary", - "blacklist_content", - "blacklist_author", - "regex_blacklist_title", - "regex_blacklist_summary", - "regex_blacklist_content", - "regex_blacklist_author", - "whitelist_title", - "whitelist_summary", - "whitelist_content", - "whitelist_author", - "regex_whitelist_title", - "regex_whitelist_summary", - "regex_whitelist_content", - "regex_whitelist_author", - ".reader.update", -) - - -def get_backup_path() -> Path | None: - """Return the configured backup path, or *None* if not configured. - - Returns: - Path to the backup repository, or None if ``GIT_BACKUP_PATH`` is unset. - """ - raw: str = os.environ.get("GIT_BACKUP_PATH", "").strip() - return Path(raw) if raw else None - - -def get_backup_remote() -> str: - """Return the configured remote URL, or an empty string if not set. - - Returns: - The remote URL string from ``GIT_BACKUP_REMOTE``, or ``""`` if unset. - """ - return os.environ.get("GIT_BACKUP_REMOTE", "").strip() - - -def setup_backup_repo(backup_path: Path) -> bool: - """Ensure the backup directory exists and contains a git repository. - - If the directory does not yet contain a ``.git`` folder a new repository is - initialised. A basic git identity is configured locally so that commits - succeed even in environments where a global ``~/.gitconfig`` is absent. - - Args: - backup_path: Local path for the backup repository. - - Returns: - ``True`` if the repository is ready, ``False`` on any error. - """ - try: - backup_path.mkdir(parents=True, exist_ok=True) - git_dir: Path = backup_path / ".git" - if not git_dir.exists(): - subprocess.run([GIT_EXECUTABLE, "init", str(backup_path)], check=True, capture_output=True) # noqa: S603 - logger.info("Initialised git backup repository at %s", backup_path) - - # Ensure a local identity exists so that `git commit` always works. - for key, value in (("user.email", "discord-rss-bot@localhost"), ("user.name", "discord-rss-bot")): - result: subprocess.CompletedProcess[bytes] = subprocess.run( # noqa: S603 - [GIT_EXECUTABLE, "-C", str(backup_path), "config", "--local", key], - check=False, - capture_output=True, - ) - if result.returncode != 0: - subprocess.run( # noqa: S603 - [GIT_EXECUTABLE, "-C", str(backup_path), "config", "--local", key, value], - check=True, - capture_output=True, - ) - - # Configure the remote if GIT_BACKUP_REMOTE is set. - remote_url: str = get_backup_remote() - if remote_url: - # Check if remote "origin" already exists. - check_remote: subprocess.CompletedProcess[bytes] = subprocess.run( # noqa: S603 - [GIT_EXECUTABLE, "-C", str(backup_path), "remote", "get-url", "origin"], - check=False, - capture_output=True, - ) - if check_remote.returncode != 0: - # Remote doesn't exist, add it. - subprocess.run( # noqa: S603 - [GIT_EXECUTABLE, "-C", str(backup_path), "remote", "add", "origin", remote_url], - check=True, - capture_output=True, - ) - logger.info("Added remote 'origin' with URL: %s", remote_url) - else: - # Remote exists, update it if the URL has changed. - current_url: str = check_remote.stdout.decode().strip() - if current_url != remote_url: - subprocess.run( # noqa: S603 - [GIT_EXECUTABLE, "-C", str(backup_path), "remote", "set-url", "origin", remote_url], - check=True, - capture_output=True, - ) - logger.info("Updated remote 'origin' URL from %s to %s", current_url, remote_url) - except Exception: - logger.exception("Failed to set up git backup repository at %s", backup_path) - return False - return True - - -def export_state(reader: Reader, backup_path: Path) -> None: - """Serialise the current bot state to ``state.json`` inside *backup_path*. - - Args: - reader: The :class:`reader.Reader` instance to read state from. - backup_path: Destination directory for the exported ``state.json``. - """ - feeds_state: list[dict] = [] - for feed in reader.get_feeds(): - feed_data: dict = {"url": feed.url} - for tag in _FEED_TAGS: - try: - value: TAG_VALUE = reader.get_tag(feed, tag, None) - if value is not None and value != "": # noqa: PLC1901 - feed_data[tag] = value - except Exception: - logger.exception("Failed to read tag '%s' for feed '%s' during state export", tag, feed.url) - feeds_state.append(feed_data) - - try: - webhooks: list[str | int | float | bool | dict[str, Any] | list[Any] | None] = list( - reader.get_tag((), "webhooks", []) - ) - 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") - - -def commit_state_change(reader: Reader, message: str) -> None: - """Export current state and commit it to the backup repository. - - This is a no-op when ``GIT_BACKUP_PATH`` is not configured. Errors are - logged but never raised so that a backup failure never interrupts normal - bot operation. - - Args: - reader: The :class:`reader.Reader` instance to read state from. - message: Commit message describing the change (e.g. ``"Add feed example.com/rss.xml"``). - """ - backup_path: Path | None = get_backup_path() - if backup_path is None: - return - - if not setup_backup_repo(backup_path): - return - - try: - export_state(reader, backup_path) - - subprocess.run([GIT_EXECUTABLE, "-C", str(backup_path), "add", "-A"], check=True, capture_output=True) # noqa: S603 - - # Only create a commit if there are staged changes. - diff_result: subprocess.CompletedProcess[bytes] = subprocess.run( # noqa: S603 - [GIT_EXECUTABLE, "-C", str(backup_path), "diff", "--cached", "--exit-code"], - check=False, - capture_output=True, - ) - if diff_result.returncode == 0: - logger.debug("No state changes to commit for: %s", message) - return - - subprocess.run( # noqa: S603 - [GIT_EXECUTABLE, "-C", str(backup_path), "commit", "-m", message], - check=True, - capture_output=True, - ) - logger.info("Committed state change to backup repo: %s", message) - - # Push to remote if configured. - if get_backup_remote(): - subprocess.run( # noqa: S603 - [GIT_EXECUTABLE, "-C", str(backup_path), "push", "origin", "HEAD"], - check=True, - capture_output=True, - ) - logger.info("Pushed state change to remote 'origin': %s", message) - except Exception: - logger.exception("Failed to commit state change '%s' to backup repo", message) diff --git a/discord_rss_bot/hoyolab_api.py b/discord_rss_bot/hoyolab_api.py index 227a413..cb1ed71 100644 --- a/discord_rss_bot/hoyolab_api.py +++ b/discord_rss_bot/hoyolab_api.py @@ -4,12 +4,10 @@ import contextlib import json import logging import re -from typing import TYPE_CHECKING -from typing import Any +from typing import TYPE_CHECKING, Any import requests -from discord_webhook import DiscordEmbed -from discord_webhook import DiscordWebhook +from discord_webhook import DiscordEmbed, DiscordWebhook if TYPE_CHECKING: from reader import Entry diff --git a/discord_rss_bot/is_url_valid.py b/discord_rss_bot/is_url_valid.py index c986b4a..cca1491 100644 --- a/discord_rss_bot/is_url_valid.py +++ b/discord_rss_bot/is_url_valid.py @@ -1,7 +1,6 @@ from __future__ import annotations -from urllib.parse import ParseResult -from urllib.parse import urlparse +from urllib.parse import ParseResult, urlparse def is_url_valid(url: str) -> bool: diff --git a/discord_rss_bot/main.py b/discord_rss_bot/main.py index 2e7af0f..7ef30da 100644 --- a/discord_rss_bot/main.py +++ b/discord_rss_bot/main.py @@ -7,62 +7,48 @@ import typing import urllib.parse from contextlib import asynccontextmanager from dataclasses import dataclass -from datetime import UTC -from datetime import datetime +from datetime import UTC, datetime from functools import lru_cache -from typing import TYPE_CHECKING -from typing import Annotated -from typing import Any -from typing import cast +from typing import TYPE_CHECKING, Annotated, cast import httpx import sentry_sdk import uvicorn from apscheduler.schedulers.asyncio import AsyncIOScheduler -from fastapi import FastAPI -from fastapi import Form -from fastapi import HTTPException -from fastapi import Request +from fastapi import FastAPI, Form, HTTPException, Request from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from httpx import Response from markdownify import markdownify -from reader import Entry -from reader import EntryNotFoundError -from reader import Feed -from reader import FeedNotFoundError -from reader import Reader -from reader import TagNotFoundError +from reader import Entry, EntryNotFoundError, Feed, FeedNotFoundError, Reader, TagNotFoundError from starlette.responses import RedirectResponse from discord_rss_bot import settings -from discord_rss_bot.custom_filters import entry_is_blacklisted -from discord_rss_bot.custom_filters import entry_is_whitelisted -from discord_rss_bot.custom_message import CustomEmbed -from discord_rss_bot.custom_message import get_custom_message -from discord_rss_bot.custom_message import get_embed -from discord_rss_bot.custom_message import get_first_image -from discord_rss_bot.custom_message import replace_tags_in_text_message -from discord_rss_bot.custom_message import save_embed -from discord_rss_bot.feeds import create_feed -from discord_rss_bot.feeds import extract_domain -from discord_rss_bot.feeds import send_entry_to_discord -from discord_rss_bot.feeds import send_to_discord -from discord_rss_bot.git_backup import commit_state_change -from discord_rss_bot.git_backup import get_backup_path +from discord_rss_bot.custom_filters import ( + entry_is_blacklisted, + entry_is_whitelisted, +) +from discord_rss_bot.custom_message import ( + CustomEmbed, + get_custom_message, + get_embed, + get_first_image, + replace_tags_in_text_message, + save_embed, +) +from discord_rss_bot.feeds import create_feed, extract_domain, send_entry_to_discord, send_to_discord from discord_rss_bot.missing_tags import add_missing_tags from discord_rss_bot.search import create_search_context from discord_rss_bot.settings import get_reader if TYPE_CHECKING: - from collections.abc import AsyncGenerator - from collections.abc import Iterable + from collections.abc import AsyncGenerator, Iterable from reader.types import JSONType -LOGGING_CONFIG: dict[str, Any] = { +LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": True, "formatters": { @@ -100,46 +86,6 @@ 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]: @@ -171,8 +117,6 @@ 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 @app.post("/add_webhook") @@ -186,11 +130,11 @@ async def post_add_webhook( webhook_name: The name of the webhook. webhook_url: The url of the webhook. - Returns: - RedirectResponse: Redirect to the index page. - Raises: HTTPException: If the webhook already exists. + + Returns: + RedirectResponse: Redirect to the index page. """ # Get current webhooks from the database if they exist otherwise use an empty list. webhooks = list(reader.get_tag((), "webhooks", [])) @@ -207,8 +151,6 @@ async def post_add_webhook( reader.set_tag((), "webhooks", webhooks) # pyright: ignore[reportArgumentType] - commit_state_change(reader, f"Add webhook {webhook_name.strip()}") - return RedirectResponse(url="/", status_code=303) # TODO(TheLovinator): Show this error on the page. @@ -223,12 +165,11 @@ async def post_delete_webhook(webhook_url: Annotated[str, Form()]) -> RedirectRe Args: webhook_url: The url of the webhook. - Returns: - RedirectResponse: Redirect to the index page. - Raises: HTTPException: If the webhook could not be deleted + Returns: + RedirectResponse: Redirect to the index page. """ # TODO(TheLovinator): Check if the webhook is in use by any feeds before deleting it. # TODO(TheLovinator): Replace HTTPException with a custom exception for both of these. @@ -255,8 +196,6 @@ async def post_delete_webhook(webhook_url: Annotated[str, Form()]) -> RedirectRe # Add our new list of webhooks to the database. reader.set_tag((), "webhooks", webhooks) # pyright: ignore[reportArgumentType] - commit_state_change(reader, f"Delete webhook {webhook_url.strip()}") - return RedirectResponse(url="/", status_code=303) @@ -276,7 +215,6 @@ async def post_create_feed( """ clean_feed_url: str = feed_url.strip() create_feed(reader, feed_url, webhook_dropdown) - commit_state_change(reader, f"Add feed {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -348,8 +286,6 @@ async def post_set_whitelist( reader.set_tag(clean_feed_url, "regex_whitelist_content", regex_whitelist_content) # pyright: ignore[reportArgumentType][call-overload] reader.set_tag(clean_feed_url, "regex_whitelist_author", regex_whitelist_author) # pyright: ignore[reportArgumentType][call-overload] - commit_state_change(reader, f"Update whitelist for {clean_feed_url}") - return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -431,7 +367,6 @@ async def post_set_blacklist( reader.set_tag(clean_feed_url, "regex_blacklist_summary", regex_blacklist_summary) # pyright: ignore[reportArgumentType][call-overload] reader.set_tag(clean_feed_url, "regex_blacklist_content", regex_blacklist_content) # pyright: ignore[reportArgumentType][call-overload] reader.set_tag(clean_feed_url, "regex_blacklist_author", regex_blacklist_author) # pyright: ignore[reportArgumentType][call-overload] - commit_state_change(reader, f"Update blacklist for {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -498,7 +433,6 @@ async def post_set_custom( reader.set_tag(feed_url, "custom_message", default_custom_message) clean_feed_url: str = feed_url.strip() - commit_state_change(reader, f"Update custom message for {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -618,7 +552,6 @@ async def post_embed( # Save the data. save_embed(reader, feed, custom_embed) - commit_state_change(reader, f"Update embed settings for {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -634,7 +567,6 @@ async def post_use_embed(feed_url: Annotated[str, Form()]) -> RedirectResponse: """ clean_feed_url: str = feed_url.strip() reader.set_tag(clean_feed_url, "should_send_embed", True) # pyright: ignore[reportArgumentType] - commit_state_change(reader, f"Enable embed mode for {clean_feed_url}") return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303) @@ -650,106 +582,9 @@ async def post_use_text(feed_url: Annotated[str, Form()]) -> RedirectResponse: """ clean_feed_url: str = feed_url.strip() reader.set_tag(clean_feed_url, "should_send_embed", False) # pyright: ignore[reportArgumentType] - commit_state_change(reader, f"Disable embed mode for {clean_feed_url}") 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. @@ -768,7 +603,7 @@ def get_add(request: Request): @app.get("/feed", response_class=HTMLResponse) -async def get_feed(feed_url: str, request: Request, starting_after: str = ""): # noqa: C901, PLR0912, PLR0914, PLR0915 +async def get_feed(feed_url: str, request: Request, starting_after: str = ""): """Get a feed by URL. Args: @@ -776,11 +611,11 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): request: The request object. starting_after: The entry to start after. Used for pagination. - Returns: - HTMLResponse: The feed page. - Raises: HTTPException: If the feed is not found. + + Returns: + HTMLResponse: The feed page. """ entries_per_page: int = 20 @@ -793,7 +628,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 - is_show_more_entries_button_visible: bool = total_entries > entries_per_page + show_more_entires_button: bool = total_entries > entries_per_page # Get entries from the feed. if starting_after: @@ -806,27 +641,6 @@ 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, @@ -836,10 +650,8 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): "should_send_embed": False, "last_entry": None, "messages": msg, - "is_show_more_entries_button_visible": is_show_more_entries_button_visible, + "show_more_entires_button": show_more_entires_button, "total_entries": total_entries, - "feed_interval": feed_interval, - "global_interval": global_interval, } return templates.TemplateResponse(request=request, name="feed.html", context=context) @@ -868,29 +680,6 @@ 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, @@ -899,10 +688,8 @@ async def get_feed(feed_url: str, request: Request, starting_after: str = ""): "html": html, "should_send_embed": should_send_embed, "last_entry": last_entry, - "is_show_more_entries_button_visible": is_show_more_entries_button_visible, + "show_more_entires_button": show_more_entires_button, "total_entries": total_entries, - "feed_interval": feed_interval, - "global_interval": global_interval, } return templates.TemplateResponse(request=request, name="feed.html", context=context) @@ -1032,56 +819,6 @@ 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. @@ -1108,25 +845,23 @@ async def get_webhooks(request: Request): @app.get("/", response_class=HTMLResponse) -def get_index(request: Request, message: str = ""): +def get_index(request: Request): """This is the root of the website. Args: request: The request object. - message: Optional message to display to the user. Returns: HTMLResponse: The index page. """ - return templates.TemplateResponse(request=request, name="index.html", context=make_context_index(request, message)) + return templates.TemplateResponse(request=request, name="index.html", context=make_context_index(request)) -def make_context_index(request: Request, message: str = ""): +def make_context_index(request: Request): """Create the needed context for the index page. Args: request: The request object. - message: Optional message to display to the user. Returns: dict: The context for the index page. @@ -1159,7 +894,6 @@ def make_context_index(request: Request, message: str = ""): "webhooks": hooks, "broken_feeds": broken_feeds, "feeds_without_attached_webhook": feeds_without_attached_webhook, - "messages": message or None, } @@ -1170,20 +904,17 @@ async def remove_feed(feed_url: Annotated[str, Form()]): Args: feed_url: The feed to add. + Raises: + HTTPException: Feed not found Returns: RedirectResponse: Redirect to the index page. - - Raises: - HTTPException: Feed not found """ try: reader.delete_feed(urllib.parse.unquote(feed_url)) except FeedNotFoundError as e: raise HTTPException(status_code=404, detail="Feed not found") from e - commit_state_change(reader, f"Remove feed {urllib.parse.unquote(feed_url)}") - return RedirectResponse(url="/", status_code=303) @@ -1195,12 +926,11 @@ async def update_feed(request: Request, feed_url: str): request: The request object. feed_url: The feed URL to update. + Raises: + HTTPException: If the feed is not found. Returns: RedirectResponse: Redirect to the feed page. - - Raises: - HTTPException: If the feed is not found. """ try: reader.update_feed(urllib.parse.unquote(feed_url)) @@ -1211,33 +941,6 @@ async def update_feed(request: Request, feed_url: str): return RedirectResponse(url="/feed?feed_url=" + urllib.parse.quote(feed_url), status_code=303) -@app.post("/backup") -async def manual_backup(request: Request) -> RedirectResponse: - """Manually trigger a git backup of the current state. - - Args: - request: The request object. - - Returns: - RedirectResponse: Redirect to the index page with a success or error message. - """ - backup_path = get_backup_path() - if backup_path is None: - message = "Git backup is not configured. Set GIT_BACKUP_PATH environment variable to enable backups." - logger.warning("Manual git backup attempted but GIT_BACKUP_PATH is not configured") - return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303) - - try: - commit_state_change(reader, "Manual backup triggered from web UI") - message = "Successfully created git backup!" - logger.info("Manual git backup completed successfully") - except Exception as e: - message = f"Failed to create git backup: {e}" - logger.exception("Manual git backup failed") - - return RedirectResponse(url=f"/?message={urllib.parse.quote(message)}", status_code=303) - - @app.get("/search", response_class=HTMLResponse) async def search(request: Request, query: str): """Get entries matching a full-text search query. @@ -1285,12 +988,11 @@ def modify_webhook(old_hook: Annotated[str, Form()], new_hook: Annotated[str, Fo old_hook: The webhook to modify. new_hook: The new webhook. - Returns: - RedirectResponse: Redirect to the webhook page. - Raises: HTTPException: Webhook could not be modified. + Returns: + RedirectResponse: Redirect to the webhook page. """ # Get current webhooks from the database if they exist otherwise use an empty list. webhooks = list(reader.get_tag((), "webhooks", [])) @@ -1340,11 +1042,11 @@ def extract_youtube_video_id(url: str) -> str | None: # Handle standard YouTube URLs (youtube.com/watch?v=VIDEO_ID) if "youtube.com/watch" in url and "v=" in url: - return url.split("v=")[1].split("&", maxsplit=1)[0] + return url.split("v=")[1].split("&")[0] # Handle shortened YouTube URLs (youtu.be/VIDEO_ID) if "youtu.be/" in url: - return url.split("youtu.be/")[1].split("?", maxsplit=1)[0] + return url.split("youtu.be/")[1].split("?")[0] return None diff --git a/discord_rss_bot/missing_tags.py b/discord_rss_bot/missing_tags.py index 589893e..84f375e 100644 --- a/discord_rss_bot/missing_tags.py +++ b/discord_rss_bot/missing_tags.py @@ -1,11 +1,8 @@ from __future__ import annotations -from reader import Feed -from reader import Reader -from reader import TagNotFoundError +from reader import Feed, Reader, TagNotFoundError -from discord_rss_bot.settings import default_custom_embed -from discord_rss_bot.settings import default_custom_message +from discord_rss_bot.settings import default_custom_embed, default_custom_message def add_custom_message(reader: Reader, feed: Feed) -> None: diff --git a/discord_rss_bot/search.py b/discord_rss_bot/search.py index a39f304..4c9a2ae 100644 --- a/discord_rss_bot/search.py +++ b/discord_rss_bot/search.py @@ -8,10 +8,7 @@ from discord_rss_bot.settings import get_reader if TYPE_CHECKING: from collections.abc import Iterable - from reader import EntrySearchResult - from reader import Feed - from reader import HighlightedString - from reader import Reader + from reader import EntrySearchResult, Feed, HighlightedString, Reader def create_search_context(query: str, custom_reader: Reader | None = None) -> dict: diff --git a/discord_rss_bot/settings.py b/discord_rss_bot/settings.py index 676a185..d730b10 100644 --- a/discord_rss_bot/settings.py +++ b/discord_rss_bot/settings.py @@ -5,9 +5,7 @@ from functools import lru_cache from pathlib import Path from platformdirs import user_data_dir -from reader import Reader -from reader import TagNotFoundError -from reader import make_reader +from reader import Reader, make_reader if typing.TYPE_CHECKING: from reader.types import JSONType @@ -40,12 +38,7 @@ 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 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}) + # Set the update interval to 15 minutes + 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 5758237..db0cfba 100644 --- a/discord_rss_bot/static/styles.css +++ b/discord_rss_bot/static/styles.css @@ -13,7 +13,3 @@ 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 d58c714..340a8a3 100644 --- a/discord_rss_bot/templates/feed.html +++ b/discord_rss_bot/templates/feed.html @@ -1,145 +1,90 @@ {% 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 }}
-
-
- {% 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 %} +
+ +

+ {{ 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 }}
- {# 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 7442554..8b9ee37 100644 --- a/discord_rss_bot/templates/nav.html +++ b/discord_rss_bot/templates/nav.html @@ -1,9 +1,6 @@