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:
2026-06-10 15:02:19 -04:00
parent 5ebadd83ea
commit bd40b4f850
25 changed files with 2273 additions and 0 deletions

View 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

View 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

View 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

View 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>}`.
}

View 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.
}

View 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.
}

View File

@@ -0,0 +1,6 @@
{
"version": "1",
"name": "Darktable API",
"type": "collection",
"ignore": []
}

View 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
]

View 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
]

View 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).
}

View 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.
}

View 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).
}

View 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.
}

View 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.
}

View 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 |
}

View 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`
}

View 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).
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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"