From bd40b4f85042b93c9766b0611633341229817931 Mon Sep 17 00:00:00 2001 From: stephen schuresko Date: Wed, 10 Jun 2026 15:02:19 -0400 Subject: [PATCH] Add darktable REST API implementation - Introduced a new Lua-based REST API for darktable, allowing external applications to interact with the darktable library. - Implemented core functionalities including image listing, retrieval, and preview serving. - Added support for OAuth 2.0 authentication with token generation. - Created a simple HTTP router to handle API requests. - Included JSON encoding/decoding utilities for API responses. - Developed a script to launch the API server alongside darktable, ensuring seamless integration. - Added necessary configuration and setup for the API server, including CORS support and error handling. --- contrib/darktable-api/api.lua | 252 ++++++++++++ contrib/darktable-api/auth.lua | 96 +++++ contrib/darktable-api/base64.lua | 52 +++ contrib/darktable-api/bruno/Health Check.bru | 33 ++ .../bruno/auth/Get Token (Bad Secret).bru | 33 ++ .../darktable-api/bruno/auth/Get Token.bru | 64 +++ contrib/darktable-api/bruno/bruno.json | 6 + .../bruno/environments/local.bru | 12 + .../bruno/environments/remote.bru | 12 + .../darktable-api/bruno/films/List Films.bru | 54 +++ .../bruno/images/Get Image (Not Found).bru | 31 ++ .../darktable-api/bruno/images/Get Image.bru | 72 ++++ .../bruno/images/Get Preview.bru | 67 +++ .../bruno/images/List Images (No Auth).bru | 27 ++ .../bruno/images/List Images.bru | 87 ++++ .../bruno/images/Refresh Cache.bru | 49 +++ .../darktable-api/bruno/tags/List Tags.bru | 54 +++ contrib/darktable-api/config.lua | 31 ++ .../darktable-api/darktable-api-bridge.lua | 192 +++++++++ contrib/darktable-api/db.lua | 387 ++++++++++++++++++ contrib/darktable-api/json.lua | 155 +++++++ contrib/darktable-api/preview.lua | 123 ++++++ contrib/darktable-api/router.lua | 67 +++ contrib/darktable-api/server.lua | 208 ++++++++++ contrib/darktable-api/start-with-api.sh | 109 +++++ 25 files changed, 2273 insertions(+) create mode 100644 contrib/darktable-api/api.lua create mode 100644 contrib/darktable-api/auth.lua create mode 100644 contrib/darktable-api/base64.lua create mode 100644 contrib/darktable-api/bruno/Health Check.bru create mode 100644 contrib/darktable-api/bruno/auth/Get Token (Bad Secret).bru create mode 100644 contrib/darktable-api/bruno/auth/Get Token.bru create mode 100644 contrib/darktable-api/bruno/bruno.json create mode 100644 contrib/darktable-api/bruno/environments/local.bru create mode 100644 contrib/darktable-api/bruno/environments/remote.bru create mode 100644 contrib/darktable-api/bruno/films/List Films.bru create mode 100644 contrib/darktable-api/bruno/images/Get Image (Not Found).bru create mode 100644 contrib/darktable-api/bruno/images/Get Image.bru create mode 100644 contrib/darktable-api/bruno/images/Get Preview.bru create mode 100644 contrib/darktable-api/bruno/images/List Images (No Auth).bru create mode 100644 contrib/darktable-api/bruno/images/List Images.bru create mode 100644 contrib/darktable-api/bruno/images/Refresh Cache.bru create mode 100644 contrib/darktable-api/bruno/tags/List Tags.bru create mode 100644 contrib/darktable-api/config.lua create mode 100644 contrib/darktable-api/darktable-api-bridge.lua create mode 100644 contrib/darktable-api/db.lua create mode 100644 contrib/darktable-api/json.lua create mode 100644 contrib/darktable-api/preview.lua create mode 100644 contrib/darktable-api/router.lua create mode 100644 contrib/darktable-api/server.lua create mode 100755 contrib/darktable-api/start-with-api.sh diff --git a/contrib/darktable-api/api.lua b/contrib/darktable-api/api.lua new file mode 100644 index 0000000..7b5c82a --- /dev/null +++ b/contrib/darktable-api/api.lua @@ -0,0 +1,252 @@ +-- Route handlers. Each function receives a request table and returns: +-- status (int), body (string), content_type (string|nil), extra_headers (table|nil) +local json = require "json" +local auth = require "auth" +local db = require "db" +local preview = require "preview" +local config = require "config" + +-- ── Refresh queue (IPC with the darktable bridge plugin) ────────────────────── +-- The bridge plugin running inside darktable polls this file and calls +-- image:drop_cache() + image:generate_cache() for each queued image ID. +-- +-- We append atomically via a .tmp + rename idiom to avoid partial reads: +-- 1. Write IDs to .tmp. +-- 2. Append that file's contents to the main queue (or rename if queue absent) +-- In practice, for low-frequency refresh calls a simple append is fine because +-- the bridge reads and renames the queue file atomically. +local function enqueue_refresh(image_id) + local qf = config.refresh_queue + local f, ferr = io.open(qf, "a") + if not f then + return false, "cannot open refresh queue " .. qf .. ": " .. (ferr or "?") + end + f:write(tostring(image_id) .. "\n") + f:close() + return true +end + +local M = {} + +-- ── Helpers ─────────────────────────────────────────────────────────────────── + +local function ok(data, ct) + return 200, json.encode(data), ct or "application/json; charset=utf-8" +end + +local function err(status, code, detail) + local body = json.encode({ error = code, detail = detail }) + return status, body, "application/json; charset=utf-8" +end + +-- Parse application/x-www-form-urlencoded or application/json body. +local function parse_body(req) + local ct = (req.headers["content-type"] or ""):lower() + if ct:find("application/json", 1, true) then + return json.decode(req.body or "{}") + end + -- form-encoded + local fields = {} + for k, v in (req.body or ""):gmatch("([^&=]+)=([^&]*)") do + k = k:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end) + v = v:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end) + fields[k] = v + end + return fields +end + +-- Authenticate the request and check an optional scope. +-- Returns: payload on success. +-- Returns: nil, status, body, ct on failure (caller does "return s,b,ct"). +local function require_auth(req, scope) + local payload, aerr = auth.authenticate(req) + if not payload then + return nil, err(401, "unauthorized", aerr) + end + if scope and not auth.require_scope(payload, scope) then + return nil, err(403, "forbidden", "scope '" .. scope .. "' required") + end + return payload +end + +-- ── Public endpoints ────────────────────────────────────────────────────────── + +-- GET /health — liveness check, no auth required +function M.health(req) + return ok({ status = "ok", timestamp = os.time() }) +end + +-- ── OAuth 2.0 ───────────────────────────────────────────────────────────────── +-- POST /oauth/token +-- Accepts: application/x-www-form-urlencoded or application/json +-- Required fields: grant_type=client_credentials, client_id, client_secret +-- Optional: scope (space-separated, must be subset of client's scopes) +-- +-- Returns: { access_token, token_type, expires_in, scope } +function M.oauth_token(req) + local fields = parse_body(req) + if not fields then + return err(400, "invalid_request", "could not parse request body") + end + + if fields.grant_type ~= "client_credentials" then + return err(400, "unsupported_grant_type", + "only 'client_credentials' is supported") + end + + -- Also accept Basic Auth header for client credentials + local client_id, client_secret = fields.client_id, fields.client_secret + if not client_id then + local basic = (req.headers["authorization"] or ""):match("^[Bb]asic%s+(.+)$") + if basic then + local decoded = require("base64").decode(basic) + client_id, client_secret = decoded:match("^([^:]+):(.+)$") + end + end + + if not client_id or not client_secret then + return err(400, "invalid_request", "client_id and client_secret are required") + end + + local ok_flag, client_scopes = auth.validate_client(client_id, client_secret) + if not ok_flag then + return err(401, "invalid_client", client_scopes) + end + + -- Restrict to requested scopes if provided + local granted = client_scopes + if fields.scope and fields.scope ~= "" then + local requested = {} + for s in fields.scope:gmatch("%S+") do requested[s] = true end + granted = {} + for _, s in ipairs(client_scopes) do + if requested[s] then granted[#granted+1] = s end + end + if #granted == 0 then + return err(400, "invalid_scope", "none of the requested scopes are available") + end + end + + local token = auth.issue_token(client_id, granted) + return ok({ + access_token = token, + token_type = "Bearer", + expires_in = config.token_ttl, + scope = table.concat(granted, " "), + }) +end + +-- ── Images ──────────────────────────────────────────────────────────────────── +-- GET /api/v1/images +-- Query params: rating_min, rating_max, rating, film_id, from, to, +-- color_label, is_raw, is_hdr, search, tag, +-- limit (1-500, default 50), offset, sort_by, sort_dir +function M.list_images(req) + local payload, s, b, ct = require_auth(req, "images:read") + if not payload then return s, b, ct end + + local result = db.list_images(req.params) + return ok(result) +end + +-- GET /api/v1/images/:id +function M.get_image(req) + local payload, s, b, ct = require_auth(req, "images:read") + if not payload then return s, b, ct end + + local id = tonumber(req.path_params.id) + if not id then return err(400, "invalid_id", "id must be an integer") end + + local img = db.get_image(id) + if not img then return err(404, "not_found", "image " .. id .. " not found") end + + return ok(img) +end + +-- ── Previews ────────────────────────────────────────────────────────────────── +-- GET /api/v1/images/:id/preview[?size=thumb|small|medium|large|xlarge|0-5] +-- Returns JPEG data directly (Content-Type: image/jpeg). +function M.get_preview(req) + local payload, s, b, ct = require_auth(req, "previews:read") + if not payload then return s, b, ct end + + local id = tonumber(req.path_params.id) + if not id then return err(400, "invalid_id", "id must be an integer") end + + local img = db.get_image(id) + if not img then return err(404, "not_found", "image " .. id .. " not found") end + + local data, ct, redirect = preview.get(id, img.path, img.filename, req.params.size) + + if data then + return 200, data, ct + end + + if redirect then + -- Attempt to serve the local file directly rather than issuing a redirect, + -- since file:// URLs are not useful to HTTP clients. + local file_data = preview.read_local(redirect) + if file_data then + local ext = (img.filename or ""):match("%.([^%.]+)$") or "" + local mime = ext:lower():match("^png$") and "image/png" or "image/jpeg" + return 200, file_data, mime + end + end + + return err(404, "no_preview", "no cached preview available for image " .. id) +end + +-- ── Tags ────────────────────────────────────────────────────────────────────── +-- GET /api/v1/tags[?search=&limit=&offset=] +function M.list_tags(req) + local payload, s, b, ct = require_auth(req, "tags:read") + if not payload then return s, b, ct end + + local tags = db.list_tags(req.params) + return ok({ items = tags }) +end + +-- ── Film rolls ──────────────────────────────────────────────────────────────── +-- GET /api/v1/films[?search=&limit=&offset=] +function M.list_films(req) + local payload, s, b, ct = require_auth(req, "films:read") + if not payload then return s, b, ct end + + local films = db.list_films(req.params) + return ok({ items = films }) +end + +-- ── Cache refresh ───────────────────────────────────────────────────────────── +-- POST /api/v1/images/:id/refresh +-- Enqueues an image ID for cache regeneration by the darktable bridge plugin. +-- The bridge plugin (darktable-api-bridge.lua) running inside a live darktable +-- instance calls image:drop_cache() + image:generate_cache() when it drains +-- the queue. Returns 202 Accepted immediately; the actual regeneration happens +-- asynchronously inside darktable. +-- Requires scope: cache:refresh +function M.refresh_cache(req) + local payload, s, b, ct = require_auth(req, "cache:refresh") + if not payload then return s, b, ct end + + local id = tonumber(req.path_params.id) + if not id then return err(400, "invalid_id", "id must be an integer") end + + -- Verify the image exists in the catalog before queuing + local img = db.get_image(id) + if not img then return err(404, "not_found", "image " .. id .. " not found") end + + local queued, qerr = enqueue_refresh(id) + if not queued then + return err(500, "queue_error", qerr) + end + + return 202, json.encode({ + status = "accepted", + image_id = id, + filename = img.filename, + queue = config.refresh_queue, + message = "cache refresh enqueued; darktable bridge will process it shortly", + }), "application/json; charset=utf-8" +end + +return M diff --git a/contrib/darktable-api/auth.lua b/contrib/darktable-api/auth.lua new file mode 100644 index 0000000..d6c6e2b --- /dev/null +++ b/contrib/darktable-api/auth.lua @@ -0,0 +1,96 @@ +-- OAuth 2.0 Client Credentials grant + HS256 JWT +-- Requires: LuaJIT FFI with libcrypto (OpenSSL) +local ffi = require "ffi" +local base64 = require "base64" +local json = require "json" +local config = require "config" + +-- ── OpenSSL HMAC-SHA256 via FFI ─────────────────────────────────────────────── + +ffi.cdef[[ + void *EVP_sha256(void); + unsigned char *HMAC(const void *evp_md, const void *key, int key_len, + const unsigned char *data, size_t data_len, + unsigned char *md, unsigned int *md_len); +]] + +-- libcrypto ships as part of OpenSSL; path varies by distro but the soname is standard +local crypto = ffi.load("crypto") + +local function hmac_sha256(key, data) + local digest = ffi.new("unsigned char[32]") + local dlen = ffi.new("unsigned int[1]") + crypto.HMAC(crypto.EVP_sha256(), key, #key, data, #data, digest, dlen) + return ffi.string(digest, 32) +end + +-- ── JWT helpers ─────────────────────────────────────────────────────────────── + +local HEADER_B64 = base64.urlencode(json.encode({ alg = "HS256", typ = "JWT" })) + +local function jwt_sign(payload_tbl) + local payload = base64.urlencode(json.encode(payload_tbl)) + local signing = HEADER_B64 .. "." .. payload + local sig = base64.urlencode(hmac_sha256(config.jwt_secret, signing)) + return signing .. "." .. sig +end + +local function jwt_verify(token) + local h, p, s = token:match("^([^.]+)%.([^.]+)%.([^.]+)$") + if not h then return nil, "malformed token" end + + local expected = base64.urlencode(hmac_sha256(config.jwt_secret, h .. "." .. p)) + if s ~= expected then return nil, "invalid signature" end + + local payload, err = json.decode(base64.urldecode(p)) + if not payload then return nil, "invalid payload: " .. (err or "?") end + + if os.time() > (payload.exp or 0) then return nil, "token expired" end + + return payload +end + +-- ── Public API ──────────────────────────────────────────────────────────────── + +local M = {} + +-- Validate client_id / client_secret pair; returns true + scopes or false + error. +function M.validate_client(client_id, client_secret) + local client = config.clients[client_id] + if not client then return false, "unknown client" end + if client.secret ~= client_secret then return false, "invalid client secret" end + return true, client.scopes +end + +-- Issue an access token for a validated client. +function M.issue_token(client_id, scopes) + local now = os.time() + return jwt_sign({ + iss = "darktable-api", + sub = client_id, + scope = table.concat(scopes, " "), + iat = now, + exp = now + config.token_ttl, + }) +end + +-- Extract and verify a Bearer token from a request. +-- Returns payload table on success, or nil + error string on failure. +function M.authenticate(req) + local auth = (req.headers or {})["authorization"] or "" + local token = auth:match("^[Bb]earer%s+(.+)$") + if not token then return nil, "missing Bearer token" end + return jwt_verify(token) +end + +-- Check that the authenticated payload contains a required scope. +function M.require_scope(payload, scope) + if not payload then return false end + local scopes = payload.scope or "" + for s in scopes:gmatch("%S+") do + if s == scope then return true end + end + return false +end + +return M diff --git a/contrib/darktable-api/base64.lua b/contrib/darktable-api/base64.lua new file mode 100644 index 0000000..af450fb --- /dev/null +++ b/contrib/darktable-api/base64.lua @@ -0,0 +1,52 @@ +-- Base64 and Base64url encode/decode (pure Lua, no dependencies) +local M = {} + +local CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +local LOOKUP = {} +for i = 1, #CHARS do LOOKUP[CHARS:sub(i,i)] = i - 1 end + +function M.encode(s) + local out = {} + for i = 1, #s, 3 do + local a, b, c = s:byte(i, i + 2) + b, c = b or 0, c or 0 + local n = a * 65536 + b * 256 + c + out[#out+1] = CHARS:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1) + out[#out+1] = CHARS:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1) + out[#out+1] = CHARS:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1) + out[#out+1] = CHARS:sub( n % 64 + 1, n % 64 + 1) + end + local rem = #s % 3 + if rem == 1 then out[#out-1], out[#out] = "=", "=" end + if rem == 2 then out[#out] = "=" end + return table.concat(out) +end + +function M.decode(s) + s = s:gsub("[^A-Za-z0-9+/=]", "") + local out = {} + for i = 1, #s, 4 do + local a = LOOKUP[s:sub(i, i )] or 0 + local b = LOOKUP[s:sub(i+1, i+1)] or 0 + local c = LOOKUP[s:sub(i+2, i+2)] or 0 + local d = LOOKUP[s:sub(i+3, i+3)] or 0 + local n = a * 262144 + b * 4096 + c * 64 + d + out[#out+1] = string.char(math.floor(n / 65536)) + if s:sub(i+2, i+2) ~= "=" then out[#out+1] = string.char(math.floor(n / 256) % 256) end + if s:sub(i+3, i+3) ~= "=" then out[#out+1] = string.char(n % 256) end + end + return table.concat(out) +end + +-- URL-safe Base64 (RFC 4648 §5): replace +/ with -_ and strip padding +function M.urlencode(s) + return M.encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=", "") +end + +function M.urldecode(s) + s = s:gsub("-", "+"):gsub("_", "/") + local pad = (4 - #s % 4) % 4 + return M.decode(s .. string.rep("=", pad)) +end + +return M diff --git a/contrib/darktable-api/bruno/Health Check.bru b/contrib/darktable-api/bruno/Health Check.bru new file mode 100644 index 0000000..530641b --- /dev/null +++ b/contrib/darktable-api/bruno/Health Check.bru @@ -0,0 +1,33 @@ +meta { + name: Health Check + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +tests { + test("returns 200", function() { + expect(res.status).to.equal(200); + }); + + test("status is ok", function() { + expect(res.body.status).to.equal("ok"); + }); + + test("has timestamp", function() { + expect(res.body.timestamp).to.be.a("number"); + expect(res.body.timestamp).to.be.above(0); + }); +} + +docs { + ## Health Check + + Liveness probe — no authentication required. + Returns `{"status":"ok","timestamp":}`. +} diff --git a/contrib/darktable-api/bruno/auth/Get Token (Bad Secret).bru b/contrib/darktable-api/bruno/auth/Get Token (Bad Secret).bru new file mode 100644 index 0000000..e7eaaaf --- /dev/null +++ b/contrib/darktable-api/bruno/auth/Get Token (Bad Secret).bru @@ -0,0 +1,33 @@ +meta { + name: Get Token (Bad Secret) + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/oauth/token + body: form-urlencoded + auth: none +} + +body:form-urlencoded { + grant_type: client_credentials + client_id: {{clientId}} + client_secret: wrong-secret +} + +tests { + test("returns 401", function() { + expect(res.status).to.equal(401); + }); + + test("error is invalid_client", function() { + expect(res.body.error).to.equal("invalid_client"); + }); +} + +docs { + ## Negative test — wrong client secret + + Verifies the server rejects bad credentials with 401. +} diff --git a/contrib/darktable-api/bruno/auth/Get Token.bru b/contrib/darktable-api/bruno/auth/Get Token.bru new file mode 100644 index 0000000..4cf3323 --- /dev/null +++ b/contrib/darktable-api/bruno/auth/Get Token.bru @@ -0,0 +1,64 @@ +meta { + name: Get Token + type: http + seq: 1 +} + +post { + url: {{baseUrl}}/oauth/token + body: form-urlencoded + auth: none +} + +body:form-urlencoded { + grant_type: client_credentials + client_id: {{clientId}} + client_secret: {{clientSecret}} +} + +script:post-response { + if (res.status === 200) { + bru.setVar("accessToken", res.body.access_token); + console.log("Token stored. Expires in", res.body.expires_in, "seconds."); + console.log("Scopes:", res.body.scope); + } else { + console.error("Token request failed:", res.status, res.body); + } +} + +tests { + test("returns 200", function() { + expect(res.status).to.equal(200); + }); + + test("has access_token", function() { + expect(res.body.access_token).to.be.a("string"); + expect(res.body.access_token.length).to.be.above(0); + }); + + test("token_type is Bearer", function() { + expect(res.body.token_type).to.equal("Bearer"); + }); + + test("scope includes images:read", function() { + expect(res.body.scope).to.include("images:read"); + }); + + test("scope includes cache:refresh", function() { + expect(res.body.scope).to.include("cache:refresh"); + }); + + test("expires_in is 3600", function() { + expect(res.body.expires_in).to.equal(3600); + }); +} + +docs { + ## OAuth 2.0 — Client Credentials Grant + + Exchanges `client_id` + `client_secret` for a short-lived Bearer JWT. + The post-response script stores the token in `{{accessToken}}` so all + other requests pick it up automatically. + + Run this request first before calling any protected endpoint. +} diff --git a/contrib/darktable-api/bruno/bruno.json b/contrib/darktable-api/bruno/bruno.json new file mode 100644 index 0000000..0727666 --- /dev/null +++ b/contrib/darktable-api/bruno/bruno.json @@ -0,0 +1,6 @@ +{ + "version": "1", + "name": "Darktable API", + "type": "collection", + "ignore": [] +} diff --git a/contrib/darktable-api/bruno/environments/local.bru b/contrib/darktable-api/bruno/environments/local.bru new file mode 100644 index 0000000..1ac0ae8 --- /dev/null +++ b/contrib/darktable-api/bruno/environments/local.bru @@ -0,0 +1,12 @@ +vars { + baseUrl: http://127.0.0.1:7645 + clientId: darktable-client + clientSecret: changeme + accessToken: + imageId: 1 +} + +vars:secret [ + clientSecret, + accessToken +] diff --git a/contrib/darktable-api/bruno/environments/remote.bru b/contrib/darktable-api/bruno/environments/remote.bru new file mode 100644 index 0000000..f3cd1b1 --- /dev/null +++ b/contrib/darktable-api/bruno/environments/remote.bru @@ -0,0 +1,12 @@ +vars { + baseUrl: http://192.168.1.134:7645 + clientId: darktable-client + clientSecret: changeme + accessToken: + imageId: 1 +} + +vars:secret [ + clientSecret, + accessToken +] diff --git a/contrib/darktable-api/bruno/films/List Films.bru b/contrib/darktable-api/bruno/films/List Films.bru new file mode 100644 index 0000000..5710544 --- /dev/null +++ b/contrib/darktable-api/bruno/films/List Films.bru @@ -0,0 +1,54 @@ +meta { + name: List Films + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/films + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +params:query { + limit: 50 + offset: 0 + ~search: 2018 +} + +tests { + test("returns 200", function() { + expect(res.status).to.equal(200); + }); + + test("has items array", function() { + expect(res.body.items).to.be.an("array"); + }); + + test("each film has id, folder, image_count", function() { + res.body.items.forEach(function(film) { + expect(film.id).to.be.a("number"); + expect(film.folder).to.be.a("string"); + expect(film.image_count).to.be.a("number"); + }); + }); + + test("image counts are positive", function() { + res.body.items.forEach(function(film) { + expect(film.image_count).to.be.above(0); + }); + }); +} + +docs { + ## List Films + + Returns all film rolls (import folders) with their image counts, sorted by folder path. + + Use `?search=` to filter by folder path substring. + Use `?limit` and `?offset` for pagination (max 2000 per page). +} diff --git a/contrib/darktable-api/bruno/images/Get Image (Not Found).bru b/contrib/darktable-api/bruno/images/Get Image (Not Found).bru new file mode 100644 index 0000000..581e5e5 --- /dev/null +++ b/contrib/darktable-api/bruno/images/Get Image (Not Found).bru @@ -0,0 +1,31 @@ +meta { + name: Get Image (Not Found) + type: http + seq: 3 +} + +get { + url: {{baseUrl}}/api/v1/images/999999 + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +tests { + test("returns 404", function() { + expect(res.status).to.equal(404); + }); + + test("error is not_found", function() { + expect(res.body.error).to.equal("not_found"); + }); +} + +docs { + ## Get Image — Not Found + + Verifies a non-existent image ID returns 404. +} diff --git a/contrib/darktable-api/bruno/images/Get Image.bru b/contrib/darktable-api/bruno/images/Get Image.bru new file mode 100644 index 0000000..71fa536 --- /dev/null +++ b/contrib/darktable-api/bruno/images/Get Image.bru @@ -0,0 +1,72 @@ +meta { + name: Get Image + type: http + seq: 2 +} + +get { + url: {{baseUrl}}/api/v1/images/{{imageId}} + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +script:post-response { + if (res.status === 200) { + // Store this image's ID for use by preview / refresh requests + bru.setVar("imageId", res.body.id); + } +} + +tests { + test("returns 200", function() { + expect(res.status).to.equal(200); + }); + + test("has correct id", function() { + expect(res.body.id).to.equal(Number(bru.getVar("imageId"))); + }); + + test("has filename", function() { + expect(res.body.filename).to.be.a("string"); + expect(res.body.filename.length).to.be.above(0); + }); + + test("has path", function() { + expect(res.body.path).to.be.a("string"); + }); + + test("has rating in valid range", function() { + expect(res.body.rating).to.be.at.least(-1); + expect(res.body.rating).to.be.at.most(5); + }); + + test("has tags array", function() { + expect(res.body.tags).to.be.an("array"); + }); + + test("has color_labels array", function() { + expect(res.body.color_labels).to.be.an("array"); + }); + + test("has metadata object", function() { + expect(res.body.metadata).to.be.an("object"); + }); + + test("has boolean flags", function() { + expect(res.body.is_raw).to.be.a("boolean"); + expect(res.body.is_hdr).to.be.a("boolean"); + expect(res.body.is_ldr).to.be.a("boolean"); + }); +} + +docs { + ## Get Image + + Returns full detail for a single image including tags, color labels, and metadata. + + Set `{{imageId}}` in the environment (or pick up from "List Images" run). +} diff --git a/contrib/darktable-api/bruno/images/Get Preview.bru b/contrib/darktable-api/bruno/images/Get Preview.bru new file mode 100644 index 0000000..2f118dc --- /dev/null +++ b/contrib/darktable-api/bruno/images/Get Preview.bru @@ -0,0 +1,67 @@ +meta { + name: Get Preview + type: http + seq: 4 +} + +get { + url: {{baseUrl}}/api/v1/images/{{imageId}}/preview + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +params:query { + size: small + ~size: medium + ~size: large + ~size: thumb + ~size: xlarge +} + +tests { + test("returns 200 or 404", function() { + expect([200, 404]).to.include(res.status); + }); + + test("if 200, content-type is image/jpeg or image/png", function() { + if (res.status === 200) { + expect(res.headers["content-type"]).to.match(/image\/(jpeg|png)/); + } + }); + + test("if 200, body is non-empty", function() { + if (res.status === 200) { + expect(res.body.length).to.be.above(0); + } + }); + + test("if 404, no_preview error returned", function() { + if (res.status === 404) { + expect(res.body.error).to.equal("no_preview"); + } + }); +} + +docs { + ## Get Preview + + Returns a JPEG thumbnail from darktable's mipmap cache. + + ### Size options + | Value | Approx. long edge | + |---|---| + | `thumb` | ~135 px | + | `small` | ~270 px | + | `medium` | ~540 px (default) | + | `large` | ~1080 px | + | `xlarge` | ~2160 px | + | `0`–`5` | Raw mip level | + + Only one `size` param is active at a time — uncomment the desired one above. + Falls back to lower mip levels automatically if the requested size isn't cached. + Returns 404 if no cached thumbnail exists at any level. +} diff --git a/contrib/darktable-api/bruno/images/List Images (No Auth).bru b/contrib/darktable-api/bruno/images/List Images (No Auth).bru new file mode 100644 index 0000000..8f35828 --- /dev/null +++ b/contrib/darktable-api/bruno/images/List Images (No Auth).bru @@ -0,0 +1,27 @@ +meta { + name: List Images (No Auth) + type: http + seq: 6 +} + +get { + url: {{baseUrl}}/api/v1/images + body: none + auth: none +} + +tests { + test("returns 401 without token", function() { + expect(res.status).to.equal(401); + }); + + test("error is unauthorized", function() { + expect(res.body.error).to.equal("unauthorized"); + }); +} + +docs { + ## List Images — No Auth + + Verifies that protected endpoints return 401 when no Bearer token is supplied. +} diff --git a/contrib/darktable-api/bruno/images/List Images.bru b/contrib/darktable-api/bruno/images/List Images.bru new file mode 100644 index 0000000..53f4b43 --- /dev/null +++ b/contrib/darktable-api/bruno/images/List Images.bru @@ -0,0 +1,87 @@ +meta { + name: List Images + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/images + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +params:query { + limit: 10 + offset: 0 + sort_by: datetime_taken + sort_dir: desc + ~rating_min: 1 + ~rating_max: 5 + ~rating: 3 + ~film_id: 1 + ~from: 2018-01-01 + ~to: 2018-12-31 + ~color_label: red + ~is_raw: true + ~is_hdr: false + ~search: 500mm + ~tag: realestate +} + +tests { + test("returns 200", function() { + expect(res.status).to.equal(200); + }); + + test("has items array", function() { + expect(res.body.items).to.be.an("array"); + }); + + test("has total count", function() { + expect(res.body.total).to.be.a("number"); + expect(res.body.total).to.be.above(0); + }); + + test("has limit and offset", function() { + expect(res.body.limit).to.equal(10); + expect(res.body.offset).to.equal(0); + }); + + test("each image has required fields", function() { + res.body.items.forEach(function(img) { + expect(img.id).to.be.a("number"); + expect(img.filename).to.be.a("string"); + expect(img.path).to.be.a("string"); + expect(img.rating).to.be.a("number"); + }); + }); +} + +docs { + ## List Images + + Returns a paginated list of images from the darktable catalog. + + ### Active query params (remove `~` prefix to enable) + | Param | Type | Description | + |---|---|---| + | `limit` | int | Results per page (max 500, default 50) | + | `offset` | int | Pagination offset | + | `sort_by` | string | `id`, `filename`, `datetime_taken`, `rating`, `focal_length`, `iso` | + | `sort_dir` | string | `asc` or `desc` | + | `rating_min` | int | Minimum star rating (−1=rejected, 0=unrated, 1–5=stars) | + | `rating_max` | int | Maximum star rating | + | `rating` | int | Exact rating | + | `film_id` | int | Filter by film roll ID | + | `from` | date | Earliest capture date (YYYY-MM-DD) | + | `to` | date | Latest capture date (YYYY-MM-DD) | + | `color_label` | string | `red`, `yellow`, `green`, `blue`, `purple` | + | `is_raw` | bool | `true` / `false` | + | `is_hdr` | bool | `true` / `false` | + | `search` | string | Free-text search in filename, maker, model, lens | + | `tag` | string | Filter by exact tag name | +} diff --git a/contrib/darktable-api/bruno/images/Refresh Cache.bru b/contrib/darktable-api/bruno/images/Refresh Cache.bru new file mode 100644 index 0000000..7cb7d37 --- /dev/null +++ b/contrib/darktable-api/bruno/images/Refresh Cache.bru @@ -0,0 +1,49 @@ +meta { + name: Refresh Cache + type: http + seq: 5 +} + +post { + url: {{baseUrl}}/api/v1/images/{{imageId}}/refresh + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +tests { + test("returns 202 Accepted", function() { + expect(res.status).to.equal(202); + }); + + test("status is accepted", function() { + expect(res.body.status).to.equal("accepted"); + }); + + test("image_id matches request", function() { + expect(res.body.image_id).to.equal(Number(bru.getVar("imageId"))); + }); + + test("has filename", function() { + expect(res.body.filename).to.be.a("string"); + }); + + test("has queue path", function() { + expect(res.body.queue).to.be.a("string"); + }); +} + +docs { + ## Refresh Cache + + Enqueues the image for thumbnail regeneration inside the running darktable instance. + + Returns **202 Accepted** immediately. The darktable bridge plugin + (`darktable-api-bridge.lua`) polls `/tmp/dt-api-refresh.queue` every second + and calls `image:drop_cache()` + `image:generate_cache()` when it finds this ID. + + Requires scope: `cache:refresh` +} diff --git a/contrib/darktable-api/bruno/tags/List Tags.bru b/contrib/darktable-api/bruno/tags/List Tags.bru new file mode 100644 index 0000000..146c59d --- /dev/null +++ b/contrib/darktable-api/bruno/tags/List Tags.bru @@ -0,0 +1,54 @@ +meta { + name: List Tags + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/api/v1/tags + body: none + auth: bearer +} + +auth:bearer { + token: {{accessToken}} +} + +params:query { + limit: 50 + offset: 0 + ~search: darktable +} + +tests { + test("returns 200", function() { + expect(res.status).to.equal(200); + }); + + test("has items array", function() { + expect(res.body.items).to.be.an("array"); + }); + + test("each tag has id, name, image_count", function() { + res.body.items.forEach(function(tag) { + expect(tag.id).to.be.a("number"); + expect(tag.name).to.be.a("string"); + expect(tag.image_count).to.be.a("number"); + }); + }); + + test("tags are sorted alphabetically", function() { + var names = res.body.items.map(function(t) { return t.name; }); + var sorted = names.slice().sort(); + expect(names).to.deep.equal(sorted); + }); +} + +docs { + ## List Tags + + Returns all tags in the catalog with their image counts, sorted alphabetically. + + Use `?search=keyword` to filter by tag name prefix/substring. + Use `?limit` and `?offset` for pagination (max 2000 per page). +} diff --git a/contrib/darktable-api/config.lua b/contrib/darktable-api/config.lua new file mode 100644 index 0000000..0bec1b5 --- /dev/null +++ b/contrib/darktable-api/config.lua @@ -0,0 +1,31 @@ +local M = {} + +local home = os.getenv("HOME") or "/root" + +M.host = os.getenv("DT_API_HOST") or "127.0.0.1" +M.port = tonumber(os.getenv("DT_API_PORT")) or 7645 + +-- Darktable paths +M.library_db = os.getenv("DT_LIBRARY") or (home .. "/.config/darktable/library.db") +M.data_db = os.getenv("DT_DATA_DB") or (home .. "/.config/darktable/data.db") +M.cache_dir = os.getenv("DT_CACHE_DIR") or (home .. "/.cache/darktable") + +-- JWT / OAuth +M.jwt_secret = os.getenv("DT_JWT_SECRET") or "change-this-secret-in-production" +M.token_ttl = tonumber(os.getenv("DT_TOKEN_TTL")) or 3600 + +-- OAuth 2.0 clients table: client_id -> { secret, scopes[] } +-- Additional clients can be registered by adding entries here or setting +-- DT_CLIENT_ID / DT_CLIENT_SECRET environment variables. +M.clients = {} +local default_id = os.getenv("DT_CLIENT_ID") or "darktable-client" +local default_secret = os.getenv("DT_CLIENT_SECRET") or "changeme" +M.clients[default_id] = { + secret = default_secret, + scopes = { "images:read", "previews:read", "tags:read", "films:read", "cache:refresh" }, +} + +-- Queue file the bridge plugin watches for cache-refresh requests. +M.refresh_queue = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue" + +return M diff --git a/contrib/darktable-api/darktable-api-bridge.lua b/contrib/darktable-api/darktable-api-bridge.lua new file mode 100644 index 0000000..ac85d03 --- /dev/null +++ b/contrib/darktable-api/darktable-api-bridge.lua @@ -0,0 +1,192 @@ +--[[ + darktable-api-bridge.lua + Companion plugin for the darktable REST API server. + + Runs inside a live darktable instance and watches a queue file written by the + standalone LuaJIT server. When image IDs appear in the queue this plugin + calls image:drop_cache() and image:generate_cache() so that darktable's + thumbnail panel reflects the latest on-disk state. + + Install: add the following line to ~/.config/darktable/luarc + require "contrib/darktable-api/darktable-api-bridge" + + Or let the startup script (start-with-api.sh) do it automatically. + + Queue file format (written by the server's /api/v1/images/:id/refresh): + One image ID (integer) per line, e.g.: + 42 + 7 + 100 +--]] + +local dt = require "darktable" +local du = require "lib/dtutils" + +du.check_min_api_version("7.0.0", "darktable-api-bridge") + +local MODULE = "darktable-api-bridge" + +-- ── Configuration ───────────────────────────────────────────────────────────── + +-- Must match config.refresh_queue in config.lua (or the DT_REFRESH_QUEUE env var). +local QUEUE_FILE = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue" +local POLL_MS = 1000 -- poll interval in milliseconds +-- Mipmap range to regenerate: 0 = smallest thumbnail, 8 = full resolution. +-- Levels 0-4 cover all GUI thumbnail sizes (~67 px … ~1080 px). +local MIP_MIN = 0 +local MIP_MAX = 4 + +-- ── Logging ─────────────────────────────────────────────────────────────────── + +local function log(msg) + dt.print_log("[" .. MODULE .. "] " .. msg) +end + +local function notify(msg) + dt.print("[" .. MODULE .. "] " .. msg) + log(msg) +end + +-- ── Image lookup ────────────────────────────────────────────────────────────── + +-- Build a fast id→image map from dt.database. +-- dt.database is a table-like object; iterating it is O(n) but fast enough +-- for typical library sizes (thousands of images). +local function build_id_map() + local map = {} + for _, img in ipairs(dt.database) do + map[img.id] = img + end + return map +end + +-- ── Queue file handling ─────────────────────────────────────────────────────── + +-- Atomically claim the queue file by renaming it, then read all IDs from it. +-- Returns a set table { [id] = true } of unique image IDs, or {} if none. +local function drain_queue() + local processing = QUEUE_FILE .. ".processing" + + -- Rename is atomic on POSIX filesystems: the server's append-only writes + -- will start a fresh QUEUE_FILE after we claim this one. + local ok = os.rename(QUEUE_FILE, processing) + if not ok then + return {} -- queue file doesn't exist yet + end + + local ids = {} + local f = io.open(processing, "r") + if f then + for line in f:lines() do + local id = tonumber(line) + if id then ids[id] = true end + end + f:close() + os.remove(processing) + end + return ids +end + +-- ── Cache regeneration ──────────────────────────────────────────────────────── + +local function refresh_image(img) + local label = string.format("id=%d %s/%s", img.id, img.path, img.filename) + log("refreshing " .. label) + + -- Drop the current cached thumbnails so darktable knows they're stale. + img:drop_cache() + + -- Regenerate thumbnails for mip levels MIP_MIN..MIP_MAX. + -- full=false uses embedded JPEG thumbnails when available (fast); + -- set full=true to force a complete RAW render (accurate but slow). + local full = false + img:generate_cache(full, MIP_MIN, MIP_MAX) + + log("done " .. label) +end + +-- Process all pending IDs in one pass. +local function process_queue() + local ids = drain_queue() + local count = 0 + for id, _ in pairs(ids) do + count = count + 1 + end + if count == 0 then return end + + log(string.format("processing %d queued refresh request(s)", count)) + local id_map = build_id_map() + + for id, _ in pairs(ids) do + local img = id_map[id] + if img then + local ok, err = pcall(refresh_image, img) + if not ok then + log("error refreshing id=" .. id .. ": " .. tostring(err)) + end + else + log("image id=" .. id .. " not found in database (may have been removed)") + end + end + + -- Nudge the lighttable to redraw so the user sees updated thumbnails + -- without having to scroll away and back. + dt.gui.views.lighttable.is_culling_mode = dt.gui.views.lighttable.is_culling_mode +end + +-- ── Background polling loop ─────────────────────────────────────────────────── + +-- dt.control.dispatch runs the function asynchronously in darktable's worker +-- thread pool, leaving the UI thread free. We loop inside with sleep() so +-- one dispatch call drives the whole bridge. +local running = true + +local function poll_loop() + notify(string.format( + "started; polling %s every %d ms", QUEUE_FILE, POLL_MS)) + + while running do + local ok, err = pcall(process_queue) + if not ok then + log("process_queue error: " .. tostring(err)) + end + dt.control.sleep(POLL_MS) + end + + notify("stopped") +end + +-- ── Plugin lifecycle ────────────────────────────────────────────────────────── + +local script_data = {} + +script_data.metadata = { + name = "darktable API bridge", + purpose = "Watch the REST API refresh queue and regenerate image thumbnails", + author = "darktable-api", + help = "https://github.com/darktable-org/lua-scripts", +} + +local function destroy() + running = false + -- Give the poll loop one final chance to exit cleanly. + dt.control.sleep(POLL_MS + 200) + dt.destroy_event(MODULE, "shortcut") +end +script_data.destroy = destroy + +-- Register a manual-trigger shortcut so the user can force an immediate poll +-- without waiting for the next timer tick. +dt.register_event(MODULE, "shortcut", + function(event, shortcut) + log("manual poll triggered via shortcut") + local ok, err = pcall(process_queue) + if not ok then notify("error during manual poll: " .. tostring(err)) end + end, + "poll refresh queue now" +) + +-- Start the background loop. +dt.control.dispatch(poll_loop) + +return script_data diff --git a/contrib/darktable-api/db.lua b/contrib/darktable-api/db.lua new file mode 100644 index 0000000..156b445 --- /dev/null +++ b/contrib/darktable-api/db.lua @@ -0,0 +1,387 @@ +-- Read-only access to the darktable SQLite catalog. +-- Schema inspected from an actual darktable library (recent version): +-- +-- images(id, group_id, film_id, filename, width, height, aspect_ratio, +-- maker_id, model_id, lens_id, camera_id, +-- exposure, aperture, iso, focal_length, focus_distance, +-- datetime_taken INTEGER (Unix epoch), +-- flags, orientation, longitude, latitude, altitude, +-- version, max_version, write_timestamp, change_timestamp, ...) +-- makers(id, name) models(id, name) lens(id, name) +-- film_rolls(id, access_timestamp, folder) +-- tagged_images(imgid, tagid, position) +-- color_labels(imgid, color) -- color: 0=red 1=yellow 2=green 3=blue 4=purple +-- data.tags(id, name, ...) -- in data.db +-- meta_data(id, key, value) -- key references data.meta_data.key +-- +-- images.flags & 0x07 → rating (0=unrated, 1-5=stars, 6=rejected) +-- images.flags & 64 → DT_IMAGE_RAW +-- images.flags & 128 → DT_IMAGE_HDR +-- images.flags & 32 → DT_IMAGE_LDR +local sqlite3 = require "lsqlite3" +local bit = require "bit" -- LuaJIT built-in; Lua 5.1 bitwise +local config = require "config" + +local band = bit.band +local unpack = unpack or table.unpack -- Lua 5.1 vs 5.2+ compatibility + +local M = {} + +-- ── Connection (single, lazy, read-only) ───────────────────────────────────── + +local _db + +local function get_db() + if _db then return _db end + _db = sqlite3.open(config.library_db, sqlite3.OPEN_READONLY) + if not _db then + error("Cannot open darktable library: " .. config.library_db) + end + -- Attach data.db for the canonical tags table (darktable ≥ 2.4) + local data_db = config.data_db:gsub("'", "''") + _db:exec(string.format("ATTACH DATABASE '%s' AS data", data_db)) + return _db +end + +-- ── Query helpers ───────────────────────────────────────────────────────────── + +local function query(sql, params) + local d = get_db() + local stmt = d:prepare(sql) + if not stmt then + error("SQL error: " .. (d:errmsg() or "?") .. "\nSQL: " .. sql) + end + if params and #params > 0 then + stmt:bind_values(unpack(params)) + end + local rows = {} + for row in stmt:nrows() do + rows[#rows+1] = row + end + stmt:finalize() + return rows +end + +local function query_one(sql, params) + return query(sql, params)[1] +end + +-- ── Schema helpers ──────────────────────────────────────────────────────────── + +-- Rating is packed into the low 3 bits of flags: 0=unrated, 1-5=stars, 6=rejected +local function decode_rating(flags) + local r = band(flags or 0, 0x07) + return r == 6 and -1 or r +end + +-- datetime_taken is stored as GLib microseconds since 0001-01-01 00:00:00 UTC. +-- Unix epoch (1970-01-01) = 62135596800 seconds after 0001-01-01. +local GLIB_EPOCH_OFFSET = 62135596800 -- seconds + +local function glib_to_unix(glib_us) + if not glib_us or glib_us == 0 then return nil end + return math.floor(glib_us / 1000000) - GLIB_EPOCH_OFFSET +end + +local function fmt_datetime(glib_us) + local unix_ts = glib_to_unix(glib_us) + if not unix_ts then return nil end + return os.date("!%Y-%m-%dT%H:%M:%SZ", unix_ts) +end + +local LABEL_NAMES = { [0]="red", [1]="yellow", [2]="green", [3]="blue", [4]="purple" } + +local function row_to_image(row) + if not row then return nil end + return { + id = row.id, + film_id = row.film_id, + group_id = row.group_id, + filename = row.filename, + path = row.folder, + width = row.width, + height = row.height, + aspect_ratio = row.aspect_ratio, + maker = row.maker, + model = row.model, + lens = row.lens, + exposure = row.exposure, + aperture = row.aperture, + iso = row.iso, + focal_length = row.focal_length, + datetime_taken = fmt_datetime(row.datetime_taken), + rating = decode_rating(row.flags), + is_raw = band(row.flags or 0, 64) ~= 0, + is_hdr = band(row.flags or 0, 128) ~= 0, + is_ldr = band(row.flags or 0, 32) ~= 0, + latitude = row.latitude, + longitude = row.longitude, + altitude = row.altitude, + version = row.version, + write_timestamp = row.write_timestamp, + change_timestamp = row.change_timestamp, + } +end + +-- Standard SELECT columns + JOINs used by both list_images and get_image. +local IMAGE_SELECT = [[ + SELECT i.id, i.group_id, i.film_id, i.filename, + i.width, i.height, i.aspect_ratio, + mk.name AS maker, mo.name AS model, ln.name AS lens, + i.exposure, i.aperture, i.iso, i.focal_length, + i.datetime_taken, i.flags, i.version, + i.longitude, i.latitude, i.altitude, + i.write_timestamp, i.change_timestamp, + f.folder + FROM images i + JOIN film_rolls f ON f.id = i.film_id + LEFT JOIN makers mk ON mk.id = i.maker_id + LEFT JOIN models mo ON mo.id = i.model_id + LEFT JOIN lens ln ON ln.id = i.lens_id +]] + +-- ── Filter builder ──────────────────────────────────────────────────────────── + +local COLOR_MAP = { red=0, yellow=1, green=2, blue=3, purple=4 } +local VALID_SORT = { + id=true, filename=true, datetime_taken=true, rating=true, + focal_length=true, iso=true, +} + +-- Convert a YYYY-MM-DD string to a GLib microsecond timestamp for DB comparison. +local function date_to_glib_us(date_str, end_of_day) + local y, m, d = date_str:match("(%d%d%d%d)[%-:](%d%d)[%-:](%d%d)") + if not y then return nil end + local unix_ts = os.time({ + year=tonumber(y), month=tonumber(m), day=tonumber(d), + hour = end_of_day and 23 or 0, + min = end_of_day and 59 or 0, + sec = end_of_day and 59 or 0, + }) + return (unix_ts + GLIB_EPOCH_OFFSET) * 1000000 +end + +local function build_filter(filters) + local conds = {} + local params = {} + + -- Star rating (packed in flags & 0x07; 6 = rejected) + if filters.rating_min then + local r = tonumber(filters.rating_min) or 0 + table.insert(conds, "(i.flags & 7) >= ?") + table.insert(params, r == -1 and 6 or r) + end + if filters.rating_max then + local r = tonumber(filters.rating_max) or 5 + table.insert(conds, "(i.flags & 7) <= ?") + table.insert(params, r == -1 and 6 or r) + end + if filters.rating then + local r = tonumber(filters.rating) + if r then + table.insert(conds, "(i.flags & 7) = ?") + table.insert(params, r == -1 and 6 or r) + end + end + + -- Film roll / folder + if filters.film_id then + table.insert(conds, "i.film_id = ?") + table.insert(params, tonumber(filters.film_id)) + end + + -- Date range (datetime_taken is GLib microseconds since 0001-01-01) + if filters.from then + local glib_us = date_to_glib_us(filters.from, false) + if glib_us then + table.insert(conds, "i.datetime_taken >= ?") + table.insert(params, glib_us) + end + end + if filters.to then + local glib_us = date_to_glib_us(filters.to, true) + if glib_us then + table.insert(conds, "i.datetime_taken <= ?") + table.insert(params, glib_us) + end + end + + -- Color label + if filters.color_label then + local cid = COLOR_MAP[filters.color_label:lower()] + if cid ~= nil then + table.insert(conds, "EXISTS (SELECT 1 FROM color_labels cl WHERE cl.imgid=i.id AND cl.color=?)") + table.insert(params, cid) + end + end + + -- RAW / HDR flags (SQLite supports & bitwise) + if filters.is_raw == "true" then table.insert(conds, "(i.flags & 64) != 0") end + if filters.is_raw == "false" then table.insert(conds, "(i.flags & 64) = 0") end + if filters.is_hdr == "true" then table.insert(conds, "(i.flags & 128) != 0") end + + -- Free-text search across maker/model/lens/filename + if filters.search and filters.search ~= "" then + table.insert(conds, "(i.filename LIKE ? OR mk.name LIKE ? OR mo.name LIKE ? OR ln.name LIKE ?)") + local pat = "%" .. filters.search .. "%" + for _ = 1, 4 do table.insert(params, pat) end + end + + -- Tag filter (correlated EXISTS; works for both data.tags and main.tags layouts) + if filters.tag and filters.tag ~= "" then + table.insert(conds, [[EXISTS ( + SELECT 1 FROM tagged_images ti2, data.tags t2 + WHERE ti2.imgid = i.id AND t2.id = ti2.tagid AND t2.name = ?)]]) + table.insert(params, filters.tag) + end + + local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or "" + return where, params +end + +-- ── Public API ──────────────────────────────────────────────────────────────── + +function M.list_images(filters) + filters = filters or {} + local where, params = build_filter(filters) + + local limit = math.max(1, math.min(tonumber(filters.limit) or 50, 500)) + local offset = math.max(0, tonumber(filters.offset) or 0) + local sort_by = VALID_SORT[filters.sort_by] and ("i." .. filters.sort_by) or "i.datetime_taken" + local sort_dir = (filters.sort_dir == "asc") and "ASC" or "DESC" + + -- Total count (same filter, no pagination) + local count_params = {} + for _, v in ipairs(params) do count_params[#count_params+1] = v end + + local count_sql = string.format( + "SELECT COUNT(*) AS total FROM images i " .. + "JOIN film_rolls f ON f.id = i.film_id " .. + "LEFT JOIN makers mk ON mk.id = i.maker_id " .. + "LEFT JOIN models mo ON mo.id = i.model_id " .. + "LEFT JOIN lens ln ON ln.id = i.lens_id %s", + where) + local count_row = query_one(count_sql, count_params) + local total = count_row and count_row.total or 0 + + -- Data rows + local data_params = {} + for _, v in ipairs(params) do data_params[#data_params+1] = v end + data_params[#data_params+1] = limit + data_params[#data_params+1] = offset + + local data_sql = IMAGE_SELECT .. + where .. + string.format(" ORDER BY %s %s LIMIT ? OFFSET ?", sort_by, sort_dir) + + local images = {} + for _, row in ipairs(query(data_sql, data_params)) do + images[#images+1] = row_to_image(row) + end + + return { total = total, limit = limit, offset = offset, items = images } +end + +function M.get_image(id) + local row = query_one(IMAGE_SELECT .. " WHERE i.id = ?", { tonumber(id) }) + if not row then return nil end + + local img = row_to_image(row) + + -- Tags + local tag_rows = query([[ + SELECT t.name FROM tagged_images ti + JOIN data.tags t ON t.id = ti.tagid + WHERE ti.imgid = ? ORDER BY t.name + ]], { img.id }) + img.tags = {} + for _, tr in ipairs(tag_rows) do img.tags[#img.tags+1] = tr.name end + + -- Color labels + local cl_rows = query("SELECT color FROM color_labels WHERE imgid=?", { img.id }) + img.color_labels = {} + for _, cl in ipairs(cl_rows) do + if LABEL_NAMES[cl.color] then + img.color_labels[#img.color_labels+1] = LABEL_NAMES[cl.color] + end + end + + -- Metadata (caption, description, creator, etc. via data.meta_data key map) + local ok_meta, meta_rows = pcall(query, [[ + SELECT dm.tagname, m.value + FROM meta_data m JOIN data.meta_data dm ON dm.key = m.key + WHERE m.id = ? + ]], { img.id }) + img.metadata = {} + if ok_meta then + for _, mr in ipairs(meta_rows) do + local subkey = mr.tagname:match("%.([^%.]+)$") or mr.tagname + img.metadata[subkey] = mr.value + end + end + + return img +end + +-- ── Tags ────────────────────────────────────────────────────────────────────── + +function M.list_tags(filters) + filters = filters or {} + local conds, params = {}, {} + if filters.search and filters.search ~= "" then + table.insert(conds, "t.name LIKE ?") + table.insert(params, "%" .. filters.search .. "%") + end + local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or "" + local limit = math.min(tonumber(filters.limit) or 200, 2000) + local offset = tonumber(filters.offset) or 0 + params[#params+1] = limit + params[#params+1] = offset + + local rows = query(string.format([[ + SELECT t.id, t.name, + (SELECT COUNT(*) FROM tagged_images ti WHERE ti.tagid=t.id) AS image_count + FROM data.tags t %s ORDER BY t.name LIMIT ? OFFSET ? + ]], where), params) + + local tags = {} + for _, r in ipairs(rows) do + tags[#tags+1] = { id=r.id, name=r.name, image_count=r.image_count } + end + return tags +end + +-- ── Film rolls ──────────────────────────────────────────────────────────────── + +function M.list_films(filters) + filters = filters or {} + local conds, params = {}, {} + if filters.search and filters.search ~= "" then + table.insert(conds, "f.folder LIKE ?") + table.insert(params, "%" .. filters.search .. "%") + end + local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or "" + local limit = math.min(tonumber(filters.limit) or 200, 2000) + local offset = tonumber(filters.offset) or 0 + params[#params+1] = limit + params[#params+1] = offset + + local rows = query(string.format([[ + SELECT f.id, f.folder, f.access_timestamp, + (SELECT COUNT(*) FROM images i WHERE i.film_id=f.id) AS image_count + FROM film_rolls f %s ORDER BY f.folder LIMIT ? OFFSET ? + ]], where), params) + + local films = {} + for _, r in ipairs(rows) do + films[#films+1] = { + id = r.id, + folder = r.folder, + access_timestamp = r.access_timestamp, + image_count = r.image_count, + } + end + return films +end + +return M diff --git a/contrib/darktable-api/json.lua b/contrib/darktable-api/json.lua new file mode 100644 index 0000000..49ecad8 --- /dev/null +++ b/contrib/darktable-api/json.lua @@ -0,0 +1,155 @@ +-- Minimal JSON encoder/decoder (pure Lua, no dependencies) +local M = {} + +-- ── Encoder ────────────────────────────────────────────────────────────────── + +-- Iterate byte-by-byte to avoid null-byte issues in Lua pattern ranges. +local function encode_str(s) + local buf = {'"'} + for i = 1, #s do + local b = s:byte(i) + if b == 34 then buf[#buf+1] = '\\"' + elseif b == 92 then buf[#buf+1] = '\\\\' + elseif b == 10 then buf[#buf+1] = '\\n' + elseif b == 13 then buf[#buf+1] = '\\r' + elseif b == 9 then buf[#buf+1] = '\\t' + elseif b < 32 or b == 127 then + buf[#buf+1] = string.format('\\u%04x', b) + else + buf[#buf+1] = s:sub(i, i) + end + end + buf[#buf+1] = '"' + return table.concat(buf) +end + +local function enc(v, depth) + if depth > 64 then error("json.encode: table too deeply nested") end + local t = type(v) + if t == "nil" then return "null" + elseif t == "boolean" then return tostring(v) + elseif t == "number" then + if v ~= v or math.abs(v) == math.huge then return "null" end + return v == math.floor(v) and string.format("%d", v) or string.format("%.10g", v) + elseif t == "string" then + return encode_str(v) + elseif t == "table" then + -- Treat as array when it has a consecutive integer key sequence from 1 + if #v > 0 then + local parts = {} + for i = 1, #v do parts[i] = enc(v[i], depth + 1) end + return "[" .. table.concat(parts, ",") .. "]" + end + local parts = {} + for k, x in pairs(v) do + if type(k) == "string" then + parts[#parts+1] = encode_str(k) .. ":" .. enc(x, depth + 1) + end + end + return "{" .. table.concat(parts, ",") .. "}" + end + return "null" +end + +function M.encode(v) return enc(v, 0) end + +-- ── Decoder ─────────────────────────────────────────────────────────────────── + +function M.decode(src) + local pos = 1 + + local function skip() + while pos <= #src and src:byte(pos) <= 32 do pos = pos + 1 end + end + + local parse -- forward declaration + + local function parse_string() + pos = pos + 1 -- consume opening " + local buf = {} + while pos <= #src do + local c = src:sub(pos, pos) + if c == '"' then pos = pos + 1; return table.concat(buf) end + if c == '\\' then + pos = pos + 1 + c = src:sub(pos, pos) + if c == '"' then buf[#buf+1] = '"' + elseif c == '\\' then buf[#buf+1] = '\\' + elseif c == '/' then buf[#buf+1] = '/' + elseif c == 'n' then buf[#buf+1] = '\n' + elseif c == 'r' then buf[#buf+1] = '\r' + elseif c == 't' then buf[#buf+1] = '\t' + elseif c == 'b' then buf[#buf+1] = '\b' + elseif c == 'f' then buf[#buf+1] = '\f' + elseif c == 'u' then + local hex = tonumber(src:sub(pos+1, pos+4), 16) or 0 + -- Only emit ASCII; multi-byte unicode emits a placeholder + buf[#buf+1] = hex < 128 and string.char(hex) or '\xEF\xBF\xBD' + pos = pos + 4 + end + else + buf[#buf+1] = c + end + pos = pos + 1 + end + error("json.decode: unterminated string near pos " .. pos) + end + + local function parse_object() + pos = pos + 1 -- consume '{' + local obj = {} + skip() + if src:sub(pos, pos) == '}' then pos = pos + 1; return obj end + while true do + skip() + local key = parse_string() + skip() + if src:sub(pos, pos) ~= ':' then error("json.decode: expected ':' near pos " .. pos) end + pos = pos + 1 + skip() + obj[key] = parse() + skip() + local ch = src:sub(pos, pos); pos = pos + 1 + if ch == '}' then return obj end + if ch ~= ',' then error("json.decode: expected ',' or '}' near pos " .. pos) end + end + end + + local function parse_array() + pos = pos + 1 -- consume '[' + local arr = {} + skip() + if src:sub(pos, pos) == ']' then pos = pos + 1; return arr end + while true do + skip() + arr[#arr+1] = parse() + skip() + local ch = src:sub(pos, pos); pos = pos + 1 + if ch == ']' then return arr end + if ch ~= ',' then error("json.decode: expected ',' or ']' near pos " .. pos) end + end + end + + parse = function() + skip() + local c = src:sub(pos, pos) + if c == '"' then return parse_string() + elseif c == '{' then return parse_object() + elseif c == '[' then return parse_array() + elseif c == 't' then pos = pos + 4; return true + elseif c == 'f' then pos = pos + 5; return false + elseif c == 'n' then pos = pos + 4; return nil + else + -- Parentheses create the capture; without them find returns only start/end. + local _, e, n = src:find("^(-?%d+%.?%d*[eE]?[+-]?%d*)", pos) + if n then pos = e + 1; return tonumber(n) end + error("json.decode: unexpected token '" .. c .. "' at pos " .. pos) + end + end + + local ok, result = pcall(parse) + if not ok then return nil, result end + return result +end + +return M diff --git a/contrib/darktable-api/preview.lua b/contrib/darktable-api/preview.lua new file mode 100644 index 0000000..7cd158d --- /dev/null +++ b/contrib/darktable-api/preview.lua @@ -0,0 +1,123 @@ +-- Serve image previews from darktable's mipmap cache. +-- +-- Darktable stores mipmaps as individual JPEG files at: +-- /mipmaps-.d//.jpg +-- +-- Mip levels (approximate pixel sizes on the long edge): +-- 0 → ~67 px 1 → ~135 px 2 → ~270 px 3 → ~540 px +-- 4 → ~1080 px (good "medium") 5 → ~2160 px +-- +-- If no cached mipmap is found we fall back to exiftool (embedded EXIF +-- thumbnail) and finally to serving the original file path as a redirect. +local ffi = require "ffi" +local config = require "config" + +ffi.cdef[[ + unsigned char *SHA1(const unsigned char *d, size_t n, unsigned char *md); +]] +local crypto = ffi.load("crypto") + +local M = {} + +-- ── Locate the mipmap cache directory (computed once) ───────────────────────── + +local _mipmap_base -- e.g. "/home/user/.cache/darktable/mipmaps-abc123" + +local function mipmap_base() + if _mipmap_base then return _mipmap_base end + + -- Resolve the real path of library.db (darktable uses realpath before hashing) + local h = io.popen("realpath " .. config.library_db .. " 2>/dev/null") + local realpath = h:read("*l"); h:close() + realpath = realpath or config.library_db + + -- SHA1 hex of the realpath (same algorithm as mipmap_cache.c) + local md = ffi.new("unsigned char[20]") + crypto.SHA1(realpath, #realpath, md) + local hex = {} + for i = 0, 19 do hex[i+1] = string.format("%02x", md[i]) end + local sha1hex = table.concat(hex) + + _mipmap_base = config.cache_dir .. "/mipmaps-" .. sha1hex + return _mipmap_base +end + +-- ── Size → mip level mapping ────────────────────────────────────────────────── + +-- size param: "thumb" (~135 px), "small" (~270 px), "medium" (~540 px), +-- "large" (~1080 px), "xlarge" (~2160 px), or an integer level 0-5. +local SIZE_TO_MIP = { + thumb = 1, + small = 2, + medium = 3, + large = 4, + xlarge = 5, +} + +local function size_to_mip(size) + if not size or size == "" then return 3 end -- default: "medium" + local n = tonumber(size) + if n then return math.max(0, math.min(5, math.floor(n))) end + return SIZE_TO_MIP[size:lower()] or 3 +end + +-- ── Try to read a file, return nil on failure ───────────────────────────────── + +local function read_file(path) + local f = io.open(path, "rb") + if not f then return nil end + local data = f:read("*a") + f:close() + return data +end + +-- ── Public: get preview data for an image id ────────────────────────────────── +-- +-- Returns: data (string), content_type (string) +-- or: nil, nil, redirect_url (caller should issue a 302) +-- or: nil, nil, nil (404) +function M.get(image_id, image_path, filename, size_param) + local mip = size_to_mip(size_param) + + -- 1. Walk mip levels downward until we find a cached JPEG. + local base = mipmap_base() + for level = mip, 0, -1 do + local path = string.format("%s.d/%d/%d.jpg", base, level, image_id) + local data = read_file(path) + if data then return data, "image/jpeg" end + end + + -- 2. Try exiftool to extract the embedded EXIF thumbnail from the source file. + if image_path and filename then + local src = image_path .. "/" .. filename + -- exiftool writes the thumbnail to stdout with -b + local cmd = string.format("exiftool -b -ThumbnailImage %q 2>/dev/null", src) + local fh = io.popen(cmd) + if fh then + local data = fh:read("*a"); fh:close() + if data and #data > 100 then + return data, "image/jpeg" + end + end + end + + -- 3. For JPEG/PNG source files redirect to the original (client caches it). + if image_path and filename then + local src = image_path .. "/" .. filename + local ext = filename:match("%.([^%.]+)$") or "" + if ext:lower():match("^jpe?g$") or ext:lower() == "png" then + return nil, nil, "file://" .. src -- caller issues redirect or reads directly + end + end + + return nil, nil, nil -- no preview available +end + +-- ── Helper: read a local file and serve it inline ───────────────────────────── +-- Used by the API handler when get() returns a file:// redirect. +function M.read_local(path) + local stripped = path:match("^file://(.+)$") or path + return read_file(stripped) +end + +return M diff --git a/contrib/darktable-api/router.lua b/contrib/darktable-api/router.lua new file mode 100644 index 0000000..03700fc --- /dev/null +++ b/contrib/darktable-api/router.lua @@ -0,0 +1,67 @@ +-- Simple radix-style HTTP router. +-- Supports static segments and named captures (:name). +-- Usage: +-- router.get("/api/v1/images", handler) +-- router.get("/api/v1/images/:id", handler) +-- router.post("/oauth/token", handler) +-- +-- local handler, params = router.match("GET", "/api/v1/images/42") +-- -- handler = , params = { id = "42" } +local M = {} + +local routes = {} -- { method, pattern_parts, handler } + +-- Convert a route pattern like "/api/v1/images/:id/preview" into a segment list. +local function compile(pattern) + local parts = {} + -- Strip leading slash so the segment list aligns with the path splitter below. + for seg in (pattern:gsub("^/", "") .. "/"):gmatch("([^/]*)/") do + if seg:sub(1,1) == ":" then + parts[#parts+1] = { capture = seg:sub(2) } + else + parts[#parts+1] = { literal = seg } + end + end + return parts +end + +local function register(method, pattern, handler) + routes[#routes+1] = { method = method:upper(), parts = compile(pattern), handler = handler } +end + +function M.get(pattern, handler) register("GET", pattern, handler) end +function M.post(pattern, handler) register("POST", pattern, handler) end +function M.put(pattern, handler) register("PUT", pattern, handler) end +function M.delete(pattern, handler) register("DELETE", pattern, handler) end + +-- Match a request against registered routes. +-- Returns: handler, path_params or nil, nil +function M.match(method, path) + -- Split path into segments + local segs = {} + for seg in (path:gsub("^/","") .. "/"):gmatch("([^/]*)/") do + segs[#segs+1] = seg + end + + method = method:upper() + + for _, route in ipairs(routes) do + if route.method == method and #route.parts == #segs then + local params = {} + local matched = true + for i, part in ipairs(route.parts) do + if part.capture then + params[part.capture] = segs[i] + elseif part.literal ~= segs[i] then + matched = false + break + end + end + if matched then return route.handler, params end + end + end + + return nil, nil +end + +return M diff --git a/contrib/darktable-api/server.lua b/contrib/darktable-api/server.lua new file mode 100644 index 0000000..d0bca7b --- /dev/null +++ b/contrib/darktable-api/server.lua @@ -0,0 +1,208 @@ +#!/usr/bin/env luajit +-- Darktable REST API — standalone LuaJIT HTTP server +-- Usage: luajit server.lua [port] [host] +-- +-- Dependencies (install via luarocks): +-- luasocket — luarocks install luasocket +-- lsqlite3 — luarocks install lsqlite3 +-- libcrypto — provided by the system OpenSSL package +-- +-- Quick-start: +-- export DT_CLIENT_ID=myclient DT_CLIENT_SECRET=mysecret DT_JWT_SECRET=longrandomstring +-- luajit server.lua +-- +-- Then get a token: +-- curl -s -X POST http://127.0.0.1:7645/oauth/token \ +-- -d 'grant_type=client_credentials&client_id=myclient&client_secret=mysecret' +-- +-- And use it: +-- curl -s -H "Authorization: Bearer " http://127.0.0.1:7645/api/v1/images + +-- ── Bootstrap: make local modules findable ──────────────────────────────────── + +local script_dir = debug.getinfo(1, "S").source:match("^@(.+)/[^/]+$") or "." +package.path = script_dir .. "/?.lua;" .. package.path + +-- ── Load modules ────────────────────────────────────────────────────────────── + +local socket = require "socket" +local config = require "config" +local router = require "router" +local api = require "api" + +-- ── Register routes ─────────────────────────────────────────────────────────── + +router.post("/oauth/token", api.oauth_token) + +router.get("/health", api.health) + +router.get("/api/v1/images", api.list_images) +router.get("/api/v1/images/:id", api.get_image) +router.get("/api/v1/images/:id/preview", api.get_preview) +router.post("/api/v1/images/:id/refresh", api.refresh_cache) + +router.get("/api/v1/tags", api.list_tags) +router.get("/api/v1/films", api.list_films) + +-- ── HTTP helpers ────────────────────────────────────────────────────────────── + +local STATUS_TEXT = { + [200] = "OK", [201] = "Created", [204] = "No Content", + [301] = "Moved Permanently", + [400] = "Bad Request", [401] = "Unauthorized", [403] = "Forbidden", + [404] = "Not Found", [405] = "Method Not Allowed", + [500] = "Internal Server Error", +} + +local function url_decode(s) + return s:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end) +end + +-- Parse the raw HTTP request from a connected socket. +-- Returns a request table, or nil on parse/timeout error. +local function read_request(client) + client:settimeout(10) + + local line, lerr = client:receive("*l") + if not line then return nil end + + local method, raw_path = line:match("^(%S+)%s+(%S+)%s+HTTP/") + if not method then return nil end + + -- Headers + local headers = {} + while true do + local hline, herr = client:receive("*l") + if not hline or hline == "" then break end + local name, value = hline:match("^([^:]+):%s*(.*)$") + if name then + -- normalise to lowercase, strip trailing whitespace + headers[name:lower():gsub("%s+$", "")] = value:gsub("%s+$", "") + end + end + + -- Body + local body = "" + local clen = tonumber(headers["content-length"]) + if clen and clen > 0 then + body = client:receive(math.min(clen, 4 * 1024 * 1024)) or "" -- 4 MiB cap + end + + -- Split path from query string + local path, qs = raw_path:match("^([^?]*)%??(.*)") + + -- Parse query parameters + local params = {} + for k, v in (qs or ""):gmatch("([^&=]+)=([^&]*)") do + params[url_decode(k)] = url_decode(v) + end + + return { + method = method:upper(), + path = path, + raw_path = raw_path, + headers = headers, + body = body, + params = params, + path_params = {}, + } +end + +local CORS_HEADERS = { + ["Access-Control-Allow-Origin"] = "*", + ["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS", + ["Access-Control-Allow-Headers"] = "Authorization, Content-Type", + ["Access-Control-Max-Age"] = "86400", +} + +-- Write an HTTP response to the socket. +local function write_response(client, status, body, content_type, extra_headers) + body = body or "" + content_type = content_type or "application/json; charset=utf-8" + local phrase = STATUS_TEXT[status] or "Unknown" + + local lines = { + string.format("HTTP/1.1 %d %s\r\n", status, phrase), + "Server: darktable-api/1.0\r\n", + "Content-Type: " .. content_type .. "\r\n", + "Content-Length: " .. #body .. "\r\n", + "Connection: close\r\n", + } + + for k, v in pairs(CORS_HEADERS) do + lines[#lines+1] = k .. ": " .. v .. "\r\n" + end + if extra_headers then + for k, v in pairs(extra_headers) do + lines[#lines+1] = k .. ": " .. v .. "\r\n" + end + end + + lines[#lines+1] = "\r\n" + lines[#lines+1] = body + + client:send(table.concat(lines)) +end + +local json = require "json" + +local function handle_client(client) + local req = read_request(client) + if not req then + write_response(client, 400, '{"error":"bad_request"}') + return + end + + -- CORS preflight + if req.method == "OPTIONS" then + write_response(client, 204, "") + return + end + + local handler, path_params = router.match(req.method, req.path) + if not handler then + write_response(client, 404, json.encode({ error = "not_found", path = req.path })) + return + end + + req.path_params = path_params or {} + + local status, body, ct, extra = handler(req) + write_response(client, status, body, ct, extra) +end + +-- ── Main server loop ────────────────────────────────────────────────────────── + +local port = tonumber(arg and arg[1]) or config.port +local host = (arg and arg[2]) or config.host + +local server, serr = socket.bind(host, port) +if not server then + io.stderr:write("darktable-api: cannot bind " .. host .. ":" .. port .. ": " .. tostring(serr) .. "\n") + os.exit(1) +end + +server:settimeout(0) -- non-blocking accept + +io.write(string.format( + "darktable-api: listening on http://%s:%d\n" .. + " library : %s\n" .. + " cache : %s\n", + host, port, config.library_db, config.cache_dir)) +io.flush() + +while true do + local client, cerr = server:accept() + if client then + local ok, result = pcall(handle_client, client) + if not ok then + pcall(write_response, client, 500, + json.encode({ error = "internal_server_error", detail = tostring(result) })) + io.stderr:write("darktable-api: error: " .. tostring(result) .. "\n") + end + pcall(function() client:close() end) + else + -- No connection yet — yield CPU briefly. + socket.select(nil, nil, 0.05) + end +end diff --git a/contrib/darktable-api/start-with-api.sh b/contrib/darktable-api/start-with-api.sh new file mode 100755 index 0000000..a3d3593 --- /dev/null +++ b/contrib/darktable-api/start-with-api.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# start-with-api.sh — Launch the darktable REST API server and darktable together. +# +# What it does: +# 1. Optionally configures darktable's luarc to load the bridge plugin. +# 2. Starts the standalone LuaJIT REST API server in the background. +# 3. Starts darktable in the foreground. +# 4. Stops the API server when darktable exits. +# +# Usage: +# ./start-with-api.sh [darktable-options...] +# +# Environment variables (all optional): +# DT_CLIENT_ID OAuth client ID (default: darktable-client) +# DT_CLIENT_SECRET OAuth client secret (default: changeme) +# DT_JWT_SECRET JWT signing secret (default: change-this-secret-in-production) +# DT_API_HOST Server bind address (default: 127.0.0.1) +# DT_API_PORT Server port (default: 7645) +# DT_LIBRARY darktable library DB (default: ~/.config/darktable/library.db) +# DT_REFRESH_QUEUE IPC queue file (default: /tmp/dt-api-refresh.queue) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +: "${DT_CLIENT_ID:=darktable-client}" +: "${DT_CLIENT_SECRET:=changeme}" +: "${DT_JWT_SECRET:=change-this-secret-in-production}" +: "${DT_API_HOST:=127.0.0.1}" +: "${DT_API_PORT:=7645}" +: "${DT_REFRESH_QUEUE:=/tmp/dt-api-refresh.queue}" + +export DT_CLIENT_ID DT_CLIENT_SECRET DT_JWT_SECRET +export DT_API_HOST DT_API_PORT DT_REFRESH_QUEUE + +# ── 1. Ensure the bridge plugin is in darktable's luarc ─────────────────────── + +LUARC="$HOME/.config/darktable/luarc" +BRIDGE_REQUIRE='require "contrib/darktable-api/darktable-api-bridge"' + +if ! grep -qF "$BRIDGE_REQUIRE" "$LUARC" 2>/dev/null; then + echo "[start-with-api] Adding bridge plugin to $LUARC" + echo "$BRIDGE_REQUIRE" >> "$LUARC" +fi + +# ── 2. Install the bridge plugin into darktable's lua directory ─────────────── +# darktable searches $HOME/.config/darktable/lua/ for scripts. +# We copy (not symlink) the bridge plugin file there so darktable can +# require "contrib/darktable-api/darktable-api-bridge" without any extra path config. + +DT_LUA_DIR="$HOME/.config/darktable/lua" +BRIDGE_DEST="$DT_LUA_DIR/contrib/darktable-api" +BRIDGE_SRC="$SCRIPT_DIR/darktable-api-bridge.lua" + +mkdir -p "$BRIDGE_DEST" +if ! cmp -s "$BRIDGE_SRC" "$BRIDGE_DEST/darktable-api-bridge.lua" 2>/dev/null; then + cp "$BRIDGE_SRC" "$BRIDGE_DEST/darktable-api-bridge.lua" + echo "[start-with-api] Installed bridge plugin → $BRIDGE_DEST/" +fi + +# ── 3. Clean up any stale queue file ───────────────────────────────────────── + +rm -f "$DT_REFRESH_QUEUE" "${DT_REFRESH_QUEUE}.processing" + +# ── 4. Start the LuaJIT REST API server ─────────────────────────────────────── + +API_LOG="${TMPDIR:-/tmp}/darktable-api.log" +echo "[start-with-api] Starting API server on http://${DT_API_HOST}:${DT_API_PORT}" +echo "[start-with-api] Log: $API_LOG" + +luajit "$SCRIPT_DIR/server.lua" "$DT_API_PORT" "$DT_API_HOST" >"$API_LOG" 2>&1 & +API_PID=$! + +# Give the server a moment to bind and confirm it's up. +sleep 0.5 +if ! kill -0 "$API_PID" 2>/dev/null; then + echo "[start-with-api] ERROR: API server failed to start. Check $API_LOG" >&2 + cat "$API_LOG" >&2 + exit 1 +fi + +if ! curl -sf "http://${DT_API_HOST}:${DT_API_PORT}/health" >/dev/null 2>&1; then + echo "[start-with-api] WARNING: health check failed; server may still be starting" +fi + +echo "[start-with-api] API server running (PID $API_PID)" +echo "" +echo " Get a token:" +echo " curl -X POST http://${DT_API_HOST}:${DT_API_PORT}/oauth/token \\" +echo " -d 'grant_type=client_credentials&client_id=${DT_CLIENT_ID}&client_secret=${DT_CLIENT_SECRET}'" +echo "" +echo " Trigger a cache refresh (replace TOKEN and IMAGE_ID):" +echo " curl -X POST http://${DT_API_HOST}:${DT_API_PORT}/api/v1/images/IMAGE_ID/refresh \\" +echo " -H 'Authorization: Bearer TOKEN'" +echo "" + +# ── 5. Start darktable ──────────────────────────────────────────────────────── + +echo "[start-with-api] Starting darktable..." +darktable "$@" || true + +# ── 6. Stop the API server on exit ─────────────────────────────────────────── + +echo "" +echo "[start-with-api] darktable exited; stopping API server (PID $API_PID)" +kill "$API_PID" 2>/dev/null || true +wait "$API_PID" 2>/dev/null || true +rm -f "$DT_REFRESH_QUEUE" "${DT_REFRESH_QUEUE}.processing" +echo "[start-with-api] done"