Compare commits

..
Author SHA1 Message Date
Releases Homarr 9b5b79525f chore(release): 1.73.0 [skip ci] 2026-07-31 18:16:44 +00:00
homarr-releases[bot] 426929556f chore(release): automatic release v1.73.0 2026-07-31 18:15:13 +00:00
Thomas ade640dcea test(navidrome): complete cleanup review (#6494)
* test(navidrome): hoist album fixture helper

* test(navidrome): address coderabbit cleanup feedback
2026-07-31 14:39:11 +02:00
Thomas 61128ddcc8 fix(navidrome): clean up audio stats integration (#6493) 2026-07-31 14:20:51 +02:00
Thomas 6a3cee8107 fix(navidrome): complete media server session data (#6492) 2026-07-31 13:53:06 +02:00
Matt Van Horn 06d9c07d22 feat: expose Navidrome as a media-server (now-playing) integration (#6070) 2026-07-31 13:30:44 +02:00
Thomas 49d0f1a141 docs: explain Podman socket setup (#6487)
* docs: explain Podman socket setup

* docs: clarify Podman runtime requirements

* docs: complete Podman compose examples
2026-07-29 16:53:29 +02:00
18 changed files with 200 additions and 269 deletions

No files matched your search

+2
View File
@@ -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.71.0](https://github.com/homarr-labs/homarr/compare/v1.70.0...v1.71.0) (2026-07-17)
@@ -10,19 +10,21 @@ import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { navidromeIntegration } from '.';
import { audioStatsWidget } from '@site/docs/widgets/audio-stats';
import { mediaServerWidget } from '@site/docs/widgets/media-server';
<IntegrationHeader
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
<IntegrationCapabilites
items={[
{ widget: audioStatsWidget },
{ widget: mediaServerWidget },
]}
/>
+24 -22
View File
@@ -1,43 +1,45 @@
---
title: 'Audio Stats'
description: 'Displays library statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration.'
title: "Audio Stats"
description: "Displays media and listening statistics from Navidrome or Audiobookshelf, adapting its display based on the linked integration."
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { audioStatsWidget } from '.';
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
import { audiobookshelfIntegration } from '@site/docs/integrations/audiobookshelf';
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { audioStatsWidget } from ".";
import { navidromeIntegration } from "@site/docs/integrations/navidrome";
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:
- **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
Only options relevant to your linked integration are shown in the configuration panel.
### Supported Integrations
<WidgetIntegrations items={[{
integration: navidromeIntegration,
note: "Displays music library stats and now playing"
}, {
integration: audiobookshelfIntegration,
note: "Displays audiobook/podcast library stats and listening time"
}]} />
<WidgetIntegrations
items={[
{
integration: navidromeIntegration,
note: "Displays music library statistics",
},
{
integration: audiobookshelfIntegration,
note: "Displays audiobook/podcast library stats and listening time",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={audioStatsWidget.configuration} />
@@ -27,24 +27,6 @@ export const audioStatsWidget: WidgetDefinition = {
values: { type: "boolean" },
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",
description: "Displays the number of libraries (Audiobookshelf only)",
@@ -14,6 +14,7 @@ import TabItem from '@theme/TabItem';
import { mediaServerWidget } from '.';
import { embyIntegration } from '@site/docs/integrations/emby';
import { jellyfinIntegration } from '@site/docs/integrations/jellyfin';
import { navidromeIntegration } from '@site/docs/integrations/navidrome';
import { plexIntegration } from '@site/docs/integrations/plex';
@@ -39,6 +40,9 @@ import mediaTable from './img/table.png';
integration: embyIntegration,
}, {
integration: jellyfinIntegration,
}, {
integration: navidromeIntegration,
note: "Will always show only currently playing audio streams, not all active sessions."
}, {
integration: plexIntegration,
note: "Will always show only currently playing streams, not all active sessions."
@@ -50,4 +54,3 @@ import mediaTable from './img/table.png';
### Configuration
<WidgetConfig configuration={mediaServerWidget.configuration} />
+1 -1
View File
@@ -10,7 +10,7 @@ export const mediaServerWidget: WidgetDefinition = {
items: [
{
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" },
defaultValue: "yes",
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.72.0",
"version": "1.73.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -17,7 +17,7 @@ export const mediaServerRouter = createTRPCRouter({
mcp: {
enabled: true,
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"))
+1 -1
View File
@@ -418,7 +418,7 @@ export const integrationDefs = {
name: "Navidrome",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
category: ["mediaLibrary"],
category: ["mediaLibrary", "mediaService"],
documentationUrl: null,
defaultPort: 4533,
},
@@ -6,6 +6,8 @@ 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 { 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 { subsonicResponseSchema } from "./navidrome-types";
@@ -22,7 +24,7 @@ const subsonicAuthErrorCodes = new Set([40, 41]);
const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
[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> {
const url = this.url("/rest/ping.view", this.getAuthParams());
const response = await input.fetchAsync(url);
@@ -53,20 +55,49 @@ export class NavidromeIntegration extends Integration {
}
public async getDashboardDataAsync(): Promise<NavidromeDashboardData> {
const [artistCount, libraryCounts, nowPlaying] = await Promise.all([
const [artistCount, libraryCounts] = await Promise.all([
this.getArtistCountAsync(),
this.getAlbumAndSongCountsAsync(),
this.getNowPlayingAsync(),
]);
return {
artistCount,
albumCount: libraryCounts.albumCount,
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> {
const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true });
return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0);
@@ -67,5 +67,4 @@ export interface NavidromeDashboardData {
artistCount: number;
albumCount: number;
songCount: number;
nowPlaying: NavidromeNowPlayingEntry[];
}
@@ -1,6 +1,12 @@
// @vitest-environment node
import { Response } from "undici";
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 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 },
});
const subsonicFailed = (message: string) =>
const subsonicFailed = (message: string, code = 70) =>
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(() => {
mockFetch.mockReset();
});
describe("NavidromeIntegration.getDashboardDataAsync", () => {
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) => {
const urlStr = toUrlString(url);
@@ -84,13 +90,7 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
>;
}
if (urlStr.includes("getNowPlaying")) {
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<
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
@@ -101,7 +101,6 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
expect(result.albumCount).toBe(700);
expect(result.songCount).toBe(500 * 10 + 200 * 5);
expect(result.artistCount).toBe(1);
expect(result.nowPlaying).toEqual([]);
});
test("handles empty library gracefully", async () => {
@@ -120,13 +119,7 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getNowPlaying")) {
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<
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
@@ -137,7 +130,6 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
expect(result.artistCount).toBe(0);
expect(result.albumCount).toBe(0);
expect(result.songCount).toBe(0);
expect(result.nowPlaying).toEqual([]);
});
test("throws on non-empty-library subsonic error", async () => {
@@ -151,8 +143,10 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
const integration = createIntegration();
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) => {
const urlStr = toUrlString(url);
@@ -161,7 +155,22 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
new Response(
subsonicOk({
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 },
@@ -169,14 +178,72 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getArtists")) {
return Promise.resolve(new Response(subsonicOk({ artists: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
if (urlStr.includes("getAlbumList2")) {
return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
const integration = createIntegration();
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
>;
}
@@ -187,15 +254,31 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => {
});
const integration = createIntegration();
const result = await integration.getDashboardDataAsync();
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: false });
expect(result.nowPlaying).toHaveLength(1);
expect(result.nowPlaying[0]).toEqual({
title: "Song",
artist: "Band",
album: "LP",
username: "user1",
playerName: "Chrome",
expect(result).toEqual([]);
});
test("throws on subsonic auth failure", async () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
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" },
});
});
});
-14
View File
@@ -3768,18 +3768,6 @@
"label": "Show songs",
"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": {
"label": "Show library count",
"description": "Show the number of libraries on your instance (Audiobookshelf)"
@@ -3813,8 +3801,6 @@
"podcasts": "Podcasts",
"listeningTime": "Listening Time",
"activeSessions": "Active Sessions",
"nowPlaying": "Now Playing",
"nowPlayingCount": "{count, plural, one {# stream} other {# streams}}",
"error": {
"internalServerError": "Failed to fetch audio stats data"
}
@@ -1,8 +1,7 @@
"use client";
import type { CSSProperties } from "react";
import { Badge, Text } from "@mantine/core";
import { IconHeadphones } from "@tabler/icons-react";
import { Text } from "@mantine/core";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
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 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 (
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
{visibleStats.length > 0 && (
@@ -80,40 +56,7 @@ export function AudioStatsContent({ backend, stats, options, width }: AudioStats
</div>
)}
{showNowPlayingSection && navidromeStats && (
<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 && (
{visibleStats.length === 0 && (
<div className={classes.emptyState}>
<Text size="sm" c="dimmed">
—
@@ -42,7 +42,6 @@ describe("getVisibleStats", () => {
artistCount: 100,
albumCount: 200,
songCount: 3000,
nowPlaying: [],
};
const audiobookshelfStats: AudiobookshelfDashboardData = {
@@ -87,92 +87,6 @@
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 {
display: flex;
flex: 1;
-12
View File
@@ -1,5 +1,4 @@
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -23,14 +22,6 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat
showArtists: factory.switch({ defaultValue: true, withDescription: true }),
showAlbums: 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 }),
showAudiobooks: 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,
showAlbums: hideUnlessNavidrome,
showSongs: hideUnlessNavidrome,
showNowPlaying: hideUnlessNavidrome,
showNowPlayingList: hideUnlessNavidrome,
maxNowPlayingItems: hideUnlessNavidrome,
showLibraryCount: hideUnlessAudiobookshelf,
showAudiobooks: hideUnlessAudiobookshelf,
showPodcasts: hideUnlessAudiobookshelf,
@@ -19,9 +19,6 @@ export interface NavidromeDisplayOptions {
showArtists: boolean;
showAlbums: boolean;
showSongs: boolean;
showNowPlaying: boolean;
showNowPlayingList: boolean;
maxNowPlayingItems: number;
compactMode?: boolean;
}