diff options
| -rw-r--r-- | README.md | 20 | ||||
| -rwxr-xr-x | bin/immich-fetch.sh | 96 | ||||
| -rw-r--r-- | conky.conf.in | 5 | ||||
| -rw-r--r-- | dashboard.lua | 26 | ||||
| -rw-r--r-- | immich.env.example | 10 | ||||
| -rw-r--r-- | lib/card.lua | 1 | ||||
| -rw-r--r-- | lib/data.lua | 37 | ||||
| -rw-r--r-- | test/fixtures/immich_index | 6 | ||||
| -rw-r--r-- | test/test_data.lua | 13 | ||||
| -rw-r--r-- | widgets/immich.lua | 164 |
10 files changed, 377 insertions, 1 deletions
@@ -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 |
