Immich bindings and CLI in OCaml
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6type t = {
7 client : Immich.t;
8 session : Session.t;
9 fs : Eio.Fs.dir_ty Eio.Path.t;
10 profile : string option;
11}
12
13(* JSON type for .well-known/immich response *)
14let well_known_jsont =
15 let api_obj =
16 Jsont.Object.map ~kind:"api" (fun endpoint -> endpoint)
17 |> Jsont.Object.mem "endpoint" Jsont.string ~enc:Fun.id
18 |> Jsont.Object.finish
19 in
20 Jsont.Object.map ~kind:"well-known" (fun api -> api)
21 |> Jsont.Object.mem "api" api_obj ~enc:Fun.id
22 |> Jsont.Object.finish
23
24(** [resolve_api_url ~session base_url] resolves the actual API URL.
25
26 If [base_url] already ends with [/api], returns it unchanged.
27 Otherwise, tries to fetch [<base_url>/.well-known/immich] to discover
28 the API endpoint. Falls back to [<base_url>/api] if discovery fails. *)
29let resolve_api_url ~session base_url =
30 (* Remove trailing slash if present *)
31 let base_url =
32 if String.ends_with ~suffix:"/" base_url then
33 String.sub base_url 0 (String.length base_url - 1)
34 else base_url
35 in
36 (* If already has /api suffix, use as-is *)
37 if String.ends_with ~suffix:"/api" base_url then
38 base_url
39 else begin
40 (* Try .well-known/immich discovery *)
41 let well_known_url = base_url ^ "/.well-known/immich" in
42 try
43 let response = Requests.get session well_known_url in
44 if Requests.Response.ok response then begin
45 let json = Requests.Response.json response in
46 let endpoint = Openapi.Runtime.Json.decode_json_exn well_known_jsont json in
47 (* Construct full API URL *)
48 if String.starts_with ~prefix:"/" endpoint then
49 base_url ^ endpoint
50 else
51 base_url ^ "/" ^ endpoint
52 end
53 else
54 (* Discovery failed, default to /api *)
55 base_url ^ "/api"
56 with _ ->
57 (* Any error, default to /api *)
58 base_url ^ "/api"
59 end
60
61let create_with_session ~sw ~env ?requests_config ?profile ~session () =
62 let fs = env#fs in
63 let server_url = Session.server_url session in
64 (* Create a Requests session, optionally from cmdliner config *)
65 let requests_session = match requests_config with
66 | Some config -> Requests.Cmd.create config env sw
67 | None -> Requests.create ~sw env
68 in
69 let requests_session =
70 match Session.auth session with
71 | Session.Jwt { access_token; _ } ->
72 Requests.set_auth requests_session (Requests.Auth.bearer ~token:access_token)
73 | Session.Api_key { key; _ } ->
74 Requests.set_default_header requests_session "x-api-key" key
75 in
76 let client = Immich.create ~session:requests_session ~sw env ~base_url:server_url in
77 { client; session; fs; profile }
78
79let login_api_key ~sw ~env ?requests_config ?profile ~server_url ~api_key ?key_name () =
80 let fs = env#fs in
81 (* Create session with API key header *)
82 let requests_session = match requests_config with
83 | Some config -> Requests.Cmd.create config env sw
84 | None -> Requests.create ~sw env
85 in
86 let requests_session = Requests.set_default_header requests_session "x-api-key" api_key in
87 (* Resolve the API URL from .well-known/immich if available *)
88 let server_url = resolve_api_url ~session:requests_session server_url in
89 let client = Immich.create ~session:requests_session ~sw env ~base_url:server_url in
90 (* Validate by calling the validate endpoint *)
91 let resp = Immich.ValidateAccessToken.validate_access_token client () in
92 if not (Immich.ValidateAccessToken.ResponseDto.auth_status resp) then
93 failwith "API key validation failed";
94 (* Create and save session *)
95 let auth = Session.Api_key { key = api_key; name = key_name } in
96 let session = Session.create ~server_url ~auth () in
97 Session.save fs ?profile session;
98 (* Set as current profile if first login or explicitly requested *)
99 let profiles = Session.list_profiles fs in
100 let profile_name = Option.value ~default:Session.default_profile profile in
101 if profiles = [] || Option.is_some profile then
102 Session.set_current_profile fs profile_name;
103 { client; session; fs; profile }
104
105let login_password ~sw ~env ?requests_config ?profile ~server_url ~email ~password () =
106 let fs = env#fs in
107 (* Create session without auth first *)
108 let requests_session = match requests_config with
109 | Some config -> Requests.Cmd.create config env sw
110 | None -> Requests.create ~sw env
111 in
112 (* Resolve the API URL from .well-known/immich if available *)
113 let server_url = resolve_api_url ~session:requests_session server_url in
114 let client = Immich.create ~session:requests_session ~sw env ~base_url:server_url in
115 (* Login using the API *)
116 let body = Immich.LoginCredential.Dto.v ~email ~password () in
117 let resp = Immich.Login.login client ~body () in
118 let access_token = Immich.Login.ResponseDto.access_token resp in
119 let user_id = Immich.Login.ResponseDto.user_id resp in
120 (* Now create a new client with the auth token *)
121 let requests_session = match requests_config with
122 | Some config -> Requests.Cmd.create config env sw
123 | None -> Requests.create ~sw env
124 in
125 let requests_session = Requests.set_auth requests_session (Requests.Auth.bearer ~token:access_token) in
126 let client = Immich.create ~session:requests_session ~sw env ~base_url:server_url in
127 (* Create and save session *)
128 let auth = Session.Jwt { access_token; user_id; email } in
129 let session = Session.create ~server_url ~auth () in
130 Session.save fs ?profile session;
131 (* Set as current profile if first login or explicitly requested *)
132 let profiles = Session.list_profiles fs in
133 let profile_name = Option.value ~default:email profile in
134 if profiles = [] || Option.is_some profile then
135 Session.set_current_profile fs profile_name;
136 { client; session; fs; profile }
137
138let resume ~sw ~env ?requests_config ?profile ~session () =
139 (* Check if JWT is expired and refresh if needed *)
140 let session =
141 if Session.is_expired session then begin
142 match Session.auth session with
143 | Session.Api_key _ -> session (* API keys don't expire *)
144 | Session.Jwt _ ->
145 (* JWT expired - for now just fail, user needs to re-login *)
146 failwith "Session expired. Please login again."
147 end
148 else session
149 in
150 create_with_session ~sw ~env ?requests_config ?profile ~session ()
151
152let logout t =
153 Session.clear t.fs ?profile:t.profile ()
154
155let client t = t.client
156let session t = t.session
157let profile t = t.profile
158let fs t = t.fs
159
160let is_valid t =
161 try
162 let resp = Immich.ValidateAccessToken.validate_access_token t.client () in
163 Immich.ValidateAccessToken.ResponseDto.auth_status resp
164 with _ -> false