diff --git a/src/feed_handler.py b/src/feed_handler.py index 8d4b1a0..645c5a0 100644 --- a/src/feed_handler.py +++ b/src/feed_handler.py @@ -1,10 +1,11 @@ import time +from typing import assert_never import feedparser import requests from feed_errors import FeedCheckFailedError, FeedNotFoundError -from models import FeedUpdate +from models import FeedEntryType, FeedUpdate from simple_logger_handler import setup_logger logger = setup_logger(__name__) @@ -68,6 +69,7 @@ def grab_gitea_release_information(rss_feed_url: str) -> FeedUpdate: entry_id = feed.entries[0]["link"], feed_title = feed.feed.title, body_title = feed.entries[0]["title"], + link = feed.entries[0]["link"], ) logger.info(f"[Feed] Latest release for feed '{rss_feed_url}': {feed_update.feed_title}") logger.debug(f"[Feed] Latest release for feed '{rss_feed_url}': {feed_update.feed_title} -> {feed_update.link}") @@ -114,7 +116,7 @@ def build_gitea_runner(version: str, gitea_token: str) -> bool: payload = { "ref": "main", "inputs": { - "version": version.lstrip("v"), + "version": version.removeprefix("v"), "revision": "1", }, } @@ -141,10 +143,19 @@ def ensure_feed_exists(url: str, timeout: tuple[float, float] = (3.0, 5.0)) -> N if status != 200: raise FeedCheckFailedError(url) +def fetch_latest(feed_type: FeedEntryType, url: str) -> FeedUpdate: + match feed_type: + case FeedEntryType.ROYALROAD: + return grab_royal_road_chapter_information(url) + case FeedEntryType.GITEA: + return grab_gitea_release_information(url) + case _: + assert_never(feed_type) + if __name__ == "__main__": - #rsssss="https://gitea.com/gitea/runner/releases.rss" + rsssss="https://gitea.com/gitea/runner/releases.rss" #print(get_rss_feed_data(rsssss)) - #print(grab_gitea_release_information(rsssss)) + print(grab_gitea_release_information(rsssss)) id="118891" rss_feed_url = f"https://www.royalroad.com/fiction/syndication/{id}" print(grab_royal_road_chapter_information(rss_feed_url)) \ No newline at end of file diff --git a/src/main.py b/src/main.py index 5ae4dd2..4370290 100644 --- a/src/main.py +++ b/src/main.py @@ -4,22 +4,26 @@ from contextlib import asynccontextmanager import uvicorn from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse from auth_config import Caller, auth from db_module import close_connection_pool, create_connection_pool, get_db, start_healthcheck_thread +from feed_errors import FeedError from feed_handler import ( build_gitea_runner, + ensure_feed_exists, grab_gitea_release_information, grab_royal_road_chapter_information, ) from metrics_server import REQUEST_COUNTER, metrics_api +from models import FeedEntry, fetch_latest from secret_manager import cleanup_secret_files, get_secret from send_notifications import NotificationClient, NotificationError, NotificationUnavailableError +from service_auth import UserId from simple_logger_handler import LOG_LEVEL, setup_logger logger = setup_logger(__name__) - @asynccontextmanager async def lifespan(app: FastAPI): logger.info("[FastAPI] Starting application...") @@ -53,6 +57,10 @@ api = FastAPI( dependencies=[Depends(auth.authenticate)], ) +@api.exception_handler(FeedError) +async def feed_error_handler(request: Request, exc: FeedError): + return JSONResponse(status_code=exc.status_code, content={"detail":str(exc)}) + @api.middleware("http") async def prometheus_middleware(request: Request, call_next): logger.debug(f"[FastAPI] Incoming request: {request.method} {request.url.path}") @@ -103,13 +111,13 @@ def get_release_information(request: Request, db = Depends(get_db)): for feed_id, rss_feed, last_entry in feeds: results["checked"] += 1 try: - release_title, link = grab_gitea_release_information(rss_feed) - logger.debug(f"[FastAPI] ID:{feed_id},RSS Feed {rss_feed}: db={last_entry}, online={link}") + gitea_release_information = grab_gitea_release_information(rss_feed) + logger.debug(f"[FastAPI] ID:{feed_id},RSS Feed {rss_feed}: db={last_entry}, online={gitea_release_information.link}") - if link == last_entry: + if gitea_release_information.link == last_entry: continue - logger.info(f"[FastAPI] Release change for ID {feed_id}: {release_title}") + logger.info(f"[FastAPI] Release change for ID {feed_id}: {gitea_release_information.release_title}") with db.cursor() as cursor: cursor.execute( @@ -124,11 +132,11 @@ def get_release_information(request: Request, db = Depends(get_db)): failed = 0 for user_id,feed_name in subscribers: try: - repo = link.split("/releases")[0].removeprefix("https://") + repo = gitea_release_information.link.split("/releases")[0].removeprefix("https://") request.app.state.notifier.send( title=feed_name or repo, - body=release_title, - link=link, + body=gitea_release_information.release_title, + link=gitea_release_information.link, category="utility", user_id=user_id, ) @@ -149,14 +157,14 @@ def get_release_information(request: Request, db = Depends(get_db)): with db.cursor() as cursor: cursor.execute( "UPDATE rss_feeds SET last_entry = %s WHERE id = %s", - (link, feed_id), + (gitea_release_information.link, feed_id), ) db.commit() logger.debug(f"[FastAPI] Notification sent and database updated for Gitea feed ID {feed_id}") results["new"] += 1 - if link.startswith("https://gitea.com/gitea/runner/releases/tag/v"): - gitea_build_success = build_gitea_runner(release_title, request.app.state.gitea_token) + if gitea_release_information.link.startswith("https://gitea.com/gitea/runner/releases/tag/v"): + gitea_build_success = build_gitea_runner(gitea_release_information.release_title, request.app.state.gitea_token) logger.info(f"[FastAPI] Gitea build ran successfully:{gitea_build_success}") except Exception as e: @@ -205,12 +213,12 @@ def get_chapters(request: Request, db = Depends(get_db)): results["checked"] += 1 try: story = grab_royal_road_chapter_information(rss_feed) - logger.debug(f"[FastAPI] ID:{feed_id},RSS Feed {rss_feed}: db={last_entry}, online={story.chapter_id}") + logger.debug(f"[FastAPI] ID:{feed_id},RSS Feed {rss_feed}: db={last_entry}, online={story.entry_id}") - if story.chapter_id == last_entry: + if story.entry_id == last_entry: continue - logger.info(f"[FastAPI] New chapter detected for story ID {feed_id}: {story.story_title}") + logger.info(f"[FastAPI] New chapter detected for story ID {feed_id}: {story.feed_title}") with db.cursor() as cursor: cursor.execute ( "SELECT user_id, feed_name " @@ -225,8 +233,8 @@ def get_chapters(request: Request, db = Depends(get_db)): for user_id, feed_name in subscribers: try: request.app.state.notifier.send( - title=feed_name or story.story_title, - body=story.chapter_title, + title=feed_name or story.feed_title, + body=story.body_title, link=story.link, category="royal-road", user_id=user_id, @@ -235,11 +243,11 @@ def get_chapters(request: Request, db = Depends(get_db)): except NotificationUnavailableError: failed = len(subscribers) - sent logger.error (f"[FastAPI] Notifications backend unavailable, skipping " - f"{failed} remaining user(s) for story={story.story_title}") + f"{failed} remaining user(s) for story={story.feed_title}") break except NotificationError as e: failed += 1 - logger.error(f"[FastAPI] Failed to send notification to user_id={user_id} for story={story.story_title} " + logger.error(f"[FastAPI] Failed to send notification to user_id={user_id} for story={story.feed_title} " f"status={getattr(e, 'status_code',None)}") if subscribers and sent == 0: results["errors"] += 1 @@ -248,7 +256,7 @@ def get_chapters(request: Request, db = Depends(get_db)): with db.cursor() as cursor: cursor.execute( "UPDATE rss_feeds SET last_entry = %s WHERE id = %s", - (story.chapter_id, feed_id), + (story.entry_id, feed_id), ) db.commit() logger.debug(f"[FastAPI] Notification sent and database updated for story ID {feed_id}") @@ -269,6 +277,50 @@ def get_chapters(request: Request, db = Depends(get_db)): logger.info(f"[FastAPI] Royalroad check complete: {results}") return results +@user_router.post("/add/feed") +def add_feed( + user_id: UserId, + feed_entry: FeedEntry, + db = Depends(get_db) + ): + url = feed_entry.rss_feed_url + logger.info(f"[FastAPI] Subscribe request: RSS URL={url}, user_id={user_id}") + with db.cursor() as cursor: + cursor.execute("SELECT id FROM rss_feeds WHERE rss_feed = %s", (feed_entry.rss_feed_url,),) + known = cursor.fetchone() + + last_entry = None + if known is None: + ensure_feed_exists(url) + logger.info(f"[FastApi] Adding new RSS feed, type={feed_entry.type}, rss_feed_url={url}") + try: + last_entry = fetch_latest(feed_entry.type, url) + except ValueError: + logger.warning(f"[FastAPI] Could not read latest entry for {url}, storing none") + try: + with db.cursor() as cursor: + cursor.execute( + "INSERT into rss_feeds (type,rss_feed,last_entry) VALUES (%s,%s,%s) " + "ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), active = 1", + (feed_entry.type, url, last_entry.entry_id), + ) + feed_id = cursor.lastrowid + + cursor.execute ( + "INSERT INTO rss_feed_subscriptions (user_id, rss_feed_id, feed_name) " + "VALUES (%s, %s, %s) " + "ON DUPLICATE KEY UPDATE active = 1, feed_name = VALUES(feed_name)", + (user_id, feed_id, feed_entry.custom_name), + ) + db.commit() + except Exception as e: + db.rollback() + logger.exception(f"[FastAPI] Failed to subscribe to user_id={user_id} to {url}") + raise HTTPException(status_code=500) from e + + logger.info(f"[FastAPI] user_id={user_id} subscribed to feed_id={feed_id}, url={url}") + return {"status":"ok","detail":"Subscribed to feed","feed_id":{feed_id}} + api.include_router(user_router) api.include_router(scheduler_router) diff --git a/src/models.py b/src/models.py index 9afce99..444c05e 100644 --- a/src/models.py +++ b/src/models.py @@ -1,12 +1,11 @@ from dataclasses import dataclass from enum import StrEnum -from typing import Self, assert_never +from typing import Self from urllib.parse import urlsplit from pydantic import BaseModel, Field, field_validator, model_validator from feed_errors import InvalidFeedUrlError, UnsupportedPathError, WrongHostError -from feed_handler import grab_gitea_release_information, grab_royal_road_chapter_information REPO_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-") @@ -21,15 +20,6 @@ class FeedEntryType(StrEnum): ROYALROAD = "royalroad" GITEA = "gitea" -def fetch_latest(feed_type: FeedEntryType, url: str) -> FeedUpdate: - match feed_type: - case FeedEntryType.ROYALROAD: - return grab_royal_road_chapter_information(url) - case FeedEntryType.GITEA: - return grab_gitea_release_information(url) - case _: - assert_never(feed_type) - def _path_segments(raw: str, feed_type: FeedEntryType, host: str) -> list[str]: """Check scheme and host, then return the non-empty path segments."""