diff --git a/contrib/immich.lua b/contrib/immich.lua new file mode 100644 index 00000000..89162719 --- /dev/null +++ b/contrib/immich.lua @@ -0,0 +1,995 @@ +--[[ + This file is part of darktable, + copyright (c) 2024 Giorgio Massussi + copyright (c) 2026 Colin Holzman + + darktable is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + darktable is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with darktable. If not, see . +]] +--[[ +IMMICH +Upload selection to an Immich server + +USAGE +This plugin allows you to upload selected photos to an Immich server (https://immich.app/) +Each image is tracked with darktable tags so re-exporting is safe and cheap: +* an "immich|asset|" tag remembers the uploaded asset id for that image +* an "immich|change|" tag remembers the darktable change timestamp at export time +If an image's change timestamp still matches the cached tag and "Skip unchanged" is +enabled, the export is skipped locally without contacting the Immich server at all. +Otherwise the plugin looks up the cached asset on the server; if it no longer exists +there, or has been trashed, it is treated as gone and the image is uploaded fresh. +When the cached asset is still present, the "Re-export Behavior" option controls what +happens to it: delete/trash it, stack the new upload with it, both, or leave it alone +and upload the image as a new, separate asset. + +Album assignment is controlled by the "Album mode" option: +* No album: exported assets are not added to any album +* Custom / Copy existing + Custom: assets are added to the album named in the "Album Title" field +* Copy albums from existing asset: assets are added to whatever albums the previous + version of the asset already belonged to on the server +* Use Album Title: same as Custom, using the "Album Title" field as the album name +The "Album Title" field is prefilled with the folder name of the exported image when +a title-based mode is selected, but can be edited freely. + +In the lua options you must specify: +* the hostname of the server immich +* an api key generated in the Account settings - API Keys menu of the immich server +The lua options also let you set the defaults for "Album mode", "Skip unchanged", and +"Re-export Behavior" shown above, so the export panel opens with your preferred choices. +An "Immich debug logging" option is also available there to log every request/response +to the darktable debug log, which is useful when troubleshooting a failed upload. + +API KEY PERMISSIONS +When creating the API key on the Immich server, grant it these permissions: +* asset.read - look up a previously uploaded asset's status +* asset.upload - upload exported images +* asset.update - required by the server when stacking a new upload with an existing asset +* asset.delete - delete/trash the previous asset when "Re-export Behavior" removes it +* album.read - find existing albums by name, and look up an asset's albums +* album.create - create the album when it doesn't already exist +* albumAsset.create - add uploaded assets to an album +* stack.create - stack a new upload with an existing asset + +INSTALL +* install luasec, cjson, and luasocket for darktable's Lua version (currently 5.4) on your system +* if darktable can't find them (common on macOS/Windows, where it bundles its own + Lua), set the "immich: Lua module install prefix" preference in the lua options + to the folder containing share/lua/ and lib/lua/, then restart + +]] +local dt = require "darktable" +local du = require "lib/dtutils" +local df = require "lib/dtutils.file" +local log = require "lib/dtutils.log" + +local gettext = dt.gettext.gettext + +local function _(msgid) + return gettext(msgid) +end + +-- forward declaration so store_image() (defined above its widget) resolves this +-- as an upvalue rather than a nil global +local title_widget +local album_mode_widget +local album_title_row_widget +local existing_asset_action_widget +local skip_unchanged_widget +local prefill_title_widget_from_images + +local ALBUM_MODE_NONE = 1 +local ALBUM_MODE_TITLE = 2 +local ALBUM_MODE_COPY_EXISTING = 3 +local ALBUM_MODE_COPY_AND_TITLE = 4 +local ALBUM_MODE_USE_ALBUM_TITLE = 5 + +local EXISTING_ASSET_ACTION_DELETE = 1 +local EXISTING_ASSET_ACTION_STACK = 2 +local EXISTING_ASSET_ACTION_DELETE_AND_STACK = 3 +local EXISTING_ASSET_ACTION_UPLOAD_NEW = 4 + +-- Shared between the combobox widgets and the "default value" preferences below, +-- so the option order only needs to be maintained in one place. +local ALBUM_MODE_LABELS = { + _("No album"), + _("Custom"), + _("Copy albums from existing asset"), + _("Copy existing + Custom"), + _("Use Album Title"), +} +local EXISTING_ASSET_ACTION_LABELS = { + _("Delete/Trash existing asset on server"), + _("Stack with existing asset"), + _("Delete/Trash & Stack"), + _("Upload as new image"), +} + +local function index_of_label(labels, label) + for i,candidate in ipairs(labels) do + if candidate == label then + return i + end + end + return nil +end + +-- Immich requires a deviceId on every uploaded asset, but we no longer use it to +-- look up previously uploaded assets -- that's handled entirely by the local +-- "immich|asset|..." / "immich|change|..." darktable tags -- so a fixed value is fine. +local IMMICH_DEVICE_ID = "darktable" + +-- The version (e.g. "5.4") of the Lua interpreter darktable is running -- bundled +-- on macOS/Windows, the system Lua on Linux. Derived from _VERSION rather than +-- hardcoded, so the search paths and user-facing messages keep working if +-- darktable's Lua changes. +local lua_version = _VERSION:match("(%d+%.%d+)") or "5.4" +local DEFAULT_LOG_LEVEL = log.warn + +local function set_log_level(level) + local old_log_level = log.log_level() + log.log_level(level) + return old_log_level +end + +local function restore_log_level(level) + log.log_level(level) +end + +local function image_can_skip_remote_work(image, skip_unchanged) + if not skip_unchanged then + return false + end + + local cached_asset_id = nil + local cached_change_marker = nil + local tags = image:get_tags() + if tags == nil then + return false + end + + for _,tag in ipairs(tags) do + if tag ~= nil and tag.name ~= nil then + if cached_asset_id == nil then + cached_asset_id = tag.name:match("^immich%|asset%|(.-)$") + end + if cached_change_marker == nil then + cached_change_marker = tag.name:match("^immich%|change%|(.-)$") + end + if cached_asset_id ~= nil and cached_change_marker ~= nil then + break + end + end + end + + if cached_asset_id == nil or cached_change_marker == nil then + return false + end + + return cached_change_marker == tostring(image.change_timestamp or "") +end + +dt.preferences.register("immich", "immich_debug_logging", "bool", + _("Immich debug logging"), + _("Enable verbose debug messages for Immich uploads and album updates."), + false) + +log.log_level(DEFAULT_LOG_LEVEL) + + + +-- Load optional deps defensively so a missing one yields an actionable message. +local missing = {} +local function need(modname, rock) + local ok, mod = pcall(require, modname) + if not ok then missing[#missing + 1] = string.format("'%s' (%s)", modname, rock) end + return ok and mod or nil +end + +local cjson = need("cjson.safe", "lua-cjson") +local https = need("ssl.https", "luasec") +local http = need("socket.http", "luasocket") +local ltn12 = need("ltn12", "luasocket") + +du.check_min_api_version("7.0.0", "immich") + +local function call_immich_api(method,api,body,content_type) + local immichserver = dt.preferences.read("immich","immich_server","string") or "" + if immichserver == "" then + log.msg(log.debug, "missing Immich server URL (immich_server preference)") + return nil, "missing_server_url", nil + end + + local client = immichserver:match("^https") and https or http + local headers = { } + headers["x-api-key"] = dt.preferences.read("immich","immich_key","string") or "" + local source = nil + if body == nil then + elseif (content_type == nil or content_type == "application/json") then + headers["Content-Type"] = "application/json" + local encoded, enc_err = cjson.encode(body) + if not encoded then + log.msg(log.debug, "JSON encode failed for /api/" .. tostring(api) .. ": " .. tostring(enc_err)) + return nil, "json_encode_failed", nil + end + headers["Content-Length"] = #encoded + source = ltn12.source.string(encoded) + elseif (content_type == "multipart/form-data") then + local boundary = "----DarktableImmichBoundary" .. math.random(1, 1e16) + headers["Content-Type"] = "multipart/form-data; boundary="..boundary + source = ltn12.source.empty() + local content_length = 0 + for name,value in pairs(body) do + if (value.filename ~= nil) then + local form_data_table = {} + if (content_length > 0) then + table.insert(form_data_table,"") + end + table.insert(form_data_table, "--"..boundary) + table.insert(form_data_table, "Content-Disposition: form-data; name=\""..name.."\"; filename=\"".. value.filename .. "\"") + table.insert(form_data_table, "Content-Type: application/octet-stream") + table.insert(form_data_table, "") + table.insert(form_data_table, "") + local form_data = table.concat(form_data_table, "\r\n") + content_length = content_length+value.file:seek("end")+string.len(form_data) + value.file:seek("set",0) + source = ltn12.source.simplify(ltn12.source.cat(source, + ltn12.source.string(form_data), + ltn12.source.file(value.file))) + else + local form_data_table = {} + if (content_length > 0) then + table.insert(form_data_table,"") + end + table.insert(form_data_table, "--"..boundary) + table.insert(form_data_table, "Content-Disposition: form-data; name=\""..name.."\"") + table.insert(form_data_table, "") + table.insert(form_data_table, value) + local form_data = table.concat(form_data_table, "\r\n") + content_length = content_length+string.len(form_data) + source = ltn12.source.cat(source,ltn12.source.string(form_data)) + end + end + content_length = content_length+6+string.len(boundary) + source = ltn12.source.cat(source,ltn12.source.string("\r\n--"..boundary.."--")) + headers["Content-Length"] = content_length + end + + log.msg(log.debug, "request " .. method .. " /api/" .. api) + local res_table={} + local res, err, response_headers = client.request{ + method=method, + url=immichserver.."/api/"..api, + headers=headers, + source=source, + sink=ltn12.sink.table(res_table) + } + local body_text = table.concat(res_table) + log.msg(log.debug, "response " .. tostring(err) .. " for /api/" .. api .. " -> " .. body_text) + local ct = response_headers ~= nil and response_headers["content-type"] or "" + if type(ct) == "string" and string.lower(ct):match("^application/json") then + return cjson.decode(body_text), err, response_headers + end + return body_text, err, response_headers +end + +local function url_encode(str) + if str == nil then + return "" + end + return (str:gsub("([^%w%-%_%.%~])", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +-- Looks up an album's id by its exact name; album names map to at most one +-- album, so there's nothing to look up more than once per name. +local function find_album_id_by_name(album_name) + if album_name == nil or album_name == "" then + return nil + end + + local res_albums, err_albums = call_immich_api("GET", "albums?name=" .. url_encode(album_name)) + if err_albums ~= 200 or res_albums == nil or res_albums[1] == nil then + log.msg(log.debug, "no existing album found named '" .. tostring(album_name) .. "' (HTTP " .. tostring(err_albums) .. ")") + return nil + end + + return res_albums[1].id +end + +local function initialize(storage,format,images,high_quality,extra_data) + local debug_enabled = dt.preferences.read("immich", "immich_debug_logging", "bool") + extra_data.log_level = set_log_level(debug_enabled and log.debug or DEFAULT_LOG_LEVEL) + + if #missing > 0 then + -- Stash the message for finalize: a dt.print here would be overwritten by + -- darktable's own "no image to export" that follows the empty return. + extra_data.error = string.format(_("immich: missing Lua libraries (luasocket, luasec, lua-cjson) for Lua %s — install them, set the 'Lua module install prefix' preference if needed, then restart"), lua_version) + return {} -- cancels the export + end + log.msg(log.debug, "initialize started for " .. tostring(#images) .. " images") + extra_data.album_assets = {} + extra_data.album_asset_updates = {} + extra_data.existing_asset_action = existing_asset_action_widget ~= nil + and existing_asset_action_widget.selected or EXISTING_ASSET_ACTION_STACK + extra_data.skip_unchanged = skip_unchanged_widget == nil or skip_unchanged_widget.value + extra_data.album_mode = album_mode_widget ~= nil and album_mode_widget.selected or ALBUM_MODE_NONE + if extra_data.album_mode == ALBUM_MODE_TITLE + or extra_data.album_mode == ALBUM_MODE_COPY_AND_TITLE then + prefill_title_widget_from_images(images) + end + + local all_images_skip_remote = #images > 0 + for _,image in ipairs(images) do + if not image_can_skip_remote_work(image, extra_data.skip_unchanged) then + all_images_skip_remote = false + break + end + end + if all_images_skip_remote then + log.msg(log.debug, "all images matched local cache tags; skipping Immich auth and album lookups") + return images + end + + -- api-keys/me requires no specific permission (unlike auth/status, which needs "all"), + -- so it validates the key and returns its granted permissions in one call. + local key_res, key_err = call_immich_api("GET", "api-keys/me") + if key_err == 403 then + log.msg(log.debug, "authentication failed") + extra_data.error = "Authentication error. Check your Immich API key in LUA settings." + return {} + elseif key_err ~= 200 then + log.msg(log.debug, "authentication check returned HTTP " .. tostring(key_err)) + extra_data.error = "Error contacting Immich server: HTTP " .. key_err + return {} + end + + local granted_permissions = key_res ~= nil and key_res.permissions or nil + local function has_permission(name) + if granted_permissions == nil then + return true -- couldn't read the permission list; don't block the export on that alone + end + for _,p in ipairs(granted_permissions) do + if p == "all" or p == name then + return true + end + end + return false + end + + -- Only require what this run's actual settings will use, not the full header list. + local required_permissions = {"asset.read", "asset.upload"} + if extra_data.existing_asset_action == EXISTING_ASSET_ACTION_DELETE + or extra_data.existing_asset_action == EXISTING_ASSET_ACTION_DELETE_AND_STACK then + table.insert(required_permissions, "asset.delete") + end + if extra_data.existing_asset_action == EXISTING_ASSET_ACTION_STACK + or extra_data.existing_asset_action == EXISTING_ASSET_ACTION_DELETE_AND_STACK then + table.insert(required_permissions, "asset.update") + table.insert(required_permissions, "stack.create") + end + if extra_data.album_mode ~= ALBUM_MODE_NONE then + table.insert(required_permissions, "album.read") + table.insert(required_permissions, "album.create") + table.insert(required_permissions, "albumAsset.create") + end + + local missing_permissions = {} + for _,permission in ipairs(required_permissions) do + if not has_permission(permission) then + table.insert(missing_permissions, permission) + end + end + + if #missing_permissions > 0 then + local missing_list = table.concat(missing_permissions, ", ") + log.msg(log.debug, "API key is missing required permission(s): " .. missing_list) + extra_data.error = "Immich API key is missing required permission(s): " .. missing_list + .. ". See the API KEY PERMISSIONS section in this script's header." + return {} + end + + return images +end + +local function iso_exif_datetime_taken(image) + local exif = image ~= nil and image.exif_datetime_taken or nil + if exif == nil or exif == "" then + return os.date("!%Y-%m-%dT%H:%M:%S") .. "Z" + end + + local yr,mo,dy,h,m,s = string.match(exif, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)") + if yr == nil then + return os.date("!%Y-%m-%dT%H:%M:%S") .. "Z" + end + + local timestamp = os.time{year=yr, month=mo, day=dy, hour=h, min=m, sec=s} + return os.date("!%Y-%m-%dT%H:%M:%S", timestamp) .. "Z" +end +local function get_asset_details(asset_id) + if asset_id == nil or asset_id == "" then + return nil, nil + end + + local res_asset, err_asset = call_immich_api("GET", "assets/" .. tostring(asset_id)) + if err_asset ~= 200 or res_asset == nil then + return nil, err_asset + end + + return res_asset, err_asset +end + +local function fetch_asset_album_ids(asset_id) + local ids = {} + if asset_id == nil or asset_id == "" then + return ids + end + + local res_albums, err_albums = call_immich_api("GET", "albums?assetId=" .. tostring(asset_id)) + if err_albums ~= 200 or res_albums == nil then + log.msg(log.debug, "failed to look up albums for existing asset " .. tostring(asset_id) .. " with HTTP " .. tostring(err_albums)) + return ids + end + + for _,album in ipairs(res_albums) do + if album ~= nil and album.id ~= nil and album.id ~= "" then + ids[#ids + 1] = album.id + end + end + + return ids +end + +local function queue_asset_for_album_id(extra_data, album_id, asset_id) + if album_id == nil or album_id == "" or asset_id == nil or asset_id == "" then + return + end + + local album_assets = extra_data.album_asset_updates[album_id] + if album_assets == nil then + album_assets = {} + extra_data.album_asset_updates[album_id] = album_assets + end + table.insert(album_assets, asset_id) +end + +local function basename_from_path(path) + if path == nil or path == "" then + return "" + end + + local name = "" + for part in string.gmatch(path, "[^/\\]+") do + name = part + end + return name +end + +local function default_album_name_from_images(images) + if images == nil or #images == 0 then + return "" + end + + local first = images[1] + if first == nil or first.path == nil then + return "" + end + + return basename_from_path(first.path) +end + +prefill_title_widget_from_images = function(images) + if title_widget == nil then + return + end + + if title_widget.text ~= nil and title_widget.text ~= "" then + return + end + + local default_name = default_album_name_from_images(images) + if default_name ~= "" then + title_widget.text = default_name + end +end + +local function prefill_title_widget_from_selection() + local ok, action_images = pcall(function() + return dt.gui.action_images + end) + if ok then + prefill_title_widget_from_images(action_images) + end +end + +local function update_album_title_row_visibility() + if album_title_row_widget == nil then + return + end + + local mode = album_mode_widget ~= nil and album_mode_widget.selected or ALBUM_MODE_NONE + album_title_row_widget.visible = (mode == ALBUM_MODE_TITLE + or mode == ALBUM_MODE_COPY_AND_TITLE) + if album_title_row_widget.visible then + prefill_title_widget_from_selection() + end +end + +local function upload_image(image,filename) + local date = iso_exif_datetime_taken(image) + local f, open_err = io.open(filename, "rb") + if f == nil then + log.msg(log.debug, "failed to open export file '" .. tostring(filename) .. "': " .. tostring(open_err)) + return nil + end + local form_data = { + deviceAssetId=tostring(image.id), + deviceId=IMMICH_DEVICE_ID, + fileCreatedAt=date, + fileModifiedAt=date, + assetData={ + filename=df.get_filename(filename), + file=f + } + } + log.msg(log.debug, "uploading new asset for image " .. tostring(image.id)) + local res,err = call_immich_api("POST","assets",form_data,"multipart/form-data") + if err == 201 and res ~= nil then + log.msg(log.debug, "upload succeeded with asset id " .. tostring(res.id)) + return res.id + end + return nil +end + +local function delete_asset(asset_id) + if asset_id == nil or asset_id == "" then + return false + end + + log.msg(log.debug, "deleting previous asset " .. tostring(asset_id)) + local _delete_res, delete_err = call_immich_api("DELETE", "assets", {ids={asset_id}, force=false}) + if delete_err == 200 or delete_err == 204 then + log.msg(log.debug, "delete succeeded for asset " .. tostring(asset_id)) + return true + end + + log.msg(log.debug, "delete failed for asset " .. tostring(asset_id) .. " with HTTP " .. tostring(delete_err)) + return false +end + +local function create_stack_with_existing_asset(image,filename,existing_asset_id) + log.msg(log.debug, "uploading new asset for image " .. tostring(image.id) .. " and stacking with existing asset " .. tostring(existing_asset_id)) + local new_asset_id, upload_err = upload_image(image,filename) + if new_asset_id == nil then + log.msg(log.debug, "upload failed while preparing stack for image " .. tostring(image.id)) + return nil + end + + local stack_res, stack_err = call_immich_api("POST","stacks",{assetIds={new_asset_id, existing_asset_id}}) + if stack_err == 201 then + log.msg(log.debug, "created stack with primary asset " .. tostring(new_asset_id) .. " and existing asset " .. tostring(existing_asset_id)) + return new_asset_id + end + + log.msg(log.debug, "stack creation failed for image " .. tostring(image.id) .. " with HTTP " .. tostring(stack_err)) + return new_asset_id +end + +local function get_cached_asset_id(image) + local tags = image.get_tags(image) + if tags ~= nil then + log.msg(log.debug, "checking tags for cached asset id on image " .. tostring(image.id)) + for _,tag in ipairs(tags) do + if tag ~= nil and tag.name ~= nil then + local asset_id = tag.name:match("^immich%|asset%|(.-)$") + if asset_id ~= nil and asset_id ~= "" then + log.msg(log.debug, "found cached asset id " .. tostring(asset_id) .. " in tag " .. tostring(tag.name)) + return asset_id + end + end + end + end + + local id = dt.preferences.read("immich", "asset_" .. tostring(image.id), "string") + if id == nil or id == "" then + log.msg(log.debug, "no cached asset id found in preferences for image " .. tostring(image.id)) + return nil + end + log.msg(log.debug, "found cached asset id " .. tostring(id) .. " in preferences for image " .. tostring(image.id)) + return id +end + +local function get_cached_change_tag(image) + local tags = image.get_tags(image) + if tags ~= nil then + log.msg(log.debug, "checking tags for cached change marker on image " .. tostring(image.id)) + for _,tag in ipairs(tags) do + if tag ~= nil and tag.name ~= nil then + local change_marker = tag.name:match("^immich%|change%|(.-)$") + if change_marker ~= nil and change_marker ~= "" then + log.msg(log.debug, "found cached change marker " .. tostring(change_marker) .. " in tag " .. tostring(tag.name)) + return change_marker + end + end + end + end + + return nil +end + +local function set_cached_asset_id(image, asset_id) + log.msg(log.debug, "writing cached asset id " .. tostring(asset_id) .. " for image " .. tostring(image.id)) + dt.preferences.write("immich", "asset_" .. tostring(image.id), "string", asset_id) + + local tag_name = "immich|asset|" .. tostring(asset_id) + log.msg(log.debug, "ensuring tag " .. tostring(tag_name) .. " exists for image " .. tostring(image.id)) + local tag = dt.tags.find(tag_name) + if tag == nil then + tag = dt.tags.create(tag_name) + end + + -- Keep only the latest Immich asset tag on the image. + local tags = image.get_tags(image) + if tags ~= nil then + for _,existing_tag in ipairs(tags) do + if existing_tag ~= nil and existing_tag.name ~= nil + and existing_tag.name:match("^immich%|asset%|") ~= nil + and existing_tag.name ~= tag_name then + log.msg(log.debug, "detaching old tag " .. tostring(existing_tag.name) .. " from image " .. tostring(image.id)) + image:detach_tag(existing_tag) + end + end + end + + if tag ~= nil then + local already_attached = false + if tags ~= nil then + for _,existing_tag in ipairs(tags) do + if existing_tag ~= nil and existing_tag.name == tag_name then + already_attached = true + break + end + end + end + if not already_attached then + log.msg(log.debug, "attaching tag " .. tostring(tag_name) .. " to image " .. tostring(image.id)) + image:attach_tag(tag) + else + log.msg(log.debug, "tag " .. tostring(tag_name) .. " already attached to image " .. tostring(image.id)) + end + else + log.msg(log.debug, "could not create or find tag " .. tostring(tag_name) .. " for image " .. tostring(image.id)) + end +end + +local function set_cached_change_tag(image, change_marker) + if change_marker == nil or change_marker == "" then + log.msg(log.debug, "no change marker available to cache for image " .. tostring(image.id)) + return + end + + local tag_name = "immich|change|" .. tostring(change_marker) + log.msg(log.debug, "ensuring change marker tag " .. tostring(tag_name) .. " exists for image " .. tostring(image.id)) + local tag = dt.tags.find(tag_name) + if tag == nil then + tag = dt.tags.create(tag_name) + end + + local tags = image.get_tags(image) + if tags ~= nil then + for _,existing_tag in ipairs(tags) do + if existing_tag ~= nil and existing_tag.name ~= nil + and existing_tag.name:match("^immich%|change%|") ~= nil + and existing_tag.name ~= tag_name then + log.msg(log.debug, "detaching old change marker tag " .. tostring(existing_tag.name) .. " from image " .. tostring(image.id)) + image:detach_tag(existing_tag) + end + end + end + + if tag ~= nil then + local already_attached = false + if tags ~= nil then + for _,existing_tag in ipairs(tags) do + if existing_tag ~= nil and existing_tag.name == tag_name then + already_attached = true + break + end + end + end + if not already_attached then + log.msg(log.debug, "attaching change marker tag " .. tostring(tag_name) .. " to image " .. tostring(image.id)) + image:attach_tag(tag) + else + log.msg(log.debug, "change marker tag " .. tostring(tag_name) .. " already attached to image " .. tostring(image.id)) + end + else + log.msg(log.debug, "could not create or find change marker tag " .. tostring(tag_name) .. " for image " .. tostring(image.id)) + end +end + +local function store_image(storage,image,format,filename,number,total,high_quality,extra_data) + log.msg(log.debug, "storing image " .. tostring(image.id) .. " (" .. tostring(number) .. "/" .. tostring(total) .. ")") + local asset_id = nil + local replaced = false + local existing_asset_action = extra_data.existing_asset_action or EXISTING_ASSET_ACTION_STACK + local skip_unchanged = extra_data.skip_unchanged + if skip_unchanged == nil then + skip_unchanged = true + end + local cached_id = get_cached_asset_id(image) + local cached_change_marker = get_cached_change_tag(image) + local local_change_timestamp = tostring(image.change_timestamp or "") + if skip_unchanged and cached_id ~= nil and cached_change_marker ~= nil and cached_change_marker == local_change_timestamp then + log.msg(log.debug, "cached change marker matches local change timestamp for image " .. tostring(image.id) .. "; reusing cached asset id " .. tostring(cached_id) .. " without metadata lookup") + asset_id = cached_id + replaced = true + end + local existing_asset_id = nil + local existing_asset = nil + + if asset_id == nil and cached_id ~= nil then + log.msg(log.debug, "looking up cached asset id " .. tostring(cached_id) .. " for image " .. tostring(image.id)) + local asset_details, asset_err = get_asset_details(cached_id) + if asset_details ~= nil then + existing_asset_id = cached_id + existing_asset = asset_details + else + log.msg(log.debug, "cached asset id " .. tostring(cached_id) .. " no longer exists on server (HTTP " .. tostring(asset_err) .. "); uploading new") + end + end + + if existing_asset_id ~= nil and existing_asset ~= nil and existing_asset.isTrashed == true then + log.msg(log.debug, "existing remote asset is trashed; ignoring cached asset id " .. tostring(existing_asset_id) .. " and uploading a fresh copy") + existing_asset_id = nil + existing_asset = nil + end + + local copied_album_ids = {} + local requested_album_mode = extra_data.album_mode or ALBUM_MODE_NONE + if existing_asset_id ~= nil and asset_id == nil + and (requested_album_mode == ALBUM_MODE_COPY_EXISTING or requested_album_mode == ALBUM_MODE_COPY_AND_TITLE) then + copied_album_ids = fetch_asset_album_ids(existing_asset_id) + log.msg(log.debug, "found " .. tostring(#copied_album_ids) .. " album(s) on existing asset " .. tostring(existing_asset_id) .. " to copy for image " .. tostring(image.id)) + end + + if asset_id ~= nil then + log.msg(log.debug, "using cached asset id " .. tostring(asset_id) .. " for image " .. tostring(image.id) .. "; skipping remote asset actions") + elseif existing_asset_id ~= nil then + if existing_asset_action == EXISTING_ASSET_ACTION_UPLOAD_NEW then + log.msg(log.debug, "existing-asset action is upload as new image; uploading independent copy, leaving existing asset id " .. tostring(existing_asset_id) .. " untouched") + asset_id = upload_image(image,filename) + elseif existing_asset_action == EXISTING_ASSET_ACTION_DELETE then + log.msg(log.debug, "existing-asset action is delete/trash; uploading replacement for existing asset id " .. tostring(existing_asset_id)) + asset_id = upload_image(image,filename) + if asset_id ~= nil then + delete_asset(existing_asset_id) + end + else + local stacked_asset_id = create_stack_with_existing_asset(image,filename,existing_asset_id) + if stacked_asset_id ~= nil then + asset_id = stacked_asset_id + replaced = false + if existing_asset_action == EXISTING_ASSET_ACTION_DELETE_AND_STACK then + log.msg(log.debug, "existing-asset action is delete-and-stack; deleting existing asset id " .. tostring(existing_asset_id) .. " after stacking") + delete_asset(existing_asset_id) + end + else + log.msg(log.debug, "stack creation failed for existing asset id " .. tostring(existing_asset_id) .. ", falling back to standalone upload") + asset_id = upload_image(image,filename) + replaced = false + if asset_id ~= nil and existing_asset_action == EXISTING_ASSET_ACTION_DELETE_AND_STACK then + log.msg(log.debug, "delete-and-stack fallback path; deleting existing asset id " .. tostring(existing_asset_id) .. " after standalone upload") + delete_asset(existing_asset_id) + end + end + end + else + log.msg(log.debug, "no existing remote asset found for image " .. tostring(image.id) .. ", uploading new") + asset_id = upload_image(image,filename) + end + + if asset_id == nil then + log.msg(log.debug, "upload failed for image " .. tostring(image.id)) + extra_data.error = "Error uploading some image" + return + end + + if not replaced then + log.msg(log.debug, "caching asset id " .. tostring(asset_id) .. " for image " .. tostring(image.id)) + set_cached_change_tag(image, image.change_timestamp) + set_cached_asset_id(image, asset_id) + end + + if not replaced then + local album_mode = extra_data.album_mode or ALBUM_MODE_NONE + if album_mode == ALBUM_MODE_TITLE + or album_mode == ALBUM_MODE_COPY_AND_TITLE + or album_mode == ALBUM_MODE_USE_ALBUM_TITLE then + local album_name + if album_mode == ALBUM_MODE_USE_ALBUM_TITLE then + -- Uses darktable's own folder name for the image rather than the manual + -- title input box, which is hidden for this mode. + album_name = basename_from_path(image.path) + else + album_name = title_widget.text or "" + end + if album_name == "" then + log.msg(log.debug, "album mode requires an album name but none was available; skipping album assignment") + else + log.msg(log.debug, "album mode resolved album name '" .. tostring(album_name) .. "' for image " .. tostring(image.id)) + local album_assets = extra_data.album_assets[album_name] + if album_assets == nil then + album_assets = {} + extra_data.album_assets[album_name] = album_assets + end + table.insert(album_assets,asset_id) + log.msg(log.debug, "queued asset " .. tostring(asset_id) .. " for album " .. tostring(album_name)) + end + end + + if album_mode == ALBUM_MODE_COPY_EXISTING or album_mode == ALBUM_MODE_COPY_AND_TITLE then + if #copied_album_ids == 0 then + log.msg(log.debug, "album copy mode selected but no albums found on existing asset for image " .. tostring(image.id)) + else + for _,album_id in ipairs(copied_album_ids) do + queue_asset_for_album_id(extra_data, album_id, asset_id) + log.msg(log.debug, "queued asset " .. tostring(asset_id) .. " for existing album id " .. tostring(album_id)) + end + end + else + log.msg(log.debug, "album mode is none; skipping album assignment for image " .. tostring(image.id)) + end + end +end + +local function finalize(storage,image_table,extra_data) + if extra_data.album_assets ~= nil then + local album_count = 0 + for _ in pairs(extra_data.album_assets) do + album_count = album_count + 1 + end + log.msg(log.debug, "finalizing album updates for " .. tostring(album_count) .. " album(s)") + for album_name,album_assets in pairs(extra_data.album_assets) do + local album_id = find_album_id_by_name(album_name) + if album_id == nil then + log.msg(log.debug, "creating new album: " .. album_name) + call_immich_api("POST","albums",{albumName=album_name,assetIds=album_assets}) + else + log.msg(log.debug, "adding assets to album: "..album_name) + call_immich_api("PUT","albums/"..album_id.."/assets",{ids=album_assets}) + end + end + end + if extra_data.album_asset_updates ~= nil then + local album_update_count = 0 + for _ in pairs(extra_data.album_asset_updates) do + album_update_count = album_update_count + 1 + end + log.msg(log.debug, "finalizing copy-to-existing-album updates for " .. tostring(album_update_count) .. " album(s)") + for album_id,album_assets in pairs(extra_data.album_asset_updates) do + log.msg(log.debug, "adding assets to existing album id: " .. tostring(album_id)) + call_immich_api("PUT","albums/"..album_id.."/assets",{ids=album_assets}) + end + end + if extra_data.error ~= nil then + log.msg(log.debug, "finalizing with error: " .. tostring(extra_data.error)) + log.msg(log.error, extra_data.error) + end + restore_log_level(extra_data.log_level) +end + +local function destroy() + dt.destroy_storage("immich") +end + +dt.preferences.register( + "immich","immich_server","string", + _("Immich server"), + _("The url of the Immich server to upload"), + "http://localhost:2283") + +dt.preferences.register( + "immich","immich_key","string", + _("Immich API key"), + _("A valid Immich API key"), + "") + +dt.preferences.register( + "immich","default_album_mode","enum", + _("Default album mode"), + _("Default value of the 'Album mode' export option."), + ALBUM_MODE_LABELS[ALBUM_MODE_COPY_EXISTING], + table.unpack(ALBUM_MODE_LABELS)) + +dt.preferences.register( + "immich","default_skip_unchanged","bool", + _("Default: Skip unchanged"), + _("Default value of the 'Skip unchanged' export option."), + true) + +dt.preferences.register( + "immich","default_existing_asset_action","enum", + _("Default re-export behavior"), + _("Default value of the 'Re-export Behavior' export option."), + EXISTING_ASSET_ACTION_LABELS[EXISTING_ASSET_ACTION_DELETE_AND_STACK], + table.unpack(EXISTING_ASSET_ACTION_LABELS)) + +local default_album_mode = index_of_label(ALBUM_MODE_LABELS, + dt.preferences.read("immich", "default_album_mode", "enum")) or ALBUM_MODE_COPY_EXISTING +local default_existing_asset_action = index_of_label(EXISTING_ASSET_ACTION_LABELS, + dt.preferences.read("immich", "default_existing_asset_action", "enum")) or EXISTING_ASSET_ACTION_DELETE_AND_STACK + +title_widget = dt.new_widget("entry") { + placeholder=_("No album") +} +album_mode_widget = dt.new_widget("combobox") { + label = _("Album mode"), + tooltip = _("Choose how exported assets are assigned to albums."), + selected = default_album_mode, + changed_callback = function(_) + update_album_title_row_visibility() + end, + table.unpack(ALBUM_MODE_LABELS) +} +skip_unchanged_widget = dt.new_widget("check_button") { + label = _("Skip unchanged"), + tooltip = _("Skip contacting the Immich server entirely when an image hasn't changed since its last export."), + value = dt.preferences.read("immich", "default_skip_unchanged", "bool") +} +existing_asset_action_widget = dt.new_widget("combobox") { + label = _("Re-export Behavior"), + tooltip = _("Choose what happens to the previous asset on the server when a changed image is re-exported."), + selected = default_existing_asset_action, + table.unpack(EXISTING_ASSET_ACTION_LABELS) +} +album_title_row_widget = dt.new_widget("box") { + orientation=horizontal, + dt.new_widget("label"){label = _("Album Title"), tooltip = _("Set the album title on Immich for the exported images.") }, + title_widget +} + +local widget = dt.new_widget("box") { + orientation=vertical, + album_mode_widget, + album_title_row_widget, + skip_unchanged_widget, + existing_asset_action_widget +} + +update_album_title_row_visibility() + +dt.register_storage("immich",_("immich"), + store_image, + finalize, + nil, + initialize, + widget) + +local script_data = {} + +script_data.metadata = { + name = "immich", + purpose = _("upload all selected images to Immich server"), + author = "Daniel N. Hansten" +} + +script_data.destroy = destroy -- function to destroy the script +script_data.destroy_method = nil -- set to hide for libs since we can't destroy them completely yet, otherwise leave as nil +script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again +script_data.show = nil -- only required for libs since the destroy_method only hides them + +return script_data +-- +-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua