feat(releases-widget): add new providers, Github Packages, linuxserver.io and Quay (#3607)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Andre Silva
2025-08-01 10:13:20 +01:00
committed by GitHub
parent 03adf538b0
commit c92bbd2da0
11 changed files with 427 additions and 27 deletions

View File

@@ -207,6 +207,27 @@ export const integrationDefs = {
category: ["releasesProvider"],
defaultUrl: "https://codeberg.org",
},
linuxServerIO: {
name: "LinuxServer.io",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg",
category: ["releasesProvider"],
defaultUrl: "https://api.linuxserver.io",
},
githubPackages: {
name: "Github Packages",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
category: ["releasesProvider"],
defaultUrl: "https://api.github.com",
},
quay: {
name: "Quay",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/quay.png",
category: ["releasesProvider"],
defaultUrl: "https://quay.io",
},
ntfy: {
name: "ntfy",
secretKinds: [["topic"], ["topic", "apiKey"]],

View File

@@ -14,11 +14,13 @@ import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorre
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration";
import { GithubPackagesIntegration } from "../github-packages/github-packages-integration";
import { GithubIntegration } from "../github/github-integration";
import { GitlabIntegration } from "../gitlab/gitlab-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration";
import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration";
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
@@ -34,6 +36,7 @@ import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-fac
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { QuayIntegration } from "../quay/quay-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import type { Integration, IntegrationInput } from "./integration";
@@ -104,6 +107,9 @@ export const integrationCreators = {
gitlab: GitlabIntegration,
npm: NPMIntegration,
codeberg: CodebergIntegration,
linuxServerIO: LinuxServerIOIntegration,
githubPackages: GithubPackagesIntegration,
quay: QuayIntegration,
ntfy: NTFYIntegration,
mock: MockIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;

View File

@@ -20,7 +20,7 @@ const localLogger = logger.child({ module: "CodebergIntegration" });
export class CodebergIntegration extends Integration implements ReleasesProviderIntegration {
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("personalAccessToken")) return await callback({});
if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
return await callback({
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
@@ -61,7 +61,7 @@ export class CodebergIntegration extends Integration implements ReleasesProvider
const details = await this.getDetailsAsync(owner, name);
const releasesResponse = await this.withHeadersAsync(async (headers) => {
return fetchWithTrustedCertificatesAsync(
return await fetchWithTrustedCertificatesAsync(
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`),
{ headers },
);

View File

@@ -30,7 +30,8 @@ export class DockerHubIntegration extends Integration implements ReleasesProvide
}
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken")) return await callback({});
if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken"))
return await callback(undefined);
const storedSession = await this.sessionStore.getAsync();

View File

@@ -0,0 +1,145 @@
import { Octokit, RequestError } from "octokit";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
DetailsProviderResponse,
ReleaseProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
const localLogger = logger.child({ module: "GithubPackagesIntegration" });
export class GithubPackagesIntegration extends Integration implements ReleasesProviderIntegration {
private static readonly userAgent = "Homarr-Lab/Homarr:GithubPackagesIntegration";
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const headers: RequestInit["headers"] = {
"User-Agent": GithubPackagesIntegration.userAgent,
};
if (this.hasSecretValue("personalAccessToken"))
headers.Authorization = `Bearer ${this.getSecretValue("personalAccessToken")}`;
const response = await input.fetchAsync(this.url("/octocat"), {
headers,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const [owner, name] = repository.identifier.split("/");
if (!owner || !name) {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with Github Packages integration`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const api = this.getApi();
const details = await this.getDetailsAsync(api, owner, name);
try {
const releasesResponse = await api.rest.packages.getAllPackageVersionsForPackageOwnedByUser({
username: owner,
package_type: "container",
package_name: name,
per_page: 100,
});
const releasesProviderResponse = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
if (!release.metadata?.container?.tags || !(release.metadata.container.tags.length > 0)) return acc;
release.metadata.container.tags.forEach((tag) => {
acc.push({
latestRelease: tag,
latestReleaseAt: new Date(release.updated_at),
releaseUrl: release.html_url,
releaseDescription: release.description ?? undefined,
});
});
return acc;
}, []);
return getLatestRelease(releasesProviderResponse, repository, details);
} catch (error) {
const errorMessage = error instanceof RequestError ? error.message : String(error);
localLogger.warn(`Failed to get releases for ${owner}\\${name} with Github Packages integration`, {
owner,
name,
error: errorMessage,
});
return {
id: repository.id,
error: { message: errorMessage },
};
}
}
protected async getDetailsAsync(
api: Octokit,
owner: string,
name: string,
): Promise<DetailsProviderResponse | undefined> {
try {
const response = await api.rest.packages.getPackageForUser({
username: owner,
package_type: "container",
package_name: name,
});
return {
projectUrl: response.data.repository?.html_url ?? response.data.html_url,
projectDescription: response.data.repository?.description ?? undefined,
isFork: response.data.repository?.fork,
isArchived: response.data.repository?.archived,
createdAt: new Date(response.data.created_at),
starsCount: response.data.repository?.stargazers_count,
openIssues: response.data.repository?.open_issues_count,
forksCount: response.data.repository?.forks_count,
};
} catch (error) {
localLogger.warn(`Failed to get details for ${owner}\\${name} with Github Packages integration`, {
owner,
name,
error: error instanceof RequestError ? error.message : String(error),
});
return undefined;
}
}
private getApi() {
return new Octokit({
baseUrl: this.url("/").origin,
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
userAgent: GithubPackagesIntegration.userAgent,
throttle: { enabled: false }, // Disable throttling for this integration, Octokit will retry by default after a set time, thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
...(this.hasSecretValue("personalAccessToken") ? { auth: this.getSecretValue("personalAccessToken") } : {}),
});
}
}

View File

@@ -58,7 +58,6 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn
}
const api = this.getApi();
const details = await this.getDetailsAsync(api, owner, name);
try {

View File

@@ -0,0 +1,88 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import type { ReleasesRepository, ReleasesResponse } from "../interfaces/releases-providers/releases-providers-types";
import { releasesResponseSchema } from "./linuxserverio-schemas";
const localLogger = logger.child({ module: "LinuxServerIOsIntegration" });
export class LinuxServerIOIntegration extends Integration implements ReleasesProviderIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/health"));
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const [owner, name] = repository.identifier.split("/");
if (!owner || !name) {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with LinuxServerIO integration`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/images"));
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) {
return {
id: repository.id,
error: {
message: error.message,
},
};
} else {
const release = data.data.repositories.linuxserver.find((repo) => repo.name === name);
if (!release) {
localLogger.warn(`Repository ${name} not found on provider, with LinuxServerIO integration`, {
owner,
name,
});
return {
id: repository.id,
error: { code: "noReleasesFound" },
};
}
return {
id: repository.id,
latestRelease: release.version,
latestReleaseAt: release.version_timestamp,
releaseDescription: release.changelog?.shift()?.desc,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
createdAt: release.initial_date ? new Date(release.initial_date) : undefined,
starsCount: release.stars,
};
}
}
}

View File

@@ -0,0 +1,31 @@
import { z } from "zod";
export const releasesResponseSchema = z.object({
data: z.object({
repositories: z.object({
linuxserver: z.array(
z.object({
name: z.string(),
initial_date: z
.string()
.transform((value) => new Date(value))
.optional(),
github_url: z.string(),
description: z.string(),
version: z.string(),
version_timestamp: z.string().transform((value) => new Date(value)),
stars: z.number(),
deprecated: z.boolean(),
changelog: z
.array(
z.object({
date: z.string().transform((value) => new Date(value)),
desc: z.string(),
}),
)
.optional(),
}),
),
}),
}),
});

View File

@@ -0,0 +1,109 @@
import type { RequestInit, Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
ReleaseProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
import { releasesResponseSchema } from "./quay-schemas";
const localLogger = logger.child({ module: "QuayIntegration" });
export class QuayIntegration extends Integration implements ReleasesProviderIntegration {
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
return await callback({
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await this.withHeadersAsync(async (headers) => {
return await input.fetchAsync(this.url("/api/v1/discovery"), {
headers,
});
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const [owner, name] = repository.identifier.split("/");
if (!owner || !name) {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with LinuxServerIO integration`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const releasesResponse = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync(
this.url(
`/api/v1/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}?includeTags=true&includeStats=true`,
),
{
headers,
},
);
});
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) {
return {
id: repository.id,
error: {
message: error.message,
},
};
} else {
const details = {
projectDescription: data.description,
};
const releasesProviderResponse = Object.entries(data.tags).reduce<ReleaseProviderResponse[]>((acc, [_, tag]) => {
if (!tag.name || !tag.last_modified) return acc;
acc.push({
latestRelease: tag.name,
latestReleaseAt: new Date(tag.last_modified),
releaseUrl: `https://quay.io/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/tag/${encodeURIComponent(tag.name)}`,
});
return acc;
}, []);
return getLatestRelease(releasesProviderResponse, repository, details);
}
}
}

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
export const releasesResponseSchema = z.object({
description: z.string().optional(),
tags: z.record(
z.object({
name: z.string(),
last_modified: z.string(),
}),
),
});

View File

@@ -570,7 +570,7 @@ const ImportRepositorySelect = ({
)}
</Group>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={!integration} withArrow>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={integration !== undefined} withArrow>
<Group>
{integration ? (
<MaskedImage
@@ -618,29 +618,19 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
() =>
docker.data?.containers.reduce<ReleasesRepositoryImport[]>((acc, container) => {
const [maybeSource, maybeIdentifierAndVersion] = container.image.split(/\/(.*)/);
const hasSource = maybeSource && maybeSource in sourceToProviderKind;
const hasSource = maybeSource && maybeSource in containerImageToProviderKind;
const source = hasSource ? maybeSource : "docker.io";
const identifierAndVersion = hasSource ? maybeIdentifierAndVersion : container.image;
const [identifier, version] =
hasSource && maybeIdentifierAndVersion ? maybeIdentifierAndVersion.split(":") : container.image.split(":");
if (!identifierAndVersion) return acc;
if (!identifier) return acc;
const providerKey = sourceToProviderKind[source];
const providerKind = containerImageToProviderKind[source] ?? "dockerHub";
const integrationId = Object.values(innerProps.integrations).find(
(integration) => integration.kind === providerKey,
(integration) => integration.kind === providerKind,
)?.id;
const [identifier, version] = identifierAndVersion.split(":");
if (!identifier || !integrationId) return acc;
if (
acc.some(
(item) =>
item.providerIntegrationId !== undefined &&
innerProps.integrations[item.providerIntegrationId]?.kind === providerKey &&
item.identifier === identifier,
)
)
if (acc.some((item) => item.providerIntegrationId === integrationId && item.identifier === identifier))
return acc;
acc.push({
@@ -651,10 +641,7 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
name: formatIdentifierName(identifier),
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
alreadyImported: innerProps.repositories.some(
(item) =>
item.providerIntegrationId !== undefined &&
innerProps.integrations[item.providerIntegrationId]?.kind === providerKey &&
item.identifier === identifier,
(item) => item.providerIntegrationId === integrationId && item.identifier === identifier,
),
});
return acc;
@@ -811,9 +798,11 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
size: "xl",
});
const sourceToProviderKind: Record<string, IntegrationKind> = {
const containerImageToProviderKind: Record<string, IntegrationKind> = {
"ghcr.io": "github",
"docker.io": "dockerHub",
"lscr.io": "linuxServerIO",
"quay.io": "quay",
};
const parseImageVersionToVersionFilter = (imageVersion: string): ReleasesVersionFilter | undefined => {