Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5b79525f | ||
|
|
426929556f | ||
|
|
ade640dcea | ||
|
|
61128ddcc8 | ||
|
|
6a3cee8107 | ||
|
|
06d9c07d22 | ||
|
|
49d0f1a141 |
No files matched your search
@@ -1,3 +1,5 @@
|
|||||||
|
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
||||||
|
|
||||||
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
|
## [1.72.0](https://github.com/homarr-labs/homarr/compare/v1.71.0...v1.72.0) (2026-07-24)
|
||||||
|
|
||||||
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
|
## [1.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ A random secret can be generated by using the following command: `openssl rand -
|
|||||||
|
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
| ------------------------ | ----------- | --------------- | ------- |
|
| ------------------------ | ----------- | --------------- | ------- |
|
||||||
| ``DOCKER_HOSTNAMES`` | Comma seperated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
| ``DOCKER_HOSTNAMES`` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
|
||||||
| ``DOCKER_PORTS`` | Comma seperated list of ports to connect to | For example `2375,2376` | - |
|
| ``DOCKER_PORTS`` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
|
||||||
| ``DOCKER_SOCKET_PATHS`` | Comma seperated list of paths to sockets | For example `/var/run/docker.sock,/run/user/1000/podman/podman.sock` | - |
|
| ``DOCKER_SOCKET_PATHS`` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
| Environment Variable | Description | Possible values | Default |
|
| Environment Variable | Description | Possible values | Default |
|
||||||
|
|||||||
@@ -55,6 +55,74 @@ services:
|
|||||||
With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`.
|
With docker run, add this parameter: `-v /var/run/docker.sock:/var/run/docker.sock`.
|
||||||
In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock`
|
In Windows, you'll need to adjust the slashes: `-v //var/run/docker.sock:/var/run/docker.sock`
|
||||||
|
|
||||||
|
### Using Podman
|
||||||
|
|
||||||
|
On Linux, Homarr can connect to Podman's [Docker-compatible API](https://docs.podman.io/en/latest/markdown/podman-system-service.1.html).
|
||||||
|
The Podman API socket must be running and mounted into the Homarr container.
|
||||||
|
The socket grants full access to Podman, including arbitrary code execution as the user running the API, so only mount it into trusted containers.
|
||||||
|
|
||||||
|
For rootless Podman, enable the socket for the user that runs Homarr:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user enable --now podman.socket
|
||||||
|
```
|
||||||
|
|
||||||
|
The rootless socket is located at `$XDG_RUNTIME_DIR/podman/podman.sock` (usually `/run/user/<uid>/podman/podman.sock`).
|
||||||
|
You can confirm the path with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman info --format '{{.Host.RemoteSocket.Path}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Mount that socket into the Homarr container and point `DOCKER_SOCKET_PATHS` to its path **inside** the container:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
homarr:
|
||||||
|
container_name: homarr
|
||||||
|
image: ghcr.io/homarr-labs/homarr:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
|
||||||
|
- ./homarr/appdata:/appdata
|
||||||
|
environment:
|
||||||
|
SECRET_ENCRYPTION_KEY: your_64_character_hex_string
|
||||||
|
DOCKER_SOCKET_PATHS: /var/run/podman.sock
|
||||||
|
ports:
|
||||||
|
- "7575:7575"
|
||||||
|
```
|
||||||
|
|
||||||
|
Start Homarr with `podman compose up -d`.
|
||||||
|
Run the compose project as the same user that owns the rootless Podman socket so `$XDG_RUNTIME_DIR` resolves to the correct path.
|
||||||
|
To keep the user socket available after logout and across reboots, enable lingering with `loginctl enable-linger <user>`.
|
||||||
|
|
||||||
|
For rootful Podman, enable the system socket with `sudo systemctl enable --now podman.socket`.
|
||||||
|
Its default path is `/run/podman/podman.sock`; mount that path instead and start Homarr with `sudo podman compose up -d`.
|
||||||
|
|
||||||
|
:::note SELinux
|
||||||
|
On systems with SELinux enabled, Podman's documentation recommends disabling label separation for a container that accesses the API socket.
|
||||||
|
Add the following to the Homarr service:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
security_opt:
|
||||||
|
- label=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
You can connect Homarr to multiple Docker-compatible sockets by mounting each socket and listing their in-container paths, separated by commas:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
homarr:
|
||||||
|
image: ghcr.io/homarr-labs/homarr:latest
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/podman.sock
|
||||||
|
environment:
|
||||||
|
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
|
||||||
|
```
|
||||||
|
|
||||||
### Managing your Docker containers within Homarr
|
### Managing your Docker containers within Homarr
|
||||||
Homarr allows you to interact with Docker containers running on your system.
|
Homarr allows you to interact with Docker containers running on your system.
|
||||||
Each container row has an action menu with the following options:
|
Each container row has an action menu with the following options:
|
||||||
|
|||||||
@@ -10,19 +10,21 @@ import { AddingIntegration } from '@site/src/components/integrations/adding';
|
|||||||
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
|
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
|
||||||
import { navidromeIntegration } from '.';
|
import { navidromeIntegration } from '.';
|
||||||
import { audioStatsWidget } from '@site/docs/widgets/audio-stats';
|
import { audioStatsWidget } from '@site/docs/widgets/audio-stats';
|
||||||
|
import { mediaServerWidget } from '@site/docs/widgets/media-server';
|
||||||
|
|
||||||
|
|
||||||
<IntegrationHeader
|
<IntegrationHeader
|
||||||
integration={navidromeIntegration}
|
integration={navidromeIntegration}
|
||||||
categories={['Media']}
|
categories={['Media', 'Media server']}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
Navidrome provides music library statistics including artist, album, and song counts, as well as now playing information via the Subsonic API.
|
Navidrome provides music library statistics including artist, album, and song counts, as well as now playing audio streams via the Subsonic API.
|
||||||
|
|
||||||
### Widgets & Capabilities
|
### Widgets & Capabilities
|
||||||
<IntegrationCapabilites
|
<IntegrationCapabilites
|
||||||
items={[
|
items={[
|
||||||
{ widget: audioStatsWidget },
|
{ widget: audioStatsWidget },
|
||||||
|
{ widget: mediaServerWidget },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,45 @@
|
|||||||
---
|
---
|
||||||
title: 'Audio Stats'
|
title: "Audio Stats"
|
||||||
description: 'Displays library statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration.'
|
description: "Displays media and listening statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration."
|
||||||
hide_title: true
|
hide_title: true
|
||||||
---
|
---
|
||||||
|
|
||||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||||
import { audioStatsWidget } from '.';
|
import { audioStatsWidget } from ".";
|
||||||
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
|
import { navidromeIntegration } from "@site/docs/integrations/navidrome";
|
||||||
import { audiobookshelfIntegration } from '@site/docs/integrations/audiobookshelf';
|
import { audiobookshelfIntegration } from "@site/docs/integrations/audiobookshelf";
|
||||||
|
|
||||||
|
<WidgetHeader widget={audioStatsWidget} categories={["Media"]} />
|
||||||
<WidgetHeader
|
|
||||||
widget={audioStatsWidget}
|
|
||||||
categories={['Media']}
|
|
||||||
/>
|
|
||||||
|
|
||||||
This widget adapts its display based on the linked integration:
|
This widget adapts its display based on the linked integration:
|
||||||
|
|
||||||
- **Navidrome**: Shows artist, album, and song counts, plus a now playing section with active streams
|
- **Navidrome**: Shows artist, album, and song counts
|
||||||
- **Audiobookshelf**: Shows library count, audiobook/podcast totals, listening time, and active sessions
|
- **Audiobookshelf**: Shows library count, audiobook/podcast totals, listening time, and active sessions
|
||||||
|
|
||||||
Only options relevant to your linked integration are shown in the configuration panel.
|
Only options relevant to your linked integration are shown in the configuration panel.
|
||||||
|
|
||||||
### Supported Integrations
|
### Supported Integrations
|
||||||
|
|
||||||
<WidgetIntegrations items={[{
|
<WidgetIntegrations
|
||||||
integration: navidromeIntegration,
|
items={[
|
||||||
note: "Displays music library stats and now playing"
|
{
|
||||||
}, {
|
integration: navidromeIntegration,
|
||||||
integration: audiobookshelfIntegration,
|
note: "Displays music library statistics",
|
||||||
note: "Displays audiobook/podcast library stats and listening time"
|
},
|
||||||
}]} />
|
{
|
||||||
|
integration: audiobookshelfIntegration,
|
||||||
|
note: "Displays audiobook/podcast library stats and listening time",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
### Adding the widget
|
### Adding the widget
|
||||||
|
|
||||||
<AddingWidget />
|
<AddingWidget />
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
<WidgetConfig configuration={audioStatsWidget.configuration} />
|
<WidgetConfig configuration={audioStatsWidget.configuration} />
|
||||||
@@ -27,24 +27,6 @@ export const audioStatsWidget: WidgetDefinition = {
|
|||||||
values: { type: "boolean" },
|
values: { type: "boolean" },
|
||||||
defaultValue: "yes",
|
defaultValue: "yes",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Show now playing",
|
|
||||||
description: "Displays currently playing tracks (Navidrome only)",
|
|
||||||
values: { type: "boolean" },
|
|
||||||
defaultValue: "yes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Show now playing list",
|
|
||||||
description: "Displays a detailed list of now playing tracks (Navidrome only)",
|
|
||||||
values: { type: "boolean" },
|
|
||||||
defaultValue: "yes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Max now playing items",
|
|
||||||
description: "Maximum number of now playing tracks to display (Navidrome only)",
|
|
||||||
values: "Number (1-5)",
|
|
||||||
defaultValue: "3",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Show library count",
|
name: "Show library count",
|
||||||
description: "Displays the number of libraries (Audiobookshelf only)",
|
description: "Displays the number of libraries (Audiobookshelf only)",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import TabItem from '@theme/TabItem';
|
|||||||
import { mediaServerWidget } from '.';
|
import { mediaServerWidget } from '.';
|
||||||
import { embyIntegration } from '@site/docs/integrations/emby';
|
import { embyIntegration } from '@site/docs/integrations/emby';
|
||||||
import { jellyfinIntegration } from '@site/docs/integrations/jellyfin';
|
import { jellyfinIntegration } from '@site/docs/integrations/jellyfin';
|
||||||
|
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
|
||||||
import { plexIntegration } from '@site/docs/integrations/plex';
|
import { plexIntegration } from '@site/docs/integrations/plex';
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ import mediaTable from './img/table.png';
|
|||||||
integration: embyIntegration,
|
integration: embyIntegration,
|
||||||
}, {
|
}, {
|
||||||
integration: jellyfinIntegration,
|
integration: jellyfinIntegration,
|
||||||
|
}, {
|
||||||
|
integration: navidromeIntegration,
|
||||||
|
note: "Will always show only currently playing audio streams, not all active sessions."
|
||||||
}, {
|
}, {
|
||||||
integration: plexIntegration,
|
integration: plexIntegration,
|
||||||
note: "Will always show only currently playing streams, not all active sessions."
|
note: "Will always show only currently playing streams, not all active sessions."
|
||||||
@@ -50,4 +54,3 @@ import mediaTable from './img/table.png';
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
<WidgetConfig configuration={mediaServerWidget.configuration} />
|
<WidgetConfig configuration={mediaServerWidget.configuration} />
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ export const mediaServerWidget: WidgetDefinition = {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: "Show only currently playing",
|
name: "Show only currently playing",
|
||||||
description: "Disabling this will not work for plex.",
|
description: "Plex and Navidrome always show only currently playing streams.",
|
||||||
values: { type: "boolean" },
|
values: { type: "boolean" },
|
||||||
defaultValue: "yes",
|
defaultValue: "yes",
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homarr",
|
"name": "homarr",
|
||||||
"version": "1.72.0",
|
"version": "1.73.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env CI=true turbo build",
|
"build": "cross-env CI=true turbo build",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const mediaServerRouter = createTRPCRouter({
|
|||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
description:
|
description:
|
||||||
"Get currently active streams from Plex/Jellyfin/Emby media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
|
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.concat(createMediaServerIntegrationMiddleware("query"))
|
.concat(createMediaServerIntegrationMiddleware("query"))
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ export const integrationDefs = {
|
|||||||
name: "Navidrome",
|
name: "Navidrome",
|
||||||
secretKinds: [["username", "password"]],
|
secretKinds: [["username", "password"]],
|
||||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
|
||||||
category: ["mediaLibrary"],
|
category: ["mediaLibrary", "mediaService"],
|
||||||
documentationUrl: null,
|
documentationUrl: null,
|
||||||
defaultPort: 4533,
|
defaultPort: 4533,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { IntegrationTestingInput } from "../base/integration";
|
|||||||
import { Integration } from "../base/integration";
|
import { Integration } from "../base/integration";
|
||||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
|
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
||||||
|
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
|
||||||
import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types";
|
import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types";
|
||||||
import { subsonicResponseSchema } from "./navidrome-types";
|
import { subsonicResponseSchema } from "./navidrome-types";
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ const subsonicAuthErrorCodes = new Set([40, 41]);
|
|||||||
const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
|
const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
|
||||||
[value].flat().filter((item): item is TValue => item !== undefined);
|
[value].flat().filter((item): item is TValue => item !== undefined);
|
||||||
|
|
||||||
export class NavidromeIntegration extends Integration {
|
export class NavidromeIntegration extends Integration implements IMediaServerIntegration {
|
||||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
const url = this.url("/rest/ping.view", this.getAuthParams());
|
const url = this.url("/rest/ping.view", this.getAuthParams());
|
||||||
const response = await input.fetchAsync(url);
|
const response = await input.fetchAsync(url);
|
||||||
@@ -53,20 +55,49 @@ export class NavidromeIntegration extends Integration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getDashboardDataAsync(): Promise<NavidromeDashboardData> {
|
public async getDashboardDataAsync(): Promise<NavidromeDashboardData> {
|
||||||
const [artistCount, libraryCounts, nowPlaying] = await Promise.all([
|
const [artistCount, libraryCounts] = await Promise.all([
|
||||||
this.getArtistCountAsync(),
|
this.getArtistCountAsync(),
|
||||||
this.getAlbumAndSongCountsAsync(),
|
this.getAlbumAndSongCountsAsync(),
|
||||||
this.getNowPlayingAsync(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
artistCount,
|
artistCount,
|
||||||
albumCount: libraryCounts.albumCount,
|
albumCount: libraryCounts.albumCount,
|
||||||
songCount: libraryCounts.songCount,
|
songCount: libraryCounts.songCount,
|
||||||
nowPlaying,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||||
|
const nowPlaying = await this.getNowPlayingAsync();
|
||||||
|
|
||||||
|
return nowPlaying.map(
|
||||||
|
(entry): StreamSession => ({
|
||||||
|
sessionId: `${entry.username}-${entry.playerName}`,
|
||||||
|
sessionName: entry.playerName,
|
||||||
|
user: {
|
||||||
|
userId: entry.username,
|
||||||
|
username: entry.username,
|
||||||
|
profilePictureUrl: null,
|
||||||
|
},
|
||||||
|
currentlyPlaying: {
|
||||||
|
type: "audio",
|
||||||
|
name: entry.title,
|
||||||
|
seasonName: entry.artist || undefined,
|
||||||
|
episodeName: null,
|
||||||
|
albumName: entry.album,
|
||||||
|
episodeCount: null,
|
||||||
|
playback: {
|
||||||
|
state: null,
|
||||||
|
positionMs: null,
|
||||||
|
durationMs: null,
|
||||||
|
},
|
||||||
|
location: null,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async getArtistCountAsync(): Promise<number> {
|
private async getArtistCountAsync(): Promise<number> {
|
||||||
const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true });
|
const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true });
|
||||||
return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0);
|
return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0);
|
||||||
|
|||||||
@@ -67,5 +67,4 @@ export interface NavidromeDashboardData {
|
|||||||
artistCount: number;
|
artistCount: number;
|
||||||
albumCount: number;
|
albumCount: number;
|
||||||
songCount: number;
|
songCount: number;
|
||||||
nowPlaying: NavidromeNowPlayingEntry[];
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
|
// @vitest-environment node
|
||||||
import { Response } from "undici";
|
import { Response } from "undici";
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
process.env.SKIP_ENV_VALIDATION = "true";
|
||||||
|
process.env.SECRET_ENCRYPTION_KEY = "0".repeat(64);
|
||||||
|
});
|
||||||
|
|
||||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
import type { IntegrationSecret } from "../../base/types";
|
import type { IntegrationSecret } from "../../base/types";
|
||||||
@@ -34,24 +40,24 @@ const subsonicOk = <T extends Record<string, unknown>>(data: T) =>
|
|||||||
"subsonic-response": { status: "ok", version: "1.16.1", ...data },
|
"subsonic-response": { status: "ok", version: "1.16.1", ...data },
|
||||||
});
|
});
|
||||||
|
|
||||||
const subsonicFailed = (message: string) =>
|
const subsonicFailed = (message: string, code = 70) =>
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
"subsonic-response": { status: "failed", error: { code: 70, message } },
|
"subsonic-response": { status: "failed", error: { code, message } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeAlbums = (count: number, songCount: number) =>
|
||||||
|
Array.from({ length: count }, (_, index) => ({
|
||||||
|
id: `album-${index}`,
|
||||||
|
name: `Album ${index}`,
|
||||||
|
songCount,
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFetch.mockReset();
|
mockFetch.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
||||||
test("paginates album list and counts songs", async () => {
|
test("paginates album list and counts songs", async () => {
|
||||||
const makeAlbums = (count: number, songCount: number) =>
|
|
||||||
Array.from({ length: count }, (_, i) => ({
|
|
||||||
id: `album-${i}`,
|
|
||||||
name: `Album ${i}`,
|
|
||||||
songCount,
|
|
||||||
}));
|
|
||||||
|
|
||||||
mockFetch.mockImplementation((url) => {
|
mockFetch.mockImplementation((url) => {
|
||||||
const urlStr = toUrlString(url);
|
const urlStr = toUrlString(url);
|
||||||
|
|
||||||
@@ -84,13 +90,7 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
|||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlStr.includes("getNowPlaying")) {
|
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType<
|
||||||
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
|
|
||||||
typeof fetchWithTrustedCertificatesAsync
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
|
||||||
typeof fetchWithTrustedCertificatesAsync
|
typeof fetchWithTrustedCertificatesAsync
|
||||||
>;
|
>;
|
||||||
});
|
});
|
||||||
@@ -101,7 +101,6 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
|||||||
expect(result.albumCount).toBe(700);
|
expect(result.albumCount).toBe(700);
|
||||||
expect(result.songCount).toBe(500 * 10 + 200 * 5);
|
expect(result.songCount).toBe(500 * 10 + 200 * 5);
|
||||||
expect(result.artistCount).toBe(1);
|
expect(result.artistCount).toBe(1);
|
||||||
expect(result.nowPlaying).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles empty library gracefully", async () => {
|
test("handles empty library gracefully", async () => {
|
||||||
@@ -120,13 +119,7 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
|||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlStr.includes("getNowPlaying")) {
|
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType<
|
||||||
return Promise.resolve(
|
|
||||||
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
|
|
||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
|
||||||
typeof fetchWithTrustedCertificatesAsync
|
typeof fetchWithTrustedCertificatesAsync
|
||||||
>;
|
>;
|
||||||
});
|
});
|
||||||
@@ -137,7 +130,6 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
|||||||
expect(result.artistCount).toBe(0);
|
expect(result.artistCount).toBe(0);
|
||||||
expect(result.albumCount).toBe(0);
|
expect(result.albumCount).toBe(0);
|
||||||
expect(result.songCount).toBe(0);
|
expect(result.songCount).toBe(0);
|
||||||
expect(result.nowPlaying).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws on non-empty-library subsonic error", async () => {
|
test("throws on non-empty-library subsonic error", async () => {
|
||||||
@@ -151,8 +143,10 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
|||||||
const integration = createIntegration();
|
const integration = createIntegration();
|
||||||
await expect(integration.getDashboardDataAsync()).rejects.toThrow();
|
await expect(integration.getDashboardDataAsync()).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("now playing maps entries correctly", async () => {
|
describe("NavidromeIntegration.getCurrentSessionsAsync", () => {
|
||||||
|
test("maps now playing entries to stream sessions", async () => {
|
||||||
mockFetch.mockImplementation((url) => {
|
mockFetch.mockImplementation((url) => {
|
||||||
const urlStr = toUrlString(url);
|
const urlStr = toUrlString(url);
|
||||||
|
|
||||||
@@ -161,7 +155,22 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
|||||||
new Response(
|
new Response(
|
||||||
subsonicOk({
|
subsonicOk({
|
||||||
nowPlaying: {
|
nowPlaying: {
|
||||||
entry: { title: "Song", artist: "Band", album: "LP", username: "user1", playerName: "Chrome" },
|
entry: [
|
||||||
|
{
|
||||||
|
title: "Song",
|
||||||
|
artist: "Band",
|
||||||
|
album: "LP",
|
||||||
|
username: "user1",
|
||||||
|
playerName: "Chrome",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Second Song",
|
||||||
|
artist: "Second Artist",
|
||||||
|
album: "Second Album",
|
||||||
|
username: "user2",
|
||||||
|
playerName: "Mobile",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
@@ -169,14 +178,72 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
|||||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlStr.includes("getArtists")) {
|
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
||||||
return Promise.resolve(new Response(subsonicOk({ artists: {} }), { status: 200 })) as unknown as ReturnType<
|
typeof fetchWithTrustedCertificatesAsync
|
||||||
typeof fetchWithTrustedCertificatesAsync
|
>;
|
||||||
>;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (urlStr.includes("getAlbumList2")) {
|
const integration = createIntegration();
|
||||||
return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
|
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: true });
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
sessionId: "user1-Chrome",
|
||||||
|
sessionName: "Chrome",
|
||||||
|
user: {
|
||||||
|
userId: "user1",
|
||||||
|
username: "user1",
|
||||||
|
profilePictureUrl: null,
|
||||||
|
},
|
||||||
|
currentlyPlaying: {
|
||||||
|
type: "audio",
|
||||||
|
name: "Song",
|
||||||
|
seasonName: "Band",
|
||||||
|
episodeName: null,
|
||||||
|
albumName: "LP",
|
||||||
|
episodeCount: null,
|
||||||
|
playback: {
|
||||||
|
state: null,
|
||||||
|
positionMs: null,
|
||||||
|
durationMs: null,
|
||||||
|
},
|
||||||
|
location: null,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sessionId: "user2-Mobile",
|
||||||
|
sessionName: "Mobile",
|
||||||
|
user: {
|
||||||
|
userId: "user2",
|
||||||
|
username: "user2",
|
||||||
|
profilePictureUrl: null,
|
||||||
|
},
|
||||||
|
currentlyPlaying: {
|
||||||
|
type: "audio",
|
||||||
|
name: "Second Song",
|
||||||
|
seasonName: "Second Artist",
|
||||||
|
episodeName: null,
|
||||||
|
albumName: "Second Album",
|
||||||
|
episodeCount: null,
|
||||||
|
playback: {
|
||||||
|
state: null,
|
||||||
|
positionMs: null,
|
||||||
|
durationMs: null,
|
||||||
|
},
|
||||||
|
location: null,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns an empty array when there are no now playing entries", async () => {
|
||||||
|
mockFetch.mockImplementation((url) => {
|
||||||
|
const urlStr = toUrlString(url);
|
||||||
|
|
||||||
|
if (urlStr.includes("getNowPlaying")) {
|
||||||
|
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
|
||||||
typeof fetchWithTrustedCertificatesAsync
|
typeof fetchWithTrustedCertificatesAsync
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
@@ -187,15 +254,31 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const integration = createIntegration();
|
const integration = createIntegration();
|
||||||
const result = await integration.getDashboardDataAsync();
|
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: false });
|
||||||
|
|
||||||
expect(result.nowPlaying).toHaveLength(1);
|
expect(result).toEqual([]);
|
||||||
expect(result.nowPlaying[0]).toEqual({
|
});
|
||||||
title: "Song",
|
|
||||||
artist: "Band",
|
test("throws on subsonic auth failure", async () => {
|
||||||
album: "LP",
|
mockFetch.mockImplementation((url) => {
|
||||||
username: "user1",
|
const urlStr = toUrlString(url);
|
||||||
playerName: "Chrome",
|
|
||||||
|
if (urlStr.includes("getNowPlaying")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(subsonicFailed("Wrong username or password", 40), { status: 200 }),
|
||||||
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
||||||
|
typeof fetchWithTrustedCertificatesAsync
|
||||||
|
>;
|
||||||
|
});
|
||||||
|
|
||||||
|
const integration = createIntegration();
|
||||||
|
|
||||||
|
await expect(integration.getCurrentSessionsAsync({ showOnlyPlaying: true })).rejects.toMatchObject({
|
||||||
|
message: "An unknown error occured while executing Integration method",
|
||||||
|
cause: { message: "Wrong username or password" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3768,18 +3768,6 @@
|
|||||||
"label": "Show songs",
|
"label": "Show songs",
|
||||||
"description": "Show the total number of songs in your music library (Navidrome)"
|
"description": "Show the total number of songs in your music library (Navidrome)"
|
||||||
},
|
},
|
||||||
"showNowPlaying": {
|
|
||||||
"label": "Show now playing",
|
|
||||||
"description": "Show tracks currently being played (Navidrome)"
|
|
||||||
},
|
|
||||||
"showNowPlayingList": {
|
|
||||||
"label": "Show now playing list",
|
|
||||||
"description": "Show individual tracks instead of only the active stream count (Navidrome)"
|
|
||||||
},
|
|
||||||
"maxNowPlayingItems": {
|
|
||||||
"label": "Max now playing items",
|
|
||||||
"description": "Maximum number of tracks to show in the now playing list (Navidrome)"
|
|
||||||
},
|
|
||||||
"showLibraryCount": {
|
"showLibraryCount": {
|
||||||
"label": "Show library count",
|
"label": "Show library count",
|
||||||
"description": "Show the number of libraries on your instance (Audiobookshelf)"
|
"description": "Show the number of libraries on your instance (Audiobookshelf)"
|
||||||
@@ -3813,8 +3801,6 @@
|
|||||||
"podcasts": "Podcasts",
|
"podcasts": "Podcasts",
|
||||||
"listeningTime": "Listening Time",
|
"listeningTime": "Listening Time",
|
||||||
"activeSessions": "Active Sessions",
|
"activeSessions": "Active Sessions",
|
||||||
"nowPlaying": "Now Playing",
|
|
||||||
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
|
|
||||||
"error": {
|
"error": {
|
||||||
"internalServerError": "Failed to fetch audio stats data"
|
"internalServerError": "Failed to fetch audio stats data"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { Badge, Text } from "@mantine/core";
|
import { Text } from "@mantine/core";
|
||||||
import { IconHeadphones } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
|
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -40,29 +39,6 @@ export function AudioStatsContent({ backend, stats, options, width }: AudioStats
|
|||||||
const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false);
|
const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false);
|
||||||
const iconSize = getIconSize(width, options.compactMode ?? false);
|
const iconSize = getIconSize(width, options.compactMode ?? false);
|
||||||
|
|
||||||
const navidromeStatsByBackend: Record<AudioStatsBackend, NavidromeDashboardData | null> = {
|
|
||||||
navidrome: stats as NavidromeDashboardData,
|
|
||||||
audiobookshelf: null,
|
|
||||||
};
|
|
||||||
const navidromeStats = navidromeStatsByBackend[backend];
|
|
||||||
const showNowPlayingSection =
|
|
||||||
navidromeStats !== null && Boolean(options.showNowPlaying) && navidromeStats.nowPlaying.length > 0;
|
|
||||||
|
|
||||||
const nowPlayingLimitByListVisibility = {
|
|
||||||
true: options.maxNowPlayingItems ?? 3,
|
|
||||||
false: 0,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const nowPlayingTracks =
|
|
||||||
navidromeStats?.nowPlaying.slice(
|
|
||||||
0,
|
|
||||||
nowPlayingLimitByListVisibility[
|
|
||||||
String(options.showNowPlayingList) as keyof typeof nowPlayingLimitByListVisibility
|
|
||||||
],
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
const hasContent = visibleStats.length > 0 || showNowPlayingSection;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
|
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
|
||||||
{visibleStats.length > 0 && (
|
{visibleStats.length > 0 && (
|
||||||
@@ -80,40 +56,7 @@ export function AudioStatsContent({ backend, stats, options, width }: AudioStats
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showNowPlayingSection && navidromeStats && (
|
{visibleStats.length === 0 && (
|
||||||
<div className={classes.nowPlaying}>
|
|
||||||
<div className={classes.nowPlayingHeader}>
|
|
||||||
<IconHeadphones size={iconSize} stroke={1.5} />
|
|
||||||
<span className={classes.nowPlayingTitle}>{t("nowPlaying" as never)}</span>
|
|
||||||
{!options.showNowPlayingList && (
|
|
||||||
<Badge className={classes.nowPlayingCount} variant="light" size="sm">
|
|
||||||
{(t as unknown as (key: string, params?: { count: number }) => string)("nowPlayingCount", {
|
|
||||||
count: navidromeStats.nowPlaying.length,
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{options.showNowPlayingList && (
|
|
||||||
<div className={classes.nowPlayingList}>
|
|
||||||
{nowPlayingTracks.map((track, index) => (
|
|
||||||
<div key={`${track.username}-${track.title}-${index}`} className={classes.nowPlayingItem}>
|
|
||||||
<div className={classes.trackInfo}>
|
|
||||||
<span className={classes.trackTitle}>{track.title}</span>
|
|
||||||
<span className={classes.trackArtist}>
|
|
||||||
{[track.artist, track.album].filter(Boolean).join(" · ")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge className={classes.trackUser} variant="outline" size="xs">
|
|
||||||
{track.username || track.playerName}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasContent && (
|
|
||||||
<div className={classes.emptyState}>
|
<div className={classes.emptyState}>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
—
|
—
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ describe("getVisibleStats", () => {
|
|||||||
artistCount: 100,
|
artistCount: 100,
|
||||||
albumCount: 200,
|
albumCount: 200,
|
||||||
songCount: 3000,
|
songCount: 3000,
|
||||||
nowPlaying: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const audiobookshelfStats: AudiobookshelfDashboardData = {
|
const audiobookshelfStats: AudiobookshelfDashboardData = {
|
||||||
|
|||||||
@@ -87,92 +87,6 @@
|
|||||||
font-size: clamp(11px, 4cqi, 16px);
|
font-size: clamp(11px, 4cqi, 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nowPlaying {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: clamp(3px, 1.2cqi, 5px);
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nowPlayingHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: clamp(4px, 1.5cqi, 6px);
|
|
||||||
font-size: clamp(10px, 2.8cqi, 12px);
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nowPlayingTitle {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nowPlayingCount {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-inline-start: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nowPlayingList {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: clamp(2px, 0.8cqi, 4px);
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nowPlayingItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: clamp(4px, 1.5cqi, 6px);
|
|
||||||
padding: clamp(3px, 1.2cqi, 6px) clamp(4px, 1.5cqi, 8px);
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
|
||||||
min-width: 0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trackInfo {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trackTitle {
|
|
||||||
font-size: clamp(9px, 2.6cqi, 12px);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.2;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trackArtist {
|
|
||||||
font-size: clamp(8px, 2.2cqi, 10px);
|
|
||||||
color: var(--mantine-color-dimmed);
|
|
||||||
line-height: 1.2;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trackUser {
|
|
||||||
flex-shrink: 0;
|
|
||||||
max-width: clamp(40px, 22cqi, 88px);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyState {
|
.emptyState {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
|
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
|
||||||
|
|
||||||
import { createWidgetDefinition } from "../definition";
|
import { createWidgetDefinition } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
@@ -23,14 +22,6 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat
|
|||||||
showArtists: factory.switch({ defaultValue: true, withDescription: true }),
|
showArtists: factory.switch({ defaultValue: true, withDescription: true }),
|
||||||
showAlbums: factory.switch({ defaultValue: true, withDescription: true }),
|
showAlbums: factory.switch({ defaultValue: true, withDescription: true }),
|
||||||
showSongs: factory.switch({ defaultValue: true, withDescription: true }),
|
showSongs: factory.switch({ defaultValue: true, withDescription: true }),
|
||||||
showNowPlaying: factory.switch({ defaultValue: true, withDescription: true }),
|
|
||||||
showNowPlayingList: factory.switch({ defaultValue: true, withDescription: true }),
|
|
||||||
maxNowPlayingItems: factory.number({
|
|
||||||
defaultValue: 3,
|
|
||||||
validate: z.number().min(1).max(5),
|
|
||||||
step: 1,
|
|
||||||
withDescription: true,
|
|
||||||
}),
|
|
||||||
showLibraryCount: factory.switch({ defaultValue: true, withDescription: true }),
|
showLibraryCount: factory.switch({ defaultValue: true, withDescription: true }),
|
||||||
showAudiobooks: factory.switch({ defaultValue: true, withDescription: true }),
|
showAudiobooks: factory.switch({ defaultValue: true, withDescription: true }),
|
||||||
showPodcasts: factory.switch({ defaultValue: true, withDescription: true }),
|
showPodcasts: factory.switch({ defaultValue: true, withDescription: true }),
|
||||||
@@ -42,9 +33,6 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat
|
|||||||
showArtists: hideUnlessNavidrome,
|
showArtists: hideUnlessNavidrome,
|
||||||
showAlbums: hideUnlessNavidrome,
|
showAlbums: hideUnlessNavidrome,
|
||||||
showSongs: hideUnlessNavidrome,
|
showSongs: hideUnlessNavidrome,
|
||||||
showNowPlaying: hideUnlessNavidrome,
|
|
||||||
showNowPlayingList: hideUnlessNavidrome,
|
|
||||||
maxNowPlayingItems: hideUnlessNavidrome,
|
|
||||||
showLibraryCount: hideUnlessAudiobookshelf,
|
showLibraryCount: hideUnlessAudiobookshelf,
|
||||||
showAudiobooks: hideUnlessAudiobookshelf,
|
showAudiobooks: hideUnlessAudiobookshelf,
|
||||||
showPodcasts: hideUnlessAudiobookshelf,
|
showPodcasts: hideUnlessAudiobookshelf,
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ export interface NavidromeDisplayOptions {
|
|||||||
showArtists: boolean;
|
showArtists: boolean;
|
||||||
showAlbums: boolean;
|
showAlbums: boolean;
|
||||||
showSongs: boolean;
|
showSongs: boolean;
|
||||||
showNowPlaying: boolean;
|
|
||||||
showNowPlayingList: boolean;
|
|
||||||
maxNowPlayingItems: number;
|
|
||||||
compactMode?: boolean;
|
compactMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user