feat(rss-feed): add periodic live updates (#5449)

This commit is contained in:
Meier Lukas authored and GitHub committed 2026-04-09 22:45:44 +02:00
1 parent bdf9439e49
commit faffb818f9
4 files changed
+83 -45

No files matched your search

+46 -31
View File
@@ -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) {
+1 -1
View File
@@ -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";
+2 -1
View File
@@ -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;
}
+34 -12
View File
@@ -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";