Files
homarr/packages/widget-sdk/examples/media.json
T

108 lines
14 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"$schema": "homarr-widget-package-v3",
"manifest": {
"id": "homarr.reference.media",
"version": "1.1.0",
"sdkVersion": "1",
"name": "Media and camera wall",
"description": "Native playing sessions plus optional authenticated camera snapshots and an Immich gallery, with responsive tiles and shared advanced image selection.",
"entrypoints": {
"tile": "widget.tsx",
"advanced": "widget.tsx",
"server": "server.ts"
},
"handlers": {
"sessions": {
"kind": "query",
"permission": "view"
},
"gallery": {
"kind": "query",
"permission": "view"
},
"image": {
"kind": "query",
"permission": "view",
"inputSchema": {
"type": "object",
"required": ["kind", "id"],
"properties": {
"kind": {
"enum": ["camera", "immich"]
},
"id": {
"type": "string",
"maxLength": 128
}
},
"additionalProperties": false
}
}
}
},
"files": {
"widget.tsx": "import type { MediaOptions, GalleryItem } from \"./types\";\nimport { Alert, Badge, Button, Card, Group, Image, Loader, SimpleGrid, Stack, Text, UnstyledButton } from \"@mantine/core\";\nimport { useWidgetHost, useWidgetOptions, useWidgetQuery, useWidgetServices, useWidgetState } from \"@homarr/widget-sdk\";\nimport Sessions from \"./sessions\";\nexport default function MediaWall() {\n const host = useWidgetHost(); const options = useWidgetOptions<MediaOptions>(); const services = useWidgetServices();\n const [selected,setSelected] = useWidgetState<GalleryItem|null>(\"selectedImage\",null);\n const gallery = useWidgetQuery<{items:GalleryItem[];errors:string[];configured:boolean}>(\"gallery\",{}, {refetchInterval:60000});\n const columns = Math.max(1,Math.floor(host.visibleWidth/220));\n return <Stack p=\"sm\" h=\"100%\" gap=\"sm\" style={{overflow:\"auto\"}}>\n <Group justify=\"space-between\"><Text fw={600}>{options.title}</Text><Button size=\"compact-xs\" variant=\"subtle\" onClick={() => void gallery.refetch()}>Refresh gallery</Button></Group>\n {gallery.error && <Alert color=\"red\">{gallery.error.message}</Alert>}\n {(gallery.data?.errors ?? []).map((error,index) => <Alert key={index} color=\"yellow\">{error}</Alert>)}\n {host.displayMode === \"advanced\" && selected && <Card withBorder><Stack gap=\"xs\"><Group justify=\"space-between\"><Text fw={600}>{selected.name}</Text><Button size=\"compact-xs\" variant=\"subtle\" onClick={() => setSelected(null)}>Close image</Button></Group><Snapshot item={selected} large /></Stack></Card>}\n <SimpleGrid cols={columns}>{(gallery.data?.items ?? []).map((item) => <Card key={`${item.kind}:${item.id}`} withBorder p=\"xs\"><UnstyledButton onClick={() => {setSelected(item);services.openAdvanced();}}><Stack gap={4}><Snapshot item={item} /><Group justify=\"space-between\"><Text size=\"sm\">{item.name}</Text><Badge size=\"xs\" variant=\"light\">{item.kind}</Badge></Group>{item.takenAt && <Text size=\"xs\" c=\"dimmed\">{new Date(item.takenAt).toLocaleDateString(host.locale)}</Text>}</Stack></UnstyledButton></Card>)}</SimpleGrid>\n {gallery.data && !gallery.data.configured && <Text size=\"sm\" c=\"dimmed\">Bind a camera or Immich connection to add authenticated images.</Text>}\n {gallery.data?.configured && !gallery.data.items.length && <Text size=\"sm\" c=\"dimmed\">No configured snapshots or matching Immich images.</Text>}\n {options.showSessions && <Sessions />}\n </Stack>;\n}\nfunction Snapshot({item,large=false}:{item:GalleryItem;large?:boolean}) {\n const options = useWidgetOptions<MediaOptions>();\n const image = useWidgetQuery<{dataUrl:string;updatedAt:string}>(\"image\",{kind:item.kind,id:item.id},{refetchInterval:item.kind === \"camera\" ? Math.max(5,Number(options.snapshotInterval)||15)*1000 : 300000,staleTime:5000});\n if (image.isPending) return <Stack h={160} justify=\"center\" align=\"center\"><Loader size=\"sm\" /></Stack>;\n if (image.error) return <Alert color=\"yellow\">{image.error.message}</Alert>;\n return <Image src={image.data?.dataUrl} alt={item.name} h={large ? 420 : 160} fit={large ? \"contain\" : \"cover\"} radius=\"sm\" />;\n}\n",
"server.ts": "import type { Camera, MediaOptions, GalleryItem, GalleryImageInput, ImmichAsset, MediaSessions } from \"./types\";\nimport { callWidgetIntegration, defineWidgetServer, fetchWidgetConnection, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\nconst cameras = (options:MediaOptions):Camera[] => Array.isArray(options.cameras) ? options.cameras.slice(0,8).filter((item) => item && typeof item.name === \"string\" && typeof item.path === \"string\") : [];\nexport default defineWidgetServer({\n sessions: async (_input,context) => {\n const {bindingNames} = await getWidgetServerContext<MediaOptions>(context);\n if (!bindingNames.includes(\"media\")) return {data:[],configured:false};\n return callWidgetIntegration<MediaSessions>(context,{connection:\"media\",capability:\"media.sessions\",input:{showOnlyPlaying:false}});\n },\n gallery: async (_input,context) => {\n const {bindingNames,options} = await getWidgetServerContext<MediaOptions>(context);\n const items:GalleryItem[] = []; const errors:string[] = [];\n if (bindingNames.includes(\"camera\")) items.push(...cameras(options).map<GalleryItem>((camera,index) => ({kind:\"camera\",id:String(index),name:camera.name})));\n if (bindingNames.includes(\"immich\")) {\n try {\n const response = await fetchWidgetConnection<{assets?:{items?:ImmichAsset[]}}>(context,{connection:\"immich\",path:\"/api/search/metadata\",method:\"POST\",body:{type:\"IMAGE\",size:Math.max(1,Math.min(12,Number(options.imageCount)||8))}});\n if (!response.ok) throw new Error(`Immich returned HTTP ${response.status}`);\n const assets = response.data?.assets?.items;\n if (!Array.isArray(assets)) throw new Error(\"Immich search returned an unexpected result\");\n items.push(...assets.slice(0,12).map<GalleryItem>((asset) => ({kind:\"immich\",id:asset.id,name:asset.originalFileName ?? \"Photo\",takenAt:asset.localDateTime ?? asset.fileCreatedAt})));\n } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }\n }\n return {items,errors,configured:bindingNames.includes(\"camera\") || bindingNames.includes(\"immich\")};\n },\n image: async (input:GalleryImageInput,context) => {\n const {options,bindingNames} = await getWidgetServerContext<MediaOptions>(context);\n let path; let connection;\n if (input?.kind === \"camera\") {\n const camera = cameras(options).find((_camera,index) => String(index) === input.id);\n if (!camera) throw new Error(\"Choose a configured camera snapshot\");\n path = camera.path; connection = \"camera\";\n } else if (input?.kind === \"immich\" && typeof input.id === \"string\" && /^[0-9a-f-]{36}$/i.test(input.id)) {\n path = `/api/assets/${encodeURIComponent(input.id)}/thumbnail?size=thumbnail`; connection = \"immich\";\n } else throw new Error(\"Unknown gallery image\");\n if (!bindingNames.includes(connection)) throw new Error(\"The image connection is not bound\");\n const response = await fetchWidgetConnection(context,{connection,path,responseType:\"base64\"});\n if (!response.ok) throw new Error(`Image returned HTTP ${response.status}. Check the configured endpoint and image permissions.`);\n const mime = response.contentType?.split(\";\")[0]?.trim().toLowerCase() ?? \"\";\n if (![\"image/jpeg\",\"image/png\",\"image/webp\",\"image/gif\",\"image/avif\"].includes(mime)) throw new Error(\"The endpoint must return a JPEG, PNG, WebP, GIF or AVIF snapshot\");\n if (typeof response.data !== \"string\" || response.data.length > 900000) throw new Error(\"Use a smaller snapshot or thumbnail (under 675 KiB)\");\n return {dataUrl:`data:${mime};base64,${response.data}`,updatedAt:response.updatedAt};\n },\n});\n",
"sessions.tsx": "import type { MediaSessions } from \"./types\";\nimport { Alert, Badge, Button, Card, Group, Progress, SimpleGrid, Stack, Text } from \"@mantine/core\";\nimport { useWidgetHost, useWidgetQuery, useWidgetServices } from \"@homarr/widget-sdk\";\nexport default function Sessions() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const query = useWidgetQuery<MediaSessions>(\"sessions\", {}, { refetchInterval: 10000 });\n const rows = query.data?.data ?? [];\n const columns = Math.max(1, Math.floor(host.visibleWidth / 240));\n return <Stack p=\"md\" style={{overflow:\"auto\"}}><Text fw={600}>Now playing · {rows.length}</Text>\n {query.error && <Alert color=\"red\">{query.error.message}<Button variant=\"subtle\" size=\"xs\" onClick={() => void query.refetch()}>Retry</Button></Alert>}\n <SimpleGrid cols={columns}>{rows.map((session) => { const playing = session.currentlyPlaying; const playback = playing?.playback; return <Card key={session.sessionId} withBorder padding=\"sm\"><Stack gap=\"xs\"><Group justify=\"space-between\"><Text size=\"sm\" fw={600}>{session.user.username}</Text><Badge size=\"xs\">{playback?.state ?? \"idle\"}</Badge></Group><Text>{playing?.name ?? session.sessionName}</Text>{playing?.episodeName && <Text size=\"xs\" c=\"dimmed\">{playing.episodeName}</Text>}{typeof playback?.durationMs === \"number\" && playback.durationMs > 0 && <Progress aria-label=\"Playback progress\" value={100 * (playback.positionMs ?? 0) / playback.durationMs} />}<Button size=\"compact-xs\" variant=\"light\" onClick={() => services.openDetail({title: session.sessionName, content: <Stack><Text>{session.user.username}</Text><Text>{playing?.name}</Text><Text size=\"sm\">{playing?.location ?? \"Unknown network\"} · {playing?.metadata?.bitrateKbps ?? \"Unknown\"} kbps</Text><Text size=\"sm\">Video direct: {String(playing?.metadata?.transcoding?.isVideoDirect ?? \"unknown\")}</Text><Text size=\"sm\">Audio direct: {String(playing?.metadata?.transcoding?.isAudioDirect ?? \"unknown\")}</Text></Stack>})}>Session details</Button></Stack></Card>; })}</SimpleGrid>\n {!query.isPending && !query.error && query.data?.configured !== false && rows.length === 0 && <Text c=\"dimmed\">No active media sessions.</Text>}\n {query.data?.configured === false && <Text c=\"dimmed\" size=\"sm\">Bind a media server integration to show now-playing sessions.</Text>}\n </Stack>;\n}",
"README.md": "# Media and camera wall\nAll connections are optional. Bind an existing Plex/Jellyfin/Emby-compatible integration for native now-playing sessions. Bind an HTTP camera snapshot server and configure up to eight name/path entries for authenticated JPEG/PNG/WebP/GIF/AVIF still images. This is snapshot polling, not RTSP or continuous video streaming.\n\nFor Immich, bind the server root URL and keep `x-api-key` in the local connection's secret headers. The key needs asset.read and asset.view. The package lists recent image assets through POST /api/search/metadata and retrieves each thumbnail through GET /api/assets/:id/thumbnail?size=thumbnail. Each image is fetched server-side; credentials are never sent to the browser. Each image is limited to 675 KiB before base64 encoding. If an endpoint redirects, configure a direct image endpoint supported by your service.\n\nTile and advanced views share the selected image. Clicking a thumbnail opens the advanced view; the media session panel retains playback progress and native session details. Images refresh independently, so one failed camera does not hide the others. The package does not promise lossless full-resolution originals or real-time video.\n\nOfficial API sources: https://github.com/immich-app/immich/blob/main/server/src/controllers/search.controller.ts and https://github.com/immich-app/immich/blob/main/server/src/controllers/asset-media.controller.ts\n",
"types.ts": "export type Camera = {name:string;path:string};\nexport type MediaOptions = {title:string;showSessions:boolean;cameras:Camera[];imageCount:number;snapshotInterval:number};\nexport interface GalleryItem {kind:\"camera\"|\"immich\";id:string;name:string;takenAt?:string;}\nexport type GalleryImageInput = Pick<GalleryItem,\"kind\"|\"id\">;\nexport interface ImmichAsset {id:string;originalFileName?:string;localDateTime?:string;fileCreatedAt?:string;}\nexport interface MediaSession {sessionId:string;sessionName:string;user:{username:string};currentlyPlaying:{name:string;episodeName?:string|null;location:\"lan\"|\"wan\"|null;playback:{state:\"playing\"|\"paused\"|\"buffering\"|null;positionMs:number|null;durationMs:number|null};metadata:{bitrateKbps:number|null;transcoding:{isVideoDirect:boolean;isAudioDirect:boolean}}|null}|null;}\nexport interface MediaSessions {data:MediaSession[];configured?:boolean;}\n"
},
"dependencies": {},
"connections": {
"media": {
"kind": "integration",
"label": "Media server",
"optional": true
},
"camera": {
"kind": "http",
"label": "Camera snapshot server",
"optional": true,
"description": "Basic, bearer or header authentication stays in this local connection."
},
"immich": {
"kind": "http",
"label": "Immich server",
"optional": true,
"description": "Use a secret x-api-key header with asset.read and asset.view permissions."
}
},
"options": {
"title": {
"control": "text",
"label": "Title",
"default": "Media wall"
},
"showSessions": {
"control": "switch",
"label": "Show media sessions",
"default": true
},
"cameras": {
"control": "json",
"label": "Camera snapshots",
"description": "Array of name/path objects on the camera connection. Up to eight snapshots. Paths stay on that connection’s origin.",
"default": [
{
"name": "Front door",
"path": "/snapshot.jpg"
}
]
},
"imageCount": {
"control": "number",
"label": "Immich image count",
"default": 8,
"min": 1,
"max": 12
},
"snapshotInterval": {
"control": "number",
"label": "Snapshot refresh seconds",
"default": 15,
"min": 5,
"max": 3600
}
}
}