aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-29 13:33:14 +0200
committerDanilo M. <danix@danix.xyz>2026-09-29 13:33:14 +0200
commit9624351d80ace747d3117fb22d6cbd00fce3eedf (patch)
treef9adc0a04445718d2d2b5e1b659b00fb8373c73c
parent272c5732b0bc932749f0e771de51ee49f6cebe5b (diff)
downloadconky-theme-udt-9624351d80ace747d3117fb22d6cbd00fce3eedf.tar.gz
conky-theme-udt-9624351d80ace747d3117fb22d6cbd00fce3eedf.zip
feat: add an Immich memories card with click and scroll
A centred card showing today's "N years ago" photos from Immich, one at a time on a 30-second rotation, with when each was taken and where. bin/immich-fetch.sh runs from conky's execi every 30 minutes: it asks /api/memories for today, converts each preview to PNG (Cairo loads nothing else) and writes an index with the year, local capture time and place. The place needs the key's asset.read permission and reads '--' without it. The key lives in ~/.config/udt/immich.env, like the weather key, because a GPG-backed store would raise a pinentry unattended. The dashboard now registers lua_mouse_hook and hands pointer events to the widget under the pointer. On this card, scrolling steps through the photos and a left click opens the photo in Immich's web UI and hides the dashboard so the browser is not stuck beneath it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--README.md20
-rwxr-xr-xbin/immich-fetch.sh96
-rw-r--r--conky.conf.in5
-rw-r--r--dashboard.lua26
-rw-r--r--immich.env.example10
-rw-r--r--lib/card.lua1
-rw-r--r--lib/data.lua37
-rw-r--r--test/fixtures/immich_index6
-rw-r--r--test/test_data.lua13
-rw-r--r--widgets/immich.lua164
10 files changed, 377 insertions, 1 deletions
diff --git a/README.md b/README.md
index 4956896..f16489a 100644
--- a/README.md
+++ b/README.md
@@ -91,6 +91,7 @@ six `${execi}` entries, even though that block renders nothing under Cairo.
${execi 1800 ~/.config/conky/bin/pubip-sample.sh}
${execi 900 ~/.config/conky/bin/slackware-sample.sh}
${execi 300 ~/.config/conky/bin/calendar-sample.sh}
+ ${execi 1800 ~/.config/conky/bin/immich-fetch.sh}
The disks sampler runs once a minute; the cache, Slackware and public-IP
samplers run every 15, 15 and 30 minutes respectively, because `du -sh
@@ -193,6 +194,25 @@ must be the day. A genuinely ambiguous pair like `03.04.2026` is read
day-first, matching khal's own defaults. A 2-digit year is refused rather than
guessed at.
+### Memories
+
+Today's "N years ago" photos from an Immich server, one at a time, rotating
+every 30 seconds. The big value is how many years ago; under the photo, when it
+was taken (the camera's local time) and where, as city and country, or
+coordinates when Immich has no place name for them.
+
+`bin/immich-fetch.sh` runs every 30 minutes. It asks `/api/memories` for
+today, downloads each photo's preview, converts it to PNG with ImageMagick
+(Cairo loads nothing else) and writes an index beside the photos in
+`~/.cache/udt/immich/`, dropping photos that are no longer today's. It needs
+`curl`, `jq` and `magick`, and a key in `~/.config/udt/immich.env`: copy
+`immich.env.example`. The key wants `memory.read` and `asset.view`; with
+`asset.read` too, the card shows the place, and without it the row reads `--`.
+
+The key sits in a plain file, like the weather key, because the fetch runs
+unattended from conky: reading it from a GPG-backed store would raise a
+pinentry whenever the agent's cache has expired.
+
### Host bindings
These hwmon bindings, and the ceilings beside them, are specific to this machine
diff --git a/bin/immich-fetch.sh b/bin/immich-fetch.sh
new file mode 100755
index 0000000..961cd63
--- /dev/null
+++ b/bin/immich-fetch.sh
@@ -0,0 +1,96 @@
+#!/bin/bash
+# Fetch today's Immich "N years ago" memories into a cache directory, for
+# widgets/immich.lua. Run from conky's ${execi}: prints nothing on success and
+# never blocks the dashboard, since the widget only ever reads the cache.
+#
+# Writes $CACHE_DIR/<asset id>.png per photo plus index.txt:
+# generated <epoch>
+# url <server URL>
+# photo <asset id>|<memory year>|<localDateTime>|<place>
+# An index with no photo lines means "no memories today", which the card shows
+# differently from a missing index ("the fetch never ran").
+#
+# Leaves any existing cache untouched when the server cannot be reached, rather
+# than replacing good data with an error.
+
+set -u
+
+ENV_FILE="${IMMICH_ENV:-$HOME/.config/udt/immich.env}"
+CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/udt/immich"
+INDEX="$CACHE_DIR/index.txt"
+
+if [ ! -r "$ENV_FILE" ]; then
+ echo "immich-fetch: no $ENV_FILE (copy immich.env.example)" >&2
+ exit 1
+fi
+
+set -a
+# shellcheck source=/dev/null
+. "$ENV_FILE"
+set +a
+
+if [ -z "${URL:-}" ] || [ -z "${KEY:-}" ]; then
+ echo "immich-fetch: URL or KEY is empty in $ENV_FILE" >&2
+ exit 1
+fi
+
+# Photos are personal data: keep the cache private from the first byte.
+umask 077
+mkdir -p "$CACHE_DIR"
+
+TMP="$INDEX.tmp.$$"
+MEM="$CACHE_DIR/memories.json.tmp.$$"
+trap 'rm -f "$TMP" "$MEM" "$CACHE_DIR"/*.part.$$' EXIT
+
+api() {
+ curl -fsS --max-time 20 -H "x-api-key: $KEY" "${URL%/}/api/$1"
+}
+
+if ! api "memories?for=$(date +%F)" > "$MEM" 2>/dev/null \
+ || ! jq -e 'type == "array"' "$MEM" > /dev/null 2>&1; then
+ echo "immich-fetch: memories request failed" >&2
+ exit 1
+fi
+
+echo "generated $(date +%s)" > "$TMP"
+# For the card's click-through to the photo in Immich's web UI.
+echo "url ${URL%/}" >> "$TMP"
+
+# One line per asset: id, the memory's year, and when the photo was taken.
+# localDateTime is the camera's wall clock, which is what "taken at" means;
+# the UTC fileCreatedAt would shift an evening photo into the next day.
+jq -r '.[] | select(.type == "on_this_day") | .data.year as $y
+ | .assets[] | [.id, $y, .localDateTime] | @tsv' "$MEM" |
+while IFS=$'\t' read -r id year taken; do
+ png="$CACHE_DIR/$id.png"
+ if [ ! -s "$png" ]; then
+ # The preview is a JPEG (or WebP); cairo only loads PNG. Shrunk here
+ # so the widget holds a ~1MB surface rather than an 11MB one.
+ # -auto-orient: previews already come rotated, this is belt and braces.
+ if ! api "assets/$id/thumbnail?size=preview" 2>/dev/null \
+ | magick - -auto-orient -resize '800x800>' "png:$png.part.$$" 2>/dev/null; then
+ continue
+ fi
+ mv -f "$png.part.$$" "$png"
+ fi
+
+ # Location needs the key's asset.read permission. Without it this 403s and
+ # the place is simply empty: the card shows '--', the photo still shows.
+ place=$(api "assets/$id" 2>/dev/null | jq -r '.exifInfo // {} |
+ ([.city, .country] | map(select(. != null and . != "")) | join(", ")) as $p
+ | if $p != "" then $p
+ elif .latitude != null and .longitude != null
+ then "\(.latitude * 1000 | round / 1000), \(.longitude * 1000 | round / 1000)"
+ else "" end' 2>/dev/null)
+
+ echo "photo $id|$year|$taken|$place" >> "$TMP"
+done
+
+mv -f "$TMP" "$INDEX"
+
+# Drop photos no longer in today's memories, so the directory holds one day.
+for f in "$CACHE_DIR"/*.png; do
+ [ -e "$f" ] || continue
+ id=$(basename "$f" .png)
+ grep -q "^photo $id|" "$INDEX" || rm -f "$f"
+done
diff --git a/conky.conf.in b/conky.conf.in
index 0d88ab6..24bc86f 100644
--- a/conky.conf.in
+++ b/conky.conf.in
@@ -34,6 +34,9 @@ conky.config = {
override_utf8_locale = true,
lua_load = '~/.config/conky/dashboard.lua',
lua_draw_hook_post = 'main',
+ -- Clicks and scrolls, dispatched to the widget under the pointer. Safe with
+ -- own_window: conky only steals root events when it has no window.
+ lua_mouse_hook = 'mouse',
color1 = '@HEADING@',
color2 = '@LABEL@',
color3 = '@RULE@',
@@ -53,4 +56,4 @@ conky.config = {
-- samplers, and tying it to conky means nothing fetches while the dashboard is
-- down. breaktimer runs at 5s, its daemon's own tick, so the countdown advances
-- in the steps the daemon counts in.
-conky.text = [[${execi 900 ~/.config/conky/bin/weather-fetch.sh}${execi 60 ~/.config/conky/bin/disks-sample.sh}${execi 900 ~/.config/conky/bin/cache-sample.sh}${execi 1800 ~/.config/conky/bin/pubip-sample.sh}${execi 900 ~/.config/conky/bin/slackware-sample.sh}${execi 300 ~/.config/conky/bin/calendar-sample.sh}${execi 5 ~/.config/conky/bin/breaktimer-sample.sh}]]
+conky.text = [[${execi 900 ~/.config/conky/bin/weather-fetch.sh}${execi 60 ~/.config/conky/bin/disks-sample.sh}${execi 900 ~/.config/conky/bin/cache-sample.sh}${execi 1800 ~/.config/conky/bin/pubip-sample.sh}${execi 900 ~/.config/conky/bin/slackware-sample.sh}${execi 300 ~/.config/conky/bin/calendar-sample.sh}${execi 5 ~/.config/conky/bin/breaktimer-sample.sh}${execi 1800 ~/.config/conky/bin/immich-fetch.sh}]]
diff --git a/dashboard.lua b/dashboard.lua
index 95b3f79..8a89a02 100644
--- a/dashboard.lua
+++ b/dashboard.lua
@@ -34,6 +34,9 @@ local layout = {
-- arrangement for the board yet.
{ widget = 'network', col = 14, row = 9, w = 3, h = 3 },
{ widget = 'slackware', col = 12, row = 10, w = 2, h = 3 },
+ -- Centred on the board. Twice as many rows as columns because cells are
+ -- about twice as wide as tall, so this span comes out square.
+ { widget = 'immich', col = 7.25, row = 3.5, w = 3.5, h = 7 },
}
-- ==========================================================================
@@ -162,6 +165,29 @@ local function draw(cr, sw, sh, colors)
end
end
+-- Pointer events, handed to the widget whose cell is under the pointer, if it
+-- defines mouse(event, rect). Coordinates are window-relative, and the window
+-- is the whole surface, so they index the same grid the draw uses.
+--
+-- Wrapped like the draw: a fault here must not take conky down. Returning true
+-- tells conky the event was consumed.
+function conky_mouse(event)
+ if conky_window == nil then return false end
+ local sw, sh = conky_window.width, conky_window.height
+ for _, entry in ipairs(layout) do
+ local r = rect_for(entry, sw, sh)
+ if event.x >= r.x and event.x < r.x + r.w
+ and event.y >= r.y and event.y < r.y + r.h then
+ local w = widget(entry.widget)
+ if not (w and w.mouse) then return false end
+ local ok, res = pcall(w.mouse, event, r)
+ if not ok then print('dashboard: mouse in ' .. entry.widget .. ': ' .. tostring(res)) end
+ return ok and res == true
+ end
+ end
+ return false
+end
+
function conky_main()
if conky_window == nil then return end
local s = conky_surface()
diff --git a/immich.env.example b/immich.env.example
new file mode 100644
index 0000000..9bbab18
--- /dev/null
+++ b/immich.env.example
@@ -0,0 +1,10 @@
+# Copy to ~/.config/udt/immich.env and fill in. That path is outside this
+# repo on purpose: it holds a key, and anything committed is potentially
+# public. chmod 600 it.
+#
+# Create the key in Immich under Account Settings > API Keys with the
+# memory.read and asset.view permissions. Add asset.read to show where each
+# photo was taken; without it the card shows the photo and date only.
+
+URL=http://immich.example.org:2283
+KEY=your_immich_api_key_here
diff --git a/lib/card.lua b/lib/card.lua
index e3cb11e..2313141 100644
--- a/lib/card.lua
+++ b/lib/card.lua
@@ -132,6 +132,7 @@ M.ICON = {
slackware = '\u{F318}', -- linux-slackware
breaktimer = '\u{F13AB}', -- timer
calendar = '\u{F0E17}', -- calendar-month
+ immich = '\u{F02F9}', -- image-multiple
}
-- The gap between a title's icon and its text. One constant so the measured
diff --git a/lib/data.lua b/lib/data.lua
index 8e60d6d..1173fce 100644
--- a/lib/data.lua
+++ b/lib/data.lua
@@ -591,4 +591,41 @@ function M.breaktimer_parse(text)
return out
end
+-- The Immich memories index written by bin/immich-fetch.sh.
+--
+-- Returns a list of { id, year, taken = { y, m, d, H, M }, place }, plus
+-- .url, the server's address for a click-through, or nil when
+-- the index is absent or carries no 'generated' key. nil means "the fetch never
+-- ran"; an empty list means "no memories today", which the card names
+-- differently. `place` is nil when the key lacks asset.read or the photo has no
+-- location, so the card can print '--'.
+function M.immich_parse(text)
+ if type(text) ~= 'string' then return nil end
+ local out, generated = {}, false
+ for line in text:gmatch('[^\n]+') do
+ local kind, rest = line:match('^(%S+)%s+(.*)$')
+ if kind == 'generated' then
+ generated = true
+ elseif kind == 'url' then
+ out.url = rest
+ elseif kind == 'photo' then
+ -- The place is last and takes the rest of the line, so a pipe in a
+ -- place name cannot shift a field.
+ local id, year, taken, place = rest:match('^([^|]+)|(%d*)|([^|]*)|(.*)$')
+ if id then
+ local y, m, d, H, Mi = taken:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+)')
+ out[#out + 1] = {
+ id = id,
+ year = tonumber(year),
+ taken = y and { tonumber(y), tonumber(m), tonumber(d),
+ tonumber(H), tonumber(Mi) } or nil,
+ place = place ~= '' and place or nil,
+ }
+ end
+ end
+ end
+ if not generated then return nil end
+ return out
+end
+
return M
diff --git a/test/fixtures/immich_index b/test/fixtures/immich_index
new file mode 100644
index 0000000..7a5b837
--- /dev/null
+++ b/test/fixtures/immich_index
@@ -0,0 +1,6 @@
+generated 1790681173
+url http://immich.example.org:2283
+photo 00000000-0000-0000-0000-000000000001|2014|2014-09-29T19:33:56+00:00|Example City, Italy
+photo 00000000-0000-0000-0000-000000000002|2014|2014-09-29T22:42:35+00:00|
+photo 00000000-0000-0000-0000-000000000003|2019||40.123, 9.456
+photo garbage
diff --git a/test/test_data.lua b/test/test_data.lua
index 65f92e1..7b84b3d 100644
--- a/test/test_data.lua
+++ b/test/test_data.lua
@@ -445,4 +445,17 @@ assert(data.breaktimer_parse('running no\n') == nil, 'no generated key: nil')
assert(data.breaktimer_parse('') == nil, 'empty cache: nil')
assert(data.breaktimer_parse(nil) == nil, 'nil cache: nil')
+-- immich_parse: one entry per photo line, place optional, garbage skipped.
+local im = data.immich_parse(read('test/fixtures/immich_index'))
+assert(im and #im == 3, 'immich photo count, got ' .. tostring(im and #im))
+assert(im[1].year == 2014 and im[1].place == 'Example City, Italy', 'immich first photo')
+assert(table.concat(im[1].taken, ' ') == '2014 9 29 19 33', 'immich taken parses')
+assert(im.url == 'http://immich.example.org:2283', 'immich url parses')
+assert(im[2].place == nil, 'empty place: nil, so the card prints --')
+assert(im[3].taken == nil and im[3].place == '40.123, 9.456', 'missing date, coords place')
+assert(data.immich_parse('photo a|2014||\n') == nil, 'no generated key: nil')
+assert(data.immich_parse(nil) == nil, 'nil index: nil')
+local none = data.immich_parse('generated 1\n')
+assert(none and #none == 0, 'no memories today: empty list, not nil')
+
print('test_data: all assertions passed')
diff --git a/widgets/immich.lua b/widgets/immich.lua
new file mode 100644
index 0000000..17e0b02
--- /dev/null
+++ b/widgets/immich.lua
@@ -0,0 +1,164 @@
+-- Immich: today's "N years ago" memories, one photo at a time.
+--
+-- Everything comes from the cache bin/immich-fetch.sh writes: the photos as
+-- PNGs (Cairo loads nothing else) and an index with the year, when each was
+-- taken and, when the API key may read it, where. The card rotates through
+-- them on the wall clock, so it needs no state beyond the loaded surface.
+
+local card = require 'lib.card'
+local data = require 'lib.data'
+
+local DIR = (os.getenv('XDG_CACHE_HOME') or (os.getenv('HOME') .. '/.cache'))
+ .. '/udt/immich/'
+
+local ROTATE = 30 -- seconds per photo
+
+-- Photos stepped by scrolling, added to the wall-clock rotation. Kept across
+-- draws, and read by the click handler to open the photo on screen.
+local offset = 0
+local shown = nil -- { url, id } of the photo drawn last
+
+local M = {}
+
+-- The loaded photo. One surface held at a time: decoding a PNG every 2s draw
+-- would be wasted work, and holding every photo would be wasted memory.
+local loaded = { path = nil, surf = nil }
+
+local function surface(path)
+ if loaded.path == path then return loaded.surf end
+ if loaded.surf then cairo_surface_destroy(loaded.surf) end
+ local s = cairo_image_surface_create_from_png(path)
+ if cairo_surface_status(s) ~= CAIRO_STATUS_SUCCESS then
+ cairo_surface_destroy(s)
+ s = nil
+ end
+ loaded.path, loaded.surf = path, s
+ return s
+end
+
+-- The photo scaled to cover the band and clipped to its rounded rect, the way
+-- a cropped thumbnail reads: no letterbox bars for a portrait photo.
+local function draw_photo(cr, s, x, y, w, h, colors)
+ card.rounded_path(cr, x, y, w, h, 10)
+ if not s then
+ card.rgba(cr, colors.rule, 0.3)
+ cairo_fill(cr)
+ return
+ end
+ cairo_save(cr)
+ cairo_clip(cr)
+ local iw, ih = cairo_image_surface_get_width(s), cairo_image_surface_get_height(s)
+ local k = math.max(w / iw, h / ih)
+ cairo_translate(cr, x + (w - iw * k) / 2, y + (h - ih * k) / 2)
+ cairo_scale(cr, k, k)
+ cairo_set_source_surface(cr, s, 0, 0)
+ cairo_paint(cr)
+ cairo_restore(cr)
+end
+
+function M.draw(cr, rect, colors)
+ local inner = card.card(cr, rect, colors)
+ local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end
+ local LABEL_F, BIG_F, ROW_F, SUB_F = 0.30, 1.0, 0.32, 0.26
+
+ local photos = data.immich_parse(data.slurp(DIR .. 'index.txt') or '')
+ if not photos then
+ card.notice(cr, inner, colors, 'MEMORIES', 'no immich data',
+ 'bin/immich-fetch.sh')
+ return
+ end
+ if #photos == 0 then
+ card.notice(cr, inner, colors, 'MEMORIES', 'no memories today',
+ os.date('%d/%m'))
+ return
+ end
+
+ local n = #photos
+ local i = (os.time() // ROTATE + offset) % n + 1
+ local p = photos[i]
+ shown = { url = photos.url, id = p.id }
+
+ local ago = p.year and (tonumber(os.date('%Y')) - p.year) or nil
+ local big = ago and tostring(ago) or '--'
+ local sub = (ago == 1 and 'year ago' or 'years ago')
+ .. (n > 1 and string.format(' %d/%d', i, n) or '')
+ local t = p.taken
+ local taken = t and string.format('%02d/%02d/%04d %02d:%02d',
+ t[3], t[2], t[1], t[4], t[5]) or '--'
+
+ -- Fluid type, sized so header and rows take at most ~40% of the card and
+ -- the photo absorbs the rest. The place is left out of the fit: its length
+ -- varies per photo, so it is truncated to the width instead of shrinking
+ -- the whole card's type every time a long place name comes round.
+ local groups = {
+ { { card.title_str(card.ICON.immich, 'MEMORIES'), card.FONT_MONO, LABEL_F },
+ { big, card.FONT_HEAVY, BIG_F } },
+ { { sub, card.FONT_UI, SUB_F } },
+ { { 'TAKEN ', card.FONT_MONO, ROW_F }, { taken, card.FONT_MONO, ROW_F } },
+ }
+ local S = clamp(math.min(inner.h * 0.40 / (1.5 + 2 * 0.66),
+ card.fit_unit(cr, inner.w * 0.96, groups, 100)), 10, 72)
+ local label_size, row_size = S * LABEL_F, S * ROW_F
+
+ card.font(cr, card.FONT_HEAVY, S * BIG_F, false)
+ card.rgba(cr, colors.body)
+ card.text_right(cr, inner.x + inner.w, inner.y + S * BIG_F, big)
+
+ card.font(cr, card.FONT_MONO, label_size, false)
+ card.title(cr, inner.x, inner.y + label_size, card.ICON.immich, 'MEMORIES', colors)
+
+ card.font(cr, card.FONT_UI, S * SUB_F, false)
+ card.rgba(cr, colors.label, 0.65)
+ card.text(cr, inner.x, inner.y + label_size + S * SUB_F * 1.5, sub)
+
+ -- Two rows pinned to the bottom; the photo takes what lies between.
+ local step = row_size * 2.0
+ local rows_top = inner.y + inner.h - step * 2 + row_size * 0.4
+ local band_y = inner.y + S * BIG_F + row_size
+ draw_photo(cr, surface(DIR .. p.id .. '.png'), inner.x, band_y, inner.w,
+ rows_top - band_y - row_size * 0.4, colors)
+
+ card.font(cr, card.FONT_MONO, row_size, false)
+ local label_w = card.advance(cr, 'PLACE ')
+ local place = p.place or '--'
+ -- Cut by characters until it fits beside its label; truncate() keeps UTF-8
+ -- intact, which a place name with an accent needs.
+ local limit = utf8.len(place) or #place
+ while limit > 1 and (card.measure(cr, card.truncate(place, limit)))
+ > inner.w - label_w do
+ limit = limit - 1
+ end
+ place = card.truncate(place, limit)
+
+ local ey = rows_top + row_size
+ for _, r in ipairs({ { 'TAKEN', taken }, { 'PLACE', place } }) do
+ card.rgba(cr, colors.label)
+ card.text(cr, inner.x, ey, r[1])
+ card.rgba(cr, colors.value)
+ card.text_right(cr, inner.x + inner.w, ey, r[2])
+ ey = ey + step
+ end
+end
+
+-- Scroll steps through today's photos; a left click opens the one on screen
+-- in Immich and hides the dashboard, since the browser would otherwise open
+-- underneath the special workspace.
+--
+-- ponytail: a scroll shows at the next draw, up to update_interval (2s) later.
+-- Conky offers Lua no way to request a redraw.
+function M.mouse(event)
+ if event.type == 'mouse_scroll' then
+ offset = offset + (event.direction == 'up' and -1 or 1)
+ return true
+ end
+ if event.type == 'button_down' and event.button == 'left'
+ and shown and shown.url then
+ local url = shown.url .. '/photos/' .. shown.id
+ os.execute("xdg-open '" .. url:gsub("'", "'\\''") .. "' >/dev/null 2>&1 &")
+ os.execute([[hyprctl dispatch 'hl.dsp.workspace.toggle_special("dash")' >/dev/null 2>&1 &]])
+ return true
+ end
+ return false
+end
+
+return M