Compare commits

..
Author SHA1 Message Date
ajnart dd99dd6d7b style: auto-fix lint and format 2026-07-21 14:02:05 +00:00
ajnart e93e9ed839 fix(media-server): guard against undefined playback from stale cache
PR #6315 added playback/location fields to StreamSession but cached
query data may still lack these fields, causing 'can't access property
state, playback is undefined' errors on the media server widget.
2026-07-21 16:00:54 +02:00
18 changed files with 66 additions and 306 deletions

No files matched your search

@@ -1,6 +1,6 @@
--- ---
title: 'Immich Album Carousel' title: 'Immich Album Carousel'
description: 'Shows a slideshow of pictures from an Immich album or your full library' description: 'Shows a slideshow of your album pictures from Immich'
hide_title: true hide_title: true
--- ---
@@ -37,3 +37,4 @@ import screen from './img/screen.png';
### Configuration ### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} /> <WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -4,16 +4,15 @@ import { IconPhoto } from "@tabler/icons-react";
export const immichAlbumCarouselWidget: WidgetDefinition = { export const immichAlbumCarouselWidget: WidgetDefinition = {
icon: IconPhoto, icon: IconPhoto,
name: "Immich Album", name: "Immich Album",
description: "Shows a slideshow of pictures from an Immich album or your full library", description: "Shows a slideshow of your album pictures from Immich",
path: "../../widgets/immich-album-carousel", path: "../../widgets/immich-album-carousel",
configuration: { configuration: {
items: [ items: [
{ {
name: "Album", name: "Album",
description: description: "Select the Immich album to show in the slideshow.",
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
values: { type: "string" }, values: { type: "string" },
defaultValue: "All photos", defaultValue: "",
}, },
{ {
name: "Interval in seconds", name: "Interval in seconds",
@@ -1,29 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
export const createQueryCacheErrorCleanup = (queryClient: QueryClient): (() => void) => {
const wasSuccessful = new Set<string>();
return queryClient.getQueryCache().subscribe((event) => {
const query = event.query;
const key = query.queryHash;
if (event.type === "removed") {
wasSuccessful.delete(key);
return;
}
if (event.type !== "updated") return;
if (query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey)) {
wasSuccessful.add(key);
return;
}
if (query.state.status === "error" && wasSuccessful.has(key)) {
queryClient.resetQueries({ queryKey: query.queryKey, exact: true });
wasSuccessful.delete(key);
}
});
};
@@ -1,7 +1,7 @@
"use client"; "use client";
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { useEffect, useState } from "react"; import { useState } from "react";
import type { QueryKey } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
@@ -36,7 +36,6 @@ import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications"; import { showWarningNotification } from "@homarr/notifications";
import { widgetImports } from "@homarr/widgets"; import { widgetImports } from "@homarr/widgets";
import { createQueryCacheErrorCleanup } from "./query-cache-error-cleanup";
import { createWidgetQueryPersister } from "./query-cache-persister"; import { createWidgetQueryPersister } from "./query-cache-persister";
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
@@ -103,10 +102,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
return client; return client;
}); });
useEffect(() => {
return createQueryCacheErrorCleanup(queryClient);
}, [queryClient]);
const [trpcClient] = useState(() => { const [trpcClient] = useState(() => {
return clientApi.createClient({ return clientApi.createClient({
links: [ links: [
@@ -167,9 +162,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey), query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey),
}, },
}} }}
onSuccess={() => {
queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
}}
> >
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration> <ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
@@ -19,7 +19,6 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css"; import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu"; import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu"; import { WidgetContextMenu } from "./widget-context-menu";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
interface BoardItemContentProps { interface BoardItemContentProps {
item: SectionItem; item: SectionItem;
@@ -113,7 +112,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
onReset={() => { onReset={() => {
removePersistedWidgetQueries(queryClient); void queryClient.invalidateQueries({ queryKey: [["widget"]] });
reset(); reset();
}} }}
fallbackRender={({ resetErrorBoundary, error }) => ( fallbackRender={({ resetErrorBoundary, error }) => (
@@ -1,157 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
import { createQueryCacheErrorCleanup } from "~/app/[locale]/_client-providers/query-cache-error-cleanup";
import { removePersistedWidgetQueries } from "./widget-query-recovery";
describe("removePersistedWidgetQueries", () => {
test("removes stale widget queries and allows fresh data to be fetched", async () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
await expect(
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
).resolves.toBe("fresh");
});
test("removes persisted queries outside the widget router", () => {
const queryClient = new QueryClient();
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
const appQueryKey = [["app", "byId"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(dockerQueryKey, "stale");
queryClient.setQueryData(appQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
test("removes persisted widget queries when the widget kind and query path differ", () => {
const queryClient = new QueryClient();
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
const unrelatedQueryKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
removePersistedWidgetQueries(queryClient);
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
});
});
describe("query cache error cleanup", () => {
test("resets data when a persistable query transitions from success to error", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const userKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(widgetKey, { old: true });
queryClient.setQueryData(userKey, { user: true });
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
expect(queryClient.getQueryData(userKey)).toEqual({ user: true });
unsubscribe();
});
test("preserves stale data for non-persistable query errors", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const boardKey = [["board", "getBoardByName"], { input: {} }];
queryClient.setQueryData(boardKey, { boards: [] });
await queryClient
.fetchQuery({ queryKey: boardKey, queryFn: () => Promise.reject(new Error("API down")) })
.catch(() => {});
expect(queryClient.getQueryData(boardKey)).toEqual({ boards: [] });
unsubscribe();
});
test("handles success→error→success→error cycling without getting stuck", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
queryClient.setQueryData(widgetKey, "first-success");
await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("second-success") });
expect(queryClient.getQueryData(widgetKey)).toBe("second-success");
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("third-success") });
expect(queryClient.getQueryData(widgetKey)).toBe("third-success");
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom-2")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBeUndefined();
unsubscribe();
});
test("unsubscribe stops processing further events", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const unsubscribe = createQueryCacheErrorCleanup(queryClient);
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
queryClient.setQueryData(widgetKey, "stale");
unsubscribe();
await queryClient
.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) })
.catch(() => {});
expect(queryClient.getQueryData(widgetKey)).toBe("stale");
});
});
describe("rehydration marks persistable data as stale", () => {
test("rehydrated queries are invalidated so they refetch on mount", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0 } },
});
const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
const userKey = [["user", "me"], { input: {} }];
queryClient.setQueryData(widgetKey, { rehydrated: true });
queryClient.setQueryData(userKey, { rehydrated: true });
queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
expect(queryClient.getQueryState(widgetKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(userKey)?.isInvalidated).toBe(false);
});
});
@@ -1,7 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
};
+1 -1
View File
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
.concat(createImmichIntegrationMiddleware("query")) .concat(createImmichIntegrationMiddleware("query"))
.input( .input(
z.object({ z.object({
albumId: z.string().optional(), albumId: z.string(),
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -1,14 +1,11 @@
import { import {
AssetMediaSize, AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAlbumInfo, getAlbumInfo,
getAllAlbums, getAllAlbums,
getMyUser, getMyUser,
getServerStatistics, getServerStatistics,
init, init,
searchAssets, searchAssets,
searchRandom,
searchUsers, searchUsers,
} from "@immich/sdk"; } from "@immich/sdk";
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk"; import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
@@ -46,7 +43,6 @@ export interface ImmichAsset {
} }
const logger = createLogger({ module: "immich-integration" }); const logger = createLogger({ module: "immich-integration" });
const randomPhotoCount = 50;
export class ImmichIntegration extends Integration { export class ImmichIntegration extends Integration {
constructor(integration: IntegrationInput) { constructor(integration: IntegrationInput) {
@@ -67,36 +63,38 @@ export class ImmichIntegration extends Integration {
}; };
} }
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> { public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
this.initClient(); this.initClient();
const requestOptions = this.getRequestOptions(); const requestOptions = this.getRequestOptions();
if (!albumId) {
const assets = await searchRandom(
{
randomSearchDto: {
size: randomPhotoCount,
type: AssetTypeEnum.Image,
visibility: AssetVisibility.Timeline,
},
},
requestOptions,
);
return {
id: "",
albumName: "",
assets: await this.createAssetsAsync(assets),
};
}
const [album, albumAssets] = await Promise.all([ const [album, albumAssets] = await Promise.all([
getAlbumInfo({ id: albumId }, requestOptions), getAlbumInfo({ id: albumId }, requestOptions),
this.searchAlbumAssetsAsync(albumId), this.searchAlbumAssetsAsync(albumId),
]); ]);
const imageProxy = new ImageProxy();
return { return {
albumName: album.albumName, albumName: album.albumName,
assets: await this.createAssetsAsync(albumAssets), assets: await Promise.all(
albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
),
id: album.id, id: album.id,
}; };
} }
@@ -138,32 +136,6 @@ export class ImmichIntegration extends Integration {
return assets; return assets;
} }
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
const imageProxy = new ImageProxy();
return await Promise.all(
assets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
);
}
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) { private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
return { return {
// Undici and node types are not the same // Undici and node types are not the same
+1 -1
View File
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
export const immichAlbumRequestHandler = createIntegrationRequestHandler< export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum, ImmichAlbum,
IntegrationKindByCategory<"photoService">, IntegrationKindByCategory<"photoService">,
{ albumId?: string } { albumId: string }
>({ >({
async requestAsync(integration, input) { async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
+1 -4
View File
@@ -9,7 +9,7 @@ export const weatherRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { latitude: number; longitude: number }) { async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => { const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync( return await fetchWithTrustedCertificatesAsync(
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean&current_weather=true&timezone=auto`, `https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max&current_weather=true&timezone=auto`,
{ signal }, { signal },
); );
}); });
@@ -27,7 +27,6 @@ export const weatherRequestHandler = createWidgetRequestHandler({
sunset: weather.daily.sunset[index], sunset: weather.daily.sunset[index],
maxWindSpeed: weather.daily.wind_speed_10m_max[index], maxWindSpeed: weather.daily.wind_speed_10m_max[index],
maxWindGusts: weather.daily.wind_gusts_10m_max[index], maxWindGusts: weather.daily.wind_gusts_10m_max[index],
humidity: weather.daily.relative_humidity_2m_mean[index],
}; };
}), }),
} satisfies Weather; } satisfies Weather;
@@ -49,7 +48,6 @@ const atLocationOutput = z.object({
sunset: z.array(z.string()), sunset: z.array(z.string()),
wind_speed_10m_max: z.array(z.number()), wind_speed_10m_max: z.array(z.number()),
wind_gusts_10m_max: z.array(z.number()), wind_gusts_10m_max: z.array(z.number()),
relative_humidity_2m_mean: z.array(z.number()),
}), }),
}); });
@@ -68,6 +66,5 @@ export interface Weather {
sunset: string | undefined; sunset: string | undefined;
maxWindSpeed: number | undefined; maxWindSpeed: number | undefined;
maxWindGusts: number | undefined; maxWindGusts: number | undefined;
humidity: number | undefined;
}[]; }[];
} }
+5 -6
View File
@@ -2257,8 +2257,7 @@
"sunrise": "Sunrise", "sunrise": "Sunrise",
"sunset": "Sunset", "sunset": "Sunset",
"maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}", "maxWindSpeed": "Max wind speed: {maxWindSpeed} {unit}",
"maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}", "maxWindGusts": "Max wind gusts: {maxWindGusts} {unit}"
"humidity": "Humidity: {humidity}%"
}, },
"kind": { "kind": {
"clear": "Clear", "clear": "Clear",
@@ -3007,11 +3006,10 @@
}, },
"immich-albumCarousel": { "immich-albumCarousel": {
"name": "Immich Album", "name": "Immich Album",
"allPhotos": "All photos",
"option": { "option": {
"albumId": { "albumId": {
"label": "Album", "label": "Album",
"description": "Select an Immich album or All photos. An empty selection also shows random photos from your full library." "description": "Select the Immich album to show in the slideshow."
}, },
"rotationIntervalSeconds": { "rotationIntervalSeconds": {
"label": "Interval in seconds", "label": "Interval in seconds",
@@ -3026,8 +3024,9 @@
"description": "Show photos in a random order instead of always starting with the same album order." "description": "Show photos in a random order instead of always starting with the same album order."
} }
}, },
"description": "Slideshow of pictures from an Immich album or your full library", "description": "Slideshow of pictures in your Immich album",
"noPhotos": "No photos found" "noAlbumSelected": "No album selected, edit widget and choose an album",
"noPhotos": "No photos in this album"
}, },
"downloads": { "downloads": {
"name": "Download Client", "name": "Download Client",
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state"; import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition"; import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css"; import classes from "./component.module.css";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export default function ImmichAlbumCarouselWidget({ export default function ImmichAlbumCarouselWidget({
integrationIds, integrationIds,
@@ -22,9 +21,9 @@ export default function ImmichAlbumCarouselWidget({
const { data: album } = clientApi.widget.immich.getAlbum.useQuery( const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{ {
integrationId: integrationIds[0] ?? "", integrationId: integrationIds[0] ?? "",
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined, albumId: options.albumId ?? "",
}, },
{ enabled: integrationIds.length > 0 }, { enabled: Boolean(options.albumId) },
); );
const photoAssets = useMemo(() => { const photoAssets = useMemo(() => {
@@ -36,6 +35,10 @@ export default function ImmichAlbumCarouselWidget({
setCurrentPhotoIndex(0); setCurrentPhotoIndex(0);
}, [photoAssets]); }, [photoAssets]);
if (!options.albumId) {
return <NoAlbumSelected />;
}
if (!album) return <WidgetEmptyState />; if (!album) return <WidgetEmptyState />;
if (album.assets.length === 0) { if (album.assets.length === 0) {
@@ -114,6 +117,20 @@ function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, sho
); );
} }
function NoAlbumSelected() {
const t = useI18n();
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertCircle size={32} />
<Text size="sm" fw={500}>
{t("widget.immich-albumCarousel.noAlbumSelected")}
</Text>
</Stack>
</Center>
);
}
function NoPhotosInAlbum() { function NoPhotosInAlbum() {
const t = useI18n(); const t = useI18n();
return ( return (
@@ -1 +0,0 @@
export const ALL_PHOTOS_ALBUM_ID = "all";
@@ -2,11 +2,9 @@ import { IconPhoto } from "@tabler/icons-react";
import z from "zod"; import z from "zod";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createWidgetDefinition } from "../../definition"; import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options"; import { optionsBuilder } from "../../options";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", { export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto, icon: IconPhoto,
@@ -17,11 +15,9 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({ albumId: factory.integrationSelect({
defaultValue: ALL_PHOTOS_ALBUM_ID,
withDescription: true, withDescription: true,
clearable: true, clearable: true,
useOptions: (integrationIds: string[]) => { useOptions: (integrationIds: string[]) => {
const t = useScopedI18n("widget.immich-albumCarousel");
const { const {
data = [], data = [],
isPending, isPending,
@@ -30,14 +26,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
{ integrationId: integrationIds[0] ?? "" }, { integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 }, { enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
); );
return { return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError };
data: [
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
...data.map((album) => ({ value: album.id, label: album.albumName })),
],
isPending,
isError,
};
}, },
}), }),
rotationIntervalSeconds: factory.number({ rotationIntervalSeconds: factory.number({
@@ -77,7 +77,9 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
? Math.min(100, Math.round((positionMs / durationMs) * 100)) ? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null; : null;
const remainingMinutes = const remainingMinutes =
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null; positionMs !== null && durationMs !== null
? Math.max(0, Math.round((durationMs - positionMs) / 60_000))
: null;
return ( return (
<Stack gap={4} style={{ minWidth: 0 }}> <Stack gap={4} style={{ minWidth: 0 }}>
@@ -124,8 +126,8 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const isTranscoding = Boolean( const isTranscoding = Boolean(
currentlyPlaying.metadata?.transcoding.target.videoCodec ?? currentlyPlaying.metadata?.transcoding.target.videoCodec ??
currentlyPlaying.metadata?.transcoding.target.audioCodec ?? currentlyPlaying.metadata?.transcoding.target.audioCodec ??
currentlyPlaying.metadata?.transcoding.container, currentlyPlaying.metadata?.transcoding.container,
); );
return ( return (
+1 -8
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core"; import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react"; import { IconArrowDownRight, IconArrowUpRight, IconMapPin, IconWind } from "@tabler/icons-react";
import combineClasses from "clsx"; import combineClasses from "clsx";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -87,12 +87,6 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
</Text> </Text>
</Group> </Group>
)} )}
{weather.daily[0]?.humidity !== undefined && (
<Group className="weather-humidity-group" wrap="nowrap" gap="xs">
<IconDroplets size={16} />
<Text fz={16}>{t("dailyForecast.humidity", { humidity: weather.daily[0].humidity })}</Text>
</Group>
)}
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm"> <Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<IconArrowUpRight size={16} /> <IconArrowUpRight size={16} />
@@ -210,7 +204,6 @@ function Forecast({ weather, options }: WeatherProps) {
sunset={dayjs(dayWeather.sunset).format("HH:mm")} sunset={dayjs(dayWeather.sunset).format("HH:mm")}
maxWindSpeed={dayWeather.maxWindSpeed} maxWindSpeed={dayWeather.maxWindSpeed}
maxWindGusts={dayWeather.maxWindGusts} maxWindGusts={dayWeather.maxWindGusts}
humidity={dayWeather.humidity}
/> />
</HoverCard.Dropdown> </HoverCard.Dropdown>
</HoverCard> </HoverCard>
-6
View File
@@ -5,7 +5,6 @@ import {
IconCloudRain, IconCloudRain,
IconCloudSnow, IconCloudSnow,
IconCloudStorm, IconCloudStorm,
IconDroplets,
IconMoon, IconMoon,
IconQuestionMark, IconQuestionMark,
IconSnowflake, IconSnowflake,
@@ -52,7 +51,6 @@ interface WeatherDescriptionProps {
sunset?: string; sunset?: string;
maxWindSpeed?: number; maxWindSpeed?: number;
maxWindGusts?: number; maxWindGusts?: number;
humidity?: number;
} }
/** /**
@@ -80,7 +78,6 @@ export const WeatherDescription = ({
sunset, sunset,
maxWindSpeed, maxWindSpeed,
maxWindGusts, maxWindGusts,
humidity,
}: WeatherDescriptionProps) => { }: WeatherDescriptionProps) => {
const t = useScopedI18n("widget.weather"); const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
@@ -100,9 +97,6 @@ export const WeatherDescription = ({
<List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item> <List.Item icon={<IconTemperatureMinus size={15} />}>{`${tCommon("information.min")}: ${minTemp}`}</List.Item>
<List.Item icon={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item> <List.Item icon={<IconSun size={15} />}>{`${t("dailyForecast.sunrise")}: ${sunrise}`}</List.Item>
<List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item> <List.Item icon={<IconMoon size={15} />}>{`${t("dailyForecast.sunset")}: ${sunset}`}</List.Item>
{humidity !== undefined && (
<List.Item icon={<IconDroplets size={15} />}>{t("dailyForecast.humidity", { humidity })}</List.Item>
)}
{maxWindSpeed !== undefined && ( {maxWindSpeed !== undefined && (
<List.Item icon={<IconWind size={15} />}> <List.Item icon={<IconWind size={15} />}>
{t("dailyForecast.maxWindSpeed", { {t("dailyForecast.maxWindSpeed", {