feat: support aria2 integration (#2226)

This commit is contained in:
Kudou Sterain
2025-04-11 02:40:40 +07:00
committed by GitHub
parent 4b0b892250
commit 94263c445b
19 changed files with 473 additions and 18 deletions

View File

@@ -49,6 +49,12 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
category: ["downloadClient", "torrent"],
},
aria2: {
name: "Aria2",
secretKinds: [[], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg",
category: ["downloadClient", "torrent", "miscellaneous"],
},
sonarr: {
name: "Sonarr",
secretKinds: [["apiKey"]],
@@ -211,6 +217,7 @@ export type IntegrationCategory =
| "downloadClient"
| "usenet"
| "torrent"
| "miscellaneous"
| "smartHomeServer"
| "indexerManager"
| "healthMonitoring"

View File

@@ -36,6 +36,7 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0",
"maria2": "^0.4.0",
"node-ical": "^0.20.1",
"proxmox-api": "1.1.1",
"tsdav": "^2.1.3",

View File

@@ -5,6 +5,7 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
@@ -78,6 +79,7 @@ export const integrationCreators = {
qBittorrent: QBitTorrentIntegration,
deluge: DelugeIntegration,
transmission: TransmissionIntegration,
aria2: Aria2Integration,
jellyseerr: JellyseerrIntegration,
overseerr: OverseerrIntegration,
prowlarr: ProwlarrIntegration,

View File

@@ -0,0 +1,180 @@
import path from "path";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { Aria2Download, Aria2GetClient } from "./aria2-types";
export class Aria2Integration extends DownloadClientIntegration {
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
const client = this.getClient();
const keys: (keyof Aria2Download)[] = [
"bittorrent",
"uploadLength",
"uploadSpeed",
"downloadSpeed",
"totalLength",
"completedLength",
"files",
"status",
"gid",
];
const [activeDownloads, waitingDownloads, stoppedDownloads, globalStats] = await Promise.all([
client.tellActive(),
client.tellWaiting(0, 1000, keys),
client.tellStopped(0, 1000, keys),
client.getGlobalStat(),
]);
const downloads = [...activeDownloads, ...waitingDownloads, ...stoppedDownloads];
const allPaused = downloads.every((download) => download.status === "paused");
return {
status: {
types: ["torrent", "miscellaneous"],
paused: allPaused,
rates: {
up: Number(globalStats.uploadSpeed),
down: Number(globalStats.downloadSpeed),
},
},
items: downloads.map((download, index) => {
const totalSize = Number(download.totalLength);
const completedSize = Number(download.completedLength);
const progress = totalSize > 0 ? completedSize / totalSize : 0;
const itemName = download.bittorrent?.info?.name ?? path.basename(download.files[0]?.path ?? "Unknown");
return {
index,
id: download.gid,
name: itemName,
type: download.bittorrent ? "torrent" : "miscellaneous",
size: totalSize,
sent: Number(download.uploadLength),
downSpeed: Number(download.downloadSpeed),
upSpeed: Number(download.uploadSpeed),
time: this.calculateEta(completedSize, totalSize, Number(download.downloadSpeed)),
state: this.getState(download.status, Boolean(download.bittorrent)),
category: [],
progress,
};
}),
} as DownloadClientJobsAndStatus;
}
public async pauseQueueAsync(): Promise<void> {
const client = this.getClient();
await client.pauseAll();
}
public async pauseItemAsync(item: DownloadClientItem): Promise<void> {
const client = this.getClient();
await client.pause(item.id);
}
public async resumeQueueAsync(): Promise<void> {
const client = this.getClient();
await client.unpauseAll();
}
public async resumeItemAsync(item: DownloadClientItem): Promise<void> {
const client = this.getClient();
await client.unpause(item.id);
}
public async deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void> {
const client = this.getClient();
// Note: Remove download file is not support by aria2, replace with forceremove
if (item.state in ["downloading", "leeching", "paused"]) {
await (fromDisk ? client.remove(item.id) : client.forceRemove(item.id));
} else {
await client.removeDownloadResult(item.id);
}
}
public async testConnectionAsync(): Promise<void> {
const client = this.getClient();
await client.getVersion();
}
private getClient() {
const url = this.url("/jsonrpc");
return new Proxy(
{},
{
get: (target, method: keyof Aria2GetClient) => {
return async (...args: Parameters<Aria2GetClient[typeof method]>) => {
let params = [...args];
if (this.hasSecretValue("apiKey")) {
params = [`token:${this.getSecretValue("apiKey")}`, ...params];
}
const body = JSON.stringify({
jsonrpc: "2.0",
id: btoa(["Homarr", Date.now().toString(), Math.random()].join(".")), // unique id per request
method: `aria2.${method}`,
params,
});
return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body })
.then(async (response) => {
const responseBody = (await response.json()) as { result: ReturnType<Aria2GetClient[typeof method]> };
if (!response.ok) {
throw new Error(response.statusText);
}
return responseBody.result;
})
.catch((error) => {
if (error instanceof Error) {
throw new Error(error.message);
} else {
throw new Error("Error communicating with Aria2");
}
});
};
},
},
) as Aria2GetClient;
}
private getState(aria2Status: Aria2Download["status"], isTorrent: boolean): DownloadClientItem["state"] {
return isTorrent ? this.getTorrentState(aria2Status) : this.getNonTorrentState(aria2Status);
}
private getNonTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
switch (aria2Status) {
case "active":
return "downloading";
case "waiting":
return "queued";
case "paused":
return "paused";
case "complete":
return "completed";
case "error":
return "failed";
case "removed":
default:
return "unknown";
}
}
private getTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
switch (aria2Status) {
case "active":
return "leeching";
case "waiting":
return "queued";
case "paused":
return "paused";
case "complete":
return "completed";
case "error":
return "failed";
case "removed":
default:
return "unknown";
}
}
private calculateEta(completed: number, total: number, speed: number): number {
if (speed === 0 || completed >= total) return 0;
return Math.floor((total - completed) / speed) * 1000; // Convert to milliseconds
}
}

View File

@@ -0,0 +1,80 @@
export interface Aria2GetClient {
getVersion: Aria2VoidFunc<Aria2GetVersion>;
getGlobalStat: Aria2VoidFunc<Aria2GetGlobalStat>;
tellActive: Aria2VoidFunc<Aria2Download[]>;
tellWaiting: Aria2VoidFunc<Aria2Download[], Aria2TellStatusListParams>;
tellStopped: Aria2VoidFunc<Aria2Download[], Aria2TellStatusListParams>;
tellStatus: Aria2GidFunc<Aria2Download, Aria2TellStatusListParams>;
pause: Aria2GidFunc<AriaGID>;
pauseAll: Aria2VoidFunc<"OK">;
unpause: Aria2GidFunc<AriaGID>;
unpauseAll: Aria2VoidFunc<"OK">;
remove: Aria2GidFunc<AriaGID>;
forceRemove: Aria2GidFunc<AriaGID>;
removeDownloadResult: Aria2GidFunc<"OK">;
}
type AriaGID = string;
type Aria2GidFunc<R = void, T extends unknown[] = []> = (gid: string, ...args: T) => Promise<R>;
type Aria2VoidFunc<R = void, T extends unknown[] = []> = (...args: T) => Promise<R>;
type Aria2TellStatusListParams = [offset: number, num: number, keys?: [keyof Aria2Download] | (keyof Aria2Download)[]];
export interface Aria2GetVersion {
enabledFeatures: string[];
version: string;
}
export interface Aria2GetGlobalStat {
downloadSpeed: string;
uploadSpeed: string;
numActive: string;
numWaiting: string;
numStopped: string;
numStoppedTotal: string;
}
export interface Aria2Download {
gid: AriaGID;
status: "active" | "waiting" | "paused" | "error" | "complete" | "removed";
totalLength: string;
completedLength: string;
uploadLength: string;
bitfield: string;
downloadSpeed: string;
uploadSpeed: string;
infoHash?: string;
numSeeders?: string;
seeder?: "true" | "false";
pieceLength: string;
numPieces: string;
connections: string;
errorCode?: string;
errorMessage?: string;
followedBy?: AriaGID[];
following?: AriaGID;
belongsTo?: AriaGID;
dir: string;
files: {
index: number;
path: string;
length: string;
completedLength: string;
selected: "true" | "false";
uris: {
status: "waiting" | "used";
uri: string;
}[];
}[];
bittorrent?: {
announceList: string[];
comment?: string | { "utf-8": string };
creationDate?: number;
mode?: "single" | "multi";
info?: {
name: string | { "utf-8": string };
};
verifiedLength?: number;
verifyIntegrityPending?: boolean;
};
}

View File

@@ -32,7 +32,7 @@ export class DelugeIntegration extends DownloadClientIntegration {
down: Math.floor(download_rate),
up: Math.floor(upload_rate),
},
type,
types: [type],
};
const items = torrents.map((torrent): DownloadClientItem => {
const state = DelugeIntegration.getTorrentState(torrent.state);

View File

@@ -21,7 +21,7 @@ export class NzbGetIntegration extends DownloadClientIntegration {
const status: DownloadClientStatus = {
paused: nzbGetStatus.DownloadPaused,
rates: { down: nzbGetStatus.DownloadRate },
type,
types: [type],
};
const items = queue
.map((file): DownloadClientItem => {

View File

@@ -24,7 +24,7 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
);
const paused =
torrents.find(({ state }) => QBitTorrentIntegration.getTorrentState(state) !== "paused") === undefined;
const status: DownloadClientStatus = { paused, rates, type };
const status: DownloadClientStatus = { paused, rates, types: [type] };
const items = torrents.map((torrent): DownloadClientItem => {
const state = QBitTorrentIntegration.getTorrentState(torrent.state);
return {

View File

@@ -24,7 +24,7 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
const status: DownloadClientStatus = {
paused: queue.paused,
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
type,
types: [type],
};
const items = queue.slots
.map((slot): DownloadClientItem => {

View File

@@ -24,7 +24,7 @@ export class TransmissionIntegration extends DownloadClientIntegration {
);
const paused =
torrents.find(({ status }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined;
const status: DownloadClientStatus = { paused, rates, type };
const status: DownloadClientStatus = { paused, rates, types: [type] };
const items = torrents.map((torrent): DownloadClientItem => {
const state = TransmissionIntegration.getTorrentState(torrent.status);
return {

View File

@@ -5,6 +5,7 @@ export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";

View File

@@ -20,8 +20,8 @@ export const downloadClientItemSchema = z.object({
index: z.number(),
/** Filename */
name: z.string(),
/** Torrent/Usenet identifier */
type: z.enum(["torrent", "usenet"]),
/** Download Client identifier */
type: z.enum(["torrent", "usenet", "miscellaneous"]),
/** Item size in Bytes */
size: z.number(),
/** Total uploaded in Bytes, only required for Torrent items */

View File

@@ -8,7 +8,7 @@ export interface DownloadClientStatus {
down: number;
up?: number;
};
type: "usenet" | "torrent";
types: ("usenet" | "torrent" | "miscellaneous")[];
}
export interface ExtendedClientStatus {
integration: Pick<Integration, "id" | "name" | "kind"> & { updatedAt: Date };

View File

@@ -0,0 +1,153 @@
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers";
import { beforeAll, describe, expect, test } from "vitest";
import { Aria2Integration } from "../src";
const API_KEY = "ARIA2_API_KEY";
const IMAGE_NAME = "hurlenko/aria2-ariang:latest";
describe("Aria2 integration", () => {
beforeAll(async () => {
const containerRuntimeClient = await getContainerRuntimeClient();
await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME));
}, 100_000);
test("Test connection should work", async () => {
// Arrange
const startedContainer = await createAria2Container().start();
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
// Act
const actAsync = async () => await aria2Integration.testConnectionAsync();
// Assert
await expect(actAsync()).resolves.not.toThrow();
// Cleanup
await startedContainer.stop();
}, 30_000);
test("pauseQueueAsync should work", async () => {
// Arrange
const startedContainer = await createAria2Container().start();
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
// Acts
const actAsync = async () => await aria2Integration.pauseQueueAsync();
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync();
// Assert
await expect(actAsync()).resolves.not.toThrow();
await expect(getAsync()).resolves.toMatchObject({ status: { paused: true } });
// Cleanup
await startedContainer.stop();
}, 30_000); // Timeout of 30 seconds
test("Items should be empty", async () => {
// Arrange
const startedContainer = await createAria2Container().start();
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
// Act
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync();
// Assert
await expect(getAsync()).resolves.not.toThrow();
await expect(getAsync()).resolves.toMatchObject({
items: [],
});
// Cleanup
await startedContainer.stop();
}, 30_000); // Timeout of 30 seconds
test("1 Items should exist after adding one", async () => {
// Arrange
const startedContainer = await createAria2Container().start();
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
await aria2AddItemAsync(startedContainer, API_KEY, aria2Integration);
// Act
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync();
// Assert
await expect(getAsync()).resolves.not.toThrow();
expect((await getAsync()).items).toHaveLength(1);
// Cleanup
await startedContainer.stop();
}, 30_000); // Timeout of 30 seconds
test("Delete item should result in empty items", async () => {
// Arrange
const startedContainer = await createAria2Container().start();
const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
const item = await aria2AddItemAsync(startedContainer, API_KEY, aria2Integration);
// Act
const actAsync = async () => await aria2Integration.deleteItemAsync(item, true);
// Assert
await expect(actAsync()).resolves.not.toThrow();
// NzbGet is slow and we wait for a second before querying the items. Test was flaky without this.
await new Promise((resolve) => setTimeout(resolve, 1000));
const result = await aria2Integration.getClientJobsAndStatusAsync();
expect(result.items).toHaveLength(0);
// Cleanup
await startedContainer.stop();
}, 30_000); // Timeout of 30 seconds
});
const createAria2Container = () => {
return new GenericContainer(IMAGE_NAME)
.withExposedPorts(8080)
.withEnvironment({
PUID: "1000",
PGID: "1000",
ARIA2RPCPORT: "443",
RPC_SECRET: API_KEY,
})
.withWaitStrategy(Wait.forLogMessage("listening on TCP port"));
};
const createAria2Intergration = (container: StartedTestContainer, apikey: string) => {
return new Aria2Integration({
id: "1",
decryptedSecrets: [
{
kind: "apiKey",
value: apikey,
},
],
name: "Aria2",
url: `http://${container.getHost()}:${container.getMappedPort(8080)}`,
});
};
const aria2AddItemAsync = async (container: StartedTestContainer, apiKey: string, integration: Aria2Integration) => {
await fetch(`http://${container.getHost()}:${container.getMappedPort(8080)}/jsonrpc`, {
method: "POST",
body: JSON.stringify({
jsonrpc: "2.0",
id: btoa(["Homarr", Date.now().toString(), Math.random()].join(".")), // unique id per request
method: "aria2.addUri",
params: [`token:${apiKey}`, ["https://google.com"]],
}),
});
await delay(3000);
const {
items: [item],
} = await integration.getClientJobsAndStatusAsync();
if (!item) {
throw new Error("No item found");
}
return item;
};
const delay = (microseconds: number) => new Promise((resolve) => setTimeout(resolve, microseconds));

View File

@@ -76,6 +76,7 @@ const optionMapping: OptionMapping = {
defaultSort: () => "type",
descendingDefaultSort: () => false,
showCompletedUsenet: () => true,
showCompletedHttp: () => true,
},
weather: {
forecastDayCount: (oldOptions) => oldOptions.forecastDays,

View File

@@ -1786,6 +1786,9 @@
"showCompletedTorrent": {
"label": "Show torrent entries marked as completed"
},
"showCompletedHttp": {
"label": "Show Miscellaneous entries marked as completed"
},
"activeTorrentThreshold": {
"label": "Hide completed torrent under this threshold (in kiB/s)"
},

View File

@@ -171,7 +171,8 @@ export default function DownloadClientsWidget({
options.showCompletedTorrent &&
(upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) ||
progress !== 1)) ||
(type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)),
(type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)) ||
(type === "miscellaneous" && ((progress === 1 && options.showCompletedHttp) || progress !== 1)),
)
//Filter following user quick setting
.filter(
@@ -189,7 +190,7 @@ export default function DownloadClientsWidget({
...item,
category: item.category !== undefined && item.category.length > 0 ? item.category : undefined,
received,
ratio: item.sent !== undefined ? item.sent / received : undefined,
ratio: item.sent !== undefined ? item.sent / (received || 1) : undefined,
//Only add if permission to use mutations
actions: integrationsWithInteractions.includes(pair.integration.id)
? {
@@ -215,6 +216,7 @@ export default function DownloadClientsWidget({
options.filterIsWhitelist,
options.showCompletedTorrent,
options.showCompletedUsenet,
options.showCompletedHttp,
quickFilters,
],
);
@@ -232,7 +234,7 @@ export default function DownloadClientsWidget({
.filter(
({ category }) =>
!options.applyFilterToRatio ||
data.status.type !== "torrent" ||
!data.status.types.includes("torrent") ||
options.filterIsWhitelist ===
options.categoryFilter.some((filter) =>
(Array.isArray(category) ? category : [category]).includes(filter),
@@ -258,7 +260,7 @@ export default function DownloadClientsWidget({
})
.sort(
({ status: statusA }, { status: statusB }) =>
(statusA?.type.length ?? Infinity) - (statusB?.type.length ?? Infinity),
(statusA?.types.length ?? Infinity) - (statusB?.types.length ?? Infinity),
),
[
currentItems,
@@ -272,8 +274,10 @@ export default function DownloadClientsWidget({
//Check existing types between torrents and usenet
const integrationTypes: ExtendedDownloadClientItem["type"][] = [];
if (data.some(({ type }) => type === "torrent")) integrationTypes.push("torrent");
if (data.some(({ type }) => type === "usenet")) integrationTypes.push("usenet");
if (data.some(({ type }) => type === "miscellaneous")) integrationTypes.push("miscellaneous");
//Set the visibility of columns depending on widget settings and available data/integrations.
const columnVisibility: MRT_VisibilityState = {
@@ -677,15 +681,22 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
<NormalizedLine itemKey="index" values={item.index} />
<NormalizedLine itemKey="type" values={item.type} />
<NormalizedLine itemKey="state" values={t(item.state)} />
<NormalizedLine
itemKey="upSpeed"
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
/>
{item.type !== "miscellaneous" && (
<NormalizedLine
itemKey="upSpeed"
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
/>
)}
<NormalizedLine
itemKey="downSpeed"
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
/>
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
{item.type !== "miscellaneous" && (
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
)}
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
<NormalizedLine
@@ -696,7 +707,7 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr
unitDisplay: "narrow",
}).format(item.progress)}
/>
<NormalizedLine itemKey="ratio" values={item.ratio} />
{item.type !== "miscellaneous" && <NormalizedLine itemKey="ratio" values={item.ratio} />}
<NormalizedLine itemKey="added" values={item.added === undefined ? "unknown" : dayjs(item.added).format()} />
<NormalizedLine itemKey="time" values={item.time !== 0 ? dayjs().add(item.time).format() : "∞"} />
<NormalizedLine itemKey="category" values={item.category} />

View File

@@ -63,6 +63,9 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
showCompletedTorrent: factory.switch({
defaultValue: true,
}),
showCompletedHttp: factory.switch({
defaultValue: true,
}),
activeTorrentThreshold: factory.number({
//in KiB/s
validate: z.number().min(0),
@@ -95,6 +98,10 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
showCompletedHttp: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("miscellaneous").some((kinds) => integrationKinds.includes(kinds)),
},
activeTorrentThreshold: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),

9
pnpm-lock.yaml generated
View File

@@ -1306,6 +1306,9 @@ importers:
'@jellyfin/sdk':
specifier: ^0.11.0
version: 0.11.0(axios@1.8.4)
maria2:
specifier: ^0.4.0
version: 0.4.0
node-ical:
specifier: ^0.20.1
version: 0.20.1
@@ -7779,6 +7782,10 @@ packages:
react: '>=18.0'
react-dom: '>=18.0'
maria2@0.4.0:
resolution: {integrity: sha512-jQ9yezKDaSeiy7SZRA8zMAYkFyfx3yoeB/lhcOSQsoGf4YyVaPDopP+dEU6HWNJwK2Oo/rmgqTb5Na1gJcLtrg==}
engines: {node: '>= 22.5.0'}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@@ -16649,6 +16656,8 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
maria2@0.4.0: {}
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1