diff --git a/discord_rss_bot/feeds.py b/discord_rss_bot/feeds.py index 833bf33..9a54535 100644 --- a/discord_rss_bot/feeds.py +++ b/discord_rss_bot/feeds.py @@ -79,6 +79,10 @@ type SentWebhookRecord = dict[str, JsonValue] type UpdateCallback = Callable[[], UpdatedFeed | None] +class FeedUpdateError(HTTPException): + """Raised when the initial update for a newly added feed fails.""" + + class JsonResponseLike(Protocol): """Response interface needed for Discord webhook JSON parsing.""" @@ -1899,6 +1903,7 @@ def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: webhook_dropdown: The webhook we should send entries to. Raises: + FeedUpdateError: If the initial feed update fails. HTTPException: If webhook_dropdown does not equal a webhook or default_custom_message not found. """ clean_feed_url: str = feed_url.strip() @@ -1929,7 +1934,7 @@ def create_feed(reader: Reader, feed_url: str, webhook_dropdown: str) -> None: try: reader.update_feed(clean_feed_url) except ReaderError as e: - raise HTTPException(status_code=404, detail=f"Error updating feed: {e}") from e + raise FeedUpdateError(status_code=404, detail=f"Error updating feed: {e}") from e # Mark every entry as read, so we don't send all the old entries to Discord. entries: Iterable[Entry] = reader.get_entries(feed=clean_feed_url, read=False) diff --git a/discord_rss_bot/main.py b/discord_rss_bot/main.py index a27199b..13b132b 100644 --- a/discord_rss_bot/main.py +++ b/discord_rss_bot/main.py @@ -52,6 +52,7 @@ 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 FeedUpdateError from discord_rss_bot.feeds import SentWebhookRecord from discord_rss_bot.feeds import coerce_media_gallery_image_limit from discord_rss_bot.feeds import create_feed @@ -122,6 +123,12 @@ class FilterPreviewContext(TypedDict): preview_helper_text: str +class AutodiscoverLink(TypedDict): + href: str + type: str | None + title: str | None + + LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, @@ -262,6 +269,57 @@ templates.env.globals["get_backup_path"] = get_backup_path # pyright: ignore[re templates.env.globals["has_webhooks"] = has_webhooks # pyright: ignore[reportArgumentType] +def get_global_delivery_mode(reader: Reader) -> str: + """Return the normalized default delivery mode for new feeds. + + Args: + reader: The Reader instance. + + Returns: + The configured delivery mode, falling back to embed. + """ + global_delivery_mode: str = str(reader.get_tag((), "delivery_mode", "embed")).strip().lower() + return global_delivery_mode if global_delivery_mode in {"embed", "text"} else "embed" + + +def get_autodiscover_links(reader: Reader, feed_url: str) -> list[AutodiscoverLink]: + """Return valid autodiscovered links stored for a failed feed update. + + Args: + reader: The Reader instance. + feed_url: The URL that failed to parse as a feed. + + Returns: + Valid discovered feed link dictionaries. + """ + try: + stored_links = reader.get_tag(feed_url, ".reader.autodiscover", []) + except ReaderError: + return [] + + if not isinstance(stored_links, list): + return [] + + links: list[AutodiscoverLink] = [] + for stored_link in stored_links: + if not isinstance(stored_link, dict): + continue + + href = stored_link.get("href") + if not isinstance(href, str) or not href: + continue + + link_type = stored_link.get("type") + title = stored_link.get("title") + links.append({ + "href": href, + "type": link_type if isinstance(link_type, str) else None, + "title": title if isinstance(title, str) else None, + }) + + return links + + @app.post("/add_webhook") async def post_add_webhook( webhook_name: Annotated[str, Form()], @@ -357,24 +415,51 @@ async def post_delete_webhook( return RedirectResponse(url="/", status_code=303) -@app.post("/add") +@app.post("/add", response_model=None) async def post_create_feed( + request: Request, feed_url: Annotated[str, Form()], webhook_dropdown: Annotated[str, Form()], reader: Annotated[Reader, Depends(get_reader_dependency)], -) -> RedirectResponse: +) -> RedirectResponse | HTMLResponse: """Add a feed to the database. Args: + request: The request object. feed_url: The feed to add. webhook_dropdown: The webhook to use. reader: The Reader instance. Returns: RedirectResponse: Redirect to the feed page. + + Raises: + FeedUpdateError: If updating the feed fails without discovered feed links. """ clean_feed_url: str = feed_url.strip() - create_feed(reader, feed_url, webhook_dropdown) + try: + create_feed(reader, feed_url, webhook_dropdown) + except FeedUpdateError as exception: + autodiscover_links = get_autodiscover_links(reader, clean_feed_url) + if not autodiscover_links: + raise + + context = { + "request": request, + "webhooks": reader.get_tag((), "webhooks", []), + "global_delivery_mode": get_global_delivery_mode(reader), + "feed_url": clean_feed_url, + "selected_webhook": webhook_dropdown, + "messages": exception.detail, + "autodiscover_links": autodiscover_links, + } + return templates.TemplateResponse( + request=request, + name="add.html", + context=context, + status_code=exception.status_code, + ) + 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) @@ -1609,14 +1694,10 @@ def get_add( Returns: HTMLResponse: The add feed page. """ - global_delivery_mode: str = str(reader.get_tag((), "delivery_mode", "embed")).strip().lower() - if global_delivery_mode not in {"embed", "text"}: - global_delivery_mode = "embed" - context = { "request": request, "webhooks": reader.get_tag((), "webhooks", []), - "global_delivery_mode": global_delivery_mode, + "global_delivery_mode": get_global_delivery_mode(reader), } return templates.TemplateResponse(request=request, name="add.html", context=context) diff --git a/discord_rss_bot/settings.py b/discord_rss_bot/settings.py index bb2b52a..2daf6d6 100644 --- a/discord_rss_bot/settings.py +++ b/discord_rss_bot/settings.py @@ -3,6 +3,7 @@ from __future__ import annotations import os import typing from functools import lru_cache +from importlib.util import find_spec from pathlib import Path from platformdirs import user_data_dir @@ -31,6 +32,31 @@ default_custom_embed: dict[str, str] = { } +def has_plugin(plugin_name: str) -> bool: + """Return whether the installed reader version provides a built-in plugin. + + We started using .autodiscover, but that is from Reader version 3.25. + """ + try: + return find_spec(f"reader.plugins.{plugin_name.removeprefix('.')}") is not None + except ModuleNotFoundError: + return False + + +def make_app_reader(db_location: Path) -> Reader: + """Create a reader with plugins supported by the installed reader version. + + Returns: + The configured reader. + """ + plugins_we_want = (".ua_fallback", ".autodiscover") + plugins: list[str] = [name for name in plugins_we_want if has_plugin(name)] + + if plugins: + return make_reader(url=str(db_location), plugins=plugins) + return make_reader(url=str(db_location)) + + @lru_cache(maxsize=1) def get_reader(custom_location: Path | None = None) -> Reader: """Get the reader. @@ -42,7 +68,7 @@ def get_reader(custom_location: Path | None = None) -> Reader: The reader. """ db_location: Path = custom_location or Path(data_dir) / "db.sqlite" - reader: Reader = make_reader(url=str(db_location)) + reader: Reader = make_app_reader(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 diff --git a/discord_rss_bot/templates/add.html b/discord_rss_bot/templates/add.html index 760a228..d8b61dd 100644 --- a/discord_rss_bot/templates/add.html +++ b/discord_rss_bot/templates/add.html @@ -8,11 +8,6 @@ {% block content %}
The submitted URL is not a feed. Choose one of the feeds advertised by that page.
+