{ "$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(); const services = useWidgetServices();\n const [selected,setSelected] = useWidgetState(\"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 \n {options.title}\n {gallery.error && {gallery.error.message}}\n {(gallery.data?.errors ?? []).map((error,index) => {error})}\n {host.displayMode === \"advanced\" && selected && {selected.name}}\n {(gallery.data?.items ?? []).map((item) => {setSelected(item);services.openAdvanced();}}>{item.name}{item.kind}{item.takenAt && {new Date(item.takenAt).toLocaleDateString(host.locale)}})}\n {gallery.data && !gallery.data.configured && Bind a camera or Immich connection to add authenticated images.}\n {gallery.data?.configured && !gallery.data.items.length && No configured snapshots or matching Immich images.}\n {options.showSessions && }\n ;\n}\nfunction Snapshot({item,large=false}:{item:GalleryItem;large?:boolean}) {\n const options = useWidgetOptions();\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 ;\n if (image.error) return {image.error.message};\n return {item.name};\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(context);\n if (!bindingNames.includes(\"media\")) return {data:[],configured:false};\n return callWidgetIntegration(context,{connection:\"media\",capability:\"media.sessions\",input:{showOnlyPlaying:false}});\n },\n gallery: async (_input,context) => {\n const {bindingNames,options} = await getWidgetServerContext(context);\n const items:GalleryItem[] = []; const errors:string[] = [];\n if (bindingNames.includes(\"camera\")) items.push(...cameras(options).map((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((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(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(\"sessions\", {}, { refetchInterval: 10000 });\n const rows = query.data?.data ?? [];\n const columns = Math.max(1, Math.floor(host.visibleWidth / 240));\n return Now playing · {rows.length}\n {query.error && {query.error.message}}\n {rows.map((session) => { const playing = session.currentlyPlaying; const playback = playing?.playback; return {session.user.username}{playback?.state ?? \"idle\"}{playing?.name ?? session.sessionName}{playing?.episodeName && {playing.episodeName}}{typeof playback?.durationMs === \"number\" && playback.durationMs > 0 && }; })}\n {!query.isPending && !query.error && query.data?.configured !== false && rows.length === 0 && No active media sessions.}\n {query.data?.configured === false && Bind a media server integration to show now-playing sessions.}\n ;\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;\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 } } }