aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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