From 7cc6b1dfb2f1e37d6dd1646619c98e635d1801bc Mon Sep 17 00:00:00 2001 From: Colin Holzman Date: Thu, 25 Jun 2026 13:21:36 -0400 Subject: [PATCH 1/2] add immich_upload.lua --- contrib/immich_upload.lua | 377 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 contrib/immich_upload.lua diff --git a/contrib/immich_upload.lua b/contrib/immich_upload.lua new file mode 100644 index 00000000..40ba8738 --- /dev/null +++ b/contrib/immich_upload.lua @@ -0,0 +1,377 @@ +--[[ + copyright (c) 2024 Guillaume Godin + + This program is a 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 2 of the License, or + (at your option) any later version. + + This program 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 this program. If not, see . +]] +--[[ + immich_upload.lua - Immich storage for darktable + + Immich is a self-hosted photo management service that provides features similar to Google Photos. + This script adds a new storage option to upload exported photos directly to an Immich server without keeping a local copy. + It uses the immich-cli tool to upload the images. It has 2 configurable preferences: + * Immich server URL (default: http://localhost:2283) + * Immich API key (default: empty) + The script will appear as "Immich Upload" in the export module storage list. It can be run in + dry-run mode to test the upload without actually sending files. + + ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT + * immich-cli (https://immich.app/docs/features/command-line-interface/) + + USAGE + -- Configuration + 1. Install immich-cli on your system + 2. Configure your Immich server URL and API key in darktable preferences + 3. The script will appear as "Immich Upload" in the export module storage list + + -- Uploading images + 1. Select images to export + 2. Choose "Immich Upload" as the storage option in the export module + 3. Optionally enter an album name + 4. Enable dry run mode if you want to test without uploading + 5. Start the export process + + BUGS, COMMENTS, SUGGESTIONS + * Send to Guillaume Godin, godin.guillaume@gmail.com + + CHANGES + * 2025-03-16 - Initial version +]] + +local dt = require "darktable" +local du = require "lib/dtutils" +local df = require "lib/dtutils.file" +local log = require "lib/dtutils.log" +local dtsys = require "lib/dtutils.system" +local gettext = dt.gettext.gettext + +-- Module namespace for preferences +local namespace = 'module_immich' + +-- Check minimum API version. This was only tested on darktable 5 with the 9.4.0 API. +du.check_min_api_version("9.4.0", "immich") + +-- Localization +local function _(msgid) + return gettext(msgid) +end + +-- Script metadata +local script_data = {} +script_data.metadata = { + name = _("immich"), + purpose = _("upload images to Immich server"), + author = "your name", + help = "https://github.com/immich-app/immich" +} + +-- Check if immich-cli is available +local function check_immich_cli() + local immich_cli = df.check_if_bin_exists("immich") + log.msg(log.debug, "checking for immich-cli at: ", immich_cli) + + if not immich_cli then + local err_msg = "immich-cli not found. Please install it first." + log.msg(log.error, err_msg) + dt.print(_(err_msg)) + return false + end + return true +end + +-- Show export progress +local function show_status(storage, image, format, filename, + number, total, high_quality, extra_data) + dt.print(string.format(_("exporting to Immich: %d / %d"), number, total)) +end + +-- Preferences namespace and keys +local PREF_SERVER_URL = "server_url" +local PREF_AUTH_TOKEN = "auth_token" + +-- Set default preferences if they don't exist +if dt.preferences.read(namespace, PREF_SERVER_URL, "string") == nil then + dt.preferences.write(namespace, PREF_SERVER_URL, "string", "http://localhost:2283") +end + +if dt.preferences.read(namespace, PREF_AUTH_TOKEN, "string") == nil then + dt.preferences.write(namespace, PREF_AUTH_TOKEN, "string", "") +end + +-- Add preferences to Darktable's preferences dialog +dt.preferences.register(namespace, + PREF_SERVER_URL, + "string", + _("Immich: Server URL"), + _("The URL of your Immich server"), + "http://localhost:2283" +) + +dt.preferences.register(namespace, + PREF_AUTH_TOKEN, + "string", + _("Immich: API Key"), + _("Your Immich API key for authentication"), + "" +) + +-- Storage widget for export dialog +local album_name = "" -- Will store the album name +local dry_run = false -- Will store the dry-run state +local album_entry = nil -- Will store reference to the entry widget + +local immich_widget = dt.new_widget("box") { + orientation = "vertical", + + dt.new_widget("entry"){ + tooltip = _("Enter album name (leave empty for no album)"), + placeholder = _("Album name (optional)"), + editable = true, + text = album_name + }, + + dt.new_widget("check_button"){ + label = _("Dry run"), + tooltip = _("Test the upload without actually sending files"), + value = dry_run, + clicked_callback = function(widget) + dry_run = widget.value + log.msg(log.debug, "dry run changed to: ", dry_run) + end + } +} + +-- Store reference to entry widget for later use +album_entry = immich_widget[1] + +-- Function to login to Immich +local function immich_login(server_url, auth_token) + local login_command = string.format( + "immich login %s %s", + server_url, + auth_token + ) + + log.msg(log.debug, "executing login command: ", login_command) + + local result = dtsys.external_command(login_command) + if not result then + local err_msg = "Failed to login to Immich" + log.msg(log.error, err_msg) + dt.print(_(err_msg)) + return false + end + + log.msg(log.debug, "login successful") + return true +end + +-- Initialize function - called before export begins +local function initialize(storage, format, images, high_quality, extra_data) + log.msg(log.debug, "initializing Immich export") + + -- Get settings + local server_url = dt.preferences.read(namespace, PREF_SERVER_URL, "string") + local auth_token = dt.preferences.read(namespace, PREF_AUTH_TOKEN, "string") + + log.msg(log.debug, "server URL: ", server_url) + log.msg(log.debug, "auth token length: ", #auth_token) + + -- Validate settings + if server_url == "" or auth_token == "" then + local err_msg = "Please configure Immich server URL and API key in preferences" + log.msg(log.error, err_msg) + dt.print(_(err_msg)) + return nil + end + + -- Check for immich-cli before attempting login + if not check_immich_cli() then + return nil + end + + -- Try to login + if not immich_login(server_url, auth_token) then + return nil + end + + -- Get current values from widgets + album_name = album_entry.text -- Get text directly from stored widget reference + log.msg(log.debug, "got album name from widget: ", album_name) + + -- Store values in extra_data for use in store function + extra_data.album_name = album_name + extra_data.dry_run = dry_run + + log.msg(log.debug, "initialization complete. Album: ", album_name, " Dry run: ", dry_run) + + -- Return the images table unchanged + return images +end + +-- Add after the requires +local status_dir = dt.configuration.tmp_dir .. "/immich_status" +df.mkdir(status_dir) -- Create status directory if it doesn't exist + +-- Store function - called for each exported image +local function store(storage, image, format, filename, number, total, high_quality, extra_data) + log.msg(log.debug, string.format("processing image %d/%d: %s", number, total, filename)) + + -- Show progress + dt.print(string.format(_("uploading to Immich: %d / %d"), number, total)) + + -- Build base upload command + local upload_command = "immich upload --delete --concurrency 1" + + -- Add dry-run if enabled + if extra_data.dry_run then + upload_command = upload_command .. " --dry-run" + log.msg(log.debug, "dry run mode enabled") + end + + -- Add album if specified + if extra_data.album_name and extra_data.album_name ~= "" then + upload_command = upload_command .. " --album-name \"" .. extra_data.album_name .. "\"" + log.msg(log.debug, "using album: ", extra_data.album_name) + end + + -- Add filename + upload_command = upload_command .. " \"" .. filename .. "\"" + + -- Create a unique status file name + local status_file = status_dir .. "/" .. df.get_basename(filename) .. ".status" + + -- Modify upload command to write status + upload_command = string.format( + "(%s && echo 'success' > '%s' || echo 'failed' > '%s') > /dev/null 2>&1 & disown", + upload_command, + status_file, + status_file + ) + + -- Log the full command + log.msg(log.debug, "executing command: ", upload_command) + + -- Execute upload + local result = dtsys.external_command(upload_command) + if not result then + local err_msg = "Failed to launch upload: " .. filename + log.msg(log.error, err_msg) + dt.print(_(err_msg)) + return + end + + -- Store status file path in extra_data for checking in finalize + if not extra_data.status_files then + extra_data.status_files = {} + end + extra_data.status_files[filename] = status_file + + log.msg(log.debug, "upload started for: ", filename) + dt.print(_("Upload started for: " .. filename)) +end + +-- Modified finalize function to check upload status +local function finalize(storage, image_table, extra_data) + if not extra_data.status_files then + dt.print(_("No uploads were started")) + return + end + + -- Wait up to 5 seconds for uploads to complete + local start_time = os.time() + local wait_time = 5 + + local success_count = 0 + local failed_files = {} + local pending_files = {} + + while (os.time() - start_time) < wait_time do + pending_files = {} + success_count = 0 + failed_files = {} + + -- Check status of each upload + for filename, status_file in pairs(extra_data.status_files) do + if df.check_if_file_exists(status_file) then + local f = io.open(status_file, "r") + if f then + local status = f:read("*all") + f:close() + os.remove(status_file) + + if status:match("success") then + success_count = success_count + 1 + else + table.insert(failed_files, filename) + end + end + else + table.insert(pending_files, filename) + end + end + + -- If no pending files, we can stop waiting + if #pending_files == 0 then + break + end + end + + -- Report results + local msg = string.format( + "Upload complete: %d successful", + success_count + ) + + if #failed_files > 0 then + msg = msg .. string.format("\nFailed uploads: %d", #failed_files) + for _, file in ipairs(failed_files) do + log.msg(log.error, "Failed to upload: ", file) + end + end + + if #pending_files > 0 then + msg = msg .. string.format("\nStill uploading: %d", #pending_files) + for _, file in ipairs(pending_files) do + log.msg(log.info, "Still uploading: ", file) + end + end + + log.msg(log.debug, msg) + dt.print(_(msg)) + + -- Clean up status directory if empty + if #pending_files == 0 then + os.remove(status_dir) + end +end + +-- Register the storage with the new functions +dt.register_storage( + namespace, + _("Immich Upload"), + store, + finalize, + nil, + initialize, + immich_widget +) + +-- Cleanup function +local function destroy() + dt.destroy_storage(namespace) +end + +script_data.destroy = destroy + +return script_data From 398048984611759150877065c7c10f3554b8331a Mon Sep 17 00:00:00 2001 From: Colin Holzman Date: Thu, 25 Jun 2026 13:59:48 -0400 Subject: [PATCH 2/2] "improvements" --- contrib/immich_upload.lua | 367 ++++++++++++++++---------------------- 1 file changed, 149 insertions(+), 218 deletions(-) diff --git a/contrib/immich_upload.lua b/contrib/immich_upload.lua index 40ba8738..cb763c98 100644 --- a/contrib/immich_upload.lua +++ b/contrib/immich_upload.lua @@ -1,5 +1,6 @@ --[[ copyright (c) 2024 Guillaume Godin + copyright (c) 2026 Colin Holzman This program is a free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -18,12 +19,18 @@ immich_upload.lua - Immich storage for darktable Immich is a self-hosted photo management service that provides features similar to Google Photos. - This script adds a new storage option to upload exported photos directly to an Immich server without keeping a local copy. - It uses the immich-cli tool to upload the images. It has 2 configurable preferences: + This script adds a new storage option that uploads exported photos to an Immich server using the + immich command line interface (immich-cli). Because the upload is delegated to the CLI, the script + stays compatible across Immich API versions as long as the installed CLI is kept up to date. + + It has these configurable preferences: * Immich server URL (default: http://localhost:2283) * Immich API key (default: empty) - The script will appear as "Immich Upload" in the export module storage list. It can be run in - dry-run mode to test the upload without actually sending files. + * immich-cli location (default: looked up on the PATH; set this if darktable can't find it, + which is common on macOS/Windows where the GUI doesn't inherit your shell PATH) + + The script appears as "Immich Upload" in the export module storage list. A dry-run mode is + available to test the upload without sending files. ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT * immich-cli (https://immich.app/docs/features/command-line-interface/) @@ -32,7 +39,8 @@ -- Configuration 1. Install immich-cli on your system 2. Configure your Immich server URL and API key in darktable preferences - 3. The script will appear as "Immich Upload" in the export module storage list + 3. If darktable reports it can't find immich-cli, set its location in the preferences too + 4. The script will appear as "Immich Upload" in the export module storage list -- Uploading images 1. Select images to export @@ -46,6 +54,10 @@ CHANGES * 2025-03-16 - Initial version + * 2026-06-25 - Upload all images in a single batch from finalize() instead of launching a + detached background process per image (fixes the post-export crash and the + status-file race); add an immich-cli location preference; quote all shell + arguments; check command exit codes correctly. ]] local dt = require "darktable" @@ -56,10 +68,10 @@ local dtsys = require "lib/dtutils.system" local gettext = dt.gettext.gettext -- Module namespace for preferences -local namespace = 'module_immich' +local namespace = 'immich_upload' -- Check minimum API version. This was only tested on darktable 5 with the 9.4.0 API. -du.check_min_api_version("9.4.0", "immich") +du.check_min_api_version("9.4.0", "immich_upload") -- Localization local function _(msgid) @@ -69,46 +81,50 @@ end -- Script metadata local script_data = {} script_data.metadata = { - name = _("immich"), - purpose = _("upload images to Immich server"), - author = "your name", - help = "https://github.com/immich-app/immich" + name = _("immich upload"), + purpose = _("upload images to an Immich server with immich-cli"), + author = "Colin Holzman", + help = "https://immich.app/docs/features/command-line-interface/" } --- Check if immich-cli is available -local function check_immich_cli() - local immich_cli = df.check_if_bin_exists("immich") - log.msg(log.debug, "checking for immich-cli at: ", immich_cli) - - if not immich_cli then - local err_msg = "immich-cli not found. Please install it first." - log.msg(log.error, err_msg) - dt.print(_(err_msg)) - return false - end - return true -end - --- Show export progress -local function show_status(storage, image, format, filename, - number, total, high_quality, extra_data) - dt.print(string.format(_("exporting to Immich: %d / %d"), number, total)) -end - --- Preferences namespace and keys +-- Preferences keys local PREF_SERVER_URL = "server_url" local PREF_AUTH_TOKEN = "auth_token" - --- Set default preferences if they don't exist -if dt.preferences.read(namespace, PREF_SERVER_URL, "string") == nil then - dt.preferences.write(namespace, PREF_SERVER_URL, "string", "http://localhost:2283") +local PREF_IMMICH_CLI = "immich_cli_path" +-- basename used when falling back to a PATH search +local IMMICH_EXECUTABLE = "immich" + +-- Resolve the immich-cli binary: prefer the user-configured location, otherwise search the PATH +-- and the usual install dirs. Returns the resolved path, or false if it can't be found. +local function resolve_immich_cli() + local configured = dt.preferences.read(namespace, PREF_IMMICH_CLI, "string") + if configured and configured ~= "" then + local found = df.check_if_bin_exists(configured) + if found then return found end + end + return df.check_if_bin_exists(IMMICH_EXECUTABLE) end -if dt.preferences.read(namespace, PREF_AUTH_TOKEN, "string") == nil then - dt.preferences.write(namespace, PREF_AUTH_TOKEN, "string", "") +-- immich-cli is a Node script (#!/usr/bin/env node) that needs `node` on PATH, and node is +-- installed in the same directory as immich. A GUI-launched darktable can start with a minimal +-- PATH that excludes that directory -- notably on macOS (Dock/Finder strip the Homebrew path), +-- and possibly for npm-global/nvm installs on Linux -- so immich's shebang can't find node. +-- Return a POSIX shell prefix that prepends immich-cli's own directory to PATH; this covers both +-- macOS and Linux. Empty on Windows, whose GUI launches inherit the system PATH and whose shell +-- doesn't accept the `PATH=value command` syntax anyway, and empty when the binary has no +-- directory part (it was found on PATH already, so node almost certainly is too). +local function path_prefix(immich_cli) + if dt.configuration.running_os == "windows" then + return "" + end + local dir = df.get_path(immich_cli) + if not dir or dir == "" then + return "" + end + return "PATH=" .. df.sanitize_filename(dir) .. ':"$PATH" ' end --- Add preferences to Darktable's preferences dialog +-- Add preferences to darktable's preferences dialog dt.preferences.register(namespace, PREF_SERVER_URL, "string", @@ -125,21 +141,29 @@ dt.preferences.register(namespace, "" ) --- Storage widget for export dialog -local album_name = "" -- Will store the album name -local dry_run = false -- Will store the dry-run state -local album_entry = nil -- Will store reference to the entry widget +-- The immich-cli location. A plain string field (rather than a file chooser) so darktable's +-- double-click-to-reset-to-default works. This is what lets users on macOS/Windows point +-- darktable at the CLI when it isn't on the GUI's PATH. +dt.preferences.register(namespace, + PREF_IMMICH_CLI, + "string", + _("Immich: immich-cli location"), + _("Path to the immich-cli executable, or just \"immich\" to search the PATH. Requires restart to take effect."), + IMMICH_EXECUTABLE +) + +-- Storage widget for the export dialog +local dry_run = false +local album_entry = dt.new_widget("entry"){ + tooltip = _("Enter album name (leave empty for no album)"), + placeholder = _("Album name (optional)"), + editable = true, + text = "" +} local immich_widget = dt.new_widget("box") { orientation = "vertical", - - dt.new_widget("entry"){ - tooltip = _("Enter album name (leave empty for no album)"), - placeholder = _("Album name (optional)"), - editable = true, - text = album_name - }, - + album_entry, dt.new_widget("check_button"){ label = _("Dry run"), tooltip = _("Test the upload without actually sending files"), @@ -151,27 +175,21 @@ local immich_widget = dt.new_widget("box") { } } --- Store reference to entry widget for later use -album_entry = immich_widget[1] - --- Function to login to Immich -local function immich_login(server_url, auth_token) - local login_command = string.format( - "immich login %s %s", - server_url, - auth_token - ) - - log.msg(log.debug, "executing login command: ", login_command) - - local result = dtsys.external_command(login_command) - if not result then - local err_msg = "Failed to login to Immich" - log.msg(log.error, err_msg) - dt.print(_(err_msg)) +-- Log in to Immich. immich-cli persists the credentials (auth.yml) so a single login here covers +-- the upload performed later in finalize(). +local function immich_login(immich_cli, server_url, auth_token) + local login_command = path_prefix(immich_cli) .. string.format("%s login %s %s", + df.sanitize_filename(immich_cli), + df.sanitize_filename(server_url), + df.sanitize_filename(auth_token)) + + log.msg(log.debug, "executing login command") + + if dtsys.external_command(login_command) ~= 0 then + log.msg(log.error, "failed to login to Immich") return false end - + log.msg(log.debug, "login successful") return true end @@ -179,184 +197,97 @@ end -- Initialize function - called before export begins local function initialize(storage, format, images, high_quality, extra_data) log.msg(log.debug, "initializing Immich export") - - -- Get settings + local server_url = dt.preferences.read(namespace, PREF_SERVER_URL, "string") local auth_token = dt.preferences.read(namespace, PREF_AUTH_TOKEN, "string") - - log.msg(log.debug, "server URL: ", server_url) - log.msg(log.debug, "auth token length: ", #auth_token) - -- Validate settings + -- Validate settings. Errors are stashed in extra_data.error and surfaced in finalize(): a + -- dt.print() here would be overwritten by darktable's own "no image to export" message that + -- follows the empty return, leaving the user with a misleading "nothing to upload". if server_url == "" or auth_token == "" then - local err_msg = "Please configure Immich server URL and API key in preferences" - log.msg(log.error, err_msg) - dt.print(_(err_msg)) - return nil + log.msg(log.error, "server URL or API key not configured") + extra_data.error = _("Immich: configure the server URL and API key in preferences") + return {} end - -- Check for immich-cli before attempting login - if not check_immich_cli() then - return nil + -- Locate immich-cli before attempting to log in + local immich_cli = resolve_immich_cli() + if not immich_cli then + log.msg(log.error, "immich-cli not found") + extra_data.error = _("Immich: immich-cli not found. Install it, or set its location in preferences, then restart.") + return {} end - -- Try to login - if not immich_login(server_url, auth_token) then - return nil + -- Log in once for the whole export + if not immich_login(immich_cli, server_url, auth_token) then + extra_data.error = _("Immich: login failed. Check the server URL and API key.") + return {} end - -- Get current values from widgets - album_name = album_entry.text -- Get text directly from stored widget reference - log.msg(log.debug, "got album name from widget: ", album_name) - - -- Store values in extra_data for use in store function - extra_data.album_name = album_name + -- Stash everything finalize() needs + extra_data.immich_cli = immich_cli + extra_data.album_name = album_entry.text extra_data.dry_run = dry_run - - log.msg(log.debug, "initialization complete. Album: ", album_name, " Dry run: ", dry_run) - -- Return the images table unchanged + log.msg(log.debug, "initialization complete. album: ", extra_data.album_name, " dry run: ", tostring(dry_run)) + return images end --- Add after the requires -local status_dir = dt.configuration.tmp_dir .. "/immich_status" -df.mkdir(status_dir) -- Create status directory if it doesn't exist - --- Store function - called for each exported image +-- Store function - called for each exported image. The exported files persist until finalize(), +-- so here we only report progress; the actual upload is a single batch call in finalize(). local function store(storage, image, format, filename, number, total, high_quality, extra_data) - log.msg(log.debug, string.format("processing image %d/%d: %s", number, total, filename)) - - -- Show progress - dt.print(string.format(_("uploading to Immich: %d / %d"), number, total)) - - -- Build base upload command - local upload_command = "immich upload --delete --concurrency 1" - - -- Add dry-run if enabled - if extra_data.dry_run then - upload_command = upload_command .. " --dry-run" - log.msg(log.debug, "dry run mode enabled") - end - - -- Add album if specified - if extra_data.album_name and extra_data.album_name ~= "" then - upload_command = upload_command .. " --album-name \"" .. extra_data.album_name .. "\"" - log.msg(log.debug, "using album: ", extra_data.album_name) - end - - -- Add filename - upload_command = upload_command .. " \"" .. filename .. "\"" - - -- Create a unique status file name - local status_file = status_dir .. "/" .. df.get_basename(filename) .. ".status" - - -- Modify upload command to write status - upload_command = string.format( - "(%s && echo 'success' > '%s' || echo 'failed' > '%s') > /dev/null 2>&1 & disown", - upload_command, - status_file, - status_file - ) - - -- Log the full command - log.msg(log.debug, "executing command: ", upload_command) - - -- Execute upload - local result = dtsys.external_command(upload_command) - if not result then - local err_msg = "Failed to launch upload: " .. filename - log.msg(log.error, err_msg) - dt.print(_(err_msg)) - return - end - - -- Store status file path in extra_data for checking in finalize - if not extra_data.status_files then - extra_data.status_files = {} - end - extra_data.status_files[filename] = status_file - - log.msg(log.debug, "upload started for: ", filename) - dt.print(_("Upload started for: " .. filename)) + dt.print(string.format(_("exporting for Immich: %d / %d"), number, total)) + log.msg(log.debug, string.format("exported image %d/%d: %s", number, total, filename)) end --- Modified finalize function to check upload status +-- Finalize function - upload every exported image in one immich-cli invocation local function finalize(storage, image_table, extra_data) - if not extra_data.status_files then - dt.print(_("No uploads were started")) + -- Surface any error stashed during initialize(). finalize() runs after darktable's own + -- "no image to export" message, so printing here is what the user actually sees. + if extra_data.error then + dt.print(extra_data.error) return end - -- Wait up to 5 seconds for uploads to complete - local start_time = os.time() - local wait_time = 5 - - local success_count = 0 - local failed_files = {} - local pending_files = {} - - while (os.time() - start_time) < wait_time do - pending_files = {} - success_count = 0 - failed_files = {} - - -- Check status of each upload - for filename, status_file in pairs(extra_data.status_files) do - if df.check_if_file_exists(status_file) then - local f = io.open(status_file, "r") - if f then - local status = f:read("*all") - f:close() - os.remove(status_file) - - if status:match("success") then - success_count = success_count + 1 - else - table.insert(failed_files, filename) - end - end - else - table.insert(pending_files, filename) - end - end + -- Build the list of exported files (image_table maps image -> exported file path) + local files = {} + for _, exported in pairs(image_table) do + files[#files + 1] = df.sanitize_filename(exported) + end - -- If no pending files, we can stop waiting - if #pending_files == 0 then - break - end + if #files == 0 then + dt.print(_("Immich: no images were exported")) + return end - -- Report results - local msg = string.format( - "Upload complete: %d successful", - success_count - ) - - if #failed_files > 0 then - msg = msg .. string.format("\nFailed uploads: %d", #failed_files) - for _, file in ipairs(failed_files) do - log.msg(log.error, "Failed to upload: ", file) - end + -- Assemble a single upload command + local command = path_prefix(extra_data.immich_cli) .. df.sanitize_filename(extra_data.immich_cli) .. " upload" + if extra_data.dry_run then + command = command .. " --dry-run" end - - if #pending_files > 0 then - msg = msg .. string.format("\nStill uploading: %d", #pending_files) - for _, file in ipairs(pending_files) do - log.msg(log.info, "Still uploading: ", file) - end + if extra_data.album_name and extra_data.album_name ~= "" then + command = command .. " --album-name " .. df.sanitize_filename(extra_data.album_name) + end + command = command .. " " .. table.concat(files, " ") + + dt.print(string.format(_("uploading %d image(s) to Immich..."), #files)) + log.msg(log.debug, "executing upload command for ", #files, " file(s)") + + if dtsys.external_command(command) ~= 0 then + log.msg(log.error, "immich-cli upload failed") + dt.print(_("Immich: upload failed")) + return end - log.msg(log.debug, msg) - dt.print(_(msg)) - - -- Clean up status directory if empty - if #pending_files == 0 then - os.remove(status_dir) + if extra_data.dry_run then + dt.print(string.format(_("Immich dry run complete: %d image(s) checked"), #files)) + else + dt.print(string.format(_("Immich upload complete: %d image(s) uploaded"), #files)) end end --- Register the storage with the new functions +-- Register the storage dt.register_storage( namespace, _("Immich Upload"), @@ -374,4 +305,4 @@ end script_data.destroy = destroy -return script_data +return script_data