Usenet and Torrent downloads in 1 widget. sabNZBd, NzbGet, Deluge, qBitTorrent, and transmission support. Columns can be reordered in Edit mode. Sorting enabled. Time uses Dayjs with auto translation. Can pause/resume single items, clients, or all. Can delete items (With option to delete assossiated files). Clients list and details. Include all filtering and processing for ratio from oldmarr torrent widget. Invalidation of old data (older than 30 seconds) to show an integration is not responding anymore. Misc (So many miscs): Fixed validation error with multiText. Fixed translation application for multiSelect to behave the same as select. Added background to gitignore (I needed to add a background to visually test opacity, probably will in the future too) Added setOptions to frontend components so potential updates made from the Dashboard can be saved. Extracted background and border color to use in widgets. humanFileSize function based on the si format (powers of 1024, not 1000). Improved integrationCreatorByKind by @Meierschlumpf. Changed integrationCreatorByKind to integrationCreator so it functions directly from the integration. Added integrationCreatorFromSecrets to directly work with secrets from db. Added getIntegrationKindsByCategory to get a list of integrations sharing categories. Added IntegrationKindByCategory type to get the types possible for a category (Great to cast on integration.kind that isn't already properly limited/typed but for which we know the limitation) Added a common AtLeastOneOf type. Applied to TKind and IntegrationSecretKind[] where it was already being used and Added to the getIntegrationKindsByCategory's output to be more freely used. Added the Modify type, instead of omiting to then add again just to change a parameters type, use the modify instead. Applied code wide already. Hook to get list of integration depending on permission level of user. (By @Meierschlumpf)
140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
import type { FeedData, FeedEntry } from "@extractus/feed-extractor";
|
|
import { extract } from "@extractus/feed-extractor";
|
|
import SuperJSON from "superjson";
|
|
|
|
import type { Modify } from "@homarr/common/types";
|
|
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
|
import { db, eq } from "@homarr/db";
|
|
import { items } from "@homarr/db/schema/sqlite";
|
|
import { logger } from "@homarr/log";
|
|
import { createItemChannel } from "@homarr/redis";
|
|
import { z } from "@homarr/validation";
|
|
|
|
// This import is done that way to avoid circular dependencies.
|
|
import type { WidgetComponentProps } from "../../../widgets";
|
|
import { createCronJob } from "../lib";
|
|
|
|
export const rssFeedsJob = createCronJob("rssFeeds", EVERY_5_MINUTES).withCallback(async () => {
|
|
const itemsForIntegration = await db.query.items.findMany({
|
|
where: eq(items.kind, "rssFeed"),
|
|
});
|
|
|
|
for (const item of itemsForIntegration) {
|
|
const options = SuperJSON.parse<WidgetComponentProps<"rssFeed">["options"]>(item.options);
|
|
|
|
const feeds = await Promise.all(
|
|
options.feedUrls.map(async (feedUrl) => ({
|
|
feedUrl,
|
|
feed: (await extract(feedUrl, {
|
|
getExtraEntryFields: (feedEntry) => {
|
|
const media = attemptGetImageFromEntry(feedUrl, feedEntry);
|
|
if (!media) {
|
|
return {};
|
|
}
|
|
return {
|
|
enclosure: media,
|
|
};
|
|
},
|
|
})) as ExtendedFeedData,
|
|
})),
|
|
);
|
|
|
|
const channel = createItemChannel<RssFeed[]>(item.id);
|
|
await channel.publishAndUpdateLastStateAsync(feeds);
|
|
}
|
|
});
|
|
|
|
const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
|
|
const media = getFirstMediaProperty(entry);
|
|
if (media !== null) {
|
|
return media;
|
|
}
|
|
return getImageFromStringAsFallback(feedUrl, JSON.stringify(entry));
|
|
};
|
|
|
|
const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
|
|
const regex = /https?:\/\/\S+?\.(jpg|jpeg|png|gif|bmp|svg|webp|tiff)/i;
|
|
const result = regex.exec(content);
|
|
|
|
if (result == null) {
|
|
return null;
|
|
}
|
|
|
|
console.debug(
|
|
`Falling back to regex image search for '${feedUrl}'. Found ${result.length} matches in content: ${content}`,
|
|
);
|
|
return result[0];
|
|
};
|
|
|
|
const mediaProperties = [
|
|
{
|
|
path: ["enclosure", "@_url"],
|
|
},
|
|
{
|
|
path: ["media:content", "@_url"],
|
|
},
|
|
];
|
|
|
|
/**
|
|
* The RSS and Atom standards are poorly adhered to in most of the web.
|
|
* We want to show pretty background images on the posts and therefore need to extract
|
|
* the enclosure (aka. media images). This function uses the dynamic properties defined above
|
|
* to search through the possible paths and detect valid image URLs.
|
|
* @param feedObject The object to scan for.
|
|
* @returns the value of the first path that is found within the object
|
|
*/
|
|
const getFirstMediaProperty = (feedObject: object) => {
|
|
for (const mediaProperty of mediaProperties) {
|
|
let propertyIndex = 0;
|
|
let objectAtPath: object = feedObject;
|
|
while (propertyIndex < mediaProperty.path.length) {
|
|
const key = mediaProperty.path[propertyIndex];
|
|
if (key === undefined) {
|
|
break;
|
|
}
|
|
const propertyEntries = Object.entries(objectAtPath);
|
|
const propertyEntry = propertyEntries.find(([entryKey]) => entryKey === key);
|
|
if (!propertyEntry) {
|
|
break;
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
const [_, propertyEntryValue] = propertyEntry;
|
|
objectAtPath = propertyEntryValue as object;
|
|
propertyIndex++;
|
|
}
|
|
|
|
const validationResult = z.string().url().safeParse(objectAtPath);
|
|
if (!validationResult.success) {
|
|
continue;
|
|
}
|
|
|
|
logger.debug(`Found an image in the feed entry: ${validationResult.data}`);
|
|
return validationResult.data;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* We extend the feed with custom properties.
|
|
* This interface adds properties on top of the default ones.
|
|
*/
|
|
interface ExtendedFeedEntry extends FeedEntry {
|
|
enclosure?: string;
|
|
}
|
|
|
|
/**
|
|
* We extend the feed with custom properties.
|
|
* This interface omits the default entries with our custom definition.
|
|
*/
|
|
type ExtendedFeedData = Modify<
|
|
FeedData,
|
|
{
|
|
entries?: ExtendedFeedEntry;
|
|
}
|
|
>;
|
|
|
|
export interface RssFeed {
|
|
feedUrl: string;
|
|
feed: ExtendedFeedData;
|
|
}
|