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.
This commit is contained in:
252
contrib/darktable-api/api.lua
Normal file
252
contrib/darktable-api/api.lua
Normal file
@@ -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 <queue>.tmp.<pid>
|
||||
-- 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
|
||||
96
contrib/darktable-api/auth.lua
Normal file
96
contrib/darktable-api/auth.lua
Normal file
@@ -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
|
||||
52
contrib/darktable-api/base64.lua
Normal file
52
contrib/darktable-api/base64.lua
Normal file
@@ -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
|
||||
33
contrib/darktable-api/bruno/Health Check.bru
Normal file
33
contrib/darktable-api/bruno/Health Check.bru
Normal file
@@ -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":<unix>}`.
|
||||
}
|
||||
33
contrib/darktable-api/bruno/auth/Get Token (Bad Secret).bru
Normal file
33
contrib/darktable-api/bruno/auth/Get Token (Bad Secret).bru
Normal file
@@ -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.
|
||||
}
|
||||
64
contrib/darktable-api/bruno/auth/Get Token.bru
Normal file
64
contrib/darktable-api/bruno/auth/Get Token.bru
Normal file
@@ -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.
|
||||
}
|
||||
6
contrib/darktable-api/bruno/bruno.json
Normal file
6
contrib/darktable-api/bruno/bruno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Darktable API",
|
||||
"type": "collection",
|
||||
"ignore": []
|
||||
}
|
||||
12
contrib/darktable-api/bruno/environments/local.bru
Normal file
12
contrib/darktable-api/bruno/environments/local.bru
Normal file
@@ -0,0 +1,12 @@
|
||||
vars {
|
||||
baseUrl: http://127.0.0.1:7645
|
||||
clientId: darktable-client
|
||||
clientSecret: changeme
|
||||
accessToken:
|
||||
imageId: 1
|
||||
}
|
||||
|
||||
vars:secret [
|
||||
clientSecret,
|
||||
accessToken
|
||||
]
|
||||
12
contrib/darktable-api/bruno/environments/remote.bru
Normal file
12
contrib/darktable-api/bruno/environments/remote.bru
Normal file
@@ -0,0 +1,12 @@
|
||||
vars {
|
||||
baseUrl: http://192.168.1.134:7645
|
||||
clientId: darktable-client
|
||||
clientSecret: changeme
|
||||
accessToken:
|
||||
imageId: 1
|
||||
}
|
||||
|
||||
vars:secret [
|
||||
clientSecret,
|
||||
accessToken
|
||||
]
|
||||
54
contrib/darktable-api/bruno/films/List Films.bru
Normal file
54
contrib/darktable-api/bruno/films/List Films.bru
Normal file
@@ -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).
|
||||
}
|
||||
31
contrib/darktable-api/bruno/images/Get Image (Not Found).bru
Normal file
31
contrib/darktable-api/bruno/images/Get Image (Not Found).bru
Normal file
@@ -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.
|
||||
}
|
||||
72
contrib/darktable-api/bruno/images/Get Image.bru
Normal file
72
contrib/darktable-api/bruno/images/Get Image.bru
Normal file
@@ -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).
|
||||
}
|
||||
67
contrib/darktable-api/bruno/images/Get Preview.bru
Normal file
67
contrib/darktable-api/bruno/images/Get Preview.bru
Normal file
@@ -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.
|
||||
}
|
||||
27
contrib/darktable-api/bruno/images/List Images (No Auth).bru
Normal file
27
contrib/darktable-api/bruno/images/List Images (No Auth).bru
Normal file
@@ -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.
|
||||
}
|
||||
87
contrib/darktable-api/bruno/images/List Images.bru
Normal file
87
contrib/darktable-api/bruno/images/List Images.bru
Normal file
@@ -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 |
|
||||
}
|
||||
49
contrib/darktable-api/bruno/images/Refresh Cache.bru
Normal file
49
contrib/darktable-api/bruno/images/Refresh Cache.bru
Normal file
@@ -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`
|
||||
}
|
||||
54
contrib/darktable-api/bruno/tags/List Tags.bru
Normal file
54
contrib/darktable-api/bruno/tags/List Tags.bru
Normal file
@@ -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).
|
||||
}
|
||||
31
contrib/darktable-api/config.lua
Normal file
31
contrib/darktable-api/config.lua
Normal file
@@ -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
|
||||
192
contrib/darktable-api/darktable-api-bridge.lua
Normal file
192
contrib/darktable-api/darktable-api-bridge.lua
Normal file
@@ -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
|
||||
387
contrib/darktable-api/db.lua
Normal file
387
contrib/darktable-api/db.lua
Normal file
@@ -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
|
||||
155
contrib/darktable-api/json.lua
Normal file
155
contrib/darktable-api/json.lua
Normal file
@@ -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
|
||||
123
contrib/darktable-api/preview.lua
Normal file
123
contrib/darktable-api/preview.lua
Normal file
@@ -0,0 +1,123 @@
|
||||
-- Serve image previews from darktable's mipmap cache.
|
||||
--
|
||||
-- Darktable stores mipmaps as individual JPEG files at:
|
||||
-- <cache_dir>/mipmaps-<sha1_of_library_realpath>.d/<mip_level>/<imgid>.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
|
||||
67
contrib/darktable-api/router.lua
Normal file
67
contrib/darktable-api/router.lua
Normal file
@@ -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 = <function>, 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
|
||||
208
contrib/darktable-api/server.lua
Normal file
208
contrib/darktable-api/server.lua
Normal file
@@ -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 <token>" 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
|
||||
109
contrib/darktable-api/start-with-api.sh
Executable file
109
contrib/darktable-api/start-with-api.sh
Executable file
@@ -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"
|
||||
Reference in New Issue
Block a user