feat(api): enhance image module management and export functionality

- Added read-write access to the darktable SQLite catalog for module parameter edits.
- Implemented new endpoints for exporting images and retrieving full-resolution images.
- Introduced PATCH method for updating module parameters with optional preview.
- Added comprehensive tests for new API functionalities, including module retrieval and error handling.
- Updated CORS headers to support new HTTP methods.
- Created detailed documentation for new API endpoints and module operations.
This commit is contained in:
2026-06-10 19:50:56 -04:00
parent bd40b4f850
commit 0831ee943c
13 changed files with 1362 additions and 52 deletions

View File

@@ -5,6 +5,8 @@ local auth = require "auth"
local db = require "db"
local preview = require "preview"
local config = require "config"
local modules = require "modules"
local socket = require "socket"
-- ── Refresh queue (IPC with the darktable bridge plugin) ──────────────────────
-- The bridge plugin running inside darktable polls this file and calls
@@ -26,6 +28,21 @@ local function enqueue_refresh(image_id)
return true
end
local function enqueue_export(image_id)
local qf = config.export_queue
local f, ferr = io.open(qf, "a")
if not f then
return false, "cannot open export queue " .. qf .. ": " .. (ferr or "?")
end
f:write(tostring(image_id) .. "\n")
f:close()
return true
end
local function export_path(image_id)
return config.export_dir .. "/" .. tostring(image_id) .. ".jpg"
end
local M = {}
-- ── Helpers ───────────────────────────────────────────────────────────────────
@@ -218,11 +235,10 @@ 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.
-- Enqueues the image for thumbnail regeneration via the darktable bridge.
-- The bridge calls image:drop_cache() + image:generate_cache(true, …), which
-- re-renders through darktable's full processing pipeline so that development
-- settings (tone curves, exposure, colour grading, etc.) are reflected.
-- Requires scope: cache:refresh
function M.refresh_cache(req)
local payload, s, b, ct = require_auth(req, "cache:refresh")
@@ -231,7 +247,6 @@ function M.refresh_cache(req)
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
@@ -245,8 +260,219 @@ function M.refresh_cache(req)
image_id = id,
filename = img.filename,
queue = config.refresh_queue,
message = "cache refresh enqueued; darktable bridge will process it shortly",
message = "cache refresh enqueued; darktable bridge will re-render thumbnails shortly",
}), "application/json; charset=utf-8"
end
-- ── Development modules ───────────────────────────────────────────────────────
-- GET /api/v1/images/:id/modules
-- Returns every operation in the history stack (active entry per operation).
-- Response includes decoded params for known modules; base64 blob for others.
-- Requires scope: images:read
function M.list_modules(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
local mods = db.list_modules(id)
return ok({ image_id = id, items = mods })
end
-- GET /api/v1/images/:id/modules/:op
-- Returns the active history entry for one operation.
-- Requires scope: images:read
function M.get_module(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)
local op = req.path_params.op
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 mod = db.get_module(id, op)
if not mod then
return err(404, "module_not_found",
"operation '" .. op .. "' not in history for image " .. id)
end
return ok(mod)
end
-- PATCH /api/v1/images/:id/modules/:op
-- Body (JSON):
-- {
-- "enabled": true|false, -- optional: change enabled state
-- "params": { field: value, … } -- optional: named params for known modules
-- "params_b64": "<base64>" -- alternative: raw binary for unknown modules
-- }
-- Query param: ?preview=thumb|small|medium|large|xlarge
-- If given, waits up to 30 s for the thumbnail to update and returns the JPEG.
-- Otherwise returns 204 No Content.
-- Requires scope: cache:refresh
function M.patch_module(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)
local op = req.path_params.op
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 mod = db.get_module(id, op)
if not mod then
return err(404, "module_not_found",
"operation '" .. op .. "' not in history for image " .. id)
end
-- Parse request body
local body = parse_body(req)
if not body then
return err(400, "invalid_body", "could not parse JSON body")
end
local new_enabled = body.enabled -- may be nil (leave unchanged)
local new_blob = nil
if body.params_b64 then
-- Caller provided raw base64 params (for unknown modules)
local ok_dec, decoded = pcall(require("base64").decode, body.params_b64)
if not ok_dec then
return err(400, "invalid_params_b64", "base64 decode failed")
end
new_blob = decoded
elseif body.params then
if mod.params then
local existing_blob = db.get_module_blob(id, op)
if not existing_blob then
return err(500, "db_error", "could not read existing params blob")
end
new_blob = modules.encode(op, mod.module_version, existing_blob, body.params)
if not new_blob then
return err(400, "unsupported_module",
"named-param editing is not supported for operation '" .. op ..
"'; use params_b64 with a raw binary blob instead")
end
else
-- Unknown module, caller must supply params_b64
return err(400, "params_required",
"operation '" .. op .. "' requires params_b64 (binary blob) for editing")
end
end
-- Persist to history table
local ok_write, werr = db.set_module_params(id, op, new_enabled, new_blob)
if not ok_write then
return err(500, "db_write_error", werr)
end
-- Enqueue a thumbnail refresh so the bridge re-renders with the new params.
enqueue_refresh(id)
-- Optional synchronous preview response
local preview_size = req.params.preview
if preview_size and preview_size ~= "" then
-- Read the thumbnail before the refresh so we can detect when it changes.
local old_data = preview.get(id, img.path, img.filename, preview_size)
local deadline = os.time() + 30
local new_data
while os.time() < deadline do
socket.select(nil, nil, 0.5)
local candidate = preview.get(id, img.path, img.filename, preview_size)
if candidate and candidate ~= old_data then
new_data = candidate
break
end
end
if new_data then
return 200, new_data, "image/jpeg"
end
-- Timeout — return what we have (may be stale) with a warning header
local fallback = preview.get(id, img.path, img.filename, preview_size)
if fallback then
return 200, fallback, "image/jpeg",
{ ["X-Preview-Warning"] = "regeneration-timeout; thumbnail may be stale" }
end
return err(504, "preview_timeout",
"thumbnail regeneration timed out after 30 s; retry the preview endpoint")
end
return 204, "", nil
end
-- ── Full-resolution export ────────────────────────────────────────────────────
-- POST /api/v1/images/:id/export
-- Enqueues a full-resolution JPEG export via the darktable bridge.
-- The bridge calls dt.new_format("jpeg"):write_image() which runs the complete
-- development pipeline. Returns 202 immediately; poll GET /full to check when ready.
-- Requires scope: cache:refresh
function M.trigger_export(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
local img = db.get_image(id)
if not img then return err(404, "not_found", "image " .. id .. " not found") end
local queued, qerr = enqueue_export(id)
if not queued then
return err(500, "queue_error", qerr)
end
return 202, json.encode({
status = "accepted",
image_id = id,
filename = img.filename,
export = export_path(id),
message = "full-resolution export enqueued; poll GET /api/v1/images/" .. id .. "/full when ready",
}), "application/json; charset=utf-8"
end
-- GET /api/v1/images/:id/full
-- Serves the full-resolution JPEG previously exported via POST /export.
-- If no export exists yet, enqueues one and returns 202 so the client can retry.
-- Requires scope: previews:read
function M.get_full(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 path = export_path(id)
local f = io.open(path, "rb")
if f then
local data = f:read("*a")
f:close()
return 200, data, "image/jpeg"
end
-- Not yet exported — enqueue and tell the client to retry.
local queued, qerr = enqueue_export(id)
if not queued then
return err(500, "queue_error", qerr)
end
return 202, json.encode({
status = "queued",
image_id = id,
filename = img.filename,
retry_after = 5,
message = "export enqueued; retry in a few seconds",
}), "application/json; charset=utf-8", { ["Retry-After"] = "5" }
end
return M

View File

@@ -0,0 +1,52 @@
meta {
name: Export Image
type: http
seq: 6
}
post {
url: {{baseUrl}}/api/v1/images/{{imageId}}/export
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 export path", function() {
expect(res.body.export).to.be.a("string");
});
}
docs {
## Export Image (Full Resolution)
Enqueues a full-resolution JPEG export processed through darktable's complete
development pipeline — tone curves, exposure, colour grading, noise reduction,
and all other active modules are applied.
Returns **202 Accepted** immediately. The darktable bridge plugin writes the
result to the path shown in `export` (default: `/tmp/dt-api-exports/<id>.jpg`).
Poll **GET /api/v1/images/:id/full** to retrieve the image once ready.
Requires scope: `cache:refresh`
}

View File

@@ -0,0 +1,57 @@
meta {
name: Get Full Image
type: http
seq: 7
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/full
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 200 or 202", function() {
expect([200, 202]).to.include(res.status);
});
test("if 200, content-type is image/jpeg", function() {
if (res.status === 200) {
expect(res.headers["content-type"]).to.equal("image/jpeg");
}
});
test("if 200, body is non-empty", function() {
if (res.status === 200) {
expect(res.body.length).to.be.above(0);
}
});
test("if 202, export was queued", function() {
if (res.status === 202) {
expect(res.body.status).to.equal("queued");
expect(res.body.retry_after).to.be.a("number");
}
});
}
docs {
## Get Full-Resolution Image
Returns the full-resolution JPEG developed through darktable's complete
processing pipeline (all active development modules applied).
### Workflow
1. POST `/api/v1/images/:id/export` — enqueue the export (returns 202)
2. Wait ~2–10 s (depending on complexity and hardware)
3. GET `/api/v1/images/:id/full` — download the result (200 with JPEG body)
If no export exists yet this endpoint auto-enqueues one and returns **202**
with a `Retry-After: 5` header. Keep retrying until you receive **200**.
Requires scope: `previews:read`
}

View File

@@ -0,0 +1,31 @@
meta {
name: Get Module (Not Found)
type: http
seq: 4
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/nonexistent_module
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 404", function() {
expect(res.status).to.equal(404);
});
test("error is module_not_found", function() {
expect(res.body.error).to.equal("module_not_found");
});
}
docs {
## Get Module — Not Found
Verifies 404 is returned for an operation that does not exist in the image history.
}

View File

@@ -0,0 +1,55 @@
meta {
name: Get Module
type: http
seq: 2
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/filmicrgb
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("operation is filmicrgb", function() {
expect(res.body.operation).to.equal("filmicrgb");
});
test("has enabled flag", function() {
expect(res.body.enabled).to.be.a("boolean");
});
test("has decoded params", function() {
expect(res.body.params).to.be.an("object");
expect(res.body.params.grey_point_source).to.be.a("number");
expect(res.body.params.black_point_source).to.be.a("number");
expect(res.body.params.white_point_source).to.be.a("number");
expect(res.body.params.contrast).to.be.a("number");
expect(res.body.params.saturation).to.be.a("number");
});
// Save for the Patch Module test
bru.setVar("filmicGreyPointSource", res.body.params.grey_point_source);
}
docs {
## Get Module
Returns the active history entry for the `filmicrgb` module.
Change the URL path segment to inspect a different module, e.g.:
- `.../modules/exposure`
- `.../modules/temperature`
- `.../modules/highlights`
- `.../modules/channelmixerrgb`
Returns 404 if the operation has never been added to this image's history.
}

View File

@@ -0,0 +1,79 @@
meta {
name: List Modules
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules
body: none
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
tests {
test("returns 200", function() {
expect(res.status).to.equal(200);
});
test("has image_id", function() {
expect(res.body.image_id).to.equal(Number(bru.getVar("imageId")));
});
test("has items array", function() {
expect(res.body.items).to.be.an("array");
expect(res.body.items.length).to.be.above(0);
});
test("each item has required fields", function() {
res.body.items.forEach(function(m) {
expect(m.operation).to.be.a("string");
expect(m.enabled).to.be.a("boolean");
expect(m.module_version).to.be.a("number");
expect(m.history_num).to.be.a("number");
});
});
test("known modules have decoded params", function() {
var known = res.body.items.filter(function(m) {
return ["exposure","temperature","filmicrgb","highlights",
"channelmixerrgb","flip","gamma","rawprepare","demosaic"].indexOf(m.operation) >= 0;
});
known.forEach(function(m) {
expect(m.params).to.not.be.null;
expect(typeof m.params).to.equal("object");
});
});
test("unknown modules have params_b64", function() {
var unknown = res.body.items.filter(function(m) { return m.params === undefined || m.params === null; });
unknown.forEach(function(m) {
expect(m.params_b64).to.be.a("string");
});
});
// Save the first module op for subsequent tests
bru.setVar("moduleOp", res.body.items[0].operation);
}
docs {
## List Modules
Returns all active development modules (history stack entries) for the image.
Each item includes:
- `operation` – module name (e.g. `"exposure"`, `"filmicrgb"`)
- `enabled` – whether the module is currently active in the pipeline
- `module_version` – params struct version stored in the database
- `multi_name` – user label for multi-instance modules (empty for single-instance)
- `history_num` – position in the history stack (higher = more recent)
- `params` – decoded named parameters (for supported modules)
- `params_b64` – base64-encoded raw binary blob (for unsupported modules)
### Supported modules (params decoded)
`exposure`, `temperature`, `filmicrgb`, `channelmixerrgb`, `highlights`,
`flip`, `gamma`, `rawprepare`, `demosaic`
}

View File

@@ -0,0 +1,89 @@
meta {
name: Patch Module
type: http
seq: 3
}
patch {
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/filmicrgb
body: json
auth: bearer
}
auth:bearer {
token: {{accessToken}}
}
params:query {
~preview: medium
}
body:json {
{
"enabled": true,
"params": {
"grey_point_source": 18.45,
"white_point_source": 4.0,
"black_point_source": -8.0,
"contrast": 1.0
}
}
}
tests {
test("returns 204 (no preview) or 200 (with preview)", function() {
expect([200, 204]).to.include(res.status);
});
test("if 200, content-type is image/jpeg", function() {
if (res.status === 200) {
expect(res.headers["content-type"]).to.equal("image/jpeg");
}
});
}
docs {
## Patch Module
Update one or more parameters on a development module and optionally get a
preview image back showing the result.
### Body fields
- `enabled` (boolean, optional) – enable or disable the module
- `params` (object, optional) – named parameter overrides for known modules
- `params_b64` (string, optional) – raw base64 binary blob for unknown modules
(mutually exclusive with `params`)
Only fields present in `params` are changed; all others keep their current values.
### Preview query param
Add `?preview=small|medium|large|xlarge` to receive a JPEG thumbnail (200)
instead of empty 204. The server waits up to 30 s for the thumbnail to
regenerate before returning. Without this param the endpoint returns
**204 No Content** immediately (the bridge regenerates async in ~1-3 s).
### filmicrgb key parameters
| Field | Description | Default |
|---|---|---|
| `grey_point_source` | middle-grey exposure % | 18.45 |
| `black_point_source` | black relative exposure EV | -8.0 |
| `white_point_source` | white relative exposure EV | 4.0 |
| `contrast` | S-curve contrast | 1.0 |
| `saturation` | extreme-luminance saturation | 0.0 |
| `balance` | shadows ↔ highlights balance | 0.0 |
| `auto_hardness` | auto-adjust curve hardness | true |
### exposure key parameters
| Field | Description |
|---|---|
| `exposure` | exposure compensation in EV |
| `black` | black level correction |
| `mode` | 0=manual, 1=deflicker |
### temperature key parameters
| Field | Description |
|---|---|
| `red` | red channel multiplier |
| `green` | green channel multiplier |
| `blue` | blue channel multiplier |
}

View File

@@ -28,4 +28,11 @@ M.clients[default_id] = {
-- Queue file the bridge plugin watches for cache-refresh requests.
M.refresh_queue = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue"
-- Queue file the bridge plugin watches for full-resolution export requests.
M.export_queue = os.getenv("DT_EXPORT_QUEUE") or "/tmp/dt-api-export.queue"
-- Directory where the bridge writes full-resolution JPEG exports.
-- Served by GET /api/v1/images/:id/full.
M.export_dir = os.getenv("DT_EXPORT_DIR") or "/tmp/dt-api-exports"
return M

View File

@@ -2,21 +2,23 @@
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.
Runs inside a live darktable instance and watches two queue files written by
the standalone LuaJIT server.
Refresh queue (DT_REFRESH_QUEUE, default /tmp/dt-api-refresh.queue)
One image ID per line. The bridge calls image:drop_cache() +
image:generate_cache(true, …) so that darktable re-renders thumbnails
through its full processing pipeline, reflecting tone curves, exposure,
colour grading, and all other development settings.
Export queue (DT_EXPORT_QUEUE, default /tmp/dt-api-export.queue)
One image ID per line. The bridge exports a full-resolution JPEG via
darktable's rendering pipeline to DT_EXPORT_DIR/<id>.jpg.
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"
@@ -28,13 +30,16 @@ 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
-- Must match config.refresh_queue / export_queue / export_dir in config.lua.
local QUEUE_FILE = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue"
local EXPORT_QUEUE = os.getenv("DT_EXPORT_QUEUE") or "/tmp/dt-api-export.queue"
local EXPORT_DIR = os.getenv("DT_EXPORT_DIR") or "/tmp/dt-api-exports"
local POLL_MS = 1000 -- poll interval in milliseconds
-- Mipmap range to regenerate: 0 = smallest thumbnail, 4 ≈ 1080 px.
local MIP_MIN = 0
local MIP_MAX = 4
-- JPEG quality used for full-resolution exports.
local EXPORT_QUALITY = tonumber(os.getenv("DT_EXPORT_QUALITY")) or 95
-- ── Logging ───────────────────────────────────────────────────────────────────
@@ -93,25 +98,58 @@ 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)
-- full=true forces darktable to re-process through its complete rendering
-- pipeline (tone curves, exposure, colour grading, etc.) instead of using
-- the stale embedded EXIF thumbnail from the source file.
img:generate_cache(true, MIP_MIN, MIP_MAX)
log("done " .. label)
end
-- Process all pending IDs in one pass.
local function export_image(img, dest_path)
local label = string.format("id=%d → %s", img.id, dest_path)
log("exporting " .. label)
local fmt = dt.new_format("jpeg")
fmt.quality = EXPORT_QUALITY
-- max_width/max_height = 0 means no scaling: full resolution.
fmt.max_width = 0
fmt.max_height = 0
local export_err = fmt:write_image(img, dest_path)
if export_err then
error("export failed: " .. tostring(export_err))
end
log("export done " .. label)
end
-- Atomically claim the export queue and return a set of unique image IDs.
local function drain_export_queue()
local processing = EXPORT_QUEUE .. ".processing"
local ok = os.rename(EXPORT_QUEUE, processing)
if not ok then return {} 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
-- Process all pending refresh 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
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))
@@ -129,11 +167,37 @@ local function process_queue()
end
end
-- Nudge the lighttable to redraw so the user sees updated thumbnails
-- without having to scroll away and back.
-- Nudge the lighttable to redraw so updated thumbnails appear immediately.
dt.gui.views.lighttable.is_culling_mode = dt.gui.views.lighttable.is_culling_mode
end
-- Process all pending export IDs in one pass.
local function process_export_queue()
local ids = drain_export_queue()
local count = 0
for id, _ in pairs(ids) do count = count + 1 end
if count == 0 then return end
-- Ensure export directory exists.
os.execute('mkdir -p "' .. EXPORT_DIR .. '"')
log(string.format("processing %d queued export request(s)", count))
local id_map = build_id_map()
for id, _ in pairs(ids) do
local img = id_map[id]
if img then
local dest = EXPORT_DIR .. "/" .. tostring(id) .. ".jpg"
local ok, err = pcall(export_image, img, dest)
if not ok then
log("error exporting id=" .. id .. ": " .. tostring(err))
end
else
log("export: image id=" .. id .. " not found in database")
end
end
end
-- ── Background polling loop ───────────────────────────────────────────────────
-- dt.control.dispatch runs the function asynchronously in darktable's worker
@@ -143,13 +207,16 @@ local running = true
local function poll_loop()
notify(string.format(
"started; polling %s every %d ms", QUEUE_FILE, POLL_MS))
"started; polling queues every %d ms\n refresh: %s\n export: %s → %s",
POLL_MS, QUEUE_FILE, EXPORT_QUEUE, EXPORT_DIR))
while running do
local ok, err = pcall(process_queue)
if not ok then
log("process_queue error: " .. tostring(err))
end
if not ok then log("process_queue error: " .. tostring(err)) end
local ok2, err2 = pcall(process_export_queue)
if not ok2 then log("process_export_queue error: " .. tostring(err2)) end
dt.control.sleep(POLL_MS)
end
@@ -182,6 +249,8 @@ dt.register_event(MODULE, "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
local ok2, err2 = pcall(process_export_queue)
if not ok2 then notify("export error during manual poll: " .. tostring(err2)) end
end,
"poll refresh queue now"
)

View File

@@ -1,4 +1,5 @@
-- Read-only access to the darktable SQLite catalog.
-- Darktable SQLite catalog access (read-only for image queries; read-write for
-- module-parameter edits via the history table).
-- Schema inspected from an actual darktable library (recent version):
--
-- images(id, group_id, film_id, filename, width, height, aspect_ratio,
@@ -27,9 +28,10 @@ local unpack = unpack or table.unpack -- Lua 5.1 vs 5.2+ compatibility
local M = {}
-- ── Connection (single, lazy, read-only) ─────────────────────────────────────
-- ── Connections ───────────────────────────────────────────────────────────────
local _db
local _db -- read-only, lazy
local _db_rw -- read-write, opened on first write
local function get_db()
if _db then return _db end
@@ -37,12 +39,22 @@ local function get_db()
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
local function get_db_rw()
if _db_rw then return _db_rw end
_db_rw = sqlite3.open(config.library_db) -- read-write
if not _db_rw then
error("Cannot open darktable library for writing: " .. config.library_db)
end
-- WAL mode: allow concurrent reads from the darktable process.
_db_rw:exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")
return _db_rw
end
-- ── Query helpers ─────────────────────────────────────────────────────────────
local function query(sql, params)
@@ -384,4 +396,129 @@ function M.list_films(filters)
return films
end
-- ── Module history ────────────────────────────────────────────────────────────
-- The history table stores one or more entries per (imgid, operation).
-- Only the entry with the highest `num` is the active one.
-- `module` column = the params-struct version number for that row.
local modules_mod -- lazy-loaded to avoid circular requires
local function get_modules_mod()
if not modules_mod then modules_mod = require "modules" end
return modules_mod
end
local function history_row_to_module(row)
local mods = get_modules_mod()
local blob = row.op_params or ""
local ver = row.module or 0
local params = mods.decode(row.operation, ver, blob)
return {
operation = row.operation,
enabled = row.enabled ~= 0,
module_version = ver,
multi_name = row.multi_name or "",
multi_priority = row.multi_priority or 0,
history_num = row.num,
params = params, -- nil for unknown modules
params_b64 = params and nil or require("base64").encode(blob),
}
end
-- Return the active (highest-num) history entry for every operation on imgid.
function M.list_modules(imgid)
local rows = query([[
SELECT h.operation, h.module, h.enabled, h.op_params,
h.multi_name, h.multi_priority, h.num
FROM history h
INNER JOIN (
SELECT operation, MAX(num) AS max_num
FROM history WHERE imgid = ?
GROUP BY operation
) latest ON h.operation = latest.operation AND h.num = latest.max_num
WHERE h.imgid = ?
ORDER BY h.operation
]], { tonumber(imgid), tonumber(imgid) })
local result = {}
for _, row in ipairs(rows) do
result[#result+1] = history_row_to_module(row)
end
return result
end
-- Return the raw op_params binary blob for one operation (latest history entry).
function M.get_module_blob(imgid, operation)
local row = query_one([[
SELECT op_params FROM history
WHERE imgid = ? AND operation = ?
ORDER BY num DESC LIMIT 1
]], { tonumber(imgid), operation })
return row and row.op_params or nil
end
-- Return the single active history entry for one operation.
local row = query_one([[
SELECT h.operation, h.module, h.enabled, h.op_params,
h.multi_name, h.multi_priority, h.num
FROM history h
WHERE h.imgid = ? AND h.operation = ?
ORDER BY h.num DESC LIMIT 1
]], { tonumber(imgid), operation })
if not row then return nil end
return history_row_to_module(row)
end
-- Update the active history entry for an operation.
-- `new_blob` (string|nil) – new binary op_params; nil to leave unchanged
-- `new_enabled` (bool|nil) – new enabled flag; nil to leave unchanged
-- Returns true on success, or false + error message.
function M.set_module_params(imgid, operation, new_enabled, new_blob)
-- Find the current latest entry and its num.
local row = query_one([[
SELECT num, module, op_params, enabled, blendop_params, blendop_version,
multi_priority, multi_name, multi_name_hand_edited
FROM history
WHERE imgid = ? AND operation = ?
ORDER BY num DESC LIMIT 1
]], { tonumber(imgid), operation })
if not row then
return false, "module '" .. operation .. "' not found in history for image " .. imgid
end
local d = get_db_rw()
-- Find the global max num for this image so we can insert after it.
local max_row = query_one("SELECT MAX(num) AS m FROM history WHERE imgid=?", {tonumber(imgid)})
local new_num = (max_row and max_row.m or 0) + 1
local enabled_val = (new_enabled == nil) and row.enabled or (new_enabled and 1 or 0)
local blob_val = new_blob or row.op_params
-- Insert a new history entry (preserves darktable's undo history).
local sql = [[
INSERT INTO history
(imgid, num, module, operation, op_params, enabled,
blendop_params, blendop_version, multi_priority, multi_name, multi_name_hand_edited)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
]]
local stmt = d:prepare(sql)
if not stmt then
return false, "SQL prepare failed: " .. (d:errmsg() or "?")
end
stmt:bind_values(
tonumber(imgid), new_num, row.module, operation, blob_val, enabled_val,
row.blendop_params, row.blendop_version,
row.multi_priority, row.multi_name, row.multi_name_hand_edited
)
local rc = stmt:step()
stmt:finalize()
if rc ~= sqlite3.DONE then
return false, "INSERT failed (rc=" .. tostring(rc) .. "): " .. (d:errmsg() or "?")
end
return true
end
return M

View File

@@ -0,0 +1,502 @@
-- Module parameter decoder / encoder for the darktable REST API.
-- Runs in the LuaJIT server process. Uses FFI to unpack and repack the
-- binary op_params blobs stored in the library.db history table.
--
-- Each struct definition mirrors the darktable 4.6 iop C struct exactly so
-- that sizeof() matches the byte count in the database. The `module` column
-- in the history table carries the version number stored for that row.
--
-- Unknown modules (or unsupported versions) pass through as a base64 string.
local ffi = require "ffi"
local base64 = require "base64"
-- ── C struct definitions ───────────────────────────────────────────────────────
ffi.cdef[[
/* --- flip (ver=2, 4 bytes) --- */
typedef struct { int32_t orientation; } dt_flip_v2_t;
/* --- gamma / display encoding (ver=1, 8 bytes) --- */
typedef struct { float gamma; float linear; } dt_gamma_v1_t;
/* --- temperature / white balance (ver=3, 16 bytes) --- */
typedef struct { float red; float green; float blue; float various; } dt_temperature_v3_t;
/* --- exposure (ver=7, 28 bytes) --- */
typedef struct {
int32_t mode;
float black;
float exposure;
float deflicker_percentile;
float deflicker_target_level;
int32_t compensate_exposure_bias;
int32_t compensate_hilite_pres;
} dt_exposure_v7_t;
/* --- rawprepare (ver=1, 28 bytes) --- */
typedef struct {
int32_t left; int32_t top; int32_t right; int32_t bottom;
uint16_t raw_black_level_separate[4];
uint16_t raw_white_point;
uint16_t _pad1;
} dt_rawprepare_v1_t;
/* --- rawprepare (ver=2, 32 bytes) --- */
typedef struct {
int32_t left; int32_t top; int32_t right; int32_t bottom;
uint16_t raw_black_level_separate[4];
uint16_t raw_white_point;
uint16_t _pad1;
int32_t flat_field;
} dt_rawprepare_v2_t;
/* --- demosaic (ver=3, 20 bytes) --- */
typedef struct {
int32_t green_eq;
float median_thrs;
int32_t color_smoothing;
int32_t demosaicing_method;
int32_t lmmse_refine;
} dt_demosaic_v3_t;
/* --- highlights (ver=4, 48 bytes) --- */
typedef struct {
int32_t mode;
float blendL;
float blendC;
float strength;
float clip;
float noise_level;
int32_t iterations;
int32_t scales;
float candidating;
float combine;
int32_t recovery;
float solid_color;
} dt_highlights_v4_t;
/* --- channelmixerrgb (ver=3, 160 bytes) --- */
typedef struct {
float red[4];
float green[4];
float blue[4];
float saturation[4];
float lightness[4];
float grey[4];
int32_t normalize_R;
int32_t normalize_G;
int32_t normalize_B;
int32_t normalize_sat;
int32_t normalize_light;
int32_t normalize_grey;
int32_t illuminant;
int32_t illum_fluo;
int32_t illum_led;
int32_t adaptation;
float x;
float y;
float temperature;
float gamut;
int32_t clip;
int32_t version;
} dt_channelmixerrgb_v3_t;
/* --- filmicrgb (ver=6, 116 bytes) --- */
typedef struct {
float grey_point_source;
float black_point_source;
float white_point_source;
float reconstruct_threshold;
float reconstruct_feather;
float reconstruct_bloom_vs_details;
float reconstruct_grey_vs_color;
float reconstruct_structure_vs_texture;
float security_factor;
float grey_point_target;
float black_point_target;
float white_point_target;
float output_power;
float latitude;
float contrast;
float saturation;
float balance;
float noise_level;
int32_t preserve_color;
int32_t version;
int32_t auto_hardness;
int32_t custom_grey;
int32_t high_quality_reconstruction;
int32_t noise_distribution;
int32_t shadows;
int32_t highlights;
int32_t compensate_icc_black;
int32_t spline_version;
int32_t enable_highlight_reconstruction;
} dt_filmicrgb_v6_t;
]]
-- ── Internal helpers ──────────────────────────────────────────────────────────
local function bool(v) return v ~= 0 end
-- Cast a Lua binary string into a pointer to ctype.
local function cast_blob(ctype, blob)
local buf = ffi.new("uint8_t[?]", #blob)
ffi.copy(buf, blob, #blob)
return ffi.cast(ctype, buf), buf -- return buf too so GC doesn't collect it
end
-- Read a 4-float array field from a cdata array into a Lua table.
local function farr(p, n)
n = n or 4
local t = {}
for i = 0, n-1 do t[i+1] = p[i] end
return t
end
-- Write a Lua table back into a cdata 4-float array.
local function set_farr(p, t, n)
n = n or 4
for i = 0, n-1 do
if t[i+1] ~= nil then p[i] = t[i+1] end
end
end
-- Apply a field-level patch: if `src[k]` exists, set `dst.k = src[k]`.
local function patch_fields(dst, src, ...)
for _, k in ipairs({...}) do
if src[k] ~= nil then dst[k] = src[k] end
end
end
-- Build a fresh binary blob by copying the existing one and applying changes.
local function make_blob(ctype, size, existing, apply_fn)
local buf = ffi.new("uint8_t[?]", size)
if existing then ffi.copy(buf, existing, size) end
local p = ffi.cast(ctype, buf)
apply_fn(p)
return ffi.string(buf, size)
end
-- ── Per-module decode / encode ────────────────────────────────────────────────
local function decode_flip(blob, ver)
if ver ~= 2 or #blob ~= 4 then return nil end
local p, _ = cast_blob("dt_flip_v2_t *", blob)
return { orientation = tonumber(p.orientation) }
end
local function encode_flip(blob, changes)
return make_blob("dt_flip_v2_t *", 4, blob, function(p)
if changes.orientation ~= nil then p.orientation = changes.orientation end
end)
end
-- ---------------------------------------------------------------------------
local function decode_gamma(blob, ver)
if ver ~= 1 or #blob ~= 8 then return nil end
local p, _ = cast_blob("dt_gamma_v1_t *", blob)
return { gamma = tonumber(p.gamma), linear = tonumber(p.linear) }
end
local function encode_gamma(blob, changes)
return make_blob("dt_gamma_v1_t *", 8, blob, function(p)
patch_fields(p, changes, "gamma", "linear")
end)
end
-- ---------------------------------------------------------------------------
local function decode_temperature(blob, ver)
if ver ~= 3 or #blob ~= 16 then return nil end
local p, _ = cast_blob("dt_temperature_v3_t *", blob)
return {
red = tonumber(p.red),
green = tonumber(p.green),
blue = tonumber(p.blue),
various = tonumber(p.various),
}
end
local function encode_temperature(blob, changes)
return make_blob("dt_temperature_v3_t *", 16, blob, function(p)
patch_fields(p, changes, "red", "green", "blue", "various")
end)
end
-- ---------------------------------------------------------------------------
local function decode_exposure(blob, ver)
if ver ~= 7 or #blob ~= 28 then return nil end
local p, _ = cast_blob("dt_exposure_v7_t *", blob)
return {
mode = tonumber(p.mode),
black = tonumber(p.black),
exposure = tonumber(p.exposure),
deflicker_percentile = tonumber(p.deflicker_percentile),
deflicker_target_level = tonumber(p.deflicker_target_level),
compensate_exposure_bias = bool(p.compensate_exposure_bias),
compensate_hilite_pres = bool(p.compensate_hilite_pres),
}
end
local function encode_exposure(blob, changes)
return make_blob("dt_exposure_v7_t *", 28, blob, function(p)
patch_fields(p, changes, "mode", "black", "exposure",
"deflicker_percentile", "deflicker_target_level")
if changes.compensate_exposure_bias ~= nil then
p.compensate_exposure_bias = changes.compensate_exposure_bias and 1 or 0
end
if changes.compensate_hilite_pres ~= nil then
p.compensate_hilite_pres = changes.compensate_hilite_pres and 1 or 0
end
end)
end
-- ---------------------------------------------------------------------------
local function decode_rawprepare(blob, ver)
if ver == 1 and #blob == 28 then
local p, _ = cast_blob("dt_rawprepare_v1_t *", blob)
return {
left = tonumber(p.left), top = tonumber(p.top),
right = tonumber(p.right), bottom = tonumber(p.bottom),
raw_black_level_separate = farr(p.raw_black_level_separate, 4),
raw_white_point = tonumber(p.raw_white_point),
}
elseif ver == 2 and #blob == 32 then
local p, _ = cast_blob("dt_rawprepare_v2_t *", blob)
return {
left = tonumber(p.left), top = tonumber(p.top),
right = tonumber(p.right), bottom = tonumber(p.bottom),
raw_black_level_separate = farr(p.raw_black_level_separate, 4),
raw_white_point = tonumber(p.raw_white_point),
flat_field = tonumber(p.flat_field),
}
end
return nil
end
local function encode_rawprepare(blob, changes, ver)
if ver == 1 then
return make_blob("dt_rawprepare_v1_t *", 28, blob, function(p)
patch_fields(p, changes, "left", "top", "right", "bottom", "raw_white_point")
if changes.raw_black_level_separate then
set_farr(p.raw_black_level_separate, changes.raw_black_level_separate, 4)
end
end)
else
return make_blob("dt_rawprepare_v2_t *", 32, blob, function(p)
patch_fields(p, changes, "left", "top", "right", "bottom", "raw_white_point", "flat_field")
if changes.raw_black_level_separate then
set_farr(p.raw_black_level_separate, changes.raw_black_level_separate, 4)
end
end)
end
end
-- ---------------------------------------------------------------------------
local function decode_demosaic(blob, ver)
if ver ~= 3 or #blob ~= 20 then return nil end
local p, _ = cast_blob("dt_demosaic_v3_t *", blob)
return {
green_eq = tonumber(p.green_eq),
median_thrs = tonumber(p.median_thrs),
color_smoothing = tonumber(p.color_smoothing),
demosaicing_method = tonumber(p.demosaicing_method),
lmmse_refine = tonumber(p.lmmse_refine),
}
end
local function encode_demosaic(blob, changes)
return make_blob("dt_demosaic_v3_t *", 20, blob, function(p)
patch_fields(p, changes, "green_eq", "median_thrs", "color_smoothing",
"demosaicing_method", "lmmse_refine")
end)
end
-- ---------------------------------------------------------------------------
local function decode_highlights(blob, ver)
if ver ~= 4 or #blob ~= 48 then return nil end
local p, _ = cast_blob("dt_highlights_v4_t *", blob)
return {
mode = tonumber(p.mode),
blendL = tonumber(p.blendL),
blendC = tonumber(p.blendC),
strength = tonumber(p.strength),
clip = tonumber(p.clip),
noise_level = tonumber(p.noise_level),
iterations = tonumber(p.iterations),
scales = tonumber(p.scales),
candidating = tonumber(p.candidating),
combine = tonumber(p.combine),
recovery = tonumber(p.recovery),
solid_color = tonumber(p.solid_color),
}
end
local function encode_highlights(blob, changes)
return make_blob("dt_highlights_v4_t *", 48, blob, function(p)
patch_fields(p, changes, "mode", "blendL", "blendC", "strength", "clip",
"noise_level", "iterations", "scales", "candidating",
"combine", "recovery", "solid_color")
end)
end
-- ---------------------------------------------------------------------------
local function decode_channelmixerrgb(blob, ver)
if ver ~= 3 or #blob ~= 160 then return nil end
local p, _ = cast_blob("dt_channelmixerrgb_v3_t *", blob)
return {
red = farr(p.red),
green = farr(p.green),
blue = farr(p.blue),
saturation = farr(p.saturation),
lightness = farr(p.lightness),
grey = farr(p.grey),
normalize_R = bool(p.normalize_R),
normalize_G = bool(p.normalize_G),
normalize_B = bool(p.normalize_B),
normalize_sat = bool(p.normalize_sat),
normalize_light = bool(p.normalize_light),
normalize_grey = bool(p.normalize_grey),
illuminant = tonumber(p.illuminant),
illum_fluo = tonumber(p.illum_fluo),
illum_led = tonumber(p.illum_led),
adaptation = tonumber(p.adaptation),
x = tonumber(p.x),
y = tonumber(p.y),
temperature = tonumber(p.temperature),
gamut = tonumber(p.gamut),
clip = bool(p.clip),
version = tonumber(p.version),
}
end
local function encode_channelmixerrgb(blob, changes)
return make_blob("dt_channelmixerrgb_v3_t *", 160, blob, function(p)
for _, k in ipairs({"red","green","blue","saturation","lightness","grey"}) do
if changes[k] then set_farr(p[k], changes[k], 4) end
end
for _, k in ipairs({"normalize_R","normalize_G","normalize_B",
"normalize_sat","normalize_light","normalize_grey","clip"}) do
if changes[k] ~= nil then p[k] = changes[k] and 1 or 0 end
end
patch_fields(p, changes, "illuminant","illum_fluo","illum_led","adaptation",
"x","y","temperature","gamut","version")
end)
end
-- ---------------------------------------------------------------------------
local function decode_filmicrgb(blob, ver)
if ver ~= 6 or #blob ~= 116 then return nil end
local p, _ = cast_blob("dt_filmicrgb_v6_t *", blob)
return {
grey_point_source = tonumber(p.grey_point_source),
black_point_source = tonumber(p.black_point_source),
white_point_source = tonumber(p.white_point_source),
reconstruct_threshold = tonumber(p.reconstruct_threshold),
reconstruct_feather = tonumber(p.reconstruct_feather),
reconstruct_bloom_vs_details = tonumber(p.reconstruct_bloom_vs_details),
reconstruct_grey_vs_color = tonumber(p.reconstruct_grey_vs_color),
reconstruct_structure_vs_texture = tonumber(p.reconstruct_structure_vs_texture),
security_factor = tonumber(p.security_factor),
grey_point_target = tonumber(p.grey_point_target),
black_point_target = tonumber(p.black_point_target),
white_point_target = tonumber(p.white_point_target),
output_power = tonumber(p.output_power),
latitude = tonumber(p.latitude),
contrast = tonumber(p.contrast),
saturation = tonumber(p.saturation),
balance = tonumber(p.balance),
noise_level = tonumber(p.noise_level),
preserve_color = tonumber(p.preserve_color),
version = tonumber(p.version),
auto_hardness = bool(p.auto_hardness),
custom_grey = bool(p.custom_grey),
high_quality_reconstruction = tonumber(p.high_quality_reconstruction),
noise_distribution = tonumber(p.noise_distribution),
shadows = tonumber(p.shadows),
highlights = tonumber(p.highlights),
compensate_icc_black = bool(p.compensate_icc_black),
spline_version = tonumber(p.spline_version),
enable_highlight_reconstruction = bool(p.enable_highlight_reconstruction),
}
end
local function encode_filmicrgb(blob, changes)
return make_blob("dt_filmicrgb_v6_t *", 116, blob, function(p)
patch_fields(p, changes,
"grey_point_source","black_point_source","white_point_source",
"reconstruct_threshold","reconstruct_feather",
"reconstruct_bloom_vs_details","reconstruct_grey_vs_color",
"reconstruct_structure_vs_texture","security_factor",
"grey_point_target","black_point_target","white_point_target",
"output_power","latitude","contrast","saturation","balance","noise_level",
"preserve_color","version","high_quality_reconstruction",
"noise_distribution","shadows","highlights","spline_version")
for _, k in ipairs({"auto_hardness","custom_grey","compensate_icc_black",
"enable_highlight_reconstruction"}) do
if changes[k] ~= nil then p[k] = changes[k] and 1 or 0 end
end
end)
end
-- ── Dispatch tables ───────────────────────────────────────────────────────────
local DECODERS = {
flip = decode_flip,
gamma = decode_gamma,
temperature = decode_temperature,
exposure = decode_exposure,
rawprepare = decode_rawprepare,
demosaic = decode_demosaic,
highlights = decode_highlights,
channelmixerrgb = decode_channelmixerrgb,
filmicrgb = decode_filmicrgb,
}
local ENCODERS = {
flip = encode_flip,
gamma = encode_gamma,
temperature = encode_temperature,
exposure = encode_exposure,
rawprepare = function(b, c, v) return encode_rawprepare(b, c, v) end,
demosaic = encode_demosaic,
highlights = encode_highlights,
channelmixerrgb = encode_channelmixerrgb,
filmicrgb = encode_filmicrgb,
}
-- ── Public API ────────────────────────────────────────────────────────────────
local M = {}
-- Decode a binary op_params blob for `operation` (ver = history.module column).
-- Returns a Lua table of named fields, or nil if the operation/version is unknown.
function M.decode(operation, ver, blob)
local fn = DECODERS[operation]
if not fn then return nil end
local ok, result = pcall(fn, blob, ver)
if ok then return result end
return nil
end
-- Apply `changes` (a table of field overrides) to `blob` and return the
-- modified binary string. Returns nil if the operation is not known.
-- For unknown modules, the caller should pass params_b64 directly.
function M.encode(operation, ver, blob, changes)
local fn = ENCODERS[operation]
if not fn then return nil end
local ok, result = pcall(fn, blob, changes, ver)
if ok then return result end
return nil
end
return M

View File

@@ -29,9 +29,10 @@ 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.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.patch(pattern, handler) register("PATCH", pattern, handler) end
function M.delete(pattern, handler) register("DELETE", pattern, handler) end
-- Match a request against registered routes.

View File

@@ -38,8 +38,13 @@ 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/images/:id/preview", api.get_preview)
router.get("/api/v1/images/:id/full", api.get_full)
router.post("/api/v1/images/:id/refresh", api.refresh_cache)
router.post("/api/v1/images/:id/export", api.trigger_export)
router.get("/api/v1/images/:id/modules", api.list_modules)
router.get("/api/v1/images/:id/modules/:op", api.get_module)
router.patch("/api/v1/images/:id/modules/:op", api.patch_module)
router.get("/api/v1/tags", api.list_tags)
router.get("/api/v1/films", api.list_films)
@@ -47,11 +52,11 @@ router.get("/api/v1/films", api.list_films)
-- ── HTTP helpers ──────────────────────────────────────────────────────────────
local STATUS_TEXT = {
[200] = "OK", [201] = "Created", [204] = "No Content",
[200] = "OK", [201] = "Created", [202] = "Accepted", [204] = "No Content",
[301] = "Moved Permanently",
[400] = "Bad Request", [401] = "Unauthorized", [403] = "Forbidden",
[404] = "Not Found", [405] = "Method Not Allowed",
[500] = "Internal Server Error",
[500] = "Internal Server Error", [504] = "Gateway Timeout",
}
local function url_decode(s)
@@ -110,7 +115,7 @@ end
local CORS_HEADERS = {
["Access-Control-Allow-Origin"] = "*",
["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS",
["Access-Control-Allow-Methods"] = "GET, POST, PATCH, PUT, DELETE, OPTIONS",
["Access-Control-Allow-Headers"] = "Authorization, Content-Type",
["Access-Control-Max-Age"] = "86400",
}