Skip to content

Commit 39f3840

Browse files
committed
New Immich export plugin
1 parent daa0877 commit 39f3840

File tree

1 file changed

+335
-0
lines changed

1 file changed

+335
-0
lines changed

contrib/immich.lua

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
--[[
2+
This file is part of darktable,
3+
copyright (c) 2024 Giorgio Massussi
4+
5+
darktable is free software: you can redistribute it and/or modify
6+
it under the terms of the GNU General Public License as published by
7+
the Free Software Foundation, either version 3 of the License, or
8+
(at your option) any later version.
9+
10+
darktable is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
GNU General Public License for more details.
14+
15+
You should have received a copy of the GNU General Public License
16+
along with darktable. If not, see <http://www.gnu.org/licenses/>.
17+
]]
18+
--[[
19+
IMMICH
20+
Upload selection to an Immich server
21+
22+
USAGE
23+
This plugin allows you to upload selected photos to a Immich server (https://immich.app/)
24+
Previously exported photos will be overwritten, using unique Dartable internal ids.
25+
26+
Photos uploaded for the first time are automatically added to an album. It is possible to specify the name of the album in the Album title field in the module options; in the absence of the title, the roll name will be used.
27+
In the lua options you must specify:
28+
* the hostname of the server immich
29+
* an api key generated in the Account settings - API Keys menu of the immich server
30+
* a unique id identiphing the Darktable instance; this id is used as the device id uploading photos to Immich
31+
32+
USAGE
33+
* install luasec and cjson for Lua 5.4 on your system
34+
35+
]]
36+
local dt = require "darktable"
37+
local du = require "lib/dtutils"
38+
local df = require "lib/dtutils.file"
39+
local cjson = require "cjson.safe"
40+
local https = require "ssl.https"
41+
local http = require "socket.http"
42+
local ltn12 = require "ltn12"
43+
44+
local gettext = dt.gettext.gettext
45+
46+
dt.gettext.bindtextdomain("immich", dt.configuration.config_dir .."/lua/locale/")
47+
48+
local function _(msgid)
49+
return gettext(msgid)
50+
end
51+
52+
du.check_min_api_version("7.0.0", "immich")
53+
54+
local function call_immich_api(method,api,body,content_type)
55+
local immichserver = dt.preferences.read("immich","immich_server","string")
56+
local client = string.find(immichserver,"^https") ~= nil and https or http
57+
local headers = { }
58+
headers["x-api-key"] = dt.preferences.read("immich","immich_key","string")
59+
local source = nil
60+
if body == nil then
61+
elseif (content_type == nil or content_type == "application/json") then
62+
headers["Content-Type"] = "application/json"
63+
source = cjson.encode(body)
64+
headers["Content-Length"] = string.len(source)
65+
source = ltn12.source.string(source)
66+
elseif (content_type == "multipart/form-data") then
67+
local boundary = "----DarktableImmichBoundary" .. math.random(1, 1e16)
68+
headers["Content-Type"] = "multipart/form-data; boundary="..boundary
69+
source = ltn12.source.empty()
70+
local content_length = 0
71+
for name,value in pairs(body) do
72+
if (value.filename ~= nil) then
73+
local form_data_table = {}
74+
if (content_length > 0) then
75+
table.insert(form_data_table,"")
76+
end
77+
table.insert(form_data_table, "--"..boundary)
78+
table.insert(form_data_table, "Content-Disposition: form-data; name=\""..name.."\"; filename=\"".. value.filename .. "\"")
79+
table.insert(form_data_table, "Content-Type: application/octet-stream")
80+
table.insert(form_data_table, "")
81+
table.insert(form_data_table, "")
82+
local form_data = table.concat(form_data_table, "\r\n")
83+
content_length = content_length+value.file:seek("end")+string.len(form_data)
84+
value.file:seek("set",0)
85+
source = ltn12.source.simplify(ltn12.source.cat(source,
86+
ltn12.source.string(form_data),
87+
ltn12.source.file(value.file)))
88+
else
89+
local form_data_table = {}
90+
if (content_length > 0) then
91+
table.insert(form_data_table,"")
92+
end
93+
table.insert(form_data_table, "--"..boundary)
94+
table.insert(form_data_table, "Content-Disposition: form-data; name=\""..name.."\"")
95+
table.insert(form_data_table, "")
96+
table.insert(form_data_table, value)
97+
local form_data = table.concat(form_data_table, "\r\n")
98+
content_length = content_length+string.len(form_data)
99+
source = ltn12.source.cat(source,ltn12.source.string(form_data))
100+
end
101+
end
102+
content_length = content_length+6+string.len(boundary)
103+
source = ltn12.source.cat(source,ltn12.source.string("\r\n--"..boundary.."--"))
104+
headers["Content-Length"] = content_length
105+
end
106+
107+
local res_table={}
108+
local res, err, response_headers = client.request{
109+
method=method,
110+
url=immichserver.."/api/"..api,
111+
headers=headers,
112+
source=source,
113+
sink=ltn12.sink.table(res_table)
114+
}
115+
if response_headers["content-type"] == "application/json; charset=utf-8" then
116+
return cjson.decode(table.concat(res_table)), err, response_headers
117+
end
118+
return table.concat(res_table), err, response_headers
119+
end
120+
121+
local function initialize(storage,format,images,high_quality,extra_data)
122+
extra_data.device_id = dt.preferences.read("immich","immich_device_id","string")
123+
if extra_data.device_id == nil then
124+
extra_data.device_id = "darktable"
125+
else
126+
extra_data.device_id = "darktable_"..extra_data.device_id
127+
end
128+
local assets_ids = {}
129+
extra_data.images_existence = {}
130+
for i,image in ipairs(images) do
131+
assets_ids[i] = tostring(image.id)
132+
extra_data.images_existence[tostring(image.id)] = false
133+
end
134+
local res,err = call_immich_api("POST","assets/exist",{deviceAssetIds = assets_ids,deviceId = extra_data.device_id})
135+
if (err ~= 200) then
136+
if err == 401 then
137+
extra_data.error = "Authentication error. Check your Immich API key in LUA settings."
138+
elseif res ~= nil and res.message ~= nil then
139+
extra_data.error = res.message
140+
else
141+
extra_data.error = "Error contacting Immich server: HTTP "..err
142+
end
143+
return {}
144+
end
145+
if (res.existingIds ~= nil) then
146+
for i,id in ipairs(res.existingIds) do
147+
extra_data.images_existence[id] = true
148+
end
149+
end
150+
151+
extra_data.album_assets = {}
152+
extra_data.remote_albums = {}
153+
154+
local res_albums, err_albums = call_immich_api("GET","albums")
155+
156+
if err_albums == 200 then
157+
for _,album in ipairs(res_albums) do
158+
extra_data.remote_albums[album.albumName] = album.id
159+
end
160+
end
161+
162+
return images
163+
end
164+
165+
local function iso_exif_datetime_taken(image)
166+
local yr,mo,dy,h,m,s = string.match(image.exif_datetime_taken, "(%d-):(%d-):(%d-) (%d-):(%d-):(%d+)")
167+
return os.date("!%Y-%m-%dT%H:%M:%S",os.time{year=yr, month=mo, day=dy, hour=h, min=m, sec=s})
168+
end
169+
170+
local function replace_image(image,filename,device_id,asset_id)
171+
local date = iso_exif_datetime_taken(image)
172+
local form_data = {
173+
deviceAssetId=tostring(image.id),
174+
deviceId=device_id,
175+
fileCreatedAt=date,
176+
fileModifiedAt=date,
177+
assetData={
178+
filename=df.get_filename(filename),
179+
file=io.open(filename)
180+
}
181+
}
182+
local res,err = call_immich_api("PUT","assets/"..asset_id.."/original",form_data,"multipart/form-data")
183+
if err == 200 then
184+
return asset_id
185+
end
186+
return nil
187+
end
188+
189+
local function upload_image(image,filename,device_id)
190+
local date = iso_exif_datetime_taken(image)
191+
local form_data = {
192+
deviceAssetId=tostring(image.id),
193+
deviceId=device_id,
194+
fileCreatedAt=date,
195+
fileModifiedAt=date,
196+
assetData={
197+
filename=df.get_filename(filename),
198+
file=io.open(filename)
199+
}
200+
}
201+
local res,err = call_immich_api("POST","assets",form_data,"multipart/form-data")
202+
if err == 201 then
203+
return res.id
204+
end
205+
return nil
206+
end
207+
208+
local function store_image(storage,image,format,filename,number,total,high_quality,extra_data)
209+
local asset_id,replaced = false
210+
if (extra_data.images_existence[tostring(image.id)]) then
211+
local res_search,err_search = call_immich_api("POST","search/metadata",{deviceId=extra_data.device_id,deviceAssetId=tostring(image.id),size=1})
212+
if (err_search == 200) then
213+
if (res_search.assets.count >= 1) then
214+
replaced = true
215+
asset_id = replace_image(image,filename,extra_data.device_id,res_search.assets.items[1].id)
216+
else
217+
asset_id = upload_image(image,filename,extra_data.device_id)
218+
end
219+
end
220+
else
221+
asset_id = upload_image(image,filename,extra_data.device_id)
222+
end
223+
224+
if asset_id == nil then
225+
extra_data.error = "Error uploading some image"
226+
return
227+
end
228+
229+
if not replaced then
230+
local album_name = title_widget.text
231+
if album_name == "" then
232+
local tags = image.get_tags(image)
233+
for i,tag in ipairs(tags) do
234+
if string.find(tag.name,"^Album|") ~= nil then
235+
for w in string.gmatch(tag.name,"[^|]+") do
236+
album_name = w
237+
end
238+
end
239+
end
240+
end
241+
if album_name == "" then
242+
for w in string.gmatch(image.path,"[^/\\]+") do
243+
album_name = w
244+
end
245+
end
246+
local album_assets = extra_data.album_assets[album_name]
247+
if album_assets == nil then
248+
album_assets = {}
249+
extra_data.album_assets[album_name] = album_assets
250+
end
251+
table.insert(album_assets,asset_id)
252+
end
253+
end
254+
255+
local function finalize(storage,image_table,extra_data)
256+
if extra_data.album_assets ~= nil then
257+
for album_name,album_assets in pairs(extra_data.album_assets) do
258+
local album_id = extra_data.remote_albums[album_name]
259+
if album_id == nil then
260+
dt.print("Creating new album: " .. album_name)
261+
call_immich_api("POST","albums",{albumName=album_name,assetIds=album_assets})
262+
else
263+
dt.print("Adding assets to album: "..album_name)
264+
call_immich_api("PUT","albums/"..album_id.."/assets",{ids=album_assets})
265+
end
266+
end
267+
end
268+
if extra_data.error ~= nil then
269+
dt.print(extra_data.error)
270+
end
271+
end
272+
273+
local function destroy()
274+
dt.destroy_storage("immich")
275+
end
276+
277+
local device_id = dt.preferences.read("immich","immich_device_id","string")
278+
if device_id == nil or device_id == "" then
279+
local uuid_template ='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
280+
device_id = string.gsub(uuid_template, '[xy]', function (c)
281+
local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb)
282+
return string.format('%x', v)
283+
end)
284+
end
285+
286+
dt.preferences.register
287+
("immich","immich_server","string",
288+
_("Immich server"),
289+
_("The url of the Immich server to upload"),
290+
"http://localhost:2283")
291+
292+
dt.preferences.register
293+
("immich","immich_key","string",
294+
_("Immich API key"),
295+
_("A valid Immich API key"),
296+
"T38JGhBrVOiWCE4tZXMoGKWoe39IIj2G8KNrfy0Eg")
297+
298+
dt.preferences.register
299+
("immich","immich_device_id","string",
300+
_("Immich Device ID"),
301+
_("A unique ID identifying this local Darktable installation"),
302+
device_id)
303+
304+
local title_widget = dt.new_widget("entry") {
305+
placeholder=_("Use roll name")
306+
}
307+
local widget = dt.new_widget("box") {
308+
orientation=horizontal,
309+
dt.new_widget("label"){label = _("Album Title"), tooltip = _("Album title. If not specied roll name will be used") },
310+
title_widget
311+
}
312+
313+
dt.register_storage("immich",_("immich"),
314+
store_image,
315+
finalize,
316+
nil,
317+
initialize,
318+
widget)
319+
320+
local script_data = {}
321+
322+
script_data.metadata = {
323+
name = "immich",
324+
purpose = _("upload all selected images to Immich server"),
325+
author = "Giorgio Massussi"
326+
}
327+
328+
script_data.destroy = destroy -- function to destory the script
329+
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
330+
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
331+
script_data.show = nil -- only required for libs since the destroy_method only hides them
332+
333+
return script_data
334+
--
335+
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua

0 commit comments

Comments
 (0)