Brain Service: - Playwright stealth crawler replacing browserless (og:image, Readability, Reddit JSON API) - AI classification with tag definitions and folder assignment - YouTube video download via yt-dlp - Karakeep migration complete (96 items) - Taxonomy management (folders with icons/colors, tags) - Discovery shuffle, sort options, search (Meilisearch + pgvector) - Item tag/folder editing, card color accents RSS Reader Service: - Custom FastAPI reader replacing Miniflux - Feed management (add/delete/refresh), category support - Full article extraction via Readability - Background content fetching for new entries - Mark all read with confirmation - Infinite scroll, retention cleanup (30/60 day) - 17 feeds migrated from Miniflux iOS App (SwiftUI): - Native iOS 17+ app with @Observable architecture - Cookie-based auth, configurable gateway URL - Dashboard with custom background photo + frosted glass widgets - Full fitness module (today/templates/goals/food library) - AI assistant chat (fitness + brain, raw JSON state management) - 120fps ProMotion support AI Assistants (Gateway): - Unified dispatcher with fitness/brain domain detection - Fitness: natural language food logging, photo analysis, multi-item splitting - Brain: save/append/update/delete notes, search & answer, undo support - Madiha user gets fitness-only (brain disabled) Firefox Extension: - One-click save to Brain from any page - Login with platform credentials - Right-click context menu (save page/link/image) - Notes field for URL saves - Signed and published on AMO Other: - Reader bookmark button routes to Brain (was Karakeep) - Fitness food library with "Add" button + add-to-meal popup - Kindle send file size check (25MB SMTP2GO limit) - Atelier UI as default (useAtelierShell=true) - Mobile upload box in nav drawer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
243 lines
7.4 KiB
Python
243 lines
7.4 KiB
Python
"""Feed endpoints."""
|
|
|
|
import logging
|
|
import re
|
|
|
|
import feedparser
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_db_session, get_user_id
|
|
from app.models import Category, Entry, Feed
|
|
|
|
log = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/feeds", tags=["feeds"])
|
|
|
|
|
|
# ── Schemas ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class CategoryRef(BaseModel):
|
|
id: int
|
|
title: str
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class FeedOut(BaseModel):
|
|
id: int
|
|
title: str
|
|
feed_url: str
|
|
site_url: str | None = None
|
|
category: CategoryRef | None = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class FeedCreate(BaseModel):
|
|
feed_url: str
|
|
category_id: int | None = None
|
|
|
|
|
|
class CountersOut(BaseModel):
|
|
unreads: dict[str, int]
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _discover_feed_url(html: str, base_url: str) -> str | None:
|
|
"""Try to find an RSS/Atom feed link in HTML."""
|
|
patterns = [
|
|
r'<link[^>]+type=["\']application/(?:rss|atom)\+xml["\'][^>]+href=["\']([^"\']+)["\']',
|
|
r'<link[^>]+href=["\']([^"\']+)["\'][^>]+type=["\']application/(?:rss|atom)\+xml["\']',
|
|
]
|
|
for pat in patterns:
|
|
match = re.search(pat, html, re.IGNORECASE)
|
|
if match:
|
|
href = match.group(1)
|
|
if href.startswith("/"):
|
|
# Resolve relative URL
|
|
from urllib.parse import urljoin
|
|
href = urljoin(base_url, href)
|
|
return href
|
|
return None
|
|
|
|
|
|
async def _fetch_and_parse_feed(feed_url: str) -> tuple[str, str, str | None]:
|
|
"""
|
|
Fetch a URL. If it's a valid feed, return (feed_url, title, site_url).
|
|
If it's HTML, try to discover the feed link and follow it.
|
|
"""
|
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
|
resp = await client.get(feed_url, headers={"User-Agent": "Reader/1.0"})
|
|
resp.raise_for_status()
|
|
|
|
body = resp.text
|
|
parsed = feedparser.parse(body)
|
|
|
|
# Check if it's a valid feed
|
|
if parsed.feed.get("title") or parsed.entries:
|
|
title = parsed.feed.get("title", feed_url)
|
|
site_url = parsed.feed.get("link")
|
|
return feed_url, title, site_url
|
|
|
|
# Not a feed — try to discover from HTML
|
|
discovered = _discover_feed_url(body, feed_url)
|
|
if not discovered:
|
|
raise HTTPException(status_code=400, detail="No RSS/Atom feed found at this URL")
|
|
|
|
# Fetch the discovered feed
|
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
|
resp2 = await client.get(discovered, headers={"User-Agent": "Reader/1.0"})
|
|
resp2.raise_for_status()
|
|
|
|
parsed2 = feedparser.parse(resp2.text)
|
|
title = parsed2.feed.get("title", discovered)
|
|
site_url = parsed2.feed.get("link") or feed_url
|
|
return discovered, title, site_url
|
|
|
|
|
|
# ── Routes ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/counters", response_model=CountersOut)
|
|
async def feed_counters(
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
result = await db.execute(
|
|
select(Entry.feed_id, func.count(Entry.id))
|
|
.where(Entry.user_id == user_id, Entry.status == "unread")
|
|
.group_by(Entry.feed_id)
|
|
)
|
|
unreads = {str(row[0]): row[1] for row in result.all()}
|
|
return {"unreads": unreads}
|
|
|
|
|
|
@router.get("", response_model=list[FeedOut])
|
|
async def list_feeds(
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
result = await db.execute(
|
|
select(Feed)
|
|
.where(Feed.user_id == user_id)
|
|
.order_by(Feed.title)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.post("", response_model=FeedOut, status_code=201)
|
|
async def create_feed(
|
|
body: FeedCreate,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
# Check for duplicate
|
|
existing = await db.execute(
|
|
select(Feed).where(Feed.feed_url == body.feed_url)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(status_code=409, detail="Feed already exists")
|
|
|
|
# Validate category belongs to user
|
|
if body.category_id:
|
|
cat = await db.execute(
|
|
select(Category).where(
|
|
Category.id == body.category_id,
|
|
Category.user_id == user_id,
|
|
)
|
|
)
|
|
if not cat.scalar_one_or_none():
|
|
raise HTTPException(status_code=404, detail="Category not found")
|
|
|
|
# Fetch and discover feed
|
|
try:
|
|
actual_url, title, site_url = await _fetch_and_parse_feed(body.feed_url)
|
|
except httpx.HTTPError as e:
|
|
log.warning("Failed to fetch feed %s: %s", body.feed_url, e)
|
|
raise HTTPException(status_code=400, detail=f"Could not fetch feed: {e}")
|
|
|
|
# Check again with discovered URL
|
|
if actual_url != body.feed_url:
|
|
existing = await db.execute(
|
|
select(Feed).where(Feed.feed_url == actual_url)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(status_code=409, detail="Feed already exists")
|
|
|
|
feed = Feed(
|
|
user_id=user_id,
|
|
category_id=body.category_id,
|
|
title=title,
|
|
feed_url=actual_url,
|
|
site_url=site_url,
|
|
)
|
|
db.add(feed)
|
|
await db.commit()
|
|
await db.refresh(feed)
|
|
return feed
|
|
|
|
|
|
@router.delete("/{feed_id}", status_code=204)
|
|
async def delete_feed(
|
|
feed_id: int,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
result = await db.execute(
|
|
select(Feed).where(Feed.id == feed_id, Feed.user_id == user_id)
|
|
)
|
|
feed = result.scalar_one_or_none()
|
|
if not feed:
|
|
raise HTTPException(status_code=404, detail="Feed not found")
|
|
await db.delete(feed)
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/{feed_id}/refresh")
|
|
async def refresh_feed(
|
|
feed_id: int,
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
result = await db.execute(
|
|
select(Feed).where(Feed.id == feed_id, Feed.user_id == user_id)
|
|
)
|
|
feed = result.scalar_one_or_none()
|
|
if not feed:
|
|
raise HTTPException(status_code=404, detail="Feed not found")
|
|
|
|
import asyncio
|
|
from app.worker.tasks import fetch_single_feed
|
|
await asyncio.to_thread(fetch_single_feed, feed_id)
|
|
|
|
return {"ok": True, "message": f"Refreshed {feed.title}"}
|
|
|
|
|
|
@router.post("/refresh-all")
|
|
async def refresh_all_feeds(
|
|
user_id: str = Depends(get_user_id),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
result = await db.execute(
|
|
select(Feed).where(Feed.user_id == user_id)
|
|
)
|
|
feeds = result.scalars().all()
|
|
|
|
import asyncio
|
|
from app.worker.tasks import fetch_single_feed
|
|
for feed in feeds:
|
|
try:
|
|
await asyncio.to_thread(fetch_single_feed, feed.id)
|
|
except Exception:
|
|
pass
|
|
|
|
return {"ok": True, "message": f"Refreshed {len(feeds)} feeds"}
|