Files
lua-scripts-w-api/contrib/darktable-api/db.lua
stephen schuresko bd40b4f850 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.
2026-06-10 15:02:19 -04:00

388 lines
14 KiB
Lua

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