feat: downloads widget (#844)

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)
This commit is contained in:
Manuel
2024-09-11 17:30:21 +02:00
committed by GitHub
parent 3d4e607a9d
commit 2535192b2c
81 changed files with 4176 additions and 390 deletions

View File

@@ -1,5 +1,6 @@
import { analyticsJob } from "./jobs/analytics";
import { iconsUpdaterJob } from "./jobs/icons-updater";
import { downloadsJob } from "./jobs/integrations/downloads";
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
@@ -17,6 +18,7 @@ export const jobGroup = createCronJobGroup({
smartHomeEntityState: smartHomeEntityStateJob,
mediaServer: mediaServerJob,
mediaOrganizer: mediaOrganizerJob,
downloads: downloadsJob,
mediaRequests: mediaRequestsJob,
rssFeeds: rssFeedsJob,
indexerManager: indexerManagerJob,

View File

@@ -0,0 +1,27 @@
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createCronJob } from "../../lib";
export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(async () => {
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["downloads"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const { integration } of itemForIntegration.integrations) {
const integrationInstance = integrationCreatorFromSecrets(integration);
await integrationInstance
.getClientJobsAndStatusAsync()
.then(async (data) => {
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
await channel.publishAndUpdateLastStateAsync(data);
})
.catch((error) => console.error(`Could not retrieve data for ${integration.name}: "${error}"`));
}
}
});

View File

@@ -1,10 +1,9 @@
import SuperJSON from "superjson";
import { decryptSecret } from "@homarr/common";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import { HomeAssistantIntegration } from "@homarr/integrations";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { logger } from "@homarr/log";
import { homeAssistantEntityState } from "@homarr/redis";
@@ -13,24 +12,8 @@ import type { WidgetComponentProps } from "../../../../widgets";
import { createCronJob } from "../../lib";
export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(async () => {
const itemsForIntegration = await db.query.items.findMany({
where: eq(items.kind, "smartHome-entityState"),
with: {
integrations: {
with: {
integration: {
with: {
secrets: {
columns: {
kind: true,
value: true,
},
},
},
},
},
},
},
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["smartHome-entityState"],
});
for (const itemForIntegration of itemsForIntegration) {
@@ -43,13 +26,7 @@ export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVE
itemForIntegration.options,
);
const homeAssistant = new HomeAssistantIntegration({
...integration,
decryptedSecrets: integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
});
const homeAssistant = integrationCreatorFromSecrets(integration);
const state = await homeAssistant.getEntityStateAsync(options.entityId);
if (!state.success) {

View File

@@ -1,42 +1,19 @@
import { decryptSecret } from "@homarr/common";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import { ProwlarrIntegration } from "@homarr/integrations";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createCronJob } from "../../lib";
export const indexerManagerJob = createCronJob("indexerManager", EVERY_MINUTE).withCallback(async () => {
const itemsForIntegration = await db.query.items.findMany({
where: eq(items.kind, "indexerManager"),
with: {
integrations: {
with: {
integration: {
with: {
secrets: {
columns: {
kind: true,
value: true,
},
},
},
},
},
},
},
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["indexerManager"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const integration of itemForIntegration.integrations) {
const prowlarr = new ProwlarrIntegration({
...integration.integration,
decryptedSecrets: integration.integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
});
await prowlarr.getIndexersAsync();
for (const { integration } of itemForIntegration.integrations) {
const integrationInstance = integrationCreatorFromSecrets(integration);
await integrationInstance.getIndexersAsync();
}
}
});

View File

@@ -1,11 +1,11 @@
import dayjs from "dayjs";
import SuperJSON from "superjson";
import { decryptSecret } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import { integrationCreatorByKind } from "@homarr/integrations";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import type { CalendarEvent } from "@homarr/integrations/types";
import { createItemAndIntegrationChannel } from "@homarr/redis";
@@ -14,46 +14,25 @@ import type { WidgetComponentProps } from "../../../../widgets";
import { createCronJob } from "../../lib";
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(async () => {
const itemsForIntegration = await db.query.items.findMany({
where: eq(items.kind, "calendar"),
with: {
integrations: {
with: {
integration: {
with: {
secrets: {
columns: {
kind: true,
value: true,
},
},
},
},
},
},
},
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["calendar"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const integration of itemForIntegration.integrations) {
for (const { integration } of itemForIntegration.integrations) {
const options = SuperJSON.parse<WidgetComponentProps<"calendar">["options"]>(itemForIntegration.options);
const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate();
const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate();
const decryptedSecrets = integration.integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
}));
const integrationInstance = integrationCreatorByKind(integration.integration.kind as "radarr" | "sonarr", {
...integration.integration,
decryptedSecrets,
});
//Asserting the integration kind until all of them get implemented
const integrationInstance = integrationCreatorFromSecrets(
integration as Modify<typeof integration, { kind: "sonarr" | "radarr" }>,
);
const events = await integrationInstance.getCalendarEventsAsync(start, end);
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.integrationId);
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
await cache.setAsync(events);
}
}

View File

@@ -1,9 +1,8 @@
import { decryptSecret } from "@homarr/common";
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations";
import { integrationCreatorByKind } from "@homarr/integrations";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createCronJob } from "../../lib";
@@ -14,23 +13,15 @@ export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS).
});
for (const itemForIntegration of itemsForIntegration) {
for (const { integration, integrationId } of itemForIntegration.integrations) {
const integrationWithSecrets = {
...integration,
decryptedSecrets: integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
};
const requestsIntegration = integrationCreatorByKind(integration.kind, integrationWithSecrets);
for (const { integration } of itemForIntegration.integrations) {
const requestsIntegration = integrationCreatorFromSecrets(integration);
const mediaRequests = await requestsIntegration.getRequestsAsync();
const requestsStats = await requestsIntegration.getStatsAsync();
const requestsUsers = await requestsIntegration.getUsersAsync();
const requestListChannel = createItemAndIntegrationChannel<MediaRequestList>(
"mediaRequests-requestList",
integrationId,
integration.id,
);
await requestListChannel.publishAndUpdateLastStateAsync({
integration: { id: integration.id },
@@ -39,7 +30,7 @@ export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS).
const requestStatsChannel = createItemAndIntegrationChannel<MediaRequestStats>(
"mediaRequests-requestStats",
integrationId,
integration.id,
);
await requestStatsChannel.publishAndUpdateLastStateAsync({
integration: { kind: integration.kind, name: integration.name },

View File

@@ -1,44 +1,21 @@
import { decryptSecret } from "@homarr/common";
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import { JellyfinIntegration } from "@homarr/integrations";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createCronJob } from "../../lib";
export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(async () => {
const itemsForIntegration = await db.query.items.findMany({
where: eq(items.kind, "mediaServer"),
with: {
integrations: {
with: {
integration: {
with: {
secrets: {
columns: {
kind: true,
value: true,
},
},
},
},
},
},
},
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: ["mediaServer"],
});
for (const itemForIntegration of itemsForIntegration) {
for (const integration of itemForIntegration.integrations) {
const jellyfinIntegration = new JellyfinIntegration({
...integration.integration,
decryptedSecrets: integration.integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
});
const streamSessions = await jellyfinIntegration.getCurrentSessionsAsync();
const channel = createItemAndIntegrationChannel("mediaServer", integration.integrationId);
for (const { integration } of itemForIntegration.integrations) {
const integrationInstance = integrationCreatorFromSecrets(integration);
const streamSessions = await integrationInstance.getCurrentSessionsAsync();
const channel = createItemAndIntegrationChannel("mediaServer", integration.id);
await channel.publishAndUpdateLastStateAsync(streamSessions);
}
}

View File

@@ -2,6 +2,7 @@ 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";
@@ -125,9 +126,12 @@ interface ExtendedFeedEntry extends FeedEntry {
* We extend the feed with custom properties.
* This interface omits the default entries with our custom definition.
*/
interface ExtendedFeedData extends Omit<FeedData, "entries"> {
entries?: ExtendedFeedEntry;
}
type ExtendedFeedData = Modify<
FeedData,
{
entries?: ExtendedFeedEntry;
}
>;
export interface RssFeed {
feedUrl: string;