- 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.
388 lines
14 KiB
Lua
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
|