Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Build and publish Docker image

on:
push:
branches:
- main

Comment thread
coderabbitai[bot] marked this conversation as resolved.
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
concurrency:
group: docker-publish-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest
Comment thread
coderabbitai[bot] marked this conversation as resolved.
permissions:
contents: read
packages: write

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}

- name: Set up QEMU (for multi-platform builds)
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ github.sha }}
18 changes: 18 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,21 @@ kiosk:
cache: true # cache select api calls
prefetch: true # fetch assets in the background
asset_weighting: true # use weighting when picking assets

## MQTT - remote control via e.g. Home Assistant
# mqtt_enabled: false
# mqtt_broker: "192.168.1.100" # IP or hostname of the MQTT broker
# mqtt_port: 1883
# mqtt_username: "" # optional
# mqtt_password: "" # optional
# mqtt_client_id: "immich-kiosk"
# mqtt_topic_prefix: "immich-kiosk"
#
# Commands are received on:
# <prefix>/command -> all connected clients
# <prefix>/<client>/command -> a specific screen only
#
# Open the kiosk with ?client=<name> in the URL to enable per-screen control:
# http://kiosk:3000/?client=living-room
#
# Home Assistant devices are created automatically via MQTT discovery.
34 changes: 33 additions & 1 deletion config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,29 @@
"asset_weighting": {
"type": "boolean"
},
"mqtt_enabled": {
"type": "boolean"
},
"mqtt_broker": {
"type": "string"
},
"mqtt_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"mqtt_username": {
"type": "string"
},
"mqtt_password": {
"type": "string"
},
"mqtt_client_id": {
"type": "string"
},
"mqtt_topic_prefix": {
"type": "string"
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"disable_url_queries": {
"type": "boolean"
},
Expand Down Expand Up @@ -603,7 +626,16 @@
}
}
},
"required": []
"required": [],
"if": {
"properties": { "mqtt_enabled": { "const": true } }
},
"then": {
"required": ["mqtt_broker"],
"properties": {
"mqtt_broker": { "type": "string", "minLength": 1 }
}
}
}
},
"required": ["immich_api_key", "immich_url"]
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/ts/kiosk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
import { sleepMode } from "./sleep";
import { preventSleep } from "./wakelock";
import { weatherRotationPosition } from "./weather";
import { initMqttSSE } from "./mqtt-sse";

("use strict");

Expand Down Expand Up @@ -216,6 +217,8 @@ async function init(): Promise<void> {

if (kioskData.livePhotos) livePhoto(kioskData.livePhotoLoopDelay);

initMqttSSE();

// Burn-in prevention
if (kioskData.burnInInterval > 0 && kioskData.burnInDuration > 0)
burnInCycle();
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/ts/mqtt-sse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* mqtt-sse.ts
* Listens to the /events SSE endpoint and translates incoming MQTT navigation
* commands into the HTMX events that the kiosk already understands.
*/

import htmx from "htmx.org";

type KioskCommand = "next" | "previous";

/**
* Initialises the SSE connection to /events.
* Must be called after the kiosk element is available in the DOM.
*/
export function initMqttSSE(): void {
const params = new URLSearchParams(window.location.search);
const url = params.toString() ? `/events?${params.toString()}` : "/events";
const evtSource = new EventSource(url);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

evtSource.addEventListener("kiosk-command", (e: MessageEvent) => {
const cmd = (e.data as string).trim() as KioskCommand;

switch (cmd) {
case "next": {
const kioskEl = document.getElementById("kiosk");
if (kioskEl) {
htmx.trigger(kioskEl, "kiosk-new-asset");
}
break;
}
case "previous": {
const prevEl = document.getElementById(
"navigation-interaction-area--previous-asset",
);
if (prevEl) {
htmx.trigger(prevEl, "kiosk-prev-asset");
Comment on lines +31 to +36
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

previous is wired to an event the button does not listen for.

internal/templates/partials/menu.templ:52-60 only declares hx-trigger="click ..." on #navigation-interaction-area--previous-asset, so kiosk-prev-asset is a no-op here. Trigger click instead, or add kiosk-prev-asset to the template trigger list.

↩️ Minimal fix
-                    htmx.trigger(prevEl, "kiosk-prev-asset");
+                    htmx.trigger(prevEl, "click");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case "previous": {
const prevEl = document.getElementById(
"navigation-interaction-area--previous-asset",
);
if (prevEl) {
htmx.trigger(prevEl, "kiosk-prev-asset");
case "previous": {
const prevEl = document.getElementById(
"navigation-interaction-area--previous-asset",
);
if (prevEl) {
htmx.trigger(prevEl, "click");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/ts/mqtt-sse.ts` around lines 32 - 37, The handler for the
"previous" MQTT/SSE message triggers a non-existent event ("kiosk-prev-asset")
on the element with id "navigation-interaction-area--previous-asset", so nothing
happens; update the code in mqtt-sse.ts to trigger the actual click event (use
htmx.trigger(prevEl, "click")) or alternatively add "kiosk-prev-asset" to the
hx-trigger list in the template for `#navigation-interaction-area--previous-asset`
(internal/templates/partials/menu.templ) so the element listens for that custom
event; modify whichever approach you choose and ensure the "previous" case in
the switch uses the matching event name.

}
break;
}
default:
console.warn("immich-kiosk: unknown MQTT command:", cmd);
}
});

evtSource.onerror = () => {
// Browser will automatically reconnect for EventSource; nothing extra needed.
console.debug("immich-kiosk: SSE connection error, will retry automatically");
};
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/a-h/templ v0.3.1001
github.com/disintegration/imaging v1.6.2
github.com/dustin/go-humanize v1.0.1
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/fogleman/gg v1.3.0
github.com/goodsign/monday v1.0.2
github.com/google/go-querystring v1.2.0
Expand Down Expand Up @@ -62,6 +63,7 @@ require (
github.com/gobwas/glob v0.2.3 // indirect
github.com/gohugoio/hugo v0.152.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ github.com/dprotaso/go-yit v0.0.0-20250513224043-18a80f8f6df4 h1:JzpdVajvTuXQXL1
github.com/dprotaso/go-yit v0.0.0-20250513224043-18a80f8f6df4/go.mod h1:lHwJo6jMevQL9tNpW6vLyhkK13bYHBcoh9tUakMhbnE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
github.com/evanw/esbuild v0.25.11 h1:NGtezc+xk+Mti4fgWaoD3dncZNCzcTA+r0BxMV3Koyw=
github.com/evanw/esbuild v0.25.11/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
Expand Down Expand Up @@ -161,6 +163,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
Expand Down
23 changes: 23 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ type KioskSettings struct {
DebugVerbose bool `json:"debugVerbose" yaml:"debug_verbose" mapstructure:"debug_verbose" default:"false"`

DemoMode bool `json:"-" yaml:"-" mapstructure:"demo_mode" default:"false"`

// MQTT settings
// MqttEnabled enables MQTT support for remote control (e.g. from Home Assistant)
MqttEnabled bool `json:"mqttEnabled" yaml:"mqtt_enabled" mapstructure:"mqtt_enabled" default:"false"`
// MqttBroker is the hostname or IP address of the MQTT broker
MqttBroker string `json:"mqttBroker" yaml:"mqtt_broker" mapstructure:"mqtt_broker" default:""`
// MqttPort is the port of the MQTT broker
MqttPort int `json:"mqttPort" yaml:"mqtt_port" mapstructure:"mqtt_port" default:"1883"`
// MqttUsername is the MQTT broker username (optional)
MqttUsername string `json:"mqttUsername" yaml:"mqtt_username" mapstructure:"mqtt_username" default:""`
// MqttPassword is the MQTT broker password (optional)
MqttPassword string `json:"-" yaml:"mqtt_password" mapstructure:"mqtt_password" default:"" redact:"true"`
// MqttClientID is the MQTT client identifier
MqttClientID string `json:"mqttClientId" yaml:"mqtt_client_id" mapstructure:"mqtt_client_id" default:"immich-kiosk"`
// MqttTopicPrefix is the MQTT topic prefix. Commands are received on <prefix>/command
MqttTopicPrefix string `json:"mqttTopicPrefix" yaml:"mqtt_topic_prefix" mapstructure:"mqtt_topic_prefix" default:"immich-kiosk"`
Comment on lines +159 to +162
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In MQTT, what happens when two clients connect to the same broker using the same client ID? Is the earlier connection dropped?

💡 Result:

In MQTT, client IDs must be unique per broker. When two clients attempt to connect using the same client ID, the broker drops the earlier (existing) connection and accepts the new one. This behavior is mandated by the MQTT specifications for both versions 3.1.1 and 5.0 to ensure session integrity and allow session recovery after network failures. For MQTT 3.1.1: If the ClientId represents a Client already connected to the Server then the Server MUST disconnect the existing Client [MQTT-3.1.4-2] (referenced across multiple sources including broker logs and discussions). For MQTT 5.0: If the ClientID represents a Client already connected to the Server, the Server sends a DISCONNECT packet to the existing Client with Reason Code 0x8E (Session taken over) and MUST close the Network Connection of the existing Client [MQTT-3.1.4-3]. This can lead to connection flapping if both clients auto-reconnect. Some brokers offer configuration options (e.g., Mosquitto's allow_duplicate_clients) to reject duplicates instead, but standard compliant behavior is to drop the older connection. Always use unique client IDs, such as by appending random suffixes or UUIDs.

Citations:


🏁 Script executed:

# Search for MqttClientID usage across the codebase
rg "MqttClientID" -A 3 -B 3

# Also check for any UUID or unique ID generation logic related to MQTT
rg -i "mqtt.*id|clientid" --type go -A 2 | head -100

Repository: damongolding/immich-kiosk

Length of output: 3637


🏁 Script executed:

# Check for initialization/startup code that might modify MqttClientID
rg -i "hostname|uuid|machine.*id" --type go internal/ | head -50

# Also check main.go or any startup sequence
find . -name "main.go" -o -name "init.go" | xargs ls -la 2>/dev/null

# Look for any config post-processing that might add uniqueness
rg "MqttClientID\s*=" --type go -A 5 -B 5

Repository: damongolding/immich-kiosk

Length of output: 4557


🏁 Script executed:

# Check main.go to see the startup sequence
head -100 main.go

# Look for where config is loaded and if it's post-processed
rg "NewConfig|LoadConfig" --type go -A 10 -B 2

# Check if KioskSettings or MqttClientID is modified after config load
rg "MqttClientID\s*=" --type go -B 5 -A 5 | grep -v "default:"

Repository: damongolding/immich-kiosk

Length of output: 2732


🏁 Script executed:

# Check the config Load method to see if it modifies MqttClientID
rg "func.*Load" internal/config/ --type go -A 15 | head -80

# Check mqtt.New() function
rg "func.*New.*Client|type Client struct" internal/mqtt/ --type go -A 10

Repository: damongolding/immich-kiosk

Length of output: 3240


Use a unique identifier for the MQTT client ID instead of a fixed default.

Multiple kiosk instances using the default "immich-kiosk" will disconnect each other on the same MQTT broker—the MQTT specification requires the broker to drop the earlier connection when a duplicate client ID connects. Generate a unique ID at runtime (e.g., using the existing GenerateUUID() utility or hostname), or require an explicit override when MQTT is enabled.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/config/config.go` around lines 159 - 162, The MqttClientID field
currently uses a fixed default "immich-kiosk" which causes client-ID collisions;
remove the hardcoded default in the MqttClientID struct tag (or make it empty)
and ensure the code that initializes or validates configuration (where the
config is constructed/loaded) sets a unique client ID when none is provided by
calling the existing GenerateUUID() utility or deriving one from the hostname;
update/mention the MqttClientID field and the config initialization/validation
path so a unique ID is generated at runtime (or alternatively require an
explicit non-empty override when MQTT is enabled).

}

type WeatherConfig struct {
Expand Down Expand Up @@ -537,6 +553,13 @@ func bindEnvironmentVariables(v *viper.Viper) error {
{"kiosk.debug_verbose", "KIOSK_DEBUG_VERBOSE"},
{"kiosk.demo_mode", "KIOSK_DEMO_MODE"},
{"kiosk.config_validation_level", "KIOSK_CONFIG_VALIDATION_LEVEL"},
{"kiosk.mqtt_enabled", "KIOSK_MQTT_ENABLED"},
{"kiosk.mqtt_broker", "KIOSK_MQTT_BROKER"},
{"kiosk.mqtt_port", "KIOSK_MQTT_PORT"},
{"kiosk.mqtt_username", "KIOSK_MQTT_USERNAME"},
{"kiosk.mqtt_password", "KIOSK_MQTT_PASSWORD"},
{"kiosk.mqtt_client_id", "KIOSK_MQTT_CLIENT_ID"},
{"kiosk.mqtt_topic_prefix", "KIOSK_MQTT_TOPIC_PREFIX"},
{"offline_mode.enabled", "KIOSK_OFFLINE_MODE_ENABLED"},
{"offline_mode.number_of_assets", "KIOSK_OFFLINE_MODE_NUMBER_OF_ASSETS"},
{"offline_mode.max_size", "KIOSK_OFFLINE_MODE_MAX_SIZE"},
Expand Down
Loading