From faffb818f99900e3abb5fa78cfbb7980692362b5 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Thu, 9 Apr 2026 22:45:44 +0200 Subject: [PATCH] feat(rss-feed): add periodic live updates (#5449) --- packages/api/src/router/widgets/rssFeed.ts | 77 +++++++++++++--------- packages/cron-jobs/src/jobs/rss-feeds.ts | 2 +- packages/request-handler/src/rss-feeds.ts | 3 +- packages/widgets/src/rssFeed/component.tsx | 46 +++++++++---- 4 files changed, 83 insertions(+), 45 deletions(-) diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts index 079d84a32..ec9c223e0 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -1,3 +1,4 @@ +import { observable } from "@trpc/server/observable"; import SuperJSON from "superjson"; import { z } from "zod/v4"; @@ -5,45 +6,59 @@ import type { Session } from "@homarr/auth"; import type { Database } from "@homarr/db"; import { eq } from "@homarr/db"; import { items, users } from "@homarr/db/schema"; +import type { ExtendedFeedEntry } from "@homarr/request-handler/rss-feeds"; import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds"; import type { WidgetComponentProps } from "../../../../widgets/src"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +const feedsInput = z.object({ + urls: z.array(z.string()).max(100), + maximumAmountPosts: z.number(), +}); + export const rssFeedRouter = createTRPCRouter({ - getFeeds: publicProcedure - .input( - z.object({ - urls: z.array(z.string()).max(100), - maximumAmountPosts: z.number(), - }), - ) - .query(async ({ ctx, input }) => { - const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session)) - ? input.urls - : await restrictUrlsAsync(ctx.db, input.urls); + getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => { + const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session)) + ? input.urls + : await restrictUrlsAsync(ctx.db, input.urls); - const rssFeeds = await Promise.all( - urls.map(async (url) => { - const innerHandler = rssFeedsRequestHandler.handler({ - url, - count: input.maximumAmountPosts, - }); - return await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); - }), - ); - - return rssFeeds - .flatMap((rssFeed) => rssFeed.data.entries) - .slice(0, input.maximumAmountPosts) - .sort((entryA, entryB) => { - return entryA.published && entryB.published - ? new Date(entryB.published).getTime() - new Date(entryA.published).getTime() - : 0; + const rssFeeds = await Promise.all( + urls.map(async (url) => { + const innerHandler = rssFeedsRequestHandler.handler({ + url, + count: input.maximumAmountPosts, }); - }), + return await innerHandler.getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); + }), + ); + + return rssFeeds + .flatMap((rssFeed) => rssFeed.data.entries) + .slice(0, input.maximumAmountPosts) + .sort((entryA, entryB) => { + return entryA.published && entryB.published + ? new Date(entryB.published).getTime() - new Date(entryA.published).getTime() + : 0; + }); + }), + subscribeFeeds: publicProcedure.input(feedsInput).subscription(({ input }) => { + // It is not necessary to check access to all feeds here, as the client will only subscribe to feeds which already exist + return observable<{ url: string; entries: ExtendedFeedEntry[] }>((emit) => { + const unsubscribes = input.urls.map((url) => { + const innerHandler = rssFeedsRequestHandler.handler({ url, count: input.maximumAmountPosts }); + return innerHandler.subscribe((data) => { + emit.next({ url, entries: data.entries }); + }); + }); + + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + }); + }), }); export async function canAccessAllFeedsAsync(db: Database, session: Session | null) { diff --git a/packages/cron-jobs/src/jobs/rss-feeds.ts b/packages/cron-jobs/src/jobs/rss-feeds.ts index 2dda83499..6a497a043 100644 --- a/packages/cron-jobs/src/jobs/rss-feeds.ts +++ b/packages/cron-jobs/src/jobs/rss-feeds.ts @@ -5,9 +5,9 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { EVERY_10_MINUTES } from "@homarr/cron-jobs-core/expressions"; import { db, eq } from "@homarr/db"; import { items } from "@homarr/db/schema"; -// This import is done that way to avoid circular dependencies. import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds"; +// This import is done that way to avoid circular dependencies. import type { WidgetComponentProps } from "../../../widgets"; import { createCronJob } from "../lib"; diff --git a/packages/request-handler/src/rss-feeds.ts b/packages/request-handler/src/rss-feeds.ts index eb13caa88..9eaf026ea 100644 --- a/packages/request-handler/src/rss-feeds.ts +++ b/packages/request-handler/src/rss-feeds.ts @@ -28,7 +28,7 @@ export const rssFeedsRequestHandler = createCachedWidgetRequestHandler({ return { ...result, - entries: result.entries?.slice(0, input.count) ?? [], + entries: result.entries?.map((entry) => ({ ...entry, feedUrl: input.url })).slice(0, input.count) ?? [], }; }, cacheDuration: dayjs.duration(5, "minutes"), @@ -109,6 +109,7 @@ const getFirstMediaProperty = (feedObject: object) => { * This interface adds properties on top of the default ones. */ export interface ExtendedFeedEntry extends FeedEntry { + feedUrl: string; enclosure?: string; } diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index ee781fb34..179a448e2 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -4,25 +4,47 @@ import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core import { IconClock } from "@tabler/icons-react"; import dayjs from "dayjs"; +import type { RouterInputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.scss"; +const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { + const [feedEntries] = clientApi.widget.rssFeed.getFeeds.useSuspenseQuery(input, { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }); + const utils = clientApi.useUtils(); + + clientApi.widget.rssFeed.subscribeFeeds.useSubscription(input, { + onData(updatedData) { + utils.widget.rssFeed.getFeeds.setData(input, (oldData) => { + return oldData + ?.filter((entry) => entry.feedUrl !== updatedData.url) + .concat(updatedData.entries) + .sort((entryA, entryB) => { + return entryA.published && entryB.published + ? new Date(entryB.published).getTime() - new Date(entryA.published).getTime() + : 0; + }) + .slice(0, input.maximumAmountPosts); + }); + }, + }); + + return feedEntries; +}; + export default function RssFeed({ options }: WidgetComponentProps<"rssFeed">) { - const [feedEntries] = clientApi.widget.rssFeed.getFeeds.useSuspenseQuery( - { - urls: options.feedUrls, - maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100, - }, - { - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - }, - ); + const feedEntries = useLiveFeedEntries({ + urls: options.feedUrls, + maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100, + }); + const board = useRequiredBoard(); const languageDir = options.enableRtl ? "RTL" : "LTR";