···11-This is a monorepo of a collection of OCaml libraries that should all work together, but they are in different stages of porting. Ultimately, the goal is to use Eio for everything and move away from Lwt entirely. For HTTP(S) requests the idea is to use River as the main high evel 'http requests' API, so anything using Cohttp or other HTTP fetchers will need to be ported.
22-
+232
stack/TODO.md
···11+# Production Quality TODO
22+33+This document tracks remaining tasks to make the immutable Requests.t and refactored library patterns production-ready.
44+55+## High Priority
66+77+### 1. Update Immiche README.md ⚠️
88+**Status:** Outdated
99+**Location:** `immiche/README.md`
1010+1111+The README still documents the old API (passing `~sw ~clock ~net ~base_url ~api_key` on every call) instead of the new session-based pattern.
1212+1313+**Tasks:**
1414+- [ ] Update API signatures to show `create` function
1515+- [ ] Update example code to show session creation
1616+- [ ] Document connection pooling benefits
1717+- [ ] Update "Implementation Notes" to mention `Requests.t` instead of `Requests.One`
1818+- [ ] Add example of sharing sessions across multiple libraries
1919+2020+**Example of needed change:**
2121+```ocaml
2222+(* OLD - documented in README *)
2323+let people = Immiche.fetch_people ~sw ~clock ~net ~base_url ~api_key
2424+2525+(* NEW - what actually works now *)
2626+let client = Immiche.create ~sw ~env ~base_url ~api_key () in
2727+let people = Immiche.fetch_people client
2828+```
2929+3030+### 2. Improve Error Handling in Immiche
3131+**Status:** Inconsistent
3232+**Location:** `immiche/immiche.ml:79,92,133`
3333+3434+Currently uses `failwith` for HTTP errors instead of returning Result types consistently.
3535+3636+**Issues:**
3737+- `fetch_people`, `fetch_person`, `search_person` use `failwith` for HTTP errors
3838+- `download_thumbnail` correctly returns `Result`
3939+- Inconsistent error handling makes it hard to gracefully handle failures
4040+4141+**Tasks:**
4242+- [ ] Change `fetch_people` to return `(people_response, [> `Msg of string]) result`
4343+- [ ] Change `fetch_person` to return `(person, [> `Msg of string]) result`
4444+- [ ] Change `search_person` to return `(person list, [> `Msg of string]) result`
4545+- [ ] Update `immiche/immiche.mli` with new signatures
4646+- [ ] Update `bushel/bin/bushel_faces.ml` to handle Result types
4747+- [ ] Document error cases in docstrings
4848+4949+### 3. Add Type Aliases for Verbose Signatures
5050+**Status:** Verbose
5151+**Location:** `immiche/immiche.mli`, `zotero-translation/zotero_translation.mli`
5252+5353+Type signatures like `([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t` are extremely verbose.
5454+5555+**Tasks:**
5656+- [ ] Consider adding type aliases:
5757+ ```ocaml
5858+ type client =
5959+ ([> float Eio.Time.clock_ty ] Eio.Resource.t,
6060+ [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t
6161+6262+ val fetch_people : client -> people_response
6363+ ```
6464+- [ ] Evaluate if this improves or harms the API (might hide important type info)
6565+- [ ] If beneficial, apply to both Immiche and zotero-translation
6666+6767+## Medium Priority
6868+6969+### 4. Add Tests for New Immiche Pattern
7070+**Status:** No tests
7171+**Location:** None - needs creation
7272+7373+The Immiche library was refactored but has no tests.
7474+7575+**Tasks:**
7676+- [ ] Create `immiche/test/test_immiche.ml`
7777+- [ ] Add test for `create` function
7878+- [ ] Add tests for all API functions with mocked responses
7979+- [ ] Test connection pooling behavior (multiple requests reuse connections)
8080+- [ ] Test session sharing (derived sessions share pools)
8181+- [ ] Add to dune test suite
8282+8383+### 5. Verify Zotero-translation with Immutable Requests.t
8484+**Status:** Unknown compatibility
8585+**Location:** `zotero-translation/`
8686+8787+Zotero-translation was already using session pattern before we made Requests.t immutable. Need to verify it still works correctly.
8888+8989+**Tasks:**
9090+- [ ] Review if zotero-translation needs updates for immutable Requests.t
9191+- [ ] Check if any derived sessions are created (and if pools are shared correctly)
9292+- [ ] Add tests if missing
9393+- [ ] Document session sharing pattern if used
9494+9595+### 6. Document Thread Safety / Concurrency Model
9696+**Status:** Unclear
9797+**Location:** Documentation
9898+9999+With immutable sessions that share mutable cookie_jar and statistics, the concurrency model needs documentation.
100100+101101+**Tasks:**
102102+- [ ] Document that derived sessions share:
103103+ - Connection pools (intentional - for performance)
104104+ - Cookie jar (intentional - session state)
105105+ - Statistics (intentional - unified monitoring)
106106+- [ ] Document that cookie_mutex protects concurrent cookie access
107107+- [ ] Clarify if derived sessions can be used safely across Eio fibers
108108+- [ ] Add concurrency examples to documentation
109109+110110+### 7. Enhanced API Documentation
111111+**Status:** Basic
112112+**Location:** All `.mli` files
113113+114114+Current documentation is minimal. Could add more examples and edge case documentation.
115115+116116+**Tasks:**
117117+- [ ] Add usage examples to `requests/lib/requests.mli` showing:
118118+ - Creating base session
119119+ - Deriving sessions with different configs
120120+ - Sharing pools across derived sessions
121121+- [ ] Document what happens when you derive from derived sessions
122122+- [ ] Add performance notes about connection pooling
123123+- [ ] Document cookie handling behavior
124124+125125+## Low Priority
126126+127127+### 8. Consider Immutable Statistics
128128+**Status:** Design decision
129129+**Location:** `requests/lib/requests.ml:51-53`
130130+131131+Statistics (requests_made, total_time, retries_count) are still mutable and shared across derived sessions.
132132+133133+**Trade-offs:**
134134+- **Current (mutable):** Simple, tracks total usage across all derived sessions
135135+- **Alternative (immutable):** Would require returning `(Response.t * t)` from all request functions
136136+137137+**Tasks:**
138138+- [ ] Evaluate if immutable statistics would be valuable
139139+- [ ] If yes, design API that returns updated sessions from request functions
140140+- [ ] If no, document the design decision and rationale
141141+142142+### 9. Add Examples Directory
143143+**Status:** No standalone examples
144144+**Location:** None
145145+146146+Would be helpful to have example code showing common patterns.
147147+148148+**Tasks:**
149149+- [ ] Create `examples/` directory
150150+- [ ] Add `examples/immiche_basic.ml` - basic Immiche usage
151151+- [ ] Add `examples/immiche_advanced.ml` - session sharing across APIs
152152+- [ ] Add `examples/requests_sessions.ml` - derived sessions with shared pools
153153+- [ ] Add `examples/zotero_immiche_shared.ml` - sharing pools between libraries
154154+- [ ] Ensure examples build and run
155155+156156+### 10. Performance Benchmarks
157157+**Status:** No benchmarks
158158+**Location:** None
159159+160160+Would be valuable to have benchmarks showing connection pooling benefits.
161161+162162+**Tasks:**
163163+- [ ] Create benchmark comparing:
164164+ - Requests.One (no pooling) vs Requests.t (with pooling)
165165+ - Single session vs derived sessions (verify pool sharing)
166166+ - Sequential vs parallel requests
167167+- [ ] Document performance characteristics
168168+- [ ] Add to CI if significant
169169+170170+## Breaking Changes Documented
171171+172172+### Immiche API (Breaking)
173173+**Old API:**
174174+```ocaml
175175+val fetch_people :
176176+ sw:Eio.Switch.t ->
177177+ clock:_ Eio.Time.clock ->
178178+ net:_ Eio.Net.t ->
179179+ base_url:string ->
180180+ api_key:string ->
181181+ people_response
182182+```
183183+184184+**New API:**
185185+```ocaml
186186+val create :
187187+ sw:Eio.Switch.t ->
188188+ env:< clock:_; net:_; fs:_; .. > ->
189189+ ?requests_session:_ Requests.t ->
190190+ base_url:string ->
191191+ api_key:string ->
192192+ unit -> t
193193+194194+val fetch_people : t -> people_response
195195+```
196196+197197+**Migration Required:**
198198+- All Immiche users must update to create a client first
199199+- Only known usage: `bushel/bin/bushel_faces.ml` (already updated)
200200+201201+### Requests Configuration Functions (Breaking)
202202+**Old API:**
203203+```ocaml
204204+val set_auth : t -> Auth.t -> unit
205205+```
206206+207207+**New API:**
208208+```ocaml
209209+val set_auth : t -> Auth.t -> t
210210+```
211211+212212+**Migration Required:**
213213+- Must capture returned session: `let req = Requests.set_auth req auth`
214214+- All usages updated: ocurl.ml, test_requests.ml, jmap_client.ml
215215+216216+## Notes
217217+218218+- **Cookie handling:** Still mutable via shared cookie_jar - this is intentional for session state
219219+- **Statistics:** Still mutable and shared - tracks total usage across derived sessions
220220+- **Connection pools:** Shared across all derived sessions - major performance benefit
221221+- **No backward compatibility:** Breaking changes are acceptable for this monorepo
222222+223223+## Completion Checklist
224224+225225+Before considering this production-ready:
226226+227227+- [ ] High priority items completed
228228+- [ ] README.md updated for Immiche
229229+- [ ] Error handling consistent across Immiche
230230+- [ ] At least basic tests added for Immiche
231231+- [ ] Documentation covers concurrency model
232232+- [ ] All examples in docs are verified to work
+121-132
stack/bushel/bin/bushel_doi.ml
···11module ZT = Zotero_translation
22-open Lwt.Infix
32module J = Ezjsonm
43open Cmdliner
54···4342let resolve_doi zt ~verbose doi =
4443 Printf.printf "Resolving DOI: %s\n%!" doi;
4544 let doi_url = Printf.sprintf "https://doi.org/%s" doi in
4646- Lwt.catch
4747- (fun () ->
4848- ZT.json_of_doi zt ~slug:"temp" doi >>= fun json ->
4949- if verbose then begin
5050- Printf.printf " Raw Zotero response:\n%s\n%!" (J.value_to_string json)
5151- end;
5252- try
5353- let keys = J.get_dict (json :> J.value) in
5454- let title = J.find json ["title"] |> J.get_string in
5555- let authors = J.find json ["author"] |> J.get_list J.get_string in
5656- let year = J.find json ["year"] |> J.get_string |> int_of_string in
5757- let bibtype = J.find json ["bibtype"] |> J.get_string in
5858- let publisher =
5959- try
6060- (* Try journal first, then booktitle, then publisher *)
6161- match List.assoc_opt "journal" keys with
6262- | Some j -> J.get_string j
4545+ try
4646+ let json = ZT.json_of_doi zt ~slug:"temp" doi in
4747+ if verbose then begin
4848+ Printf.printf " Raw Zotero response:\n%s\n%!" (J.value_to_string json)
4949+ end;
5050+ try
5151+ let keys = J.get_dict (json :> J.value) in
5252+ let title = J.find json ["title"] |> J.get_string in
5353+ let authors = J.find json ["author"] |> J.get_list J.get_string in
5454+ let year = J.find json ["year"] |> J.get_string |> int_of_string in
5555+ let bibtype = J.find json ["bibtype"] |> J.get_string in
5656+ let publisher =
5757+ try
5858+ (* Try journal first, then booktitle, then publisher *)
5959+ match List.assoc_opt "journal" keys with
6060+ | Some j -> J.get_string j
6161+ | None ->
6262+ match List.assoc_opt "booktitle" keys with
6363+ | Some b -> J.get_string b
6364 | None ->
6464- match List.assoc_opt "booktitle" keys with
6565- | Some b -> J.get_string b
6666- | None ->
6767- match List.assoc_opt "publisher" keys with
6868- | Some p -> J.get_string p
6969- | None -> ""
7070- with _ -> ""
7171- in
7272- let entry = Bushel.Doi_entry.create_resolved ~doi ~title ~authors ~year ~bibtype ~publisher ~source_urls:[doi_url] () in
7373- Printf.printf " ✓ Resolved: %s (%d)\n%!" title year;
7474- Lwt.return entry
7575- with e ->
7676- Printf.eprintf " ✗ Failed to parse response for %s: %s\n%!" doi (Printexc.to_string e);
7777- Lwt.return (Bushel.Doi_entry.create_failed ~doi ~error:(Printexc.to_string e) ~source_urls:[doi_url] ())
7878- )
7979- (fun exn ->
8080- Printf.eprintf " ✗ Failed to resolve %s: %s\n%!" doi (Printexc.to_string exn);
8181- Lwt.return (Bushel.Doi_entry.create_failed ~doi ~error:(Printexc.to_string exn) ~source_urls:[doi_url] ())
8282- )
6565+ match List.assoc_opt "publisher" keys with
6666+ | Some p -> J.get_string p
6767+ | None -> ""
6868+ with _ -> ""
6969+ in
7070+ let entry = Bushel.Doi_entry.create_resolved ~doi ~title ~authors ~year ~bibtype ~publisher ~source_urls:[doi_url] () in
7171+ Printf.printf " ✓ Resolved: %s (%d)\n%!" title year;
7272+ entry
7373+ with e ->
7474+ Printf.eprintf " ✗ Failed to parse response for %s: %s\n%!" doi (Printexc.to_string e);
7575+ Bushel.Doi_entry.create_failed ~doi ~error:(Printexc.to_string e) ~source_urls:[doi_url] ()
7676+ with exn ->
7777+ Printf.eprintf " ✗ Failed to resolve %s: %s\n%!" doi (Printexc.to_string exn);
7878+ Bushel.Doi_entry.create_failed ~doi ~error:(Printexc.to_string exn) ~source_urls:[doi_url] ()
83798480(* Resolve a publisher URL via Zotero /web endpoint *)
8581let resolve_url zt ~verbose url =
8682 Printf.printf "Resolving URL: %s\n%!" url;
8787- Lwt.catch
8888- (fun () ->
8989- (* Use Zotero's resolve_url which calls /web endpoint with the URL directly *)
9090- ZT.resolve_url zt url >>= function
9191- | Error (`Msg err) ->
9292- Printf.eprintf " ✗ Failed to resolve URL: %s\n%!" err;
9393- Lwt.return (Bushel.Doi_entry.create_failed ~doi:url ~error:err ~source_urls:[url] ())
9494- | Ok json ->
9595- if verbose then begin
9696- Printf.printf " Raw Zotero response:\n%s\n%!" (J.value_to_string json)
9797- end;
9898- try
9999- (* Extract metadata from the JSON response *)
100100- let json_list = match json with
101101- | `A lst -> lst
102102- | single -> [single]
8383+ try
8484+ (* Use Zotero's resolve_url which calls /web endpoint with the URL directly *)
8585+ match ZT.resolve_url zt url with
8686+ | Error (`Msg err) ->
8787+ Printf.eprintf " ✗ Failed to resolve URL: %s\n%!" err;
8888+ Bushel.Doi_entry.create_failed ~doi:url ~error:err ~source_urls:[url] ()
8989+ | Ok json ->
9090+ if verbose then begin
9191+ Printf.printf " Raw Zotero response:\n%s\n%!" (J.value_to_string (json :> J.value))
9292+ end;
9393+ try
9494+ (* Extract metadata from the JSON response *)
9595+ let json_list = match json with
9696+ | `A lst -> lst
9797+ | single -> [(single :> J.value)]
9898+ in
9999+ match json_list with
100100+ | [] ->
101101+ Printf.eprintf " ✗ Empty response\n%!";
102102+ Bushel.Doi_entry.create_failed ~doi:url ~error:"Empty response" ~source_urls:[url] ()
103103+ | item :: _ ->
104104+ (* Extract DOI if present, otherwise use URL *)
105105+ let doi = try J.find item ["DOI"] |> J.get_string with _ ->
106106+ try J.find item ["doi"] |> J.get_string with _ -> url
107107+ in
108108+ let title = try J.find item ["title"] |> J.get_string with _ ->
109109+ "Unknown Title"
110110+ in
111111+ (* Extract authors from Zotero's "creators" field *)
112112+ let authors = try
113113+ J.find item ["creators"] |> J.get_list (fun creator_obj ->
114114+ try
115115+ let last_name = J.find creator_obj ["lastName"] |> J.get_string in
116116+ let first_name = try J.find creator_obj ["firstName"] |> J.get_string with _ -> "" in
117117+ if first_name = "" then last_name else first_name ^ " " ^ last_name
118118+ with _ -> "Unknown Author"
119119+ )
120120+ with _ -> []
121121+ in
122122+ (* Extract year from Zotero's "date" field *)
123123+ (* Handles both ISO format "2025-07" and text format "November 28, 2023" *)
124124+ let year = try
125125+ let date_str = J.find item ["date"] |> J.get_string in
126126+ (* First try splitting on '-' for ISO dates like "2025-07" or "2024-11-04" *)
127127+ let parts = String.split_on_char '-' date_str in
128128+ match parts with
129129+ | year_str :: _ when String.length year_str = 4 ->
130130+ (try int_of_string year_str with _ -> 0)
131131+ | _ ->
132132+ (* Try splitting on space and comma for dates like "November 28, 2023" *)
133133+ let space_parts = String.split_on_char ' ' date_str in
134134+ let year_candidate = List.find_opt (fun s ->
135135+ let s = String.trim (String.map (fun c -> if c = ',' then ' ' else c) s) in
136136+ String.length s = 4 && String.for_all (function '0'..'9' -> true | _ -> false) s
137137+ ) space_parts in
138138+ (match year_candidate with
139139+ | Some year_str -> int_of_string (String.trim year_str)
140140+ | None -> 0)
141141+ with _ -> 0
103142 in
104104- match json_list with
105105- | [] ->
106106- Printf.eprintf " ✗ Empty response\n%!";
107107- Lwt.return (Bushel.Doi_entry.create_failed ~doi:url ~error:"Empty response" ~source_urls:[url] ())
108108- | item :: _ ->
109109- (* Extract DOI if present, otherwise use URL *)
110110- let doi = try J.find item ["DOI"] |> J.get_string with _ ->
111111- try J.find item ["doi"] |> J.get_string with _ -> url
112112- in
113113- let title = try J.find item ["title"] |> J.get_string with _ ->
114114- "Unknown Title"
115115- in
116116- (* Extract authors from Zotero's "creators" field *)
117117- let authors = try
118118- J.find item ["creators"] |> J.get_list (fun creator_obj ->
119119- try
120120- let last_name = J.find creator_obj ["lastName"] |> J.get_string in
121121- let first_name = try J.find creator_obj ["firstName"] |> J.get_string with _ -> "" in
122122- if first_name = "" then last_name else first_name ^ " " ^ last_name
123123- with _ -> "Unknown Author"
124124- )
125125- with _ -> []
126126- in
127127- (* Extract year from Zotero's "date" field *)
128128- (* Handles both ISO format "2025-07" and text format "November 28, 2023" *)
129129- let year = try
130130- let date_str = J.find item ["date"] |> J.get_string in
131131- (* First try splitting on '-' for ISO dates like "2025-07" or "2024-11-04" *)
132132- let parts = String.split_on_char '-' date_str in
133133- match parts with
134134- | year_str :: _ when String.length year_str = 4 ->
135135- (try int_of_string year_str with _ -> 0)
136136- | _ ->
137137- (* Try splitting on space and comma for dates like "November 28, 2023" *)
138138- let space_parts = String.split_on_char ' ' date_str in
139139- let year_candidate = List.find_opt (fun s ->
140140- let s = String.trim (String.map (fun c -> if c = ',' then ' ' else c) s) in
141141- String.length s = 4 && String.for_all (function '0'..'9' -> true | _ -> false) s
142142- ) space_parts in
143143- (match year_candidate with
144144- | Some year_str -> int_of_string (String.trim year_str)
145145- | None -> 0)
146146- with _ -> 0
147147- in
148148- (* Extract type/bibtype from Zotero's "itemType" field *)
149149- let bibtype = try J.find item ["itemType"] |> J.get_string with _ -> "article" in
150150- (* Extract publisher/journal from Zotero's "publicationTitle" field *)
151151- let publisher = try
152152- J.find item ["publicationTitle"] |> J.get_string
153153- with _ -> ""
154154- in
155155- (* Include both the original URL and the DOI URL in source_urls *)
156156- let doi_url = if doi = url then [] else [Printf.sprintf "https://doi.org/%s" doi] in
157157- let source_urls = url :: doi_url in
158158- let entry = Bushel.Doi_entry.create_resolved ~doi ~title ~authors ~year ~bibtype ~publisher ~source_urls () in
159159- Printf.printf " ✓ Resolved: %s (%d) [DOI: %s]\n%!" title year doi;
160160- Lwt.return entry
161161- with e ->
162162- Printf.eprintf " ✗ Failed to parse response: %s\n%!" (Printexc.to_string e);
163163- Lwt.return (Bushel.Doi_entry.create_failed ~doi:url ~error:(Printexc.to_string e) ~source_urls:[url] ())
164164- )
165165- (fun exn ->
166166- Printf.eprintf " ✗ Failed to resolve %s: %s\n%!" url (Printexc.to_string exn);
167167- Lwt.return (Bushel.Doi_entry.create_failed ~doi:url ~error:(Printexc.to_string exn) ~source_urls:[url] ())
168168- )
143143+ (* Extract type/bibtype from Zotero's "itemType" field *)
144144+ let bibtype = try J.find item ["itemType"] |> J.get_string with _ -> "article" in
145145+ (* Extract publisher/journal from Zotero's "publicationTitle" field *)
146146+ let publisher = try
147147+ J.find item ["publicationTitle"] |> J.get_string
148148+ with _ -> ""
149149+ in
150150+ (* Include both the original URL and the DOI URL in source_urls *)
151151+ let doi_url = if doi = url then [] else [Printf.sprintf "https://doi.org/%s" doi] in
152152+ let source_urls = url :: doi_url in
153153+ let entry = Bushel.Doi_entry.create_resolved ~doi ~title ~authors ~year ~bibtype ~publisher ~source_urls () in
154154+ Printf.printf " ✓ Resolved: %s (%d) [DOI: %s]\n%!" title year doi;
155155+ entry
156156+ with e ->
157157+ Printf.eprintf " ✗ Failed to parse response: %s\n%!" (Printexc.to_string e);
158158+ Bushel.Doi_entry.create_failed ~doi:url ~error:(Printexc.to_string e) ~source_urls:[url] ()
159159+ with exn ->
160160+ Printf.eprintf " ✗ Failed to resolve %s: %s\n%!" url (Printexc.to_string exn);
161161+ Bushel.Doi_entry.create_failed ~doi:url ~error:(Printexc.to_string exn) ~source_urls:[url] ()
169162170163let run base_dir force verbose =
171164 Printf.printf "Loading bushel database...\n%!";
···222215 end else begin
223216 Printf.printf "Resolving %d DOI(s) and %d URL(s)...\n%!" (List.length dois_to_resolve) (List.length urls_to_resolve);
224217225225- let zt = ZT.v "http://svr-avsm2-eeg-ce:1969" in
226226-227227- (* Resolve all DOIs *)
228228- let resolved_doi_entries_lwt =
229229- Lwt_list.map_s (resolve_doi zt ~verbose) dois_to_resolve
230230- in
231231-232232- (* Resolve all publisher URLs *)
233233- let resolved_url_entries_lwt =
234234- Lwt_list.map_s (resolve_url zt ~verbose) urls_to_resolve
218218+ (* Resolve all DOIs and URLs using Eio *)
219219+ let new_entries = Eio_main.run @@ fun env ->
220220+ Eio.Switch.run @@ fun sw ->
221221+ (* Create Zotero Translation client with connection pooling *)
222222+ let zt = ZT.create ~sw ~env "http://svr-avsm2-eeg-ce:1969" in
223223+ (* Resolve all DOIs *)
224224+ let new_doi_entries = List.map (resolve_doi zt ~verbose) dois_to_resolve in
225225+ (* Resolve all publisher URLs *)
226226+ let new_url_entries = List.map (resolve_url zt ~verbose) urls_to_resolve in
227227+ new_doi_entries @ new_url_entries
235228 in
236236-237237- let new_doi_entries = Lwt_main.run resolved_doi_entries_lwt in
238238- let new_url_entries = Lwt_main.run resolved_url_entries_lwt in
239239- let new_entries = new_doi_entries @ new_url_entries in
240229241230 (* Merge with existing entries, combining source_urls for entries with the same DOI *)
242231 let all_entries =
+38-95
stack/bushel/bin/bushel_faces.ml
···11open Cmdliner
22-open Lwt.Infix
32open Printf
4355-(* Type for person response *)
66-type person = {
77- id: string;
88- name: string;
99- thumbnailPath: string option;
1010-}
1111-1212-(* Parse a person from JSON *)
1313-let parse_person json =
1414- let open Ezjsonm in
1515- let id = find json ["id"] |> get_string in
1616- let name = find json ["name"] |> get_string in
1717- let thumbnailPath =
1818- try Some (find json ["thumbnailPath"] |> get_string)
1919- with _ -> None
2020- in
2121- { id; name; thumbnailPath }
2222-2323-(* Parse a list of people from JSON response *)
2424-let parse_people_response json =
2525- let open Ezjsonm in
2626- get_list parse_person json
2727-284(* Read API key from file *)
295let read_api_key file =
306 let ic = open_in file in
···328 close_in ic;
339 key
34103535-(* Search for a person by name *)
3636-let search_person base_url api_key name =
3737- let open Cohttp_lwt_unix in
3838- let headers = Cohttp.Header.init_with "X-Api-Key" api_key in
3939- let encoded_name = Uri.pct_encode name in
4040- let url = Printf.sprintf "%s/api/search/person?name=%s" base_url encoded_name in
4141-4242- Client.get ~headers (Uri.of_string url) >>= fun (resp, body) ->
4343- if resp.status = `OK then
4444- Cohttp_lwt.Body.to_string body >>= fun body_str ->
4545- let json = Ezjsonm.from_string body_str in
4646- Lwt.return (parse_people_response json)
4747- else
4848- let status_code = Cohttp.Code.code_of_status resp.status in
4949- Lwt.fail_with (Printf.sprintf "HTTP error: %d" status_code)
5050-5151-(* Download thumbnail for a person *)
5252-let download_thumbnail base_url api_key person_id output_path =
5353- let open Cohttp_lwt_unix in
5454- let headers = Cohttp.Header.init_with "X-Api-Key" api_key in
5555- let url = Printf.sprintf "%s/api/people/%s/thumbnail" base_url person_id in
5656-5757- Client.get ~headers (Uri.of_string url) >>= fun (resp, body) ->
5858- match resp.status with
5959- | `OK ->
6060- Cohttp_lwt.Body.to_string body >>= fun img_data ->
6161- (* Ensure output directory exists *)
6262- (try
6363- let dir = Filename.dirname output_path in
6464- if not (Sys.file_exists dir) then Unix.mkdir dir 0o755;
6565- Lwt.return_unit
6666- with _ -> Lwt.return_unit) >>= fun () ->
6767- Lwt_io.with_file ~mode:Lwt_io.output output_path
6868- (fun oc -> Lwt_io.write oc img_data) >>= fun () ->
6969- Lwt.return_ok output_path
7070- | _ ->
7171- let status_code = Cohttp.Code.code_of_status resp.status in
7272- Lwt.return_error (Printf.sprintf "HTTP error: %d" status_code)
7373-7411(* Get face for a single contact *)
7575-(* TODO:claude *)
7676-let get_face_for_contact base_url api_key output_dir contact =
1212+let get_face_for_contact immiche_client ~fs output_dir contact =
7713 let names = Bushel.Contact.names contact in
7814 let handle = Bushel.Contact.handle contact in
7915 let output_path = Filename.concat output_dir (handle ^ ".jpg") in
80168117 (* Skip if file already exists *)
8218 if Sys.file_exists output_path then
8383- Lwt.return (`Skipped (sprintf "Thumbnail for '%s' already exists at %s" (List.hd names) output_path))
1919+ `Skipped (sprintf "Thumbnail for '%s' already exists at %s" (List.hd names) output_path)
8420 else begin
8521 printf "Processing contact: %s (handle: %s)\n%!" (List.hd names) handle;
86228723 (* Try each name in the list until we find a match *)
8824 let rec try_names = function
8925 | [] ->
9090- Lwt.return (`Error (sprintf "No person found with any name for contact '%s'" handle))
2626+ `Error (sprintf "No person found with any name for contact '%s'" handle)
9127 | name :: rest_names ->
9228 printf " Trying name: %s\n%!" name;
9393- search_person base_url api_key name >>= function
2929+ let people = Immiche.search_person immiche_client ~name in
3030+ (match people with
9431 | [] ->
9532 printf " No results for '%s', trying next name...\n%!" name;
9633 try_names rest_names
9734 | person :: _ ->
9835 printf " Found match for '%s'\n%!" name;
9999- download_thumbnail base_url api_key person.id output_path >>= function
100100- | Ok path ->
101101- Lwt.return (`Ok (sprintf "Saved thumbnail for '%s' to %s" name path))
102102- | Error err ->
103103- Lwt.return (`Error (sprintf "Error for '%s': %s" name err))
3636+ let result = Immiche.download_thumbnail immiche_client
3737+ ~fs ~person_id:person.id ~output_path in
3838+ (match result with
3939+ | Ok _ ->
4040+ `Ok (sprintf "Saved thumbnail for '%s' to %s" name output_path)
4141+ | Error (`Msg err) ->
4242+ `Error (sprintf "Error for '%s': %s" name err)))
10443 in
10544 try_names names
10645 end
1074610847(* Process all contacts or a specific one *)
109109-let process_contacts base_dir output_dir specific_handle api_key base_url =
4848+let process_contacts ~sw ~env base_dir output_dir specific_handle api_key base_url =
11049 printf "Loading Bushel database from %s\n%!" base_dir;
11150 let db = Bushel.load base_dir in
11251 let contacts = Bushel.Entry.contacts db in
11352 printf "Found %d contacts\n%!" (List.length contacts);
114114-5353+5454+ (* Create Immiche client for connection pooling *)
5555+ let immiche_client = Immiche.create ~sw ~env ~base_url ~api_key () in
5656+11557 (* Ensure output directory exists *)
11658 if not (Sys.file_exists output_dir) then Unix.mkdir output_dir 0o755;
117117-5959+11860 (* Filter contacts based on specific_handle if provided *)
119119- let contacts_to_process =
6161+ let contacts_to_process =
12062 match specific_handle with
121121- | Some handle ->
6363+ | Some handle ->
12264 begin match Bushel.Contact.find_by_handle contacts handle with
12365 | Some contact -> [contact]
124124- | None ->
6666+ | None ->
12567 eprintf "No contact found with handle '%s'\n%!" handle;
12668 []
12769 end
12870 | None -> contacts
12971 in
130130-7272+13173 (* Process each contact *)
132132- let results = Lwt_main.run begin
133133- Lwt_list.map_s
134134- (fun contact ->
135135- get_face_for_contact base_url api_key output_dir contact >>= fun result ->
136136- Lwt.return (Bushel.Contact.handle contact, result))
137137- contacts_to_process
138138- end in
139139-7474+ let results = List.map
7575+ (fun contact ->
7676+ let result = get_face_for_contact immiche_client ~fs:env#fs output_dir contact in
7777+ (Bushel.Contact.handle contact, result))
7878+ contacts_to_process
7979+ in
8080+14081 (* Print summary *)
14182 let ok_count = List.length (List.filter (fun (_, r) -> match r with `Ok _ -> true | _ -> false) results) in
14283 let error_count = List.length (List.filter (fun (_, r) -> match r with `Error _ -> true | _ -> false) results) in
14384 let skipped_count = List.length (List.filter (fun (_, r) -> match r with `Skipped _ -> true | _ -> false) results) in
144144-8585+14586 printf "\nSummary:\n";
14687 printf " Successfully processed: %d\n" ok_count;
14788 printf " Errors: %d\n" error_count;
14889 printf " Skipped (already exist): %d\n" skipped_count;
149149-9090+15091 (* Print detailed results *)
15192 if error_count > 0 then begin
15293 printf "\nError details:\n";
···15697 | _ -> ())
15798 results;
15899 end;
159159-100100+160101 if ok_count > 0 || skipped_count > 0 then 0 else 1
161102162103(* Command line interface *)
···167108 const (fun base_dir output_dir handle api_key_file base_url ->
168109 try
169110 let api_key = read_api_key api_key_file in
170170- process_contacts base_dir output_dir handle api_key base_url
171171- with e ->
111111+ Eio_main.run @@ fun env ->
112112+ Eio.Switch.run @@ fun sw ->
113113+ process_contacts ~sw ~env base_dir output_dir handle api_key base_url
114114+ with e ->
172115 eprintf "Error: %s\n%!" (Printexc.to_string e);
173116 1
174174- ) $ Bushel_common.base_dir $ Bushel_common.output_dir ~default:"." $ Bushel_common.handle_opt $
175175- Bushel_common.api_key_file ~default:".photos-api" $
117117+ ) $ Bushel_common.base_dir $ Bushel_common.output_dir ~default:"." $ Bushel_common.handle_opt $
118118+ Bushel_common.api_key_file ~default:".photos-api" $
176119 Bushel_common.url_term ~default:"https://photos.recoil.org" ~doc:"Base URL of the Immich instance")
177120178121let cmd =
+187-189
stack/bushel/bin/bushel_links.ml
···11open Cmdliner
22-open Lwt.Infix
3243(* Helper function for logging with proper flushing *)
54let log fmt = Fmt.kstr (fun s -> prerr_string s; flush stderr) fmt
66-let log_verbose verbose fmt =
77- if verbose then Fmt.kstr (fun s -> prerr_string s; flush stderr) fmt
55+let log_verbose verbose fmt =
66+ if verbose then Fmt.kstr (fun s -> prerr_string s; flush stderr) fmt
87 else Fmt.kstr (fun _ -> ()) fmt
98109(* Initialize a new links.yml file or ensure it exists *)
···1918 0
20192120(* Update links.yml from Karakeep *)
2222-let update_from_karakeep base_url api_key_opt tag links_file download_assets =
2121+let update_from_karakeep ~sw ~env base_url api_key_opt tag links_file download_assets =
2322 match api_key_opt with
2423 | None ->
2524 prerr_endline "Error: API key is required.";
···2726 1
2827 | Some api_key ->
2928 let assets_dir = "data/assets" in
3030-3131- (* Run the Lwt program *)
3232- Lwt_main.run (
2929+3030+ try
3331 print_endline (Fmt.str "Fetching links from %s with tag '%s'..." base_url tag);
3434-3232+3533 (* Prepare tag filter *)
3634 let filter_tags = if tag = "" then [] else [tag] in
3737-3838- (* Fetch bookmarks from Karakeep with error handling *)
3939- Lwt.catch
4040- (fun () ->
4141- Karakeep.fetch_all_bookmarks ~api_key ~filter_tags base_url >>= fun bookmarks ->
4242-4343- print_endline (Fmt.str "Retrieved %d bookmarks from Karakeep" (List.length bookmarks));
4444-4545- (* Read existing links if file exists *)
4646- let existing_links = Bushel.Link.load_links_file links_file in
4747-4848- (* Convert bookmarks to bushel links *)
4949- let new_links = List.map (fun bookmark ->
5050- Karakeep.to_bushel_link ~base_url bookmark
5151- ) bookmarks in
5252-5353- (* Merge with existing links - keep existing dates (karakeep dates may be unreliable) *)
5454- let merged_links = Bushel.Link.merge_links existing_links new_links in
5555-5656- (* Save the updated links file *)
5757- Bushel.Link.save_links_file links_file merged_links;
5858-5959- print_endline (Fmt.str "Updated %s with %d links" links_file (List.length merged_links));
6060-6161- (* Download assets if requested *)
6262- if download_assets then begin
6363- print_endline "Downloading assets for bookmarks...";
6464-6565- (* Ensure the assets directory exists *)
6666- (try Unix.mkdir assets_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
6767-6868- (* Process each bookmark with assets *)
6969- Lwt_list.iter_s (fun bookmark ->
7070- (* Extract asset IDs from bookmark *)
7171- let assets = bookmark.Karakeep.assets in
7272-7373- (* Skip if no assets *)
7474- if assets = [] then
7575- Lwt.return_unit
7676- else
7777- (* Process each asset *)
7878- Lwt_list.iter_s (fun (asset_id, asset_type) ->
7979- let asset_dir = Fmt.str "%s/%s" assets_dir asset_id in
8080- let asset_file = Fmt.str "%s/asset.bin" asset_dir in
8181- let meta_file = Fmt.str "%s/metadata.json" asset_dir in
8282-8383- (* Skip if the asset already exists *)
8484- if Sys.file_exists asset_file then
8585- Lwt.return_unit
8686- else begin
8787- (* Create the asset directory *)
8888- (try Unix.mkdir asset_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
8989-9090- (* Download the asset *)
9191- print_endline (Fmt.str "Downloading %s asset %s..." asset_type asset_id);
9292- Karakeep.fetch_asset ~api_key base_url asset_id >>= fun data ->
9393-9494- (* Guess content type based on first bytes *)
9595- let content_type =
9696- if String.length data >= 4 && String.sub data 0 4 = "\x89PNG" then
9797- "image/png"
9898- else if String.length data >= 3 && String.sub data 0 3 = "\xFF\xD8\xFF" then
9999- "image/jpeg"
100100- else if String.length data >= 4 && String.sub data 0 4 = "%PDF" then
101101- "application/pdf"
102102- else
103103- "application/octet-stream"
104104- in
105105-106106- (* Write the asset data *)
107107- Lwt_io.with_file ~mode:Lwt_io.Output asset_file (fun oc ->
108108- Lwt_io.write oc data
109109- ) >>= fun () ->
110110-111111- (* Write metadata file *)
112112- let metadata = Fmt.str "{\n \"contentType\": \"%s\",\n \"assetType\": \"%s\"\n}"
113113- content_type asset_type in
114114- Lwt_io.with_file ~mode:Lwt_io.Output meta_file (fun oc ->
115115- Lwt_io.write oc metadata
116116- )
117117- end
118118- ) assets
119119- ) bookmarks >>= fun () ->
120120-121121- print_endline "Asset download completed.";
122122- Lwt.return 0
123123- end else
124124- Lwt.return 0
125125- )
126126- (fun exn ->
127127- prerr_endline (Fmt.str "Error fetching bookmarks: %s" (Printexc.to_string exn));
128128- Lwt.return 1
129129- )
130130- )
3535+3636+ (* Fetch bookmarks from Karakeep *)
3737+ let bookmarks = Karakeepe.fetch_all_bookmarks ~sw ~env ~api_key ~filter_tags base_url in
3838+3939+ print_endline (Fmt.str "Retrieved %d bookmarks from Karakeep" (List.length bookmarks));
4040+4141+ (* Read existing links if file exists *)
4242+ let existing_links = Bushel.Link.load_links_file links_file in
4343+4444+ (* Convert bookmarks to bushel links *)
4545+ let new_links = List.map (fun bookmark ->
4646+ Karakeepe.to_bushel_link ~base_url bookmark
4747+ ) bookmarks in
4848+4949+ (* Merge with existing links - keep existing dates (karakeep dates may be unreliable) *)
5050+ let merged_links = Bushel.Link.merge_links existing_links new_links in
5151+5252+ (* Save the updated links file *)
5353+ Bushel.Link.save_links_file links_file merged_links;
5454+5555+ print_endline (Fmt.str "Updated %s with %d links" links_file (List.length merged_links));
5656+5757+ (* Download assets if requested *)
5858+ if download_assets then begin
5959+ print_endline "Downloading assets for bookmarks...";
6060+6161+ (* Ensure the assets directory exists *)
6262+ (try Unix.mkdir assets_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
6363+6464+ (* Process each bookmark with assets *)
6565+ List.iter (fun bookmark ->
6666+ (* Extract asset IDs from bookmark *)
6767+ let assets = bookmark.Karakeepe.assets in
6868+6969+ (* Skip if no assets *)
7070+ if assets <> [] then
7171+ (* Process each asset *)
7272+ List.iter (fun (asset_id, asset_type) ->
7373+ let asset_dir = Fmt.str "%s/%s" assets_dir asset_id in
7474+ let asset_file = Fmt.str "%s/asset.bin" asset_dir in
7575+ let meta_file = Fmt.str "%s/metadata.json" asset_dir in
7676+7777+ (* Skip if the asset already exists *)
7878+ if not (Sys.file_exists asset_file) then begin
7979+ (* Create the asset directory *)
8080+ (try Unix.mkdir asset_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
8181+8282+ (* Download the asset *)
8383+ print_endline (Fmt.str "Downloading %s asset %s..." asset_type asset_id);
8484+ let data = Karakeepe.fetch_asset ~sw ~env ~api_key base_url asset_id in
8585+8686+ (* Guess content type based on first bytes *)
8787+ let content_type =
8888+ if String.length data >= 4 && String.sub data 0 4 = "\x89PNG" then
8989+ "image/png"
9090+ else if String.length data >= 3 && String.sub data 0 3 = "\xFF\xD8\xFF" then
9191+ "image/jpeg"
9292+ else if String.length data >= 4 && String.sub data 0 4 = "%PDF" then
9393+ "application/pdf"
9494+ else
9595+ "application/octet-stream"
9696+ in
9797+9898+ (* Write the asset data *)
9999+ let oc = open_out_bin asset_file in
100100+ output_string oc data;
101101+ close_out oc;
102102+103103+ (* Write metadata file *)
104104+ let metadata = Fmt.str "{\n \"contentType\": \"%s\",\n \"assetType\": \"%s\"\n}"
105105+ content_type asset_type in
106106+ let oc = open_out meta_file in
107107+ output_string oc metadata;
108108+ close_out oc
109109+ end
110110+ ) assets
111111+ ) bookmarks;
112112+113113+ print_endline "Asset download completed.";
114114+ 0
115115+ end else
116116+ 0
117117+ with exn ->
118118+ prerr_endline (Fmt.str "Error fetching bookmarks: %s" (Printexc.to_string exn));
119119+ 1
131120132121(* Extract outgoing links from Bushel entries *)
133122let update_from_bushel bushel_dir links_file include_domains exclude_domains =
···297286 create_batches_aux links []
298287299288(* Helper function to upload a single link to Karakeep *)
300300-let upload_single_link api_key base_url tag verbose updated_links link =
289289+let upload_single_link ~sw ~env api_key base_url tag verbose updated_links link =
301290 let url = link.Bushel.Link.url in
302302- let title =
303303- if link.Bushel.Link.description <> "" then
304304- Some link.Bushel.Link.description
305305- else None
291291+ let title =
292292+ if link.Bushel.Link.description <> "" then
293293+ Some link.Bushel.Link.description
294294+ else None
306295 in
307296 let tags = prepare_tags_for_link tag link in
308308-297297+309298 if verbose then begin
310299 log " Uploading: %s\n" url;
311311- if tags <> [] then
300300+ if tags <> [] then
312301 log " Tags: %s\n" (String.concat ", " tags);
313313- if title <> None then
302302+ if title <> None then
314303 log " Title: %s\n" (Option.get title);
315304 end else begin
316305 log "Uploading: %s\n" url;
317306 end;
318318-307307+319308 (* Create the bookmark with tags *)
320320- Lwt.catch
321321- (fun () ->
322322- Karakeep.create_bookmark
323323- ~api_key
324324- ~url
325325- ?title
326326- ~tags
327327- base_url
328328- >>= fun bookmark ->
329329-330330- (* Create updated link with karakeep data *)
331331- let updated_link = {
332332- link with
333333- Bushel.Link.karakeep =
334334- Some {
335335- Bushel.Link.remote_url = base_url;
336336- id = bookmark.id;
337337- tags = bookmark.tags;
338338- metadata = []; (* Will be populated on next sync *)
339339- }
340340- } in
341341- updated_links := updated_link :: !updated_links;
342342-343343- if verbose then
344344- log " ✓ Added to Karakeep with ID: %s\n" bookmark.id
345345- else
346346- log " - Added to Karakeep with ID: %s\n" bookmark.id;
347347- Lwt.return 1 (* Success *)
348348- )
349349- (fun exn ->
350350- if verbose then
351351- log " ✗ Error uploading %s: %s\n" url (Printexc.to_string exn)
352352- else
353353- log " - Error uploading %s: %s\n" url (Printexc.to_string exn);
354354- Lwt.return 0 (* Failure *)
355355- )
309309+ try
310310+ let bookmark = Karakeepe.create_bookmark
311311+ ~sw ~env
312312+ ~api_key
313313+ ~url
314314+ ?title
315315+ ~tags
316316+ base_url
317317+ in
318318+319319+ (* Create updated link with karakeep data *)
320320+ let updated_link = {
321321+ link with
322322+ Bushel.Link.karakeep =
323323+ Some {
324324+ Bushel.Link.remote_url = base_url;
325325+ id = bookmark.id;
326326+ tags = bookmark.tags;
327327+ metadata = []; (* Will be populated on next sync *)
328328+ }
329329+ } in
330330+ updated_links := updated_link :: !updated_links;
331331+332332+ if verbose then
333333+ log " ✓ Added to Karakeep with ID: %s\n" bookmark.id
334334+ else
335335+ log " - Added to Karakeep with ID: %s\n" bookmark.id;
336336+ 1 (* Success *)
337337+ with exn ->
338338+ if verbose then
339339+ log " ✗ Error uploading %s: %s\n" url (Printexc.to_string exn)
340340+ else
341341+ log " - Error uploading %s: %s\n" url (Printexc.to_string exn);
342342+ 0 (* Failure *)
356343357344(* Helper function to process a batch of links *)
358358-let process_batch api_key base_url tag verbose updated_links batch_num total_batches batch =
359359- log_verbose verbose "\nProcessing batch %d/%d (%d links)...\n"
345345+let process_batch ~sw ~env api_key base_url tag verbose updated_links batch_num total_batches batch =
346346+ log_verbose verbose "\nProcessing batch %d/%d (%d links)...\n"
360347 (batch_num + 1) total_batches (List.length batch);
361361-362362- (* Process links in this batch concurrently *)
363363- Lwt_list.map_p (upload_single_link api_key base_url tag verbose updated_links) batch
348348+349349+ (* Process links in this batch in parallel *)
350350+ let results = ref [] in
351351+ Eio.Fiber.all (List.map (fun link ->
352352+ fun () ->
353353+ let count = upload_single_link ~sw ~env api_key base_url tag verbose updated_links link in
354354+ results := count :: !results
355355+ ) batch);
356356+ List.rev !results
364357365358(* Helper function to update links file with new karakeep data *)
366359let update_links_file links_file original_links updated_links =
···384377 end
385378386379(* Upload links to Karakeep that don't already have karakeep data *)
387387-let upload_to_karakeep base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose =
380380+let upload_to_karakeep ~sw ~env base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose =
388381 match api_key_opt with
389382 | None ->
390383 log "Error: API key is required.\n";
···395388 log_verbose verbose "Loading links from %s...\n" links_file;
396389 let links = Bushel.Link.load_links_file links_file in
397390 log_verbose verbose "Loaded %d total links\n" (List.length links);
398398-391391+399392 (* Filter links that don't have karakeep data for this remote *)
400393 log_verbose verbose "Filtering links that don't have karakeep data for %s...\n" base_url;
401394 let filtered_links = filter_links_without_karakeep base_url links in
402395 log_verbose verbose "Found %d links without karakeep data\n" (List.length filtered_links);
403403-396396+404397 (* Apply limit if specified *)
405398 let links_to_upload = apply_limit_to_links limit filtered_links in
406406-399399+407400 if links_to_upload = [] then begin
408401 log "No links to upload to %s (all links already have karakeep data)\n" base_url;
409402 0
410403 end else begin
411404 log "Found %d links to upload to %s\n" (List.length links_to_upload) base_url;
412412-405405+413406 (* Split links into batches for parallel processing *)
414407 let batches = create_batches max_concurrent links_to_upload in
415415- log_verbose verbose "Processing in %d batches of up to %d links each...\n"
408408+ log_verbose verbose "Processing in %d batches of up to %d links each...\n"
416409 (List.length batches) max_concurrent;
417410 log_verbose verbose "Delay between batches: %.1f seconds\n" delay_seconds;
418418-411411+419412 (* Process batches and accumulate updated links *)
420413 let updated_links = ref [] in
421421-422422- let result = Lwt_main.run (
423423- Lwt.catch
424424- (fun () ->
425425- Lwt_list.fold_left_s (fun (total_count, batch_num) batch ->
426426- process_batch api_key base_url tag verbose updated_links
427427- batch_num (List.length batches) batch >>= fun results ->
428428-414414+415415+ let result = try
416416+ let rec process_batches total_count batch_num = function
417417+ | [] -> total_count
418418+ | batch :: rest ->
419419+ let results = process_batch ~sw ~env api_key base_url tag verbose updated_links
420420+ batch_num (List.length batches) batch in
421421+429422 (* Count successes in this batch *)
430423 let batch_successes = List.fold_left (+) 0 results in
431424 let new_total = total_count + batch_successes in
432432-433433- log_verbose verbose " Batch %d complete: %d/%d successful (Total: %d/%d)\n"
425425+426426+ log_verbose verbose " Batch %d complete: %d/%d successful (Total: %d/%d)\n"
434427 (batch_num + 1) batch_successes (List.length batch) new_total (new_total + (List.length links_to_upload - new_total));
435435-428428+436429 (* Add a delay before processing the next batch *)
437437- if batch_num + 1 < List.length batches then begin
430430+ if rest <> [] then begin
438431 log_verbose verbose " Waiting %.1f seconds before next batch...\n" delay_seconds;
439439- Lwt_unix.sleep delay_seconds >>= fun () ->
440440- Lwt.return (new_total, batch_num + 1)
441441- end else
442442- Lwt.return (new_total, batch_num + 1)
443443- ) (0, 0) batches >>= fun (final_count, _) ->
444444- Lwt.return final_count
445445- )
446446- (fun exn ->
447447- log "Error during upload operation: %s\n" (Printexc.to_string exn);
448448- Lwt.return 0
449449- )
450450- ) in
451451-432432+ Eio.Time.sleep (Eio.Stdenv.clock env) delay_seconds;
433433+ end;
434434+ process_batches new_total (batch_num + 1) rest
435435+ in
436436+ process_batches 0 0 batches
437437+ with exn ->
438438+ log "Error during upload operation: %s\n" (Printexc.to_string exn);
439439+ 0
440440+ in
441441+452442 (* Update the links file with the new karakeep_ids *)
453443 update_links_file links_file links updated_links;
454454-455455- log "Upload complete. %d/%d links uploaded successfully.\n"
444444+445445+ log "Upload complete. %d/%d links uploaded successfully.\n"
456446 result (List.length links_to_upload);
457457-447447+458448 0
459449 end
460450···527517let karakeep_cmd =
528518 let doc = "Update links.yml with links from Karakeep" in
529519 let info = Cmd.info "karakeep" ~doc in
530530- Cmd.v info Term.(const update_from_karakeep $ base_url_arg $ api_key_arg $ tag_arg $ links_file_arg $ download_assets_arg)
520520+ Cmd.v info Term.(const (fun base_url api_key_opt tag links_file download_assets ->
521521+ Eio_main.run @@ fun env ->
522522+ Eio.Switch.run @@ fun sw ->
523523+ update_from_karakeep ~sw ~env base_url api_key_opt tag links_file download_assets)
524524+ $ base_url_arg $ api_key_arg $ tag_arg $ links_file_arg $ download_assets_arg)
531525532526let bushel_cmd =
533527 let doc = "Update links.yml with outgoing links from Bushel entries" in
···537531let upload_cmd =
538532 let doc = "Upload links without karakeep data to Karakeep" in
539533 let info = Cmd.info "upload" ~doc in
540540- Cmd.v info Term.(const upload_to_karakeep $ base_url_arg $ api_key_arg $ links_file_arg $ tag_arg $ concurrent_arg $ delay_arg $ limit_arg $ verbose_arg)
534534+ Cmd.v info Term.(const (fun base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose ->
535535+ Eio_main.run @@ fun env ->
536536+ Eio.Switch.run @@ fun sw ->
537537+ upload_to_karakeep ~sw ~env base_url api_key_opt links_file tag max_concurrent delay_seconds limit verbose)
538538+ $ base_url_arg $ api_key_arg $ links_file_arg $ tag_arg $ concurrent_arg $ delay_arg $ limit_arg $ verbose_arg)
541539542540(* Export the term and cmd for use in main bushel.ml *)
543541let cmd =
+12-10
stack/bushel/bin/bushel_paper.ml
···11module ZT = Zotero_translation
22-open Lwt.Infix
32open Printf
43module J = Ezjsonm
54open Cmdliner
···1817 J.update j ["author"] (Some (`A a))
19182019let of_doi zt ~base_dir ~slug ~version doi =
2121- ZT.json_of_doi zt ~slug doi >>= fun j ->
2020+ let j = ZT.json_of_doi zt ~slug doi in
2221 let papers_dir = Printf.sprintf "%s/papers/%s" base_dir slug in
2322 (* Ensure papers directory exists *)
2423 (try Unix.mkdir papers_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ());
2525-2424+2625 (* Extract abstract from JSON data *)
2726 let abstract = try
2827 let keys = Ezjsonm.get_dict (j :> Ezjsonm.value) in
···3029 | Some abstract_json -> Some (Ezjsonm.get_string abstract_json)
3130 | None -> None
3231 with _ -> None in
3333-3232+3433 (* Remove abstract from frontmatter - it goes in body *)
3534 let keys = Ezjsonm.get_dict (j :> Ezjsonm.value) in
3635 let filtered_keys = List.filter (fun (k, _) -> k <> "abstract") keys in
3736 let json_without_abstract = `O filtered_keys in
3838-3737+3938 (* Use library function to generate YAML with abstract in body *)
4039 let content = Bushel.Paper.to_yaml ?abstract ~ver:version json_without_abstract in
4141-4040+4241 let filename = Printf.sprintf "%s.md" version in
4342 let filepath = Filename.concat papers_dir filename in
4443 let oc = open_out filepath in
4544 output_string oc content;
4645 close_out oc;
4747- Printf.printf "Created paper file: %s\n" filepath;
4848- Lwt.return ()
4646+ Printf.printf "Created paper file: %s\n" filepath
49475048let slug_arg =
5149 let doc = "Slug for the entry." in
···6260(* Export the term for use in main bushel.ml *)
6361let term =
6462 Term.(const (fun base slug version doi ->
6565- let zt = ZT.v "http://svr-avsm2-eeg-ce:1969" in
6666- Lwt_main.run @@ of_doi zt ~base_dir:base ~slug ~version doi; 0
6363+ Eio_main.run @@ fun env ->
6464+ Eio.Switch.run @@ fun sw ->
6565+ (* Create Zotero Translation client with connection pooling *)
6666+ let zt = ZT.create ~sw ~env "http://svr-avsm2-eeg-ce:1969" in
6767+ of_doi zt ~base_dir:base ~slug ~version doi;
6868+ 0
6769 ) $ Bushel_common.base_dir $ slug_arg $ version_arg $ doi_arg)
68706971let cmd =
+20-22
stack/bushel/bin/bushel_search.ml
···11open Cmdliner
22-open Lwt.Syntax
3243(** TODO:claude Bushel search command for integration with main CLI *)
54···4241 Printf.printf "Query: \"%s\"\n" query_text;
4342 Printf.printf "Limit: %d, Offset: %d\n" limit offset;
4443 Printf.printf "\n";
4545-4646- Lwt_main.run (
4747- Lwt.catch (fun () ->
4848- let* result = Bushel.Typesense.multisearch config query_text ~limit:50 () in
4949- match result with
5050- | Ok multisearch_resp ->
5151- let combined_response = Bushel.Typesense.combine_multisearch_results multisearch_resp ~limit ~offset () in
5252- Printf.printf "Found %d results (%.2fms)\n\n" combined_response.total combined_response.query_time;
5353-5454- List.iteri (fun i (hit : Bushel.Typesense.search_result) ->
5555- Printf.printf "%d. %s (score: %.2f)\n" (i + 1) (Bushel.Typesense.pp_search_result_oneline hit) hit.Bushel.Typesense.score
5656- ) combined_response.hits;
5757- Lwt.return_unit
5858- | Error err ->
5959- Format.eprintf "Search error: %a\n" Bushel.Typesense.pp_error err;
6060- exit 1
6161- ) (fun exn ->
6262- Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
4444+4545+ Eio_main.run @@ fun env ->
4646+ Eio.Switch.run @@ fun sw ->
4747+ (try
4848+ let result = Bushel.Typesense.multisearch ~sw ~env config query_text ~limit:50 () in
4949+ match result with
5050+ | Ok multisearch_resp ->
5151+ let combined_response = Bushel.Typesense.combine_multisearch_results multisearch_resp ~limit ~offset () in
5252+ Printf.printf "Found %d results (%.2fms)\n\n" combined_response.total combined_response.query_time;
5353+5454+ List.iteri (fun i (hit : Bushel.Typesense.search_result) ->
5555+ Printf.printf "%d. %s (score: %.2f)\n" (i + 1) (Bushel.Typesense.pp_search_result_oneline hit) hit.Bushel.Typesense.score
5656+ ) combined_response.hits;
5757+ 0
5858+ | Error err ->
5959+ Format.eprintf "Search error: %a\n" Bushel.Typesense.pp_error err;
6360 exit 1
6464- )
6565- );
6666- 0
6161+ with exn ->
6262+ Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
6363+ exit 1
6464+ )
67656866(** TODO:claude Command line term *)
6967let term = Term.(const search $ endpoint $ api_key $ query_text $ limit $ offset)
+53-57
stack/bushel/bin/bushel_typesense.ml
···11open Cmdliner
22-open Lwt.Syntax
3243(** TODO:claude Bushel Typesense binary with upload and query functionality *)
54···38373938 Printf.printf "Uploading bushel data to Typesense at %s\n" endpoint;
40394141- Lwt_main.run (
4242- Lwt.catch (fun () ->
4343- Bushel.Typesense.upload_all config entries
4444- ) (fun exn ->
4545- Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
4646- exit 1
4747- )
4040+ Eio_main.run @@ fun env ->
4141+ Eio.Switch.run @@ fun sw ->
4242+ (try
4343+ Bushel.Typesense.upload_all ~sw ~env config entries
4444+ with exn ->
4545+ Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
4646+ exit 1
4847 )
49485049···6766 if collection <> "" then Printf.printf "Collection: %s\n" collection;
6867 Printf.printf "Limit: %d, Offset: %d\n" limit offset;
6968 Printf.printf "\n";
7070-7171- Lwt_main.run (
7272- Lwt.catch (fun () ->
7373- let search_fn = if collection = "" then
7474- Bushel.Typesense.search_all config query_text ~limit ~offset
7575- else
7676- Bushel.Typesense.search_collection config collection query_text ~limit ~offset
7777- in
7878- let* result = search_fn () in
7979- match result with
8080- | Ok response ->
8181- Printf.printf "Found %d results (%.2fms)\n\n" response.total response.query_time;
8282- List.iteri (fun i (hit : Bushel.Typesense.search_result) ->
8383- Printf.printf "%d. [%s] %s (score: %.2f)\n" (i + 1) hit.collection hit.title hit.score;
8484- if hit.content <> "" then Printf.printf " %s\n" hit.content;
8585- if hit.highlights <> [] then (
8686- Printf.printf " Highlights:\n";
8787- List.iter (fun (field, snippets) ->
8888- List.iter (fun snippet ->
8989- Printf.printf " %s: %s\n" field snippet
9090- ) snippets
9191- ) hit.highlights
9292- );
9393- Printf.printf "\n"
9494- ) response.hits;
9595- Lwt.return_unit
9696- | Error err ->
9797- Format.eprintf "Search error: %a\n" Bushel.Typesense.pp_error err;
9898- exit 1
9999- ) (fun exn ->
100100- Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
6969+7070+ Eio_main.run @@ fun env ->
7171+ Eio.Switch.run @@ fun sw ->
7272+ (try
7373+ let result = if collection = "" then
7474+ Bushel.Typesense.search_all ~sw ~env config query_text ~limit ~offset ()
7575+ else
7676+ Bushel.Typesense.search_collection ~sw ~env config collection query_text ~limit ~offset ()
7777+ in
7878+ match result with
7979+ | Ok response ->
8080+ Printf.printf "Found %d results (%.2fms)\n\n" response.total response.query_time;
8181+ List.iteri (fun i (hit : Bushel.Typesense.search_result) ->
8282+ Printf.printf "%d. [%s] %s (score: %.2f)\n" (i + 1) hit.collection hit.title hit.score;
8383+ if hit.content <> "" then Printf.printf " %s\n" hit.content;
8484+ if hit.highlights <> [] then (
8585+ Printf.printf " Highlights:\n";
8686+ List.iter (fun (field, snippets) ->
8787+ List.iter (fun snippet ->
8888+ Printf.printf " %s: %s\n" field snippet
8989+ ) snippets
9090+ ) hit.highlights
9191+ );
9292+ Printf.printf "\n"
9393+ ) response.hits
9494+ | Error err ->
9595+ Format.eprintf "Search error: %a\n" Bushel.Typesense.pp_error err;
10196 exit 1
102102- )
9797+ with exn ->
9898+ Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
9999+ exit 1
103100 )
104101105102(** TODO:claude List collections function *)
···117114 );
118115119116 Printf.printf "Listing collections at %s\n\n" config.endpoint;
120120-121121- Lwt_main.run (
122122- Lwt.catch (fun () ->
123123- let* result = Bushel.Typesense.list_collections config in
124124- match result with
125125- | Ok collections ->
126126- Printf.printf "Collections:\n";
127127- List.iter (fun (name, count) ->
128128- Printf.printf " %s (%d documents)\n" name count
129129- ) collections;
130130- Lwt.return_unit
131131- | Error err ->
132132- Format.eprintf "List error: %a\n" Bushel.Typesense.pp_error err;
133133- exit 1
134134- ) (fun exn ->
135135- Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
117117+118118+ Eio_main.run @@ fun env ->
119119+ Eio.Switch.run @@ fun sw ->
120120+ (try
121121+ let result = Bushel.Typesense.list_collections ~sw ~env config in
122122+ match result with
123123+ | Ok collections ->
124124+ Printf.printf "Collections:\n";
125125+ List.iter (fun (name, count) ->
126126+ Printf.printf " %s (%d documents)\n" name count
127127+ ) collections
128128+ | Error err ->
129129+ Format.eprintf "List error: %a\n" Bushel.Typesense.pp_error err;
136130 exit 1
137137- )
131131+ with exn ->
132132+ Printf.eprintf "Error: %s\n" (Printexc.to_string exn);
133133+ exit 1
138134 )
139135140136(** TODO:claude Command line arguments for query *)
+43-55
stack/bushel/bin/bushel_video.ml
···11[@@@warning "-26-27-32"]
2233-open Lwt.Infix
43open Cmdliner
5465let setup_log style_renderer level =
···98 Logs.set_reporter (Logs_fmt.reporter ());
109 ()
11101212-let process_videos output_dir overwrite base_url channel fetch_thumbs thumbs_dir =
1313- Peertube.fetch_all_channel_videos base_url channel >>= fun all_videos ->
1111+let process_videos ~sw ~env output_dir overwrite base_url channel fetch_thumbs thumbs_dir =
1212+ let all_videos = Peertubee.fetch_all_channel_videos ~sw ~env base_url channel in
1413 Logs.info (fun f -> f "Total videos: %d" (List.length all_videos));
15141615 (* Create thumbnails directory if needed *)
···1817 Unix.mkdir thumbs_dir 0o755);
19182019 (* Process each video, fetching full details for complete descriptions *)
2121- Lwt_list.map_s (fun video ->
2020+ let vids = List.map (fun video ->
2221 (* Fetch complete video details to get full description *)
2323- Peertube.fetch_video_details base_url video.Peertube.uuid >>= fun full_video ->
2222+ let full_video = Peertubee.fetch_video_details ~sw ~env base_url video.Peertubee.uuid in
2423 let (description, published_date, title, url, uuid, slug) =
2525- Peertube.to_bushel_video full_video
2424+ Peertubee.to_bushel_video full_video
2625 in
2726 Logs.info (fun f -> f "Title: %s, URL: %s" title url);
28272928 (* Download thumbnail if requested *)
3029 (if fetch_thumbs then
3130 let thumb_path = Filename.concat thumbs_dir (uuid ^ ".jpg") in
3232- Peertube.download_thumbnail base_url full_video thumb_path >>= fun result ->
3131+ let result = Peertubee.download_thumbnail ~sw ~env base_url full_video thumb_path in
3332 match result with
3433 | Ok () ->
3535- Logs.info (fun f -> f "Downloaded thumbnail for %s to %s" title thumb_path);
3636- Lwt.return_unit
3434+ Logs.info (fun f -> f "Downloaded thumbnail for %s to %s" title thumb_path)
3735 | Error (`Msg e) ->
3838- Logs.warn (fun f -> f "Failed to download thumbnail for %s: %s" title e);
3939- Lwt.return_unit
4040- else
4141- Lwt.return_unit) >>= fun () ->
3636+ Logs.warn (fun f -> f "Failed to download thumbnail for %s: %s" title e)
3737+ );
3838+3939+ {Bushel.Video.description; published_date; title; url; uuid; slug;
4040+ talk=false; paper=None; project=None; tags=full_video.tags}
4141+ ) all_videos in
42424343- Lwt.return {Bushel.Video.description; published_date; title; url; uuid; slug;
4444- talk=false; paper=None; project=None; tags=full_video.tags}
4545- ) all_videos >>= fun vids ->
4646-4743 (* Write video files *)
4848- Lwt_list.iter_s (fun video ->
4444+ List.iter (fun video ->
4945 let file_path = Filename.concat output_dir (video.Bushel.Video.uuid ^ ".md") in
5046 let file_exists = Sys.file_exists file_path in
5151-4747+5248 if file_exists then
5349 try
5450 (* If file exists, load it to preserve specific fields *)
···6157 project = existing_video.project; (* Preserve project field *)
6258 talk = existing_video.talk; (* Preserve talk field *)
6359 } in
6464-6060+6561 (* Write the merged video data *)
6662 if overwrite then
6763 match Bushel.Video.to_file output_dir merged_video with
6868- | Ok () ->
6969- Logs.info (fun f -> f "Updated video %s with preserved fields in %s"
7070- merged_video.Bushel.Video.title file_path);
7171- Lwt.return_unit
7272- | Error (`Msg e) ->
7373- Logs.err (fun f -> f "Failed to update video %s: %s"
7474- merged_video.Bushel.Video.title e);
7575- Lwt.return_unit
7676- else begin
7777- Logs.info (fun f -> f "Skipping existing video %s (use --overwrite to replace)"
7878- video.Bushel.Video.title);
7979- Lwt.return_unit
8080- end
6464+ | Ok () ->
6565+ Logs.info (fun f -> f "Updated video %s with preserved fields in %s"
6666+ merged_video.Bushel.Video.title file_path)
6767+ | Error (`Msg e) ->
6868+ Logs.err (fun f -> f "Failed to update video %s: %s"
6969+ merged_video.Bushel.Video.title e)
7070+ else
7171+ Logs.info (fun f -> f "Skipping existing video %s (use --overwrite to replace)"
7272+ video.Bushel.Video.title)
8173 with _ ->
8274 (* If reading existing file fails, proceed with new data *)
8375 if overwrite then
8476 match Bushel.Video.to_file output_dir video with
8585- | Ok () ->
8686- Logs.info (fun f -> f "Wrote video %s to %s (existing file could not be read)"
8787- video.Bushel.Video.title file_path);
8888- Lwt.return_unit
8989- | Error (`Msg e) ->
9090- Logs.err (fun f -> f "Failed to write video %s: %s"
9191- video.Bushel.Video.title e);
9292- Lwt.return_unit
9393- else begin
9494- Logs.info (fun f -> f "Skipping existing video %s (use --overwrite to replace)"
9595- video.Bushel.Video.title);
9696- Lwt.return_unit
9797- end
7777+ | Ok () ->
7878+ Logs.info (fun f -> f "Wrote video %s to %s (existing file could not be read)"
7979+ video.Bushel.Video.title file_path)
8080+ | Error (`Msg e) ->
8181+ Logs.err (fun f -> f "Failed to write video %s: %s"
8282+ video.Bushel.Video.title e)
8383+ else
8484+ Logs.info (fun f -> f "Skipping existing video %s (use --overwrite to replace)"
8585+ video.Bushel.Video.title)
9886 else
9987 (* If file doesn't exist, just write new data *)
10088 match Bushel.Video.to_file output_dir video with
101101- | Ok () ->
102102- Logs.info (fun f -> f "Wrote new video %s to %s"
103103- video.Bushel.Video.title file_path);
104104- Lwt.return_unit
105105- | Error (`Msg e) ->
106106- Logs.err (fun f -> f "Failed to write video %s: %s"
107107- video.Bushel.Video.title e);
108108- Lwt.return_unit
8989+ | Ok () ->
9090+ Logs.info (fun f -> f "Wrote new video %s to %s"
9191+ video.Bushel.Video.title file_path)
9292+ | Error (`Msg e) ->
9393+ Logs.err (fun f -> f "Failed to write video %s: %s"
9494+ video.Bushel.Video.title e)
10995 ) vids
1109611197(* Command line arguments are now imported from Bushel_common *)
···121107 Arg.(value & opt string "images/videos" & info ["thumbs-dir"] ~docv:"DIR" ~doc)
122108 in
123109 Term.(const (fun output_dir overwrite base_url channel fetch_thumbs thumbs_dir () ->
124124- Lwt_main.run (process_videos output_dir overwrite base_url channel fetch_thumbs thumbs_dir); 0)
110110+ Eio_main.run @@ fun env ->
111111+ Eio.Switch.run @@ fun sw ->
112112+ process_videos ~sw ~env output_dir overwrite base_url channel fetch_thumbs thumbs_dir; 0)
125113 $ Bushel_common.output_dir ~default:"." $
126114 Bushel_common.overwrite $
127115 Bushel_common.url_term ~default:"https://crank.recoil.org" ~doc:"PeerTube base URL" $
+12-15
stack/bushel/bin/bushel_video_thumbs.ml
···11[@@@warning "-26-27-32"]
2233-open Lwt.Infix
43open Cmdliner
5465let setup_log style_renderer level =
···98 Logs.set_reporter (Logs_fmt.reporter ());
109 ()
11101212-let process_video_thumbs videos_dir thumbs_dir base_url =
1111+let process_video_thumbs ~sw ~env videos_dir thumbs_dir base_url =
1312 (* Ensure thumbnail directory exists *)
1413 (if not (Sys.file_exists thumbs_dir) then
1514 Unix.mkdir thumbs_dir 0o755);
···2423 Logs.info (fun f -> f "Found %d video files to process" (List.length video_files));
25242625 (* Process each video file *)
2727- Lwt_list.iter_s (fun video_file ->
2626+ List.iter (fun video_file ->
2827 try
2928 (* Load existing video *)
3029 let video = Bushel.Video.of_md video_file in
···3332 Logs.info (fun f -> f "Processing video: %s (UUID: %s)" video.title uuid);
34333534 (* Fetch video details from PeerTube to get thumbnail info *)
3636- Peertube.fetch_video_details base_url uuid >>= fun peertube_video ->
3535+ let peertube_video = Peertubee.fetch_video_details ~sw ~env base_url uuid in
37363837 (* Download thumbnail *)
3938 let thumb_path = Filename.concat thumbs_dir (uuid ^ ".jpg") in
4040- Peertube.download_thumbnail base_url peertube_video thumb_path >>= fun result ->
3939+ let result = Peertubee.download_thumbnail ~sw ~env base_url peertube_video thumb_path in
41404241 match result with
4342 | Ok () ->
4443 Logs.info (fun f -> f "Downloaded thumbnail for %s to %s" video.title thumb_path);
45444645 (* Update video file with thumbnail_url field *)
4747- (match Peertube.thumbnail_url base_url peertube_video with
4646+ (match Peertubee.thumbnail_url base_url peertube_video with
4847 | Some url ->
4949- Logs.info (fun f -> f "Thumbnail URL: %s" url);
5050- Lwt.return_unit
4848+ Logs.info (fun f -> f "Thumbnail URL: %s" url)
5149 | None ->
5252- Logs.warn (fun f -> f "No thumbnail URL for video %s" video.title);
5353- Lwt.return_unit)
5050+ Logs.warn (fun f -> f "No thumbnail URL for video %s" video.title))
5451 | Error (`Msg e) ->
5555- Logs.err (fun f -> f "Failed to download thumbnail for %s: %s" video.title e);
5656- Lwt.return_unit
5252+ Logs.err (fun f -> f "Failed to download thumbnail for %s: %s" video.title e)
5753 with exn ->
5858- Logs.err (fun f -> f "Error processing %s: %s" video_file (Printexc.to_string exn));
5959- Lwt.return_unit
5454+ Logs.err (fun f -> f "Error processing %s: %s" video_file (Printexc.to_string exn))
6055 ) video_files
61566257let term =
···6964 Arg.(value & opt string "images/videos" & info ["thumbs-dir"; "t"] ~docv:"DIR" ~doc)
7065 in
7166 Term.(const (fun videos_dir thumbs_dir base_url () ->
7272- Lwt_main.run (process_video_thumbs videos_dir thumbs_dir base_url); 0)
6767+ Eio_main.run @@ fun env ->
6868+ Eio.Switch.run @@ fun sw ->
6969+ process_video_thumbs ~sw ~env videos_dir thumbs_dir base_url; 0)
7370 $ videos_dir $
7471 thumbs_dir $
7572 Bushel_common.url_term ~default:"https://crank.recoil.org" ~doc:"PeerTube base URL" $
···11-(** Karakeep API client implementation *)
22-33-open Lwt.Infix
44-55-module J = Ezjsonm
66-77-(** Type representing a Karakeep bookmark *)
88-type bookmark = {
99- id: string;
1010- title: string option;
1111- url: string;
1212- note: string option;
1313- created_at: Ptime.t;
1414- updated_at: Ptime.t option;
1515- favourited: bool;
1616- archived: bool;
1717- tags: string list;
1818- tagging_status: string option;
1919- summary: string option;
2020- content: (string * string) list;
2121- assets: (string * string) list;
2222-}
2323-2424-(** Type for Karakeep API response containing bookmarks *)
2525-type bookmark_response = {
2626- total: int;
2727- data: bookmark list;
2828- next_cursor: string option;
2929-}
3030-3131-(** Parse a date string to Ptime.t, defaulting to epoch if invalid *)
3232-let parse_date str =
3333- match Ptime.of_rfc3339 str with
3434- | Ok (date, _, _) -> date
3535- | Error _ ->
3636- Fmt.epr "Warning: could not parse date '%s'\n" str;
3737- (* Default to epoch time *)
3838- let span_opt = Ptime.Span.of_d_ps (0, 0L) in
3939- match span_opt with
4040- | None -> failwith "Internal error: couldn't create epoch time span"
4141- | Some span ->
4242- match Ptime.of_span span with
4343- | Some t -> t
4444- | None -> failwith "Internal error: couldn't create epoch time"
4545-4646-(** Extract a string field from JSON, returns None if not present or not a string *)
4747-let get_string_opt json path =
4848- try Some (J.find json path |> J.get_string)
4949- with _ -> None
5050-5151-(** Extract a string list field from JSON, returns empty list if not present *)
5252-let get_string_list json path =
5353- try
5454- let items_json = J.find json path in
5555- J.get_list (fun tag -> J.find tag ["name"] |> J.get_string) items_json
5656- with _ -> []
5757-5858-(** Extract a boolean field from JSON, with default value *)
5959-let get_bool_def json path default =
6060- try J.find json path |> J.get_bool
6161- with _ -> default
6262-6363-(** Parse a single bookmark from Karakeep JSON *)
6464-let parse_bookmark json =
6565- (* Remove debug prints for production *)
6666- (* Printf.eprintf "%s\n%!" (J.value_to_string json); *)
6767-6868- let id =
6969- try J.find json ["id"] |> J.get_string
7070- with e ->
7171- prerr_endline (Fmt.str "Error parsing bookmark ID: %s" (Printexc.to_string e));
7272- prerr_endline (Fmt.str "JSON: %s" (J.value_to_string json));
7373- failwith "Unable to parse bookmark ID"
7474- in
7575-7676- (* Title can be null *)
7777- let title =
7878- try Some (J.find json ["title"] |> J.get_string)
7979- with _ -> None
8080- in
8181- (* Remove debug prints for production *)
8282- (* Printf.eprintf "%s -> %s\n%!" id (match title with None -> "???" | Some v -> v); *)
8383- (* Get URL - try all possible locations *)
8484- let url =
8585- try J.find json ["url"] |> J.get_string (* Direct url field *)
8686- with _ -> try
8787- J.find json ["content"; "url"] |> J.get_string (* Inside content.url *)
8888- with _ -> try
8989- J.find json ["content"; "sourceUrl"] |> J.get_string (* Inside content.sourceUrl *)
9090- with _ ->
9191- (* For assets/PDF type links *)
9292- match J.find_opt json ["content"; "type"] with
9393- | Some (`String "asset") ->
9494- (* Extract URL from sourceUrl in content *)
9595- (try J.find json ["content"; "sourceUrl"] |> J.get_string
9696- with _ ->
9797- (match J.find_opt json ["id"] with
9898- | Some (`String id) -> "karakeep-asset://" ^ id
9999- | _ -> failwith "No URL or asset ID found in bookmark"))
100100- | _ ->
101101- (* Debug output to understand what we're getting *)
102102- prerr_endline (Fmt.str "Bookmark JSON structure: %s" (J.value_to_string json));
103103- failwith "No URL found in bookmark"
104104- in
105105-106106- let note = get_string_opt json ["note"] in
107107-108108- (* Parse dates *)
109109- let created_at =
110110- try J.find json ["createdAt"] |> J.get_string |> parse_date
111111- with _ ->
112112- try J.find json ["created_at"] |> J.get_string |> parse_date
113113- with _ -> failwith "No creation date found"
114114- in
115115-116116- let updated_at =
117117- try Some (J.find json ["updatedAt"] |> J.get_string |> parse_date)
118118- with _ ->
119119- try Some (J.find json ["modifiedAt"] |> J.get_string |> parse_date)
120120- with _ -> None
121121- in
122122-123123- let favourited = get_bool_def json ["favourited"] false in
124124- let archived = get_bool_def json ["archived"] false in
125125- let tags = get_string_list json ["tags"] in
126126-127127- (* Extract additional metadata *)
128128- let tagging_status = get_string_opt json ["taggingStatus"] in
129129- let summary = get_string_opt json ["summary"] in
130130-131131- (* Extract content details *)
132132- let content =
133133- try
134134- let content_json = J.find json ["content"] in
135135- let rec extract_fields acc = function
136136- | [] -> acc
137137- | (k, v) :: rest ->
138138- let value = match v with
139139- | `String s -> s
140140- | `Bool b -> string_of_bool b
141141- | `Float f -> string_of_float f
142142- | `Null -> "null"
143143- | _ -> "complex_value" (* For objects and arrays *)
144144- in
145145- extract_fields ((k, value) :: acc) rest
146146- in
147147- match content_json with
148148- | `O fields -> extract_fields [] fields
149149- | _ -> []
150150- with _ -> []
151151- in
152152-153153- (* Extract assets *)
154154- let assets =
155155- try
156156- let assets_json = J.find json ["assets"] in
157157- J.get_list (fun asset_json ->
158158- let id = J.find asset_json ["id"] |> J.get_string in
159159- let asset_type =
160160- try J.find asset_json ["assetType"] |> J.get_string
161161- with _ -> "unknown"
162162- in
163163- (id, asset_type)
164164- ) assets_json
165165- with _ -> []
166166- in
167167-168168- { id; title; url; note; created_at; updated_at; favourited; archived; tags;
169169- tagging_status; summary; content; assets }
170170-171171-(** Parse a Karakeep bookmark response *)
172172-let parse_bookmark_response json =
173173- (* The response format is different based on endpoint, need to handle both structures *)
174174- (* Print the whole JSON structure for debugging *)
175175- prerr_endline (Fmt.str "Full response JSON: %s" (J.value_to_string json));
176176-177177- try
178178- (* Standard list format with total count *)
179179- let total = J.find json ["total"] |> J.get_int in
180180- let bookmarks_json = J.find json ["data"] in
181181- prerr_endline "Found bookmarks in data array";
182182- let data = J.get_list parse_bookmark bookmarks_json in
183183-184184- (* Try to extract nextCursor if available *)
185185- let next_cursor =
186186- try Some (J.find json ["nextCursor"] |> J.get_string)
187187- with _ -> None
188188- in
189189-190190- { total; data; next_cursor }
191191- with e1 ->
192192- prerr_endline (Fmt.str "First format parse error: %s" (Printexc.to_string e1));
193193- try
194194- (* Format with bookmarks array *)
195195- let bookmarks_json = J.find json ["bookmarks"] in
196196- prerr_endline "Found bookmarks in bookmarks array";
197197- let data =
198198- try J.get_list parse_bookmark bookmarks_json
199199- with e ->
200200- prerr_endline (Fmt.str "Error parsing bookmarks array: %s" (Printexc.to_string e));
201201- prerr_endline (Fmt.str "First bookmark sample: %s"
202202- (try J.value_to_string (List.hd (J.get_list (fun x -> x) bookmarks_json))
203203- with _ -> "Could not extract sample"));
204204- []
205205- in
206206-207207- (* Try to extract nextCursor if available *)
208208- let next_cursor =
209209- try Some (J.find json ["nextCursor"] |> J.get_string)
210210- with _ -> None
211211- in
212212-213213- { total = List.length data; data; next_cursor }
214214- with e2 ->
215215- prerr_endline (Fmt.str "Second format parse error: %s" (Printexc.to_string e2));
216216- try
217217- (* Check if it's an error response *)
218218- let error = J.find json ["error"] |> J.get_string in
219219- let message =
220220- try J.find json ["message"] |> J.get_string
221221- with _ -> "Unknown error"
222222- in
223223- prerr_endline (Fmt.str "API Error: %s - %s" error message);
224224- { total = 0; data = []; next_cursor = None }
225225- with _ ->
226226- try
227227- (* Alternate format without total (for endpoints like /tags/<id>/bookmarks) *)
228228- prerr_endline "Trying alternate array format";
229229-230230- (* Debug the structure to identify the format *)
231231- prerr_endline (Fmt.str "JSON structure keys: %s"
232232- (match json with
233233- | `O fields ->
234234- String.concat ", " (List.map (fun (k, _) -> k) fields)
235235- | _ -> "not an object"));
236236-237237- (* Check if it has a nextCursor but bookmarks are nested differently *)
238238- if J.find_opt json ["nextCursor"] <> None then begin
239239- prerr_endline "Found nextCursor, checking alternate structures";
240240-241241- (* Try different bookmark container paths *)
242242- let bookmarks_json =
243243- try Some (J.find json ["data"])
244244- with _ -> None
245245- in
246246-247247- match bookmarks_json with
248248- | Some json_array ->
249249- prerr_endline "Found bookmarks in data field";
250250- begin try
251251- let data = J.get_list parse_bookmark json_array in
252252- let next_cursor =
253253- try Some (J.find json ["nextCursor"] |> J.get_string)
254254- with _ -> None
255255- in
256256- { total = List.length data; data; next_cursor }
257257- with e ->
258258- prerr_endline (Fmt.str "Error parsing bookmarks from data: %s" (Printexc.to_string e));
259259- { total = 0; data = []; next_cursor = None }
260260- end
261261- | None ->
262262- prerr_endline "No bookmarks found in alternate structure";
263263- { total = 0; data = []; next_cursor = None }
264264- end
265265- else begin
266266- (* Check if it's an array at root level *)
267267- match json with
268268- | `A _ ->
269269- let data =
270270- try J.get_list parse_bookmark json
271271- with e ->
272272- prerr_endline (Fmt.str "Error parsing root array: %s" (Printexc.to_string e));
273273- []
274274- in
275275- { total = List.length data; data; next_cursor = None }
276276- | _ ->
277277- prerr_endline "Not an array at root level";
278278- { total = 0; data = []; next_cursor = None }
279279- end
280280- with e3 ->
281281- prerr_endline (Fmt.str "Third format parse error: %s" (Printexc.to_string e3));
282282- { total = 0; data = []; next_cursor = None }
283283-284284-(** Helper function to consume and return response body data *)
285285-let consume_body body =
286286- Cohttp_lwt.Body.to_string body >>= fun _ ->
287287- Lwt.return_unit
288288-289289-(** Fetch bookmarks from a Karakeep instance with pagination support *)
290290-let fetch_bookmarks ~api_key ?(limit=50) ?(offset=0) ?cursor ?(include_content=false) ?filter_tags base_url =
291291- let open Cohttp_lwt_unix in
292292-293293- (* Base URL for bookmarks API *)
294294- let url_base = Fmt.str "%s/api/v1/bookmarks?limit=%d&includeContent=%b"
295295- base_url limit include_content in
296296-297297- (* Add pagination parameter - either cursor or offset *)
298298- let url =
299299- match cursor with
300300- | Some cursor_value ->
301301- url_base ^ "&cursor=" ^ cursor_value
302302- | None ->
303303- url_base ^ "&offset=" ^ string_of_int offset
304304- in
305305-306306- (* Add tags filter if provided *)
307307- let url = match filter_tags with
308308- | Some tags when tags <> [] ->
309309- (* URL encode each tag and join with commas *)
310310- let encoded_tags =
311311- List.map (fun tag ->
312312- Uri.pct_encode ~component:`Query_key tag
313313- ) tags
314314- in
315315- let tags_param = String.concat "," encoded_tags in
316316- prerr_endline (Fmt.str "Adding tags filter: %s" tags_param);
317317- url ^ "&tags=" ^ tags_param
318318- | _ -> url
319319- in
320320-321321- (* Set up headers with API key *)
322322- let headers = Cohttp.Header.init ()
323323- |> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) in
324324-325325- prerr_endline (Fmt.str "Fetching bookmarks from: %s" url);
326326-327327- (* Make the request *)
328328- Lwt.catch
329329- (fun () ->
330330- Client.get ~headers (Uri.of_string url) >>= fun (resp, body) ->
331331- if resp.status = `OK then
332332- Cohttp_lwt.Body.to_string body >>= fun body_str ->
333333- prerr_endline (Fmt.str "Received %d bytes of response data" (String.length body_str));
334334-335335- Lwt.catch
336336- (fun () ->
337337- let json = J.from_string body_str in
338338- Lwt.return (parse_bookmark_response json)
339339- )
340340- (fun e ->
341341- prerr_endline (Fmt.str "JSON parsing error: %s" (Printexc.to_string e));
342342- prerr_endline (Fmt.str "Response body (first 200 chars): %s"
343343- (if String.length body_str > 200 then String.sub body_str 0 200 ^ "..." else body_str));
344344- Lwt.fail e
345345- )
346346- else
347347- let status_code = Cohttp.Code.code_of_status resp.status in
348348- consume_body body >>= fun _ ->
349349- prerr_endline (Fmt.str "HTTP error %d" status_code);
350350- Lwt.fail_with (Fmt.str "HTTP error: %d" status_code)
351351- )
352352- (fun e ->
353353- prerr_endline (Fmt.str "Network error: %s" (Printexc.to_string e));
354354- Lwt.fail e
355355- )
356356-357357-(** Fetch all bookmarks from a Karakeep instance using pagination *)
358358-let fetch_all_bookmarks ~api_key ?(page_size=50) ?max_pages ?filter_tags ?(include_content=false) base_url =
359359- let rec fetch_pages page_num cursor acc _total_count =
360360- (* Use cursor if available, otherwise use offset-based pagination *)
361361- (match cursor with
362362- | Some cursor_str -> fetch_bookmarks ~api_key ~limit:page_size ~cursor:cursor_str ~include_content ?filter_tags base_url
363363- | None -> fetch_bookmarks ~api_key ~limit:page_size ~offset:(page_num * page_size) ~include_content ?filter_tags base_url)
364364- >>= fun response ->
365365-366366- let all_bookmarks = acc @ response.data in
367367-368368- (* Determine if we need to fetch more pages *)
369369- let more_available =
370370- match response.next_cursor with
371371- | Some _ -> true (* We have a cursor, so there are more results *)
372372- | None ->
373373- (* Fall back to offset-based check *)
374374- let fetched_count = (page_num * page_size) + List.length response.data in
375375- fetched_count < response.total
376376- in
377377-378378- let under_max_pages = match max_pages with
379379- | None -> true
380380- | Some max -> page_num + 1 < max
381381- in
382382-383383- if more_available && under_max_pages then
384384- fetch_pages (page_num + 1) response.next_cursor all_bookmarks response.total
385385- else
386386- Lwt.return all_bookmarks
387387- in
388388- fetch_pages 0 None [] 0
389389-390390-(** Fetch detailed information for a single bookmark by ID *)
391391-let fetch_bookmark_details ~api_key base_url bookmark_id =
392392- let open Cohttp_lwt_unix in
393393- let url = Fmt.str "%s/api/v1/bookmarks/%s" base_url bookmark_id in
394394-395395- (* Set up headers with API key *)
396396- let headers = Cohttp.Header.init ()
397397- |> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) in
398398-399399- Client.get ~headers (Uri.of_string url) >>= fun (resp, body) ->
400400- if resp.status = `OK then
401401- Cohttp_lwt.Body.to_string body >>= fun body_str ->
402402- let json = J.from_string body_str in
403403- Lwt.return (parse_bookmark json)
404404- else
405405- let status_code = Cohttp.Code.code_of_status resp.status in
406406- consume_body body >>= fun () ->
407407- Lwt.fail_with (Fmt.str "HTTP error: %d" status_code)
408408-409409-(** Get the asset URL for a given asset ID *)
410410-let get_asset_url base_url asset_id =
411411- Fmt.str "%s/api/assets/%s" base_url asset_id
412412-413413-(** Fetch an asset from the Karakeep server as a binary string *)
414414-let fetch_asset ~api_key base_url asset_id =
415415- let open Cohttp_lwt_unix in
416416-417417- let url = get_asset_url base_url asset_id in
418418-419419- (* Set up headers with API key *)
420420- let headers = Cohttp.Header.init ()
421421- |> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key) in
422422-423423- Client.get ~headers (Uri.of_string url) >>= fun (resp, body) ->
424424- if resp.status = `OK then
425425- Cohttp_lwt.Body.to_string body
426426- else
427427- let status_code = Cohttp.Code.code_of_status resp.status in
428428- consume_body body >>= fun () ->
429429- Lwt.fail_with (Fmt.str "Asset fetch error: %d" status_code)
430430-431431-(** Create a new bookmark in Karakeep with optional tags *)
432432-let create_bookmark ~api_key ~url ?title ?note ?tags ?(favourited=false) ?(archived=false) base_url =
433433- let open Cohttp_lwt_unix in
434434-435435- (* Prepare the bookmark request body *)
436436- let body_obj = [
437437- ("type", `String "link");
438438- ("url", `String url);
439439- ("favourited", `Bool favourited);
440440- ("archived", `Bool archived);
441441- ] in
442442-443443- (* Add optional fields *)
444444- let body_obj = match title with
445445- | Some title_str -> ("title", `String title_str) :: body_obj
446446- | None -> body_obj
447447- in
448448-449449- let body_obj = match note with
450450- | Some note_str -> ("note", `String note_str) :: body_obj
451451- | None -> body_obj
452452- in
453453-454454- (* Convert to JSON *)
455455- let body_json = `O body_obj in
456456- let body_str = J.to_string body_json in
457457-458458- (* Set up headers with API key *)
459459- let headers = Cohttp.Header.init ()
460460- |> fun h -> Cohttp.Header.add h "Authorization" ("Bearer " ^ api_key)
461461- |> fun h -> Cohttp.Header.add h "Content-Type" "application/json"
462462- in
463463-464464- (* Helper function to ensure we consume all response body data *)
465465- let consume_body body =
466466- Cohttp_lwt.Body.to_string body >>= fun _ ->
467467- Lwt.return_unit
468468- in
469469-470470- (* Create the bookmark *)
471471- let url_endpoint = Fmt.str "%s/api/v1/bookmarks" base_url in
472472- Client.post ~headers ~body:(Cohttp_lwt.Body.of_string body_str) (Uri.of_string url_endpoint) >>= fun (resp, body) ->
473473-474474- if resp.status = `Created || resp.status = `OK then
475475- Cohttp_lwt.Body.to_string body >>= fun body_str ->
476476- let json = J.from_string body_str in
477477- let bookmark = parse_bookmark json in
478478-479479- (* If tags are provided, add them to the bookmark *)
480480- (match tags with
481481- | Some tag_list when tag_list <> [] ->
482482- (* Prepare the tags request body *)
483483- let tag_objects = List.map (fun tag_name ->
484484- `O [("tagName", `String tag_name)]
485485- ) tag_list in
486486-487487- let tags_body = `O [("tags", `A tag_objects)] in
488488- let tags_body_str = J.to_string tags_body in
489489-490490- (* Add tags to the bookmark *)
491491- let tags_url = Fmt.str "%s/api/v1/bookmarks/%s/tags" base_url bookmark.id in
492492- Client.post ~headers ~body:(Cohttp_lwt.Body.of_string tags_body_str) (Uri.of_string tags_url) >>= fun (resp, body) ->
493493-494494- (* Always consume the response body *)
495495- consume_body body >>= fun () ->
496496-497497- if resp.status = `OK then
498498- (* Fetch the bookmark again to get updated tags *)
499499- fetch_bookmark_details ~api_key base_url bookmark.id
500500- else
501501- (* Return the bookmark without tags if tag addition failed *)
502502- Lwt.return bookmark
503503- | _ -> Lwt.return bookmark)
504504- else
505505- let status_code = Cohttp.Code.code_of_status resp.status in
506506- Cohttp_lwt.Body.to_string body >>= fun error_body ->
507507- Lwt.fail_with (Fmt.str "Failed to create bookmark. HTTP error: %d. Details: %s" status_code error_body)
508508-509509-(** Convert a Karakeep bookmark to Bushel.Link.t compatible structure *)
510510-let to_bushel_link ?base_url bookmark =
511511- (* Try to find the best title from multiple possible sources *)
512512- let description =
513513- match bookmark.title with
514514- | Some title when title <> "" -> title
515515- | _ ->
516516- (* Check if there's a title in the content *)
517517- let content_title = List.assoc_opt "title" bookmark.content in
518518- match content_title with
519519- | Some title when title <> "" && title <> "null" -> title
520520- | _ -> bookmark.url
521521- in
522522- let date = Ptime.to_date bookmark.created_at in
523523-524524- (* Build selective metadata - only include useful fields *)
525525- let metadata =
526526- (match bookmark.summary with Some s -> [("summary", s)] | None -> []) @
527527- (* Extract key asset IDs *)
528528- (List.filter_map (fun (id, asset_type) ->
529529- match asset_type with
530530- | "screenshot" | "bannerImage" -> Some (asset_type, id)
531531- | _ -> None
532532- ) bookmark.assets) @
533533- (* Extract only the favicon from content *)
534534- (List.filter_map (fun (k, v) ->
535535- if k = "favicon" && v <> "" && v <> "null" then Some ("favicon", v) else None
536536- ) bookmark.content)
537537- in
538538-539539- (* Create karakeep data if base_url is provided *)
540540- let karakeep =
541541- match base_url with
542542- | Some url ->
543543- Some {
544544- Bushel.Link.remote_url = url;
545545- id = bookmark.id;
546546- tags = bookmark.tags;
547547- metadata = metadata;
548548- }
549549- | None -> None
550550- in
551551-552552- (* Extract bushel slugs from tags *)
553553- let bushel_slugs =
554554- List.filter_map (fun tag ->
555555- if String.starts_with ~prefix:"bushel:" tag then
556556- Some (String.sub tag 7 (String.length tag - 7))
557557- else
558558- None
559559- ) bookmark.tags
560560- in
561561-562562- (* Create bushel data if we have bushel-related information *)
563563- let bushel =
564564- if bushel_slugs = [] then None
565565- else Some { Bushel.Link.slugs = bushel_slugs; tags = [] }
566566- in
567567-568568- { Bushel.Link.url = bookmark.url; date; description; karakeep; bushel }
···11-(** PeerTube API client implementation
22- TODO:claude *)
33-44-open Lwt.Infix
11+(** PeerTube API client implementation (Eio version) *)
5263module J = Ezjsonm
74···2926let parse_date str =
3027 match Ptime.of_rfc3339 str with
3128 | Ok (date, _, _) -> date
3232- | Error _ ->
2929+ | Error _ ->
3330 Fmt.epr "Warning: could not parse date '%s'\n" str;
3431 (* Default to epoch time *)
3532 let span_opt = Ptime.Span.of_d_ps (0, 0L) in
···47444845(** Extract a string list field from JSON, returns empty list if not present *)
4946let get_string_list json path =
5050- try
4747+ try
5148 let tags_json = J.find json path in
5249 J.get_list J.get_string tags_json
5350 with _ -> []
···6057 let description = get_string_opt json ["description"] in
6158 let url = J.find json ["url"] |> J.get_string in
6259 let embed_path = J.find json ["embedPath"] |> J.get_string in
6363-6060+6461 (* Parse dates *)
6565- let published_at =
6666- J.find json ["publishedAt"] |> J.get_string |> parse_date
6262+ let published_at =
6363+ J.find json ["publishedAt"] |> J.get_string |> parse_date
6764 in
6868-6565+6966 let originally_published_at =
7067 match get_string_opt json ["originallyPublishedAt"] with
7168 | Some date -> Some (parse_date date)
7269 | None -> None
7370 in
7474-7171+7572 let thumbnail_path = get_string_opt json ["thumbnailPath"] in
7673 let tags = get_string_list json ["tags"] in
7777-7878- { id; uuid; name; description; url; embed_path;
7979- published_at; originally_published_at;
7474+7575+ { id; uuid; name; description; url; embed_path;
7676+ published_at; originally_published_at;
8077 thumbnail_path; tags }
81788279(** Parse a PeerTube video response *)
···9188 @param start Starting index for pagination (0-based)
9289 @param base_url Base URL of the PeerTube instance
9390 @param channel Channel name to fetch videos from
9494- @return A Lwt promise with the video response
9595- TODO:claude *)
9696-let fetch_channel_videos ?(count=20) ?(start=0) base_url channel =
9797- let open Cohttp_lwt_unix in
9898- let url = Printf.sprintf "%s/api/v1/video-channels/%s/videos?count=%d&start=%d"
9191+ @return The video response *)
9292+let fetch_channel_videos ~sw ~env ?(count=20) ?(start=0) base_url channel =
9393+ let url = Printf.sprintf "%s/api/v1/video-channels/%s/videos?count=%d&start=%d"
9994 base_url channel count start in
100100- Client.get (Uri.of_string url) >>= fun (resp, body) ->
101101- if resp.status = `OK then
102102- Cohttp_lwt.Body.to_string body >>= fun body_str ->
103103- let json = J.from_string body_str in
104104- Lwt.return (parse_video_response json)
9595+ let response = Requests.One.get ~sw ~clock:(Eio.Stdenv.clock env) ~net:(Eio.Stdenv.net env) url in
9696+ let status_code = Requests.Response.status_code response in
9797+ if status_code = 200 then
9898+ let s = Requests.Response.body response |> Eio.Flow.read_all in
9999+ let json = J.from_string s in
100100+ parse_video_response json
105101 else
106106- let status_code = Cohttp.Code.code_of_status resp.status in
107107- Lwt.fail_with (Fmt.str "HTTP error: %d" status_code)
102102+ failwith (Fmt.str "HTTP error: %d" status_code)
108103109104(** Fetch all videos from a PeerTube instance channel using pagination
110105 @param page_size Number of videos to fetch per page
111106 @param max_pages Maximum number of pages to fetch (None for all pages)
112107 @param base_url Base URL of the PeerTube instance
113108 @param channel Channel name to fetch videos from
114114- @return A Lwt promise with all videos combined
115115- TODO:claude *)
116116-let fetch_all_channel_videos ?(page_size=20) ?max_pages base_url channel =
109109+ @return All videos combined *)
110110+let fetch_all_channel_videos ~sw ~env ?(page_size=20) ?max_pages base_url channel =
117111 let rec fetch_pages start acc _total_count =
118118- fetch_channel_videos ~count:page_size ~start base_url channel >>= fun response ->
112112+ let response = fetch_channel_videos ~sw ~env ~count:page_size ~start base_url channel in
119113 let all_videos = acc @ response.data in
120120-114114+121115 (* Determine if we need to fetch more pages *)
122116 let fetched_count = start + List.length response.data in
123117 let more_available = fetched_count < response.total in
···125119 | None -> true
126120 | Some max -> (start / page_size) + 1 < max
127121 in
128128-122122+129123 if more_available && under_max_pages then
130124 fetch_pages fetched_count all_videos response.total
131125 else
132132- Lwt.return all_videos
126126+ all_videos
133127 in
134128 fetch_pages 0 [] 0
135129136130(** Fetch detailed information for a single video by UUID
137131 @param base_url Base URL of the PeerTube instance
138132 @param uuid UUID of the video to fetch
139139- @return A Lwt promise with the complete video details
140140- TODO:claude *)
141141-let fetch_video_details base_url uuid =
142142- let open Cohttp_lwt_unix in
133133+ @return The complete video details *)
134134+let fetch_video_details ~sw ~env base_url uuid =
143135 let url = Printf.sprintf "%s/api/v1/videos/%s" base_url uuid in
144144- Client.get (Uri.of_string url) >>= fun (resp, body) ->
145145- if resp.status = `OK then
146146- Cohttp_lwt.Body.to_string body >>= fun body_str ->
147147- let json = J.from_string body_str in
136136+ let response = Requests.One.get ~sw ~clock:(Eio.Stdenv.clock env) ~net:(Eio.Stdenv.net env) url in
137137+ let status_code = Requests.Response.status_code response in
138138+ if status_code = 200 then
139139+ let s = Requests.Response.body response |> Eio.Flow.read_all in
140140+ let json = J.from_string s in
148141 (* Parse the single video details *)
149149- Lwt.return (parse_video json)
142142+ parse_video json
150143 else
151151- let status_code = Cohttp.Code.code_of_status resp.status in
152152- Lwt.fail_with (Fmt.str "HTTP error: %d" status_code)
144144+ failwith (Fmt.str "HTTP error: %d" status_code)
153145154146(** Convert a PeerTube video to Bushel.Video.t compatible structure *)
155147let to_bushel_video video =
···167159 @param base_url Base URL of the PeerTube instance
168160 @param video The video to download the thumbnail for
169161 @param output_path Path where to save the thumbnail
170170- @return A Lwt promise with unit on success *)
171171-let download_thumbnail base_url video output_path =
162162+ @return Ok () on success or Error with message *)
163163+let download_thumbnail ~sw ~env base_url video output_path =
172164 match thumbnail_url base_url video with
173165 | None ->
174174- Lwt.return (Error (`Msg (Printf.sprintf "No thumbnail available for video %s" video.uuid)))
166166+ Error (`Msg (Printf.sprintf "No thumbnail available for video %s" video.uuid))
175167 | Some url ->
176176- let open Cohttp_lwt_unix in
177177- Client.get (Uri.of_string url) >>= fun (resp, body) ->
178178- if resp.status = `OK then
179179- Cohttp_lwt.Body.to_string body >>= fun body_str ->
180180- Lwt.catch
181181- (fun () ->
182182- let oc = open_out_bin output_path in
183183- output_string oc body_str;
184184- close_out oc;
185185- Lwt.return (Ok ()))
186186- (fun exn ->
187187- Lwt.return (Error (`Msg (Printf.sprintf "Failed to write thumbnail: %s"
188188- (Printexc.to_string exn)))))
189189- else
190190- let status_code = Cohttp.Code.code_of_status resp.status in
191191- Lwt.return (Error (`Msg (Printf.sprintf "HTTP error downloading thumbnail: %d" status_code)))168168+ try
169169+ let response = Requests.One.get ~sw ~clock:(Eio.Stdenv.clock env) ~net:(Eio.Stdenv.net env) url in
170170+ let status_code = Requests.Response.status_code response in
171171+ if status_code = 200 then
172172+ let body_str = Requests.Response.body response |> Eio.Flow.read_all in
173173+ try
174174+ let fs = Eio.Stdenv.fs env in
175175+ let output_eio_path = Eio.Path.(fs / output_path) in
176176+ Eio.Path.save ~create:(`Or_truncate 0o644) output_eio_path body_str;
177177+ Ok ()
178178+ with exn ->
179179+ Error (`Msg (Printf.sprintf "Failed to write thumbnail: %s"
180180+ (Printexc.to_string exn)))
181181+ else
182182+ Error (`Msg (Printf.sprintf "HTTP error downloading thumbnail: %d" status_code))
183183+ with exn ->
184184+ Error (`Msg (Printf.sprintf "Failed to download thumbnail: %s"
185185+ (Printexc.to_string exn)))
···11-(** PeerTube API client interface
22- TODO:claude *)
11+(** PeerTube API client interface (Eio version) *)
3243(** Type representing a PeerTube video *)
54type video = {
···2827val parse_video_response : Ezjsonm.value -> video_response
29283029(** Fetch videos from a PeerTube instance channel with pagination support
3030+ @param sw Eio switch for resource management
3131+ @param env Eio environment for network and clock access
3132 @param count Number of videos to fetch per page (default: 20)
3233 @param start Starting index for pagination (0-based) (default: 0)
3334 @param base_url Base URL of the PeerTube instance
3435 @param channel Channel name to fetch videos from *)
3535-val fetch_channel_videos : ?count:int -> ?start:int -> string -> string -> video_response Lwt.t
3636+val fetch_channel_videos : sw:Eio.Switch.t -> env:< clock : 'a Eio.Time.clock; net : 'b Eio.Net.t; .. > -> ?count:int -> ?start:int -> string -> string -> video_response
36373738(** Fetch all videos from a PeerTube instance channel using pagination
3939+ @param sw Eio switch for resource management
4040+ @param env Eio environment for network and clock access
3841 @param page_size Number of videos to fetch per page (default: 20)
3942 @param max_pages Maximum number of pages to fetch (None for all pages)
4043 @param base_url Base URL of the PeerTube instance
4144 @param channel Channel name to fetch videos from *)
4242-val fetch_all_channel_videos : ?page_size:int -> ?max_pages:int -> string -> string -> video list Lwt.t
4545+val fetch_all_channel_videos : sw:Eio.Switch.t -> env:< clock : 'a Eio.Time.clock; net : 'b Eio.Net.t; .. > -> ?page_size:int -> ?max_pages:int -> string -> string -> video list
43464447(** Fetch detailed information for a single video by UUID
4848+ @param sw Eio switch for resource management
4949+ @param env Eio environment for network and clock access
4550 @param base_url Base URL of the PeerTube instance
4651 @param uuid UUID of the video to fetch *)
4747-val fetch_video_details : string -> string -> video Lwt.t
5252+val fetch_video_details : sw:Eio.Switch.t -> env:< clock : 'a Eio.Time.clock; net : 'b Eio.Net.t; .. > -> string -> string -> video
48534954(** Convert a PeerTube video to Bushel.Video.t compatible structure
5055 Returns (description, published_date, title, url, uuid, slug) *)
···5661val thumbnail_url : string -> video -> string option
57625863(** Download a thumbnail to a file
6464+ @param sw Eio switch for resource management
6565+ @param env Eio environment for network and filesystem access
5966 @param base_url Base URL of the PeerTube instance
6067 @param video The video to download the thumbnail for
6168 @param output_path Path where to save the thumbnail *)
6262-val download_thumbnail : string -> video -> string -> (unit, [> `Msg of string]) result Lwt.t6969+val download_thumbnail : sw:Eio.Switch.t -> env:< clock : 'a Eio.Time.clock; net : 'b Eio.Net.t; fs : 'c Eio.Path.t; .. > -> string -> video -> string -> (unit, [> `Msg of string]) result
-36
stack/bushel/typesense-client.opam
···11-# This file is generated by dune, edit dune-project instead
22-opam-version: "2.0"
33-synopsis: "Standalone Typesense client for OCaml"
44-description:
55- "A standalone Typesense client that can be compiled to JavaScript"
66-maintainer: ["anil@recoil.org"]
77-authors: ["Anil Madhavapeddy"]
88-license: "ISC"
99-homepage: "https://github.com/avsm/bushel"
1010-bug-reports: "https://github.com/avsm/bushel/issues"
1111-depends: [
1212- "dune" {>= "3.17"}
1313- "ocaml" {>= "5.2.0"}
1414- "ezjsonm"
1515- "lwt"
1616- "cohttp-lwt-unix"
1717- "ptime"
1818- "fmt"
1919- "uri"
2020- "odoc" {with-doc}
2121-]
2222-build: [
2323- ["dune" "subst"] {dev}
2424- [
2525- "dune"
2626- "build"
2727- "-p"
2828- name
2929- "-j"
3030- jobs
3131- "@install"
3232- "@runtest" {with-test}
3333- "@doc" {with-doc}
3434- ]
3535-]
3636-dev-repo: "git+https://github.com/avsm/bushel.git"
···11-(** Standalone Typesense client for OCaml *)
22-33-(** Configuration for Typesense client *)
44-type config = {
55- endpoint : string;
66- api_key : string;
77-}
88-99-(** Error types for Typesense operations *)
1010-type error =
1111- | Http_error of int * string
1212- | Json_error of string
1313- | Connection_error of string
1414-1515-val pp_error : Format.formatter -> error -> unit
1616-1717-(** Search result types *)
1818-type search_result = {
1919- id: string;
2020- title: string;
2121- content: string;
2222- score: float;
2323- collection: string;
2424- highlights: (string * string list) list;
2525- document: Ezjsonm.value; (* Store raw document for flexible field access *)
2626-}
2727-2828-type search_response = {
2929- hits: search_result list;
3030- total: int;
3131- query_time: float;
3232-}
3333-3434-(** Multisearch result types *)
3535-type multisearch_response = {
3636- results: search_response list;
3737-}
3838-3939-(** Search a single collection *)
4040-val search_collection : config -> string -> string -> ?limit:int -> ?offset:int -> unit -> (search_response, error) result Lwt.t
4141-4242-(** Perform multisearch across all collections *)
4343-val multisearch : config -> string -> ?limit:int -> unit -> (multisearch_response, error) result Lwt.t
4444-4545-(** Combine multisearch results into single result set *)
4646-val combine_multisearch_results : multisearch_response -> ?limit:int -> ?offset:int -> unit -> search_response
4747-4848-(** List all collections *)
4949-val list_collections : config -> ((string * int) list, error) result Lwt.t
5050-5151-(** Pretty printer utilities *)
5252-val extract_field_string : Ezjsonm.value -> string -> string
5353-val extract_field_string_list : Ezjsonm.value -> string -> string list
5454-val extract_field_bool : Ezjsonm.value -> string -> bool
5555-val format_authors : string list -> string
5656-val format_date : string -> string
5757-val format_tags : string list -> string
5858-5959-(** One-line pretty printer for search results *)
6060-val pp_search_result_oneline : search_result -> string
+195
stack/immiche/README.md
···11+# Immiche - Immich API Client Library
22+33+A clean Eio-based OCaml library for interacting with Immich instances, focusing on people and face recognition data.
44+55+## Overview
66+77+Immiche provides a straightforward API for interacting with Immich's people management endpoints. It uses the Requests library for HTTP operations and follows Eio patterns for concurrency and resource management.
88+99+## Features
1010+1111+- Fetch all people from an Immich instance
1212+- Search for people by name
1313+- Fetch individual person details
1414+- Download person thumbnails
1515+- Full Eio integration (no Lwt dependency)
1616+- Type-safe API with result types for error handling
1717+1818+## API
1919+2020+### Types
2121+2222+```ocaml
2323+(* Client type - encapsulates session with connection pooling *)
2424+type ('clock, 'net) t
2525+2626+type person = {
2727+ id: string;
2828+ name: string;
2929+ birth_date: string option;
3030+ thumbnail_path: string;
3131+ is_hidden: bool;
3232+}
3333+3434+type people_response = {
3535+ total: int;
3636+ visible: int;
3737+ people: person list;
3838+}
3939+```
4040+4141+### Client Creation
4242+4343+#### `create`
4444+Create an Immich client with connection pooling.
4545+4646+```ocaml
4747+val create :
4848+ sw:Eio.Switch.t ->
4949+ env:< clock: _ ; net: _ ; fs: _ ; .. > ->
5050+ ?requests_session:('clock, 'net) Requests.t ->
5151+ base_url:string ->
5252+ api_key:string ->
5353+ unit -> ('clock, 'net) t
5454+```
5555+5656+**Parameters:**
5757+- `sw` - Eio switch for resource management
5858+- `env` - Eio environment (provides clock, net, fs)
5959+- `requests_session` - Optional Requests session for connection pooling. If not provided, a new session is created.
6060+- `base_url` - Base URL of the Immich instance (e.g., "https://photos.example.com")
6161+- `api_key` - API key for authentication
6262+6363+**Returns:** An Immich client configured for the specified instance
6464+6565+### API Functions
6666+6767+All API functions take a client as their first parameter. The client automatically handles:
6868+- Connection pooling (reuses TCP connections)
6969+- Authentication (API key set as default header)
7070+- Base URL configuration
7171+7272+#### `fetch_people`
7373+Fetch all people from an Immich instance.
7474+7575+```ocaml
7676+val fetch_people : ('clock, 'net) t -> people_response
7777+```
7878+7979+#### `search_person`
8080+Search for people by name.
8181+8282+```ocaml
8383+val search_person : ('clock, 'net) t -> name:string -> person list
8484+```
8585+8686+#### `fetch_person`
8787+Fetch details for a specific person.
8888+8989+```ocaml
9090+val fetch_person : ('clock, 'net) t -> person_id:string -> person
9191+```
9292+9393+#### `download_thumbnail`
9494+Download a person's thumbnail image.
9595+9696+```ocaml
9797+val download_thumbnail :
9898+ ('clock, 'net) t ->
9999+ fs:_ Eio.Path.t ->
100100+ person_id:string ->
101101+ output_path:string ->
102102+ (unit, [> `Msg of string]) result
103103+```
104104+105105+## Example Usage
106106+107107+### Basic Usage
108108+109109+```ocaml
110110+open Eio.Std
111111+112112+let () =
113113+ Eio_main.run @@ fun env ->
114114+ Switch.run @@ fun sw ->
115115+116116+ (* Create client once with connection pooling *)
117117+ let client = Immiche.create ~sw ~env
118118+ ~base_url:"https://photos.example.com"
119119+ ~api_key:"your-api-key" () in
120120+121121+ (* Fetch all people - connection pooling automatic *)
122122+ let response = Immiche.fetch_people client in
123123+ Printf.printf "Total people: %d\n" response.total;
124124+125125+ (* Search for a person - reuses connections *)
126126+ let results = Immiche.search_person client ~name:"John" in
127127+128128+ (* Download first result's thumbnail *)
129129+ match results with
130130+ | person :: _ ->
131131+ let result = Immiche.download_thumbnail client
132132+ ~fs:(Eio.Stdenv.fs env)
133133+ ~person_id:person.id
134134+ ~output_path:"thumbnail.jpg" in
135135+ begin match result with
136136+ | Ok () -> print_endline "Thumbnail downloaded!"
137137+ | Error (`Msg err) -> Printf.eprintf "Error: %s\n" err
138138+ end
139139+ | [] -> print_endline "No results found"
140140+```
141141+142142+### Sharing Connection Pools
143143+144144+You can share a `Requests.t` session across multiple API clients for maximum connection reuse:
145145+146146+```ocaml
147147+Eio_main.run @@ fun env ->
148148+Switch.run @@ fun sw ->
149149+150150+ (* Create shared Requests session *)
151151+ let shared_session = Requests.create ~sw env in
152152+153153+ (* Create multiple clients sharing the same connection pools *)
154154+ let immich1 = Immiche.create ~sw ~env ~requests_session:shared_session
155155+ ~base_url:"https://photos1.example.com"
156156+ ~api_key:"key1" () in
157157+158158+ let immich2 = Immiche.create ~sw ~env ~requests_session:shared_session
159159+ ~base_url:"https://photos2.example.com"
160160+ ~api_key:"key2" () in
161161+162162+ (* Both clients share the same connection pools! *)
163163+ let people1 = Immiche.fetch_people immich1 in
164164+ let people2 = Immiche.fetch_people immich2 in
165165+ ()
166166+```
167167+168168+## Dependencies
169169+170170+- `eio` - Concurrent I/O library
171171+- `requests` - HTTP client library
172172+- `ezjsonm` - JSON parsing
173173+- `uri` - URI encoding
174174+- `fmt` - Formatting
175175+- `ptime` - Time handling
176176+177177+## Implementation Notes
178178+179179+- Uses `Requests.t` for session-based HTTP requests with connection pooling
180180+- Authentication via `x-api-key` header (set as default on session creation)
181181+- Direct Eio style - no Lwt dependencies
182182+- Connection pools shared across derived sessions
183183+- Extracted and adapted from `bushel/bin/bushel_faces.ml`
184184+185185+## Benefits
186186+187187+✅ **Connection Pooling** - Automatic TCP connection reuse across requests
188188+✅ **Clean API** - Session-level configuration passed once, not per-request
189189+✅ **Injectable Sessions** - Can share `Requests.t` across multiple libraries
190190+✅ **Type Safety** - Client parameterized by clock/net types
191191+✅ **Immutable Configuration** - Derived sessions don't affect original
192192+193193+## License
194194+195195+ISC
···11+(** Immiche - Immich API client library *)
22+33+open Printf
44+55+(** {1 Types} *)
66+77+type ('clock, 'net) t = {
88+ base_url: string;
99+ api_key: string;
1010+ requests_session: ('clock, 'net) Requests.t;
1111+}
1212+1313+type person = {
1414+ id: string;
1515+ name: string;
1616+ birth_date: string option;
1717+ thumbnail_path: string;
1818+ is_hidden: bool;
1919+}
2020+2121+type people_response = {
2222+ total: int;
2323+ visible: int;
2424+ people: person list;
2525+}
2626+2727+(** {1 Client Creation} *)
2828+2929+let create ~sw ~env ?requests_session ~base_url ~api_key () =
3030+ let requests_session = match requests_session with
3131+ | Some session -> session
3232+ | None -> Requests.create ~sw env
3333+ in
3434+ (* Set API key header on the session *)
3535+ let requests_session = Requests.set_default_header requests_session "x-api-key" api_key in
3636+ { base_url; api_key; requests_session }
3737+3838+(** {1 JSON Parsing} *)
3939+4040+(* Parse a single person from JSON *)
4141+let parse_person json =
4242+ let open Ezjsonm in
4343+ let id = find json ["id"] |> get_string in
4444+ let name = find json ["name"] |> get_string in
4545+ let birth_date =
4646+ try Some (find json ["birthDate"] |> get_string)
4747+ with _ -> None
4848+ in
4949+ let thumbnail_path = find json ["thumbnailPath"] |> get_string in
5050+ let is_hidden =
5151+ try find json ["isHidden"] |> get_bool
5252+ with _ -> false
5353+ in
5454+ { id; name; birth_date; thumbnail_path; is_hidden }
5555+5656+(* Parse people response from JSON *)
5757+let parse_people_response json =
5858+ let open Ezjsonm in
5959+ let total = find json ["total"] |> get_int in
6060+ let visible = find json ["visible"] |> get_int in
6161+ let people_json = find json ["people"] in
6262+ let people = get_list parse_person people_json in
6363+ { total; visible; people }
6464+6565+(* Parse a list of people from search results *)
6666+let parse_person_list json =
6767+ let open Ezjsonm in
6868+ get_list parse_person json
6969+7070+(** {1 API Functions} *)
7171+7272+let fetch_people { base_url; requests_session; _ } =
7373+ let url = sprintf "%s/api/people" base_url in
7474+7575+ let response = Requests.get requests_session url in
7676+ let status = Requests.Response.status_code response in
7777+7878+ if status <> 200 then
7979+ failwith (sprintf "HTTP error: %d" status)
8080+ else
8181+ let body_str = Requests.Response.body response |> Eio.Flow.read_all in
8282+ let json = Ezjsonm.from_string body_str in
8383+ parse_people_response json
8484+8585+let fetch_person { base_url; requests_session; _ } ~person_id =
8686+ let url = sprintf "%s/api/people/%s" base_url person_id in
8787+8888+ let response = Requests.get requests_session url in
8989+ let status = Requests.Response.status_code response in
9090+9191+ if status <> 200 then
9292+ failwith (sprintf "HTTP error: %d" status)
9393+ else
9494+ let body_str = Requests.Response.body response |> Eio.Flow.read_all in
9595+ let json = Ezjsonm.from_string body_str in
9696+ parse_person json
9797+9898+let download_thumbnail { base_url; requests_session; _ } ~fs ~person_id ~output_path =
9999+ try
100100+ let url = sprintf "%s/api/people/%s/thumbnail" base_url person_id in
101101+102102+ let response = Requests.get requests_session url in
103103+ let status = Requests.Response.status_code response in
104104+105105+ if status <> 200 then
106106+ Error (`Msg (sprintf "HTTP error: %d" status))
107107+ else begin
108108+ let img_data = Requests.Response.body response |> Eio.Flow.read_all in
109109+110110+ (* Ensure output directory exists *)
111111+ let dir = Filename.dirname output_path in
112112+ if not (Sys.file_exists dir) then
113113+ Unix.mkdir dir 0o755;
114114+115115+ (* Write the image data to file *)
116116+ let path = Eio.Path.(fs / output_path) in
117117+ Eio.Path.save ~create:(`Or_truncate 0o644) path img_data;
118118+119119+ Ok ()
120120+ end
121121+ with
122122+ | Failure msg -> Error (`Msg msg)
123123+ | exn -> Error (`Msg (Printexc.to_string exn))
124124+125125+let search_person { base_url; requests_session; _ } ~name =
126126+ let encoded_name = Uri.pct_encode name in
127127+ let url = sprintf "%s/api/search/person?name=%s" base_url encoded_name in
128128+129129+ let response = Requests.get requests_session url in
130130+ let status = Requests.Response.status_code response in
131131+132132+ if status <> 200 then
133133+ failwith (sprintf "HTTP error: %d" status)
134134+ else
135135+ let body_str = Requests.Response.body response |> Eio.Flow.read_all in
136136+ let json = Ezjsonm.from_string body_str in
137137+ parse_person_list json
+100
stack/immiche/immiche.mli
···11+(** Immiche - Immich API client library
22+33+ This library provides a clean Eio-based interface to interact with Immich
44+ instances for managing people and face recognition data.
55+*)
66+77+(** {1 Types} *)
88+99+(** Type representing an Immich client with connection pooling *)
1010+type ('clock, 'net) t
1111+1212+(** Type representing a person in Immich *)
1313+type person = {
1414+ id: string;
1515+ name: string;
1616+ birth_date: string option;
1717+ thumbnail_path: string;
1818+ is_hidden: bool;
1919+}
2020+2121+(** Type for the people API response *)
2222+type people_response = {
2323+ total: int;
2424+ visible: int;
2525+ people: person list;
2626+}
2727+2828+(** {1 Client Creation} *)
2929+3030+(** [create ~sw ~env ?requests_session ~base_url ~api_key ()] creates a new Immich client.
3131+3232+ @param sw The Eio switch for resource management
3333+ @param env The Eio environment (provides clock, net, fs)
3434+ @param requests_session Optional Requests session for connection pooling.
3535+ If not provided, a new session is created.
3636+ @param base_url The base URL of the Immich instance (e.g., "https://photos.example.com")
3737+ @param api_key The API key for authentication
3838+ @return An Immich client configured for the specified instance
3939+*)
4040+val create :
4141+ sw:Eio.Switch.t ->
4242+ env:< clock: ([> float Eio.Time.clock_ty ] as 'clock) Eio.Resource.t;
4343+ net: ([> [> `Generic ] Eio.Net.ty ] as 'net) Eio.Resource.t;
4444+ fs: Eio.Fs.dir_ty Eio.Path.t; .. > ->
4545+ ?requests_session:('clock Eio.Resource.t, 'net Eio.Resource.t) Requests.t ->
4646+ base_url:string ->
4747+ api_key:string ->
4848+ unit -> ('clock Eio.Resource.t, 'net Eio.Resource.t) t
4949+5050+(** {1 API Functions} *)
5151+5252+(** [fetch_people client] fetches all people from an Immich instance.
5353+5454+ @param client The Immich client
5555+ @return A people_response containing all people and metadata
5656+ @raise Failure if the API request fails
5757+*)
5858+val fetch_people :
5959+ ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
6060+ people_response
6161+6262+(** [fetch_person client ~person_id] fetches details for a single person.
6363+6464+ @param client The Immich client
6565+ @param person_id The ID of the person to fetch
6666+ @return The person details
6767+ @raise Failure if the API request fails or person not found
6868+*)
6969+val fetch_person :
7070+ ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
7171+ person_id:string ->
7272+ person
7373+7474+(** [download_thumbnail client ~fs ~person_id ~output_path] downloads
7575+ a person's thumbnail image to a file.
7676+7777+ @param client The Immich client
7878+ @param fs The Eio filesystem capability
7979+ @param person_id The ID of the person whose thumbnail to download
8080+ @param output_path The file path where the thumbnail should be saved
8181+ @return Ok () on success, or Error with a message on failure
8282+*)
8383+val download_thumbnail :
8484+ ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
8585+ fs:_ Eio.Path.t ->
8686+ person_id:string ->
8787+ output_path:string ->
8888+ (unit, [> `Msg of string]) result
8989+9090+(** [search_person client ~name] searches for people by name.
9191+9292+ @param client The Immich client
9393+ @param name The name to search for
9494+ @return A list of matching people
9595+ @raise Failure if the API request fails
9696+*)
9797+val search_person :
9898+ ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
9999+ name:string ->
100100+ person list
+31
stack/immiche/immiche.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+version: "0.1.0"
44+synopsis: "Immich API client for OCaml using Eio"
55+description: "An Eio-based OCaml client library for Immich photo service API"
66+depends: [
77+ "dune" {>= "3.0"}
88+ "ocaml" {>= "4.14"}
99+ "eio"
1010+ "eio_main" {>= "1.0"}
1111+ "requests"
1212+ "ezjsonm"
1313+ "fmt"
1414+ "ptime"
1515+ "uri"
1616+ "odoc" {with-doc}
1717+]
1818+build: [
1919+ ["dune" "subst"] {dev}
2020+ [
2121+ "dune"
2222+ "build"
2323+ "-p"
2424+ name
2525+ "-j"
2626+ jobs
2727+ "@install"
2828+ "@runtest" {with-test}
2929+ "@doc" {with-doc}
3030+ ]
3131+]
+5-4
stack/jmap/jmap-client/jmap_client.ml
···1212 let requests_session = Requests.create ~sw env in
13131414 (* Set authentication if configured *)
1515- (match Jmap_connection.auth conn with
1515+ let requests_session = match Jmap_connection.auth conn with
1616 | Some (Jmap_connection.Bearer token) ->
1717 Requests.set_auth requests_session (Requests.Auth.bearer ~token)
1818 | Some (Jmap_connection.Basic (user, pass)) ->
1919 Requests.set_auth requests_session (Requests.Auth.basic ~username:user ~password:pass)
2020- | None -> ());
2020+ | None -> requests_session
2121+ in
21222223 (* Set user agent *)
2324 let config = Jmap_connection.config conn in
2424- Requests.set_default_header requests_session "User-Agent"
2525- (Jmap_connection.user_agent config);
2525+ let requests_session = Requests.set_default_header requests_session "User-Agent"
2626+ (Jmap_connection.user_agent config) in
26272728 { session_url;
2829 get_request = (fun ~timeout url -> Requests.get requests_session ~timeout url);
···11+(lang dune 3.0)
22+(name karakeepe)
33+(version 0.1.0)
44+55+(generate_opam_files true)
66+77+(package
88+ (name karakeepe)
99+ (synopsis "Karakeep API client for OCaml using Eio")
1010+ (description "An Eio-based OCaml client library for the Karakeep bookmark management service API")
1111+ (depends
1212+ (ocaml (>= 4.14))
1313+ eio
1414+ (eio_main (>= 1.0))
1515+ requests
1616+ ezjsonm
1717+ fmt
1818+ ptime
1919+ uri))
+472
stack/karakeepe/karakeepe.ml
···11+(** Karakeepe API client implementation (Eio version) *)
22+33+module J = Ezjsonm
44+55+(** Type representing a Karakeep bookmark *)
66+type bookmark = {
77+ id: string;
88+ title: string option;
99+ url: string;
1010+ note: string option;
1111+ created_at: Ptime.t;
1212+ updated_at: Ptime.t option;
1313+ favourited: bool;
1414+ archived: bool;
1515+ tags: string list;
1616+ tagging_status: string option;
1717+ summary: string option;
1818+ content: (string * string) list;
1919+ assets: (string * string) list;
2020+}
2121+2222+(** Type for Karakeep API response containing bookmarks *)
2323+type bookmark_response = {
2424+ total: int;
2525+ data: bookmark list;
2626+ next_cursor: string option;
2727+}
2828+2929+(** Parse a date string to Ptime.t, defaulting to epoch if invalid *)
3030+let parse_date str =
3131+ match Ptime.of_rfc3339 str with
3232+ | Ok (date, _, _) -> date
3333+ | Error _ ->
3434+ Fmt.epr "Warning: could not parse date '%s'\n" str;
3535+ (* Default to epoch time *)
3636+ let span_opt = Ptime.Span.of_d_ps (0, 0L) in
3737+ match span_opt with
3838+ | None -> failwith "Internal error: couldn't create epoch time span"
3939+ | Some span ->
4040+ match Ptime.of_span span with
4141+ | Some t -> t
4242+ | None -> failwith "Internal error: couldn't create epoch time"
4343+4444+(** Extract a string field from JSON, returns None if not present or not a string *)
4545+let get_string_opt json path =
4646+ try Some (J.find json path |> J.get_string)
4747+ with _ -> None
4848+4949+(** Extract a string list field from JSON, returns empty list if not present *)
5050+let get_string_list json path =
5151+ try
5252+ let items_json = J.find json path in
5353+ J.get_list (fun tag -> J.find tag ["name"] |> J.get_string) items_json
5454+ with _ -> []
5555+5656+(** Extract a boolean field from JSON, with default value *)
5757+let get_bool_def json path default =
5858+ try J.find json path |> J.get_bool
5959+ with _ -> default
6060+6161+(** Parse a single bookmark from Karakeep JSON *)
6262+let parse_bookmark json =
6363+ let id =
6464+ try J.find json ["id"] |> J.get_string
6565+ with e ->
6666+ prerr_endline (Fmt.str "Error parsing bookmark ID: %s" (Printexc.to_string e));
6767+ prerr_endline (Fmt.str "JSON: %s" (J.value_to_string json));
6868+ failwith "Unable to parse bookmark ID"
6969+ in
7070+7171+ let title =
7272+ try Some (J.find json ["title"] |> J.get_string)
7373+ with _ -> None
7474+ in
7575+7676+ let url =
7777+ try J.find json ["url"] |> J.get_string
7878+ with _ -> try
7979+ J.find json ["content"; "url"] |> J.get_string
8080+ with _ -> try
8181+ J.find json ["content"; "sourceUrl"] |> J.get_string
8282+ with _ ->
8383+ match J.find_opt json ["content"; "type"] with
8484+ | Some (`String "asset") ->
8585+ (try J.find json ["content"; "sourceUrl"] |> J.get_string
8686+ with _ ->
8787+ (match J.find_opt json ["id"] with
8888+ | Some (`String id) -> "karakeep-asset://" ^ id
8989+ | _ -> failwith "No URL or asset ID found in bookmark"))
9090+ | _ ->
9191+ prerr_endline (Fmt.str "Bookmark JSON structure: %s" (J.value_to_string json));
9292+ failwith "No URL found in bookmark"
9393+ in
9494+9595+ let note = get_string_opt json ["note"] in
9696+9797+ let created_at =
9898+ try J.find json ["createdAt"] |> J.get_string |> parse_date
9999+ with _ ->
100100+ try J.find json ["created_at"] |> J.get_string |> parse_date
101101+ with _ -> failwith "No creation date found"
102102+ in
103103+104104+ let updated_at =
105105+ try Some (J.find json ["updatedAt"] |> J.get_string |> parse_date)
106106+ with _ ->
107107+ try Some (J.find json ["modifiedAt"] |> J.get_string |> parse_date)
108108+ with _ -> None
109109+ in
110110+111111+ let favourited = get_bool_def json ["favourited"] false in
112112+ let archived = get_bool_def json ["archived"] false in
113113+ let tags = get_string_list json ["tags"] in
114114+ let tagging_status = get_string_opt json ["taggingStatus"] in
115115+ let summary = get_string_opt json ["summary"] in
116116+117117+ let content =
118118+ try
119119+ let content_json = J.find json ["content"] in
120120+ let rec extract_fields acc = function
121121+ | [] -> acc
122122+ | (k, v) :: rest ->
123123+ let value = match v with
124124+ | `String s -> s
125125+ | `Bool b -> string_of_bool b
126126+ | `Float f -> string_of_float f
127127+ | `Null -> "null"
128128+ | _ -> "complex_value"
129129+ in
130130+ extract_fields ((k, value) :: acc) rest
131131+ in
132132+ match content_json with
133133+ | `O fields -> extract_fields [] fields
134134+ | _ -> []
135135+ with _ -> []
136136+ in
137137+138138+ let assets =
139139+ try
140140+ let assets_json = J.find json ["assets"] in
141141+ J.get_list (fun asset_json ->
142142+ let id = J.find asset_json ["id"] |> J.get_string in
143143+ let asset_type =
144144+ try J.find asset_json ["assetType"] |> J.get_string
145145+ with _ -> "unknown"
146146+ in
147147+ (id, asset_type)
148148+ ) assets_json
149149+ with _ -> []
150150+ in
151151+152152+ { id; title; url; note; created_at; updated_at; favourited; archived; tags;
153153+ tagging_status; summary; content; assets }
154154+155155+(** Parse a Karakeep bookmark response *)
156156+let parse_bookmark_response json =
157157+ prerr_endline (Fmt.str "Full response JSON: %s" (J.value_to_string json));
158158+159159+ try
160160+ let total = J.find json ["total"] |> J.get_int in
161161+ let bookmarks_json = J.find json ["data"] in
162162+ prerr_endline "Found bookmarks in data array";
163163+ let data = J.get_list parse_bookmark bookmarks_json in
164164+ let next_cursor =
165165+ try Some (J.find json ["nextCursor"] |> J.get_string)
166166+ with _ -> None
167167+ in
168168+ { total; data; next_cursor }
169169+ with e1 ->
170170+ prerr_endline (Fmt.str "First format parse error: %s" (Printexc.to_string e1));
171171+ try
172172+ let bookmarks_json = J.find json ["bookmarks"] in
173173+ prerr_endline "Found bookmarks in bookmarks array";
174174+ let data =
175175+ try J.get_list parse_bookmark bookmarks_json
176176+ with e ->
177177+ prerr_endline (Fmt.str "Error parsing bookmarks array: %s" (Printexc.to_string e));
178178+ []
179179+ in
180180+ let next_cursor =
181181+ try Some (J.find json ["nextCursor"] |> J.get_string)
182182+ with _ -> None
183183+ in
184184+ { total = List.length data; data; next_cursor }
185185+ with e2 ->
186186+ prerr_endline (Fmt.str "Second format parse error: %s" (Printexc.to_string e2));
187187+ try
188188+ let error = J.find json ["error"] |> J.get_string in
189189+ let message =
190190+ try J.find json ["message"] |> J.get_string
191191+ with _ -> "Unknown error"
192192+ in
193193+ prerr_endline (Fmt.str "API Error: %s - %s" error message);
194194+ { total = 0; data = []; next_cursor = None }
195195+ with _ ->
196196+ try
197197+ prerr_endline "Trying alternate array format";
198198+ prerr_endline (Fmt.str "JSON structure keys: %s"
199199+ (match json with
200200+ | `O fields -> String.concat ", " (List.map (fun (k, _) -> k) fields)
201201+ | _ -> "not an object"));
202202+203203+ if J.find_opt json ["nextCursor"] <> None then begin
204204+ prerr_endline "Found nextCursor, checking alternate structures";
205205+ let bookmarks_json =
206206+ try Some (J.find json ["data"])
207207+ with _ -> None
208208+ in
209209+ match bookmarks_json with
210210+ | Some json_array ->
211211+ prerr_endline "Found bookmarks in data field";
212212+ begin try
213213+ let data = J.get_list parse_bookmark json_array in
214214+ let next_cursor =
215215+ try Some (J.find json ["nextCursor"] |> J.get_string)
216216+ with _ -> None
217217+ in
218218+ { total = List.length data; data; next_cursor }
219219+ with e ->
220220+ prerr_endline (Fmt.str "Error parsing bookmarks from data: %s" (Printexc.to_string e));
221221+ { total = 0; data = []; next_cursor = None }
222222+ end
223223+ | None ->
224224+ prerr_endline "No bookmarks found in alternate structure";
225225+ { total = 0; data = []; next_cursor = None }
226226+ end
227227+ else begin
228228+ match json with
229229+ | `A _ ->
230230+ let data =
231231+ try J.get_list parse_bookmark json
232232+ with e ->
233233+ prerr_endline (Fmt.str "Error parsing root array: %s" (Printexc.to_string e));
234234+ []
235235+ in
236236+ { total = List.length data; data; next_cursor = None }
237237+ | _ ->
238238+ prerr_endline "Not an array at root level";
239239+ { total = 0; data = []; next_cursor = None }
240240+ end
241241+ with e3 ->
242242+ prerr_endline (Fmt.str "Third format parse error: %s" (Printexc.to_string e3));
243243+ { total = 0; data = []; next_cursor = None }
244244+245245+(** Fetch bookmarks from a Karakeep instance with pagination support *)
246246+let fetch_bookmarks ~sw ~env ~api_key ?(limit=50) ?(offset=0) ?cursor ?(include_content=false) ?filter_tags base_url =
247247+ let url_base = Fmt.str "%s/api/v1/bookmarks?limit=%d&includeContent=%b"
248248+ base_url limit include_content in
249249+250250+ let url =
251251+ match cursor with
252252+ | Some cursor_value -> url_base ^ "&cursor=" ^ cursor_value
253253+ | None -> url_base ^ "&offset=" ^ string_of_int offset
254254+ in
255255+256256+ let url = match filter_tags with
257257+ | Some tags when tags <> [] ->
258258+ let encoded_tags =
259259+ List.map (fun tag -> Uri.pct_encode ~component:`Query_key tag) tags
260260+ in
261261+ let tags_param = String.concat "," encoded_tags in
262262+ prerr_endline (Fmt.str "Adding tags filter: %s" tags_param);
263263+ url ^ "&tags=" ^ tags_param
264264+ | _ -> url
265265+ in
266266+267267+ let headers = Requests.Headers.empty
268268+ |> Requests.Headers.set "Authorization" ("Bearer " ^ api_key) in
269269+270270+ prerr_endline (Fmt.str "Fetching bookmarks from: %s" url);
271271+272272+ try
273273+ let response = Requests.One.get ~sw ~clock:env#clock ~net:env#net ~headers url in
274274+ let status_code = Requests.Response.status_code response in
275275+ if status_code = 200 then begin
276276+ let body_str = Requests.Response.body response |> Eio.Flow.read_all in
277277+ prerr_endline (Fmt.str "Received %d bytes of response data" (String.length body_str));
278278+279279+ try
280280+ let json = J.from_string body_str in
281281+ parse_bookmark_response json
282282+ with e ->
283283+ prerr_endline (Fmt.str "JSON parsing error: %s" (Printexc.to_string e));
284284+ prerr_endline (Fmt.str "Response body (first 200 chars): %s"
285285+ (if String.length body_str > 200 then String.sub body_str 0 200 ^ "..." else body_str));
286286+ raise e
287287+ end else begin
288288+ prerr_endline (Fmt.str "HTTP error %d" status_code);
289289+ failwith (Fmt.str "HTTP error: %d" status_code)
290290+ end
291291+ with e ->
292292+ prerr_endline (Fmt.str "Network error: %s" (Printexc.to_string e));
293293+ raise e
294294+295295+(** Fetch all bookmarks from a Karakeep instance using pagination *)
296296+let fetch_all_bookmarks ~sw ~env ~api_key ?(page_size=50) ?max_pages ?filter_tags ?(include_content=false) base_url =
297297+ let rec fetch_pages page_num cursor acc _total_count =
298298+ let response =
299299+ match cursor with
300300+ | Some cursor_str -> fetch_bookmarks ~sw ~env ~api_key ~limit:page_size ~cursor:cursor_str ~include_content ?filter_tags base_url
301301+ | None -> fetch_bookmarks ~sw ~env ~api_key ~limit:page_size ~offset:(page_num * page_size) ~include_content ?filter_tags base_url
302302+ in
303303+304304+ let all_bookmarks = acc @ response.data in
305305+306306+ let more_available =
307307+ match response.next_cursor with
308308+ | Some _ -> true
309309+ | None ->
310310+ let fetched_count = (page_num * page_size) + List.length response.data in
311311+ fetched_count < response.total
312312+ in
313313+314314+ let under_max_pages = match max_pages with
315315+ | None -> true
316316+ | Some max -> page_num + 1 < max
317317+ in
318318+319319+ if more_available && under_max_pages then
320320+ fetch_pages (page_num + 1) response.next_cursor all_bookmarks response.total
321321+ else
322322+ all_bookmarks
323323+ in
324324+ fetch_pages 0 None [] 0
325325+326326+(** Fetch detailed information for a single bookmark by ID *)
327327+let fetch_bookmark_details ~sw ~env ~api_key base_url bookmark_id =
328328+ let url = Fmt.str "%s/api/v1/bookmarks/%s" base_url bookmark_id in
329329+330330+ let headers = Requests.Headers.empty
331331+ |> Requests.Headers.set "Authorization" ("Bearer " ^ api_key) in
332332+333333+ let response = Requests.One.get ~sw ~clock:env#clock ~net:env#net ~headers url in
334334+ let status_code = Requests.Response.status_code response in
335335+ if status_code = 200 then begin
336336+ let body_str = Requests.Response.body response |> Eio.Flow.read_all in
337337+ let json = J.from_string body_str in
338338+ parse_bookmark json
339339+ end else
340340+ failwith (Fmt.str "HTTP error: %d" status_code)
341341+342342+(** Get the asset URL for a given asset ID *)
343343+let get_asset_url base_url asset_id =
344344+ Fmt.str "%s/api/assets/%s" base_url asset_id
345345+346346+(** Fetch an asset from the Karakeep server as a binary string *)
347347+let fetch_asset ~sw ~env ~api_key base_url asset_id =
348348+ let url = get_asset_url base_url asset_id in
349349+350350+ let headers = Requests.Headers.empty
351351+ |> Requests.Headers.set "Authorization" ("Bearer " ^ api_key) in
352352+353353+ let response = Requests.One.get ~sw ~clock:env#clock ~net:env#net ~headers url in
354354+ let status_code = Requests.Response.status_code response in
355355+ if status_code = 200 then
356356+ Requests.Response.body response |> Eio.Flow.read_all
357357+ else
358358+ failwith (Fmt.str "Asset fetch error: %d" status_code)
359359+360360+(** Create a new bookmark in Karakeep with optional tags *)
361361+let create_bookmark ~sw ~env ~api_key ~url ?title ?note ?tags ?(favourited=false) ?(archived=false) base_url =
362362+ let body_obj = [
363363+ ("type", `String "link");
364364+ ("url", `String url);
365365+ ("favourited", `Bool favourited);
366366+ ("archived", `Bool archived);
367367+ ] in
368368+369369+ let body_obj = match title with
370370+ | Some title_str -> ("title", `String title_str) :: body_obj
371371+ | None -> body_obj
372372+ in
373373+374374+ let body_obj = match note with
375375+ | Some note_str -> ("note", `String note_str) :: body_obj
376376+ | None -> body_obj
377377+ in
378378+379379+ let body_json = `O body_obj in
380380+ let body_str = J.to_string body_json in
381381+382382+ let headers = Requests.Headers.empty
383383+ |> Requests.Headers.set "Authorization" ("Bearer " ^ api_key)
384384+ |> Requests.Headers.set "Content-Type" "application/json"
385385+ in
386386+387387+ let url_endpoint = Fmt.str "%s/api/v1/bookmarks" base_url in
388388+ let body = Requests.Body.of_string Requests.Mime.json body_str in
389389+ let response = Requests.One.post ~sw ~clock:env#clock ~net:env#net ~headers ~body url_endpoint in
390390+391391+ let status_code = Requests.Response.status_code response in
392392+ if status_code = 201 || status_code = 200 then begin
393393+ let body_str = Requests.Response.body response |> Eio.Flow.read_all in
394394+ let json = J.from_string body_str in
395395+ let bookmark = parse_bookmark json in
396396+397397+ match tags with
398398+ | Some tag_list when tag_list <> [] ->
399399+ let tag_objects = List.map (fun tag_name ->
400400+ `O [("tagName", `String tag_name)]
401401+ ) tag_list in
402402+403403+ let tags_body = `O [("tags", `A tag_objects)] in
404404+ let tags_body_str = J.to_string tags_body in
405405+406406+ let tags_url = Fmt.str "%s/api/v1/bookmarks/%s/tags" base_url bookmark.id in
407407+ let tags_body = Requests.Body.of_string Requests.Mime.json tags_body_str in
408408+ let tags_response = Requests.One.post ~sw ~clock:env#clock ~net:env#net ~headers ~body:tags_body tags_url in
409409+410410+ let tags_status = Requests.Response.status_code tags_response in
411411+ if tags_status = 200 then
412412+ fetch_bookmark_details ~sw ~env ~api_key base_url bookmark.id
413413+ else
414414+ bookmark
415415+ | _ -> bookmark
416416+ end else begin
417417+ let error_body = Requests.Response.body response |> Eio.Flow.read_all in
418418+ failwith (Fmt.str "Failed to create bookmark. HTTP error: %d. Details: %s" status_code error_body)
419419+ end
420420+421421+(** Convert a Karakeep bookmark to Bushel.Link.t compatible structure *)
422422+let to_bushel_link ?base_url bookmark =
423423+ let description =
424424+ match bookmark.title with
425425+ | Some title when title <> "" -> title
426426+ | _ ->
427427+ let content_title = List.assoc_opt "title" bookmark.content in
428428+ match content_title with
429429+ | Some title when title <> "" && title <> "null" -> title
430430+ | _ -> bookmark.url
431431+ in
432432+ let date = Ptime.to_date bookmark.created_at in
433433+434434+ let metadata =
435435+ (match bookmark.summary with Some s -> [("summary", s)] | None -> []) @
436436+ (List.filter_map (fun (id, asset_type) ->
437437+ match asset_type with
438438+ | "screenshot" | "bannerImage" -> Some (asset_type, id)
439439+ | _ -> None
440440+ ) bookmark.assets) @
441441+ (List.filter_map (fun (k, v) ->
442442+ if k = "favicon" && v <> "" && v <> "null" then Some ("favicon", v) else None
443443+ ) bookmark.content)
444444+ in
445445+446446+ let karakeep =
447447+ match base_url with
448448+ | Some url ->
449449+ Some {
450450+ Bushel.Link.remote_url = url;
451451+ id = bookmark.id;
452452+ tags = bookmark.tags;
453453+ metadata = metadata;
454454+ }
455455+ | None -> None
456456+ in
457457+458458+ let bushel_slugs =
459459+ List.filter_map (fun tag ->
460460+ if String.starts_with ~prefix:"bushel:" tag then
461461+ Some (String.sub tag 7 (String.length tag - 7))
462462+ else
463463+ None
464464+ ) bookmark.tags
465465+ in
466466+467467+ let bushel =
468468+ if bushel_slugs = [] then None
469469+ else Some { Bushel.Link.slugs = bushel_slugs; tags = [] }
470470+ in
471471+472472+ { Bushel.Link.url = bookmark.url; date; description; karakeep; bushel }
+32
stack/karakeepe/karakeepe.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+version: "0.1.0"
44+synopsis: "Karakeep API client for OCaml using Eio"
55+description:
66+ "An Eio-based OCaml client library for the Karakeep bookmark management service API"
77+depends: [
88+ "dune" {>= "3.0"}
99+ "ocaml" {>= "4.14"}
1010+ "eio"
1111+ "eio_main" {>= "1.0"}
1212+ "requests"
1313+ "ezjsonm"
1414+ "fmt"
1515+ "ptime"
1616+ "uri"
1717+ "odoc" {with-doc}
1818+]
1919+build: [
2020+ ["dune" "subst"] {dev}
2121+ [
2222+ "dune"
2323+ "build"
2424+ "-p"
2525+ name
2626+ "-j"
2727+ jobs
2828+ "@install"
2929+ "@runtest" {with-test}
3030+ "@doc" {with-doc}
3131+ ]
3232+]
+39
stack/peertubee/README.md
···11+# PeerTubee - Eio-based PeerTube API Client
22+33+This is a port of the PeerTube library from Lwt/Cohttp to Eio/Requests.
44+55+## Changes from Original
66+77+- **Removed** `open Lwt.Infix`
88+- **Replaced** all `Lwt.t` return types with direct return types (direct-style)
99+- **Added** `~sw:Eio.Switch.t` and `~env:<...>` parameters to all public functions
1010+- **Replaced** `Cohttp_lwt_unix.Client.get` with `Requests.One.get`
1111+- **Replaced** `Cohttp_lwt.Body.to_string body >>= fun s ->` with `let s = Requests.Response.body response |> Eio.Flow.read_all in`
1212+- **Replaced** `>>=` (Lwt.bind) with direct let bindings
1313+- **Replaced** `Lwt.return` with direct values
1414+- **Replaced** `Lwt.return_ok`/`Lwt.return_error` with `Ok`/`Error`
1515+- **Replaced** `open_out_bin` with `Eio.Path.save` for file writing
1616+- **Used** `Requests.One.create` with `Eio.Stdenv.clock` and `Eio.Stdenv.net`
1717+1818+## Statistics
1919+2020+- Original: 191 lines
2121+- Ported: 188 lines
2222+- Functions: 8 public functions
2323+- All JSON parsing logic preserved
2424+- All type definitions preserved
2525+2626+## Files Created
2727+2828+- `/workspace/stack/bushel/peertubee/peertubee.ml` - Implementation (188 lines)
2929+- `/workspace/stack/bushel/peertubee/peertubee.mli` - Interface (69 lines)
3030+- `/workspace/stack/bushel/peertubee/dune` - Build configuration
3131+- `/workspace/stack/bushel/peertubee.opam` - Package metadata
3232+3333+## Dependencies
3434+3535+- ezjsonm - JSON parsing
3636+- eio + eio.core - Effects-based concurrency
3737+- requests - HTTP client
3838+- ptime - Time handling
3939+- fmt - Formatted output
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+version: "0.1.0"
44+synopsis: "PeerTube API client for OCaml using Eio"
55+description:
66+ "An Eio-based OCaml client library for PeerTube video platform instances"
77+depends: [
88+ "dune" {>= "3.0"}
99+ "ocaml" {>= "4.14"}
1010+ "eio"
1111+ "eio_main" {>= "1.0"}
1212+ "requests"
1313+ "ezjsonm"
1414+ "fmt"
1515+ "ptime"
1616+ "odoc" {with-doc}
1717+]
1818+build: [
1919+ ["dune" "subst"] {dev}
2020+ [
2121+ "dune"
2222+ "build"
2323+ "-p"
2424+ name
2525+ "-j"
2626+ jobs
2727+ "@install"
2828+ "@runtest" {with-test}
2929+ "@doc" {with-doc}
3030+ ]
3131+]
+5-3
stack/requests/bin/ocurl.ml
···229229 ~follow_redirects ~max_redirects ?timeout:timeout_obj env in
230230231231 (* Set authentication if provided *)
232232- (match auth with
232232+ let req = match auth with
233233 | Some auth_str ->
234234 (match parse_auth auth_str with
235235 | Some (user, pass) ->
236236 Requests.set_auth req
237237 (Requests.Auth.basic ~username:user ~password:pass)
238238 | None ->
239239- Logs.warn (fun m -> m "Invalid auth format, ignoring"))
240240- | None -> ());
239239+ Logs.warn (fun m -> m "Invalid auth format, ignoring");
240240+ req)
241241+ | None -> req
242242+ in
241243242244 (* Build headers from command line *)
243245 let cmd_headers = List.fold_left (fun hdrs header_str ->
+112-167
stack/requests/lib/one.ml
···11let src = Logs.Src.create "requests.one" ~doc:"One-shot HTTP Requests"
22module Log = (val Logs.src_log src : Logs.LOG)
3344-type ('clock,'net) t = {
55- clock : 'clock;
66- net : 'net;
77- default_headers : Headers.t;
88- timeout : Timeout.t;
99- max_retries : int;
1010- retry_backoff : float;
1111- verify_tls : bool;
1212- tls_config : Tls.Config.client option;
1313- http_pool : ('clock, 'net) Conpool.t; (* For HTTP connections *)
1414- https_pool : ('clock, 'net) Conpool.t; (* For HTTPS connections *)
1515-}
44+(* Helper to create TCP connection to host:port *)
55+let connect_tcp ~sw ~net ~host ~port =
66+ Log.debug (fun m -> m "Connecting to %s:%d" host port);
77+ (* Resolve hostname to IP address *)
88+ let addrs = Eio.Net.getaddrinfo_stream net host ~service:(string_of_int port) in
99+ match addrs with
1010+ | addr :: _ ->
1111+ Log.debug (fun m -> m "Resolved %s, connecting..." host);
1212+ Eio.Net.connect ~sw net addr
1313+ | [] ->
1414+ let msg = Printf.sprintf "Failed to resolve hostname: %s" host in
1515+ Log.err (fun m -> m "%s" msg);
1616+ failwith msg
16171717-let create
1818- ~sw
1919- ?(default_headers = Headers.empty)
2020- ?(timeout = Timeout.default)
2121- ?(max_retries = 3)
2222- ?(retry_backoff = 2.0)
2323- ?(verify_tls = true)
2424- ?tls_config
2525- ?(max_connections_per_host = 10)
2626- ?(connection_idle_timeout = 60.0)
2727- ?(connection_lifetime = 300.0)
2828- ~clock
2929- ~net
3030- () =
3131- (* Create default TLS config if verify_tls is true and no custom config provided *)
3232- let tls_config =
3333- match tls_config, verify_tls with
3434- | Some config, _ -> Some config
1818+(* Helper to wrap connection with TLS if needed *)
1919+let wrap_tls flow ~host ~verify_tls ~tls_config =
2020+ Log.debug (fun m -> m "Wrapping connection with TLS for %s (verify=%b)" host verify_tls);
2121+2222+ (* Get or create TLS config *)
2323+ let tls_cfg = match tls_config, verify_tls with
2424+ | Some cfg, _ -> cfg
3525 | None, true ->
3626 (* Use CA certificates for verification *)
3727 (match Ca_certs.authenticator () with
3828 | Ok authenticator ->
3929 (match Tls.Config.client ~authenticator () with
4040- | Ok cfg -> Some cfg
3030+ | Ok cfg -> cfg
4131 | Error (`Msg msg) ->
4242- Log.warn (fun m -> m "Failed to create TLS config: %s" msg);
4343- None)
3232+ Log.err (fun m -> m "Failed to create TLS config: %s" msg);
3333+ failwith ("TLS config error: " ^ msg))
4434 | Error (`Msg msg) ->
4545- Log.warn (fun m -> m "Failed to load CA certificates: %s" msg);
4646- None)
4747- | None, false -> None
3535+ Log.err (fun m -> m "Failed to load CA certificates: %s" msg);
3636+ failwith ("CA certificates error: " ^ msg))
3737+ | None, false ->
3838+ (* No verification *)
3939+ match Tls.Config.client ~authenticator:(fun ?ip:_ ~host:_ _ -> Ok None) () with
4040+ | Ok cfg -> cfg
4141+ | Error (`Msg msg) -> failwith ("TLS config error: " ^ msg)
4842 in
49435050- (* Create connection pools - one for HTTP, one for HTTPS *)
5151- let pool_config = Conpool.Config.make
5252- ~max_connections_per_endpoint:max_connections_per_host
5353- ~max_idle_time:connection_idle_timeout
5454- ~max_connection_lifetime:connection_lifetime
5555- ()
4444+ (* Get domain name for SNI *)
4545+ let domain = match Domain_name.of_string host with
4646+ | Ok dn -> (match Domain_name.host dn with
4747+ | Ok d -> d
4848+ | Error (`Msg msg) ->
4949+ Log.err (fun m -> m "Invalid hostname for TLS: %s (%s)" host msg);
5050+ failwith ("Invalid hostname: " ^ msg))
5151+ | Error (`Msg msg) ->
5252+ Log.err (fun m -> m "Invalid hostname for TLS: %s (%s)" host msg);
5353+ failwith ("Invalid hostname: " ^ msg)
5654 in
57555858- (* HTTP pool - plain TCP connections *)
5959- let http_pool = Conpool.create
6060- ~sw
6161- ~net
6262- ~clock
6363- ~config:pool_config
6464- ()
6565- in
5656+ (Tls_eio.client_of_flow ~host:domain tls_cfg flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t)
66576767- (* HTTPS pool - TLS-wrapped connections *)
6868- let https_tls_config = Option.map (fun cfg ->
6969- Conpool.Tls_config.make ~config:cfg ()
7070- ) tls_config in
5858+(* Parse URL and connect directly (no pooling) *)
5959+let connect_to_url ~sw ~clock ~net ~url ~timeout ~verify_tls ~tls_config =
6060+ let uri = Uri.of_string url in
71617272- let https_pool = Conpool.create
7373- ~sw
7474- ~net
7575- ~clock
7676- ?tls:https_tls_config
7777- ~config:pool_config
7878- ()
6262+ (* Extract host and port *)
6363+ let host = match Uri.host uri with
6464+ | Some h -> h
6565+ | None -> failwith ("URL must contain a host: " ^ url)
7966 in
80678181- Log.info (fun m -> m "Created HTTP client with connection pools (max_per_host=%d, TLS=%b)"
8282- max_connections_per_host (Option.is_some https_tls_config));
6868+ let is_https = Uri.scheme uri = Some "https" in
6969+ let default_port = if is_https then 443 else 80 in
7070+ let port = Option.value (Uri.port uri) ~default:default_port in
83718484- {
8585- clock;
8686- net;
8787- default_headers;
8888- timeout;
8989- max_retries;
9090- retry_backoff;
9191- verify_tls;
9292- tls_config;
9393- http_pool;
9494- https_pool;
9595- }
7272+ (* Apply connection timeout if specified *)
7373+ let connect_fn () =
7474+ let tcp_flow = connect_tcp ~sw ~net ~host ~port in
7575+ if is_https then
7676+ wrap_tls tcp_flow ~host ~verify_tls ~tls_config
7777+ else
7878+ (tcp_flow :> [`Close | `Flow | `R | `Shutdown | `W] Eio.Resource.t)
7979+ in
96809797-let default ~sw ~clock ~net =
9898- create ~sw ~clock ~net ()
8181+ match timeout with
8282+ | Some t ->
8383+ let timeout_seconds = Timeout.total t in
8484+ (match timeout_seconds with
8585+ | Some seconds ->
8686+ Log.debug (fun m -> m "Setting connection timeout: %.2f seconds" seconds);
8787+ Eio.Time.with_timeout_exn clock seconds connect_fn
8888+ | None -> connect_fn ())
8989+ | None -> connect_fn ()
9990100100-(* Accessors *)
101101-let clock t = t.clock
102102-let net t = t.net
103103-let default_headers t = t.default_headers
104104-let timeout t = t.timeout
105105-let max_retries t = t.max_retries
106106-let retry_backoff t = t.retry_backoff
107107-let verify_tls t = t.verify_tls
108108-let tls_config t = t.tls_config
9191+(* Main request implementation - completely stateless *)
9292+let request ~sw ~clock ~net ?headers ?body ?auth ?timeout
9393+ ?(follow_redirects = true) ?(max_redirects = 10)
9494+ ?(verify_tls = true) ?tls_config ~method_ url =
10995110110-(* HTTP Request Methods *)
111111-112112-(* Helper to get client or use default *)
113113-let get_client client =
114114- match client with
115115- | Some c -> c
116116- | None -> failwith "No client provided"
117117-118118-(* Main request implementation with connection pooling *)
119119-let request ~sw ?client ?headers ?body ?auth ?timeout ?follow_redirects
120120- ?max_redirects ~method_ url =
121121- let client = get_client client in
12296 let start_time = Unix.gettimeofday () in
123123-12497 let method_str = Method.to_string method_ in
12598 Log.debug (fun m -> m "[One] Executing %s request to %s" method_str url);
12699127100 (* Prepare headers *)
128128- let headers = match headers with
129129- | Some h -> h
130130- | None -> default_headers client
131131- in
101101+ let headers = Option.value headers ~default:Headers.empty in
132102133103 (* Apply auth *)
134104 let headers = match auth with
···152122 | Some b -> Body.Private.to_string b
153123 in
154124155155- (* Execute request with pooled connection *)
125125+ (* Execute request with redirects *)
156126 let rec make_with_redirects url_to_fetch redirects_left =
157127 let uri_to_fetch = Uri.of_string url_to_fetch in
158128159159- (* Parse the redirect URL to get correct host and port *)
160160- let redirect_host = match Uri.host uri_to_fetch with
161161- | Some h -> h
162162- | None -> failwith "Redirect URL must contain a host"
163163- in
164164- let redirect_port = match Uri.scheme uri_to_fetch, Uri.port uri_to_fetch with
165165- | Some "https", None -> 443
166166- | Some "https", Some p -> p
167167- | Some "http", None -> 80
168168- | Some "http", Some p -> p
169169- | _, Some p -> p
170170- | _ -> 80
171171- in
129129+ (* Connect to URL (opens new TCP connection) *)
130130+ let flow = connect_to_url ~sw ~clock ~net ~url:url_to_fetch
131131+ ~timeout ~verify_tls ~tls_config in
172132173173- (* Create endpoint for this specific URL *)
174174- let endpoint = Conpool.Endpoint.make ~host:redirect_host ~port:redirect_port in
175175-176176- (* Determine if we need TLS based on this URL's scheme *)
177177- let is_https = match Uri.scheme uri_to_fetch with
178178- | Some "https" -> true
179179- | _ -> false
180180- in
181181-182182- (* Choose the appropriate connection pool for this URL *)
183183- let pool = if is_https then client.https_pool else client.http_pool in
184184-185185- let make_request_fn () =
186186- Conpool.with_connection pool endpoint (fun flow ->
187187- (* Flow is already TLS-wrapped if from https_pool, plain TCP if from http_pool *)
188188- (* Use our low-level HTTP client *)
189189- Http_client.make_request ~method_:method_str ~uri:uri_to_fetch ~headers ~body_str:request_body_str flow
190190- )
191191- in
192192-193193- (* Apply timeout if specified *)
133133+ (* Make HTTP request using low-level client *)
194134 let status, resp_headers, response_body_str =
195195- match timeout with
196196- | Some t ->
197197- let timeout_seconds = Timeout.total t in
198198- (match timeout_seconds with
199199- | Some seconds ->
200200- Log.debug (fun m -> m "Setting timeout: %.2f seconds" seconds);
201201- Eio.Time.with_timeout_exn (clock client) seconds make_request_fn
202202- | None -> make_request_fn ())
203203- | None -> make_request_fn ()
135135+ Http_client.make_request ~method_:method_str ~uri:uri_to_fetch
136136+ ~headers ~body_str:request_body_str flow
204137 in
205138206139 Log.info (fun m -> m "Received response: status=%d" status);
207140208141 (* Handle redirects if enabled *)
209209- let follow_redirects = Option.value follow_redirects ~default:true in
210142 if follow_redirects && (status >= 300 && status < 400) then begin
211143 if redirects_left <= 0 then begin
212212- let max_redirects = Option.value max_redirects ~default:10 in
213144 Log.err (fun m -> m "Too many redirects (%d) for %s" max_redirects url);
214145 raise (Error.TooManyRedirects { url; count = max_redirects; max = max_redirects })
215146 end;
···225156 (status, resp_headers, response_body_str, url_to_fetch)
226157 in
227158228228- let max_redirects = Option.value max_redirects ~default:10 in
229159 let final_status, final_headers, final_body_str, final_url =
230160 make_with_redirects url max_redirects
231161 in
···245175 ~elapsed
246176247177(* Convenience methods *)
248248-let get ~sw ?client ?headers ?auth ?timeout ?follow_redirects ?max_redirects url =
249249- request ~sw ?client ?headers ?auth ?timeout ?follow_redirects ?max_redirects
178178+let get ~sw ~clock ~net ?headers ?auth ?timeout
179179+ ?follow_redirects ?max_redirects ?verify_tls ?tls_config url =
180180+ request ~sw ~clock ~net ?headers ?auth ?timeout
181181+ ?follow_redirects ?max_redirects ?verify_tls ?tls_config
250182 ~method_:`GET url
251183252252-let post ~sw ?client ?headers ?body ?auth ?timeout url =
253253- request ~sw ?client ?headers ?body ?auth ?timeout ~method_:`POST url
184184+let post ~sw ~clock ~net ?headers ?body ?auth ?timeout
185185+ ?verify_tls ?tls_config url =
186186+ request ~sw ~clock ~net ?headers ?body ?auth ?timeout
187187+ ?verify_tls ?tls_config ~method_:`POST url
254188255255-let put ~sw ?client ?headers ?body ?auth ?timeout url =
256256- request ~sw ?client ?headers ?body ?auth ?timeout ~method_:`PUT url
189189+let put ~sw ~clock ~net ?headers ?body ?auth ?timeout
190190+ ?verify_tls ?tls_config url =
191191+ request ~sw ~clock ~net ?headers ?body ?auth ?timeout
192192+ ?verify_tls ?tls_config ~method_:`PUT url
257193258258-let delete ~sw ?client ?headers ?auth ?timeout url =
259259- request ~sw ?client ?headers ?auth ?timeout ~method_:`DELETE url
194194+let delete ~sw ~clock ~net ?headers ?auth ?timeout
195195+ ?verify_tls ?tls_config url =
196196+ request ~sw ~clock ~net ?headers ?auth ?timeout
197197+ ?verify_tls ?tls_config ~method_:`DELETE url
260198261261-let head ~sw ?client ?headers ?auth ?timeout url =
262262- request ~sw ?client ?headers ?auth ?timeout ~method_:`HEAD url
199199+let head ~sw ~clock ~net ?headers ?auth ?timeout
200200+ ?verify_tls ?tls_config url =
201201+ request ~sw ~clock ~net ?headers ?auth ?timeout
202202+ ?verify_tls ?tls_config ~method_:`HEAD url
263203264264-let patch ~sw ?client ?headers ?body ?auth ?timeout url =
265265- request ~sw ?client ?headers ?body ?auth ?timeout ~method_:`PATCH url
204204+let patch ~sw ~clock ~net ?headers ?body ?auth ?timeout
205205+ ?verify_tls ?tls_config url =
206206+ request ~sw ~clock ~net ?headers ?body ?auth ?timeout
207207+ ?verify_tls ?tls_config ~method_:`PATCH url
266208267267-let upload ~sw ?client ?headers ?auth ?timeout ?method_ ?mime ?length
268268- ?on_progress ~source url =
209209+let upload ~sw ~clock ~net ?headers ?auth ?timeout ?method_ ?mime ?length
210210+ ?on_progress ?verify_tls ?tls_config ~source url =
269211 let method_ = Option.value method_ ~default:`POST in
270212 let mime = Option.value mime ~default:Mime.octet_stream in
271213···281223 in
282224283225 let body = Body.of_stream ?length mime tracked_source in
284284- request ~sw ?client ?headers ~body ?auth ?timeout ~method_ url
226226+ request ~sw ~clock ~net ?headers ~body ?auth ?timeout
227227+ ?verify_tls ?tls_config ~method_ url
285228286286-let download ~sw ?client ?headers ?auth ?timeout ?on_progress url ~sink =
287287- let response = get ~sw ?client ?headers ?auth ?timeout url in
229229+let download ~sw ~clock ~net ?headers ?auth ?timeout ?on_progress
230230+ ?verify_tls ?tls_config url ~sink =
231231+ let response = get ~sw ~clock ~net ?headers ?auth ?timeout
232232+ ?verify_tls ?tls_config url in
288233289234 try
290235 (* Get content length for progress tracking *)
+80-94
stack/requests/lib/one.mli
···11(** One-shot HTTP client for stateless requests
2233 The One module provides a stateless HTTP client for single requests without
44- session state like cookies or persistent configuration. For stateful requests
55- with automatic cookie handling and persistent configuration, use the main
66- {!Requests} module instead.
44+ session state like cookies, connection pooling, or persistent configuration.
55+ Each request opens a new connection that is closed after use.
66+77+ For stateful requests with automatic cookie handling, connection pooling,
88+ and persistent configuration, use the main {!Requests} module instead.
79810 {2 Examples}
911···12141315 let () = run @@ fun env ->
1416 Switch.run @@ fun sw ->
1515-1616- (* Create a one-shot client *)
1717- let client = One.create ~clock:env#clock ~net:env#net () in
18171918 (* Simple GET request *)
2020- let response = One.get ~sw ~client "https://example.com" in
1919+ let response = One.get ~sw
2020+ ~clock:env#clock ~net:env#net
2121+ "https://example.com" in
2122 Printf.printf "Status: %d\n" (Response.status_code response);
2223 Response.close response;
23242425 (* POST with JSON body *)
2525- let response = One.post ~sw ~client
2626+ let response = One.post ~sw
2727+ ~clock:env#clock ~net:env#net
2628 ~body:(Body.json {|{"key": "value"}|})
2729 ~headers:(Headers.empty |> Headers.content_type Mime.json)
2830 "https://api.example.com/data" in
2931 Response.close response;
30323133 (* Download file with streaming *)
3232- One.download ~sw ~client
3434+ One.download ~sw
3535+ ~clock:env#clock ~net:env#net
3336 "https://example.com/large-file.zip"
3437 ~sink:(Eio.Path.(fs / "download.zip" |> sink))
3538 ]}
···3841(** Log source for one-shot request operations *)
3942val src : Logs.Src.t
40434141-type ('a,'b) t
4242-(** One-shot client configuration with clock and network types.
4343- The type parameters track the Eio environment capabilities. *)
4444-4545-(** {1 Creation} *)
4646-4747-val create :
4848- sw:Eio.Switch.t ->
4949- ?default_headers:Headers.t ->
5050- ?timeout:Timeout.t ->
5151- ?max_retries:int ->
5252- ?retry_backoff:float ->
5353- ?verify_tls:bool ->
5454- ?tls_config:Tls.Config.client ->
5555- ?max_connections_per_host:int ->
5656- ?connection_idle_timeout:float ->
5757- ?connection_lifetime:float ->
5858- clock:'a Eio.Time.clock ->
5959- net:'b Eio.Net.t ->
6060- unit -> ('a Eio.Time.clock, 'b Eio.Net.t) t
6161-(** [create ~sw ?default_headers ?timeout ?max_retries ?retry_backoff ?verify_tls ?tls_config ~clock ~net ()]
6262- creates a new HTTP client with connection pooling.
6363-6464- @param sw Switch for resource management (client bound to this switch)
6565- @param default_headers Headers to include in every request (default: empty)
6666- @param timeout Default timeout configuration (default: 30s connect, 60s read)
6767- @param max_retries Maximum number of retries for failed requests (default: 3)
6868- @param retry_backoff Exponential backoff factor for retries (default: 2.0)
6969- @param verify_tls Whether to verify TLS certificates (default: true)
7070- @param tls_config Custom TLS configuration (default: uses system CA certificates)
7171- @param max_connections_per_host Maximum pooled connections per host:port (default: 10)
7272- @param connection_idle_timeout Max idle time before closing pooled connection (default: 60s)
7373- @param connection_lifetime Max lifetime of any pooled connection (default: 300s)
7474- @param clock Eio clock for timeouts and scheduling
7575- @param net Eio network capability for making connections
7676-*)
7777-7878-val default : sw:Eio.Switch.t -> clock:'a Eio.Time.clock -> net:'b Eio.Net.t -> ('a Eio.Time.clock, 'b Eio.Net.t) t
7979-(** [default ~sw ~clock ~net] creates a client with default configuration.
8080- Equivalent to [create ~sw ~clock ~net ()]. *)
8181-8282-(** {1 Configuration Access} *)
8383-8484-val clock : ('a,'b) t -> 'a
8585-(** [clock client] returns the clock capability. *)
8686-8787-val net : ('a,'b) t -> 'b
8888-(** [net client] returns the network capability. *)
8989-9090-val default_headers : ('a,'b) t -> Headers.t
9191-(** [default_headers client] returns the default headers. *)
9292-9393-val timeout : ('a,'b) t -> Timeout.t
9494-(** [timeout client] returns the timeout configuration. *)
9595-9696-val max_retries : ('a,'b) t -> int
9797-(** [max_retries client] returns the maximum retry count. *)
9898-9999-val retry_backoff : ('a,'b) t -> float
100100-(** [retry_backoff client] returns the retry backoff factor. *)
101101-102102-val verify_tls : ('a,'b) t -> bool
103103-(** [verify_tls client] returns whether TLS verification is enabled. *)
104104-105105-val tls_config : ('a,'b) t -> Tls.Config.client option
106106-(** [tls_config client] returns the TLS configuration if set. *)
4444+(** {1 HTTP Request Methods}
10745108108-(** {1 HTTP Request Methods} *)
4646+ All functions are stateless - they open a new TCP connection for each request
4747+ and close it when the switch closes. No connection pooling or reuse. *)
1094811049val request :
11150 sw:Eio.Switch.t ->
112112- ?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
5151+ clock:_ Eio.Time.clock ->
5252+ net:_ Eio.Net.t ->
11353 ?headers:Headers.t ->
11454 ?body:Body.t ->
11555 ?auth:Auth.t ->
11656 ?timeout:Timeout.t ->
11757 ?follow_redirects:bool ->
11858 ?max_redirects:int ->
5959+ ?verify_tls:bool ->
6060+ ?tls_config:Tls.Config.client ->
11961 method_:Method.t ->
12062 string ->
12163 Response.t
122122-(** Make a streaming request *)
6464+(** [request ~sw ~clock ~net ?headers ?body ?auth ?timeout ?follow_redirects
6565+ ?max_redirects ?verify_tls ?tls_config ~method_ url] makes a single HTTP
6666+ request without connection pooling.
6767+6868+ Each call opens a new TCP connection (with TLS if https://), makes the
6969+ request, and closes the connection when the switch closes.
7070+7171+ @param sw Switch for resource management (response/connection bound to this)
7272+ @param clock Clock for timeouts
7373+ @param net Network interface for TCP connections
7474+ @param headers Request headers (default: empty)
7575+ @param body Request body (default: none)
7676+ @param auth Authentication to apply (default: none)
7777+ @param timeout Request timeout (default: 30s connect, 60s read)
7878+ @param follow_redirects Whether to follow HTTP redirects (default: true)
7979+ @param max_redirects Maximum redirects to follow (default: 10)
8080+ @param verify_tls Whether to verify TLS certificates (default: true)
8181+ @param tls_config Custom TLS configuration (default: system CA certs)
8282+ @param method_ HTTP method (GET, POST, etc.)
8383+ @param url URL to request
8484+*)
1238512486val get :
12587 sw:Eio.Switch.t ->
126126- ?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
8888+ clock:_ Eio.Time.clock ->
8989+ net:_ Eio.Net.t ->
12790 ?headers:Headers.t ->
12891 ?auth:Auth.t ->
12992 ?timeout:Timeout.t ->
13093 ?follow_redirects:bool ->
13194 ?max_redirects:int ->
9595+ ?verify_tls:bool ->
9696+ ?tls_config:Tls.Config.client ->
13297 string ->
13398 Response.t
134134-(** GET request *)
9999+(** GET request. See {!request} for parameter details. *)
135100136101val post :
137102 sw:Eio.Switch.t ->
138138- ?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
103103+ clock:_ Eio.Time.clock ->
104104+ net:_ Eio.Net.t ->
139105 ?headers:Headers.t ->
140106 ?body:Body.t ->
141107 ?auth:Auth.t ->
142108 ?timeout:Timeout.t ->
109109+ ?verify_tls:bool ->
110110+ ?tls_config:Tls.Config.client ->
143111 string ->
144112 Response.t
145145-(** POST request *)
113113+(** POST request. See {!request} for parameter details. *)
146114147115val put :
148116 sw:Eio.Switch.t ->
149149- ?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
117117+ clock:_ Eio.Time.clock ->
118118+ net:_ Eio.Net.t ->
150119 ?headers:Headers.t ->
151120 ?body:Body.t ->
152121 ?auth:Auth.t ->
153122 ?timeout:Timeout.t ->
123123+ ?verify_tls:bool ->
124124+ ?tls_config:Tls.Config.client ->
154125 string ->
155126 Response.t
156156-(** PUT request *)
127127+(** PUT request. See {!request} for parameter details. *)
157128158129val delete :
159130 sw:Eio.Switch.t ->
160160- ?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
131131+ clock:_ Eio.Time.clock ->
132132+ net:_ Eio.Net.t ->
161133 ?headers:Headers.t ->
162134 ?auth:Auth.t ->
163135 ?timeout:Timeout.t ->
136136+ ?verify_tls:bool ->
137137+ ?tls_config:Tls.Config.client ->
164138 string ->
165139 Response.t
166166-(** DELETE request *)
140140+(** DELETE request. See {!request} for parameter details. *)
167141168142val head :
169143 sw:Eio.Switch.t ->
170170- ?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
144144+ clock:_ Eio.Time.clock ->
145145+ net:_ Eio.Net.t ->
171146 ?headers:Headers.t ->
172147 ?auth:Auth.t ->
173148 ?timeout:Timeout.t ->
149149+ ?verify_tls:bool ->
150150+ ?tls_config:Tls.Config.client ->
174151 string ->
175152 Response.t
176176-(** HEAD request *)
153153+(** HEAD request. See {!request} for parameter details. *)
177154178155val patch :
179156 sw:Eio.Switch.t ->
180180- ?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
157157+ clock:_ Eio.Time.clock ->
158158+ net:_ Eio.Net.t ->
181159 ?headers:Headers.t ->
182160 ?body:Body.t ->
183161 ?auth:Auth.t ->
184162 ?timeout:Timeout.t ->
163163+ ?verify_tls:bool ->
164164+ ?tls_config:Tls.Config.client ->
185165 string ->
186166 Response.t
187187-(** PATCH request *)
167167+(** PATCH request. See {!request} for parameter details. *)
188168189169val upload :
190170 sw:Eio.Switch.t ->
191191- ?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
171171+ clock:_ Eio.Time.clock ->
172172+ net:_ Eio.Net.t ->
192173 ?headers:Headers.t ->
193174 ?auth:Auth.t ->
194175 ?timeout:Timeout.t ->
···196177 ?mime:Mime.t ->
197178 ?length:int64 ->
198179 ?on_progress:(sent:int64 -> total:int64 option -> unit) ->
180180+ ?verify_tls:bool ->
181181+ ?tls_config:Tls.Config.client ->
199182 source:Eio.Flow.source_ty Eio.Resource.t ->
200183 string ->
201184 Response.t
202202-(** Upload from stream *)
185185+(** Upload from stream. See {!request} for parameter details. *)
203186204187val download :
205188 sw:Eio.Switch.t ->
206206- ?client:(_ Eio.Time.clock , _ Eio.Net.t) t ->
189189+ clock:_ Eio.Time.clock ->
190190+ net:_ Eio.Net.t ->
207191 ?headers:Headers.t ->
208192 ?auth:Auth.t ->
209193 ?timeout:Timeout.t ->
210194 ?on_progress:(received:int64 -> total:int64 option -> unit) ->
195195+ ?verify_tls:bool ->
196196+ ?tls_config:Tls.Config.client ->
211197 string ->
212198 sink:Eio.Flow.sink_ty Eio.Resource.t ->
213199 unit
214214-(** Download to stream *)
200200+(** Download to stream. See {!request} for parameter details. *)
+239-80
stack/requests/lib/requests.ml
···2121 We don't call use_default() here as it spawns background threads
2222 that are incompatible with Eio's structured concurrency. *)
23232424-(* Main API - Session functionality with concurrent fiber spawning *)
2424+(* Main API - Session functionality with connection pooling *)
25252626type ('clock, 'net) t = {
2727 sw : Eio.Switch.t;
2828- client : ('clock, 'net) One.t;
2928 clock : 'clock;
2929+ net : 'net;
3030+3131+ (* Connection pools owned by this session *)
3232+ http_pool : ('clock, 'net) Conpool.t;
3333+ https_pool : ('clock, 'net) Conpool.t;
3434+3535+ (* Session state - immutable *)
3036 cookie_jar : Cookeio.jar;
3137 cookie_mutex : Eio.Mutex.t;
3232- mutable default_headers : Headers.t;
3333- mutable auth : Auth.t option;
3434- mutable timeout : Timeout.t;
3535- mutable follow_redirects : bool;
3636- mutable max_redirects : int;
3737- mutable retry : Retry.config option;
3838+ default_headers : Headers.t;
3939+ auth : Auth.t option;
4040+ timeout : Timeout.t;
4141+ follow_redirects : bool;
4242+ max_redirects : int;
4343+ verify_tls : bool;
4444+ tls_config : Tls.Config.client option;
4545+ retry : Retry.config option;
3846 persist_cookies : bool;
3947 xdg : Xdge.t option;
4048 cache : Cache.t option;
4141- (* Statistics *)
4949+5050+ (* Statistics - mutable for tracking across all derived sessions *)
4251 mutable requests_made : int;
4352 mutable total_time : float;
4453 mutable retries_count : int;
···46554756let create
4857 ~sw
4949- ?client
5858+ ?http_pool
5959+ ?https_pool
5060 ?cookie_jar
5161 ?(default_headers = Headers.empty)
5262 ?auth
···5464 ?(follow_redirects = true)
5565 ?(max_redirects = 10)
5666 ?(verify_tls = true)
6767+ ?tls_config
6868+ ?(max_connections_per_host = 10)
6969+ ?(connection_idle_timeout = 60.0)
7070+ ?(connection_lifetime = 300.0)
5771 ?retry
5872 ?(persist_cookies = false)
5973 ?(enable_cache = false)
6074 ?xdg
6175 env =
62767777+ let clock = env#clock in
7878+ let net = env#net in
7979+6380 let xdg = match xdg, persist_cookies || enable_cache with
6481 | Some x, _ -> Some x
6582 | None, true -> Some (Xdge.create env#fs "requests")
6683 | None, false -> None
6784 in
68856969- let client = match client with
7070- | Some c -> c
8686+ (* Create TLS config for HTTPS pool if needed *)
8787+ let tls_config = match tls_config, verify_tls with
8888+ | Some cfg, _ -> Some cfg
8989+ | None, true ->
9090+ (* Use CA certificates for verification *)
9191+ (match Ca_certs.authenticator () with
9292+ | Ok authenticator ->
9393+ (match Tls.Config.client ~authenticator () with
9494+ | Ok cfg -> Some cfg
9595+ | Error (`Msg msg) ->
9696+ Log.warn (fun m -> m "Failed to create TLS config: %s" msg);
9797+ None)
9898+ | Error (`Msg msg) ->
9999+ Log.warn (fun m -> m "Failed to load CA certificates: %s" msg);
100100+ None)
101101+ | None, false -> None
102102+ in
103103+104104+ (* Create connection pools if not provided *)
105105+ let pool_config = Conpool.Config.make
106106+ ~max_connections_per_endpoint:max_connections_per_host
107107+ ~max_idle_time:connection_idle_timeout
108108+ ~max_connection_lifetime:connection_lifetime
109109+ ()
110110+ in
111111+112112+ (* HTTP pool - plain TCP connections *)
113113+ let http_pool = match http_pool with
114114+ | Some p -> p
71115 | None ->
7272- One.create ~sw ~verify_tls ~timeout
7373- ~clock:env#clock ~net:env#net ()
116116+ Conpool.create ~sw ~net ~clock ~config:pool_config ()
74117 in
75118119119+ (* HTTPS pool - TLS-wrapped connections *)
120120+ let https_pool = match https_pool with
121121+ | Some p -> p
122122+ | None ->
123123+ let https_tls_config = Option.map (fun cfg ->
124124+ Conpool.Tls_config.make ~config:cfg ()
125125+ ) tls_config in
126126+ Conpool.create ~sw ~net ~clock ?tls:https_tls_config ~config:pool_config ()
127127+ in
128128+129129+ Log.info (fun m -> m "Created Requests session with connection pools (max_per_host=%d, TLS=%b)"
130130+ max_connections_per_host (Option.is_some tls_config));
131131+76132 let cookie_jar = match cookie_jar, persist_cookies, xdg with
77133 | Some jar, _, _ -> jar
78134 | None, true, Some xdg_ctx ->
···9515196152 {
97153 sw;
9898- client;
9999- clock = env#clock;
154154+ clock;
155155+ net;
156156+ http_pool;
157157+ https_pool;
100158 cookie_jar;
101159 cookie_mutex = Eio.Mutex.create ();
102160 default_headers;
···104162 timeout;
105163 follow_redirects;
106164 max_redirects;
165165+ verify_tls;
166166+ tls_config;
107167 retry;
108168 persist_cookies;
109169 xdg;
···113173 retries_count = 0;
114174 }
115175116116-(* Configuration management *)
117176let set_default_header t key value =
118118- t.default_headers <- Headers.set key value t.default_headers
177177+ { t with default_headers = Headers.set key value t.default_headers }
119178120179let remove_default_header t key =
121121- t.default_headers <- Headers.remove key t.default_headers
180180+ { t with default_headers = Headers.remove key t.default_headers }
122181123182let set_auth t auth =
124183 Log.debug (fun m -> m "Setting authentication method");
125125- t.auth <- Some auth
184184+ { t with auth = Some auth }
126185127186let clear_auth t =
128187 Log.debug (fun m -> m "Clearing authentication");
129129- t.auth <- None
188188+ { t with auth = None }
130189131190let set_timeout t timeout =
132191 Log.debug (fun m -> m "Setting timeout: %a" Timeout.pp timeout);
133133- t.timeout <- timeout
192192+ { t with timeout }
134193135194let set_retry t config =
136195 Log.debug (fun m -> m "Setting retry config: max_retries=%d" config.Retry.max_retries);
137137- t.retry <- Some config
196196+ { t with retry = Some config }
138197139198let cookies t = t.cookie_jar
140199let clear_cookies t = Cookeio.clear t.cookie_jar
141200142142-(* Internal request function that runs in a fiber *)
201201+(* Internal request function using connection pools *)
143202let make_request_internal t ?headers ?body ?auth ?timeout ?follow_redirects ?max_redirects ~method_ url =
144144- Log.info (fun m -> m "Making %s request to %s" (Method.to_string method_) url);
203203+ let start_time = Unix.gettimeofday () in
204204+ let method_str = Method.to_string method_ in
205205+206206+ Log.info (fun m -> m "Making %s request to %s" method_str url);
207207+208208+ (* Parse URL *)
209209+ let uri = Uri.of_string url in
210210+ let domain = Option.value ~default:"" (Uri.host uri) in
211211+ let path = Uri.path uri in
212212+ let is_secure = Uri.scheme uri = Some "https" in
213213+145214 (* Merge headers *)
146215 let headers = match headers with
147216 | Some h -> Headers.merge t.default_headers h
···154223 | None -> t.auth
155224 in
156225157157- (* Get cookies for this URL *)
158158- let uri = Uri.of_string url in
159159- let domain = Option.value ~default:"" (Uri.host uri) in
160160- let path = Uri.path uri in
161161- let is_secure = Uri.scheme uri = Some "https" in
226226+ (* Apply auth *)
227227+ let headers = match auth with
228228+ | Some a ->
229229+ Log.debug (fun m -> m "Applying authentication");
230230+ Auth.apply a headers
231231+ | None -> headers
232232+ in
162233234234+ (* Add content type from body *)
235235+ let headers = match body with
236236+ | Some b -> (match Body.content_type b with
237237+ | Some mime -> Headers.content_type mime headers
238238+ | None -> headers)
239239+ | None -> headers
240240+ in
241241+242242+ (* Get cookies for this URL *)
163243 let headers =
164244 Eio.Mutex.use_ro t.cookie_mutex (fun () ->
165245 let cookies = Cookeio.get_cookies t.cookie_jar ~domain ~path ~is_secure in
···172252 )
173253 in
174254255255+ (* Convert body to string for sending *)
256256+ let request_body_str = match body with
257257+ | None -> ""
258258+ | Some b -> Body.Private.to_string b
259259+ in
260260+175261 (* Check cache for GET and HEAD requests when body is not present *)
176176- let response = match t.cache, method_, body with
262262+ let cached_response = match t.cache, method_, body with
177263 | Some cache, (`GET | `HEAD), None ->
178178- Log.debug (fun m -> m "Checking cache for %s request to %s" (Method.to_string method_) url);
264264+ Log.debug (fun m -> m "Checking cache for %s request to %s" method_str url);
179265 let headers_cohttp = Cohttp.Header.of_list (Headers.to_list headers) in
180180- (match Cache.get cache ~method_ ~url:uri ~headers:headers_cohttp with
181181- | Some cached_response ->
182182- Log.info (fun m -> m "Cache HIT for %s request to %s" (Method.to_string method_) url);
183183- (* Convert cached response to Response.t *)
184184- let status = Cohttp.Code.code_of_status cached_response.Cache.status in
185185- let resp_headers = Headers.of_list (Cohttp.Header.to_list cached_response.Cache.headers) in
186186- let body_flow = Eio.Flow.string_source cached_response.Cache.body in
187187- Response.make ~sw:t.sw ~status ~headers:resp_headers ~body:body_flow ~url ~elapsed:0.0
188188- | None ->
189189- Log.info (fun m -> m "Cache MISS for %s request to %s" (Method.to_string method_) url);
190190- (* Make the actual request *)
191191- let response = One.request ~sw:t.sw ~client:t.client
192192- ?body ?auth
193193- ~timeout:(Option.value timeout ~default:t.timeout)
194194- ~follow_redirects:(Option.value follow_redirects ~default:t.follow_redirects)
195195- ~max_redirects:(Option.value max_redirects ~default:t.max_redirects)
196196- ~headers ~method_ url
197197- in
198198- (* Store in cache if successful *)
199199- (match t.cache with
200200- | Some cache when Response.ok response ->
201201- Log.debug (fun m -> m "Storing response in cache for %s" url);
202202- let status_code = Response.status_code response in
203203- let status = Cohttp.Code.status_of_code status_code in
204204- let resp_headers = Response.headers response in
205205- let resp_headers_cohttp = Cohttp.Header.of_list (Headers.to_list resp_headers) in
206206- (* Read body to cache it *)
207207- let body_flow = Response.body response in
208208- let buf = Buffer.create 4096 in
209209- Eio.Flow.copy body_flow (Eio.Flow.buffer_sink buf);
210210- let body_str = Buffer.contents buf in
211211- Cache.put cache ~method_ ~url:uri ~request_headers:headers_cohttp
212212- ~status ~headers:resp_headers_cohttp ~body:body_str;
213213- (* Return a new response with the buffered body *)
214214- let new_body_flow = Eio.Flow.string_source body_str in
215215- Response.make ~sw:t.sw ~status:status_code ~headers:resp_headers ~body:new_body_flow ~url ~elapsed:0.0
216216- | _ -> response))
217217- | _ ->
218218- Log.debug (fun m -> m "Cache not applicable for %s request to %s (cache enabled: %b, body present: %b)"
219219- (Method.to_string method_) url (Option.is_some t.cache) (Option.is_some body));
220220- (* Make the actual request without caching *)
221221- One.request ~sw:t.sw ~client:t.client
222222- ?body ?auth
223223- ~timeout:(Option.value timeout ~default:t.timeout)
224224- ~follow_redirects:(Option.value follow_redirects ~default:t.follow_redirects)
225225- ~max_redirects:(Option.value max_redirects ~default:t.max_redirects)
226226- ~headers ~method_ url
266266+ Cache.get cache ~method_ ~url:uri ~headers:headers_cohttp
267267+ | _ -> None
268268+ in
269269+270270+ let response = match cached_response with
271271+ | Some cached ->
272272+ Log.info (fun m -> m "Cache HIT for %s request to %s" method_str url);
273273+ (* Convert cached response to Response.t *)
274274+ let status = Cohttp.Code.code_of_status cached.Cache.status in
275275+ let resp_headers = Headers.of_list (Cohttp.Header.to_list cached.Cache.headers) in
276276+ let body_flow = Eio.Flow.string_source cached.Cache.body in
277277+ Response.Private.make ~sw:t.sw ~status ~headers:resp_headers ~body:body_flow ~url ~elapsed:0.0
278278+ | None ->
279279+ Log.info (fun m -> m "Cache MISS or not applicable for %s request to %s" method_str url);
280280+281281+ (* Execute request with redirect handling *)
282282+ let rec make_with_redirects url_to_fetch redirects_left =
283283+ let uri_to_fetch = Uri.of_string url_to_fetch in
284284+285285+ (* Parse the redirect URL to get correct host and port *)
286286+ let redirect_host = match Uri.host uri_to_fetch with
287287+ | Some h -> h
288288+ | None -> failwith "Redirect URL must contain a host"
289289+ in
290290+ let redirect_port = match Uri.scheme uri_to_fetch, Uri.port uri_to_fetch with
291291+ | Some "https", None -> 443
292292+ | Some "https", Some p -> p
293293+ | Some "http", None -> 80
294294+ | Some "http", Some p -> p
295295+ | _, Some p -> p
296296+ | _ -> 80
297297+ in
298298+299299+ (* Create endpoint for this specific URL *)
300300+ let redirect_endpoint = Conpool.Endpoint.make ~host:redirect_host ~port:redirect_port in
301301+302302+ (* Determine if we need TLS based on this URL's scheme *)
303303+ let redirect_is_https = match Uri.scheme uri_to_fetch with
304304+ | Some "https" -> true
305305+ | _ -> false
306306+ in
307307+308308+ (* Choose the appropriate connection pool for this URL *)
309309+ let redirect_pool = if redirect_is_https then t.https_pool else t.http_pool in
310310+311311+ let make_request_fn () =
312312+ Conpool.with_connection redirect_pool redirect_endpoint (fun flow ->
313313+ (* Flow is already TLS-wrapped if from https_pool, plain TCP if from http_pool *)
314314+ (* Use our low-level HTTP client *)
315315+ Http_client.make_request ~method_:method_str ~uri:uri_to_fetch
316316+ ~headers ~body_str:request_body_str flow
317317+ )
318318+ in
319319+320320+ (* Apply timeout if specified *)
321321+ let status, resp_headers, response_body_str =
322322+ let timeout_val = Option.value timeout ~default:t.timeout in
323323+ match Timeout.total timeout_val with
324324+ | Some seconds ->
325325+ Log.debug (fun m -> m "Setting timeout: %.2f seconds" seconds);
326326+ Eio.Time.with_timeout_exn t.clock seconds make_request_fn
327327+ | None -> make_request_fn ()
328328+ in
329329+330330+ Log.info (fun m -> m "Received response: status=%d" status);
331331+332332+ (* Handle redirects if enabled *)
333333+ let follow = Option.value follow_redirects ~default:t.follow_redirects in
334334+ let max_redir = Option.value max_redirects ~default:t.max_redirects in
335335+336336+ if follow && (status >= 300 && status < 400) then begin
337337+ if redirects_left <= 0 then begin
338338+ Log.err (fun m -> m "Too many redirects (%d) for %s" max_redir url);
339339+ raise (Error.TooManyRedirects { url; count = max_redir; max = max_redir })
340340+ end;
341341+342342+ match Headers.get "location" resp_headers with
343343+ | None ->
344344+ Log.debug (fun m -> m "Redirect response missing Location header");
345345+ (status, resp_headers, response_body_str, url_to_fetch)
346346+ | Some location ->
347347+ Log.info (fun m -> m "Following redirect to %s (%d remaining)" location redirects_left);
348348+ make_with_redirects location (redirects_left - 1)
349349+ end else
350350+ (status, resp_headers, response_body_str, url_to_fetch)
351351+ in
352352+353353+ let max_redir = Option.value max_redirects ~default:t.max_redirects in
354354+ let final_status, final_headers, final_body_str, final_url =
355355+ make_with_redirects url max_redir
356356+ in
357357+358358+ let elapsed = Unix.gettimeofday () -. start_time in
359359+ Log.info (fun m -> m "Request completed in %.3f seconds" elapsed);
360360+361361+ (* Store in cache if successful and caching enabled *)
362362+ (match t.cache with
363363+ | Some cache when final_status >= 200 && final_status < 300 ->
364364+ Log.debug (fun m -> m "Storing response in cache for %s" url);
365365+ let status = Cohttp.Code.status_of_code final_status in
366366+ let resp_headers_cohttp = Cohttp.Header.of_list (Headers.to_list final_headers) in
367367+ let headers_cohttp = Cohttp.Header.of_list (Headers.to_list headers) in
368368+ Cache.put cache ~method_ ~url:uri ~request_headers:headers_cohttp
369369+ ~status ~headers:resp_headers_cohttp ~body:final_body_str
370370+ | _ -> ());
371371+372372+ (* Create a flow from the body string *)
373373+ let body_flow = Eio.Flow.string_source final_body_str in
374374+375375+ Response.Private.make
376376+ ~sw:t.sw
377377+ ~status:final_status
378378+ ~headers:final_headers
379379+ ~body:body_flow
380380+ ~url:final_url
381381+ ~elapsed
227382 in
228383229384 (* Extract and store cookies from response *)
···246401247402 (* Update statistics *)
248403 t.requests_made <- t.requests_made + 1;
404404+ t.total_time <- t.total_time +. (Unix.gettimeofday () -. start_time);
249405 Log.info (fun m -> m "Request completed with status %d" (Response.status_code response));
250406251407 response
···323479 env in
324480325481 (* Set user agent if provided *)
326326- Option.iter (set_default_header req "User-Agent") config.user_agent;
482482+ let req = match config.user_agent with
483483+ | Some ua -> set_default_header req "User-Agent" ua
484484+ | None -> req
485485+ in
327486328487 req
329488
+43-16
stack/requests/lib/requests.mli
···133133*)
134134135135type ('clock, 'net) t
136136-(** A stateful HTTP client that maintains cookies, auth, and configuration across requests. *)
136136+(** A stateful HTTP client that maintains cookies, auth, configuration, and
137137+ connection pools across requests. *)
137138138139(** {2 Creation and Configuration} *)
139140140141val create :
141142 sw:Eio.Switch.t ->
142142- ?client:('clock Eio.Time.clock,'net Eio.Net.t) One.t ->
143143+ ?http_pool:('clock Eio.Time.clock, 'net Eio.Net.t) Conpool.t ->
144144+ ?https_pool:('clock Eio.Time.clock, 'net Eio.Net.t) Conpool.t ->
143145 ?cookie_jar:Cookeio.jar ->
144146 ?default_headers:Headers.t ->
145147 ?auth:Auth.t ->
···147149 ?follow_redirects:bool ->
148150 ?max_redirects:int ->
149151 ?verify_tls:bool ->
152152+ ?tls_config:Tls.Config.client ->
153153+ ?max_connections_per_host:int ->
154154+ ?connection_idle_timeout:float ->
155155+ ?connection_lifetime:float ->
150156 ?retry:Retry.config ->
151157 ?persist_cookies:bool ->
152158 ?enable_cache:bool ->
153159 ?xdg:Xdge.t ->
154160 < clock: 'clock Eio.Resource.t; net: 'net Eio.Resource.t; fs: Eio.Fs.dir_ty Eio.Path.t; .. > ->
155161 ('clock Eio.Resource.t, 'net Eio.Resource.t) t
156156-(** Create a new requests instance with persistent state.
157157- All resources are bound to the provided switch and will be cleaned up automatically. *)
162162+(** Create a new requests instance with persistent state and connection pooling.
163163+ All resources are bound to the provided switch and will be cleaned up automatically.
164164+165165+ @param sw Switch for resource management
166166+ @param http_pool Optional pre-configured HTTP connection pool (creates new if not provided)
167167+ @param https_pool Optional pre-configured HTTPS connection pool (creates new if not provided)
168168+ @param cookie_jar Cookie storage (default: empty in-memory jar)
169169+ @param default_headers Headers included in every request
170170+ @param auth Default authentication
171171+ @param timeout Default timeout configuration
172172+ @param follow_redirects Whether to follow HTTP redirects (default: true)
173173+ @param max_redirects Maximum redirects to follow (default: 10)
174174+ @param verify_tls Whether to verify TLS certificates (default: true)
175175+ @param tls_config Custom TLS configuration for HTTPS pool (default: system CA certs)
176176+ @param max_connections_per_host Maximum pooled connections per host:port (default: 10)
177177+ @param connection_idle_timeout Max idle time before closing pooled connection (default: 60s)
178178+ @param connection_lifetime Max lifetime of any pooled connection (default: 300s)
179179+ @param retry Retry configuration for failed requests
180180+ @param persist_cookies Whether to persist cookies to disk (default: false)
181181+ @param enable_cache Whether to enable HTTP caching (default: false)
182182+ @param xdg XDG directory context for cookies/cache (required if persist_cookies or enable_cache)
183183+*)
158184159185(** {2 Configuration Management} *)
160186161161-val set_default_header : ('clock, 'net) t -> string -> string -> unit
162162-(** Set a default header for all requests *)
187187+val set_default_header : ('clock, 'net) t -> string -> string -> ('clock, 'net) t
188188+(** Add or update a default header. Returns a new session with the updated header.
189189+ The original session's connection pools are shared. *)
163190164164-val remove_default_header : ('clock, 'net) t -> string -> unit
165165-(** Remove a default header *)
191191+val remove_default_header : ('clock, 'net) t -> string -> ('clock, 'net) t
192192+(** Remove a default header. Returns a new session without the header. *)
166193167167-val set_auth : ('clock, 'net) t -> Auth.t -> unit
168168-(** Set default authentication *)
194194+val set_auth : ('clock, 'net) t -> Auth.t -> ('clock, 'net) t
195195+(** Set default authentication. Returns a new session with auth configured. *)
169196170170-val clear_auth : ('clock, 'net) t -> unit
171171-(** Clear authentication *)
197197+val clear_auth : ('clock, 'net) t -> ('clock, 'net) t
198198+(** Clear authentication. Returns a new session without auth. *)
172199173173-val set_timeout : ('clock, 'net) t -> Timeout.t -> unit
174174-(** Set default timeout *)
200200+val set_timeout : ('clock, 'net) t -> Timeout.t -> ('clock, 'net) t
201201+(** Set default timeout. Returns a new session with the timeout configured. *)
175202176176-val set_retry : ('clock, 'net) t -> Retry.config -> unit
177177-(** Set retry configuration *)
203203+val set_retry : ('clock, 'net) t -> Retry.config -> ('clock, 'net) t
204204+(** Set retry configuration. Returns a new session with retry configured. *)
178205179206(** {2 Request Methods}
180207
+12-23
stack/requests/test/test_connection_pool.ml
···11-(** Test connection pooling with conpool integration *)
11+(** Test stateless One API - each request opens a fresh connection *)
2233open Eio.Std
4455-let test_connection_pooling () =
55+let test_one_stateless () =
66 (* Initialize RNG for TLS *)
77 Mirage_crypto_rng_unix.use_default ();
8899 Eio_main.run @@ fun env ->
1010 Switch.run @@ fun sw ->
11111212- (* Configure logging to see connection pool activity *)
1212+ (* Configure logging to see One request activity *)
1313 Logs.set_reporter (Logs_fmt.reporter ());
1414 Logs.set_level (Some Logs.Info);
1515- Logs.Src.set_level Conpool.src (Some Logs.Info);
1615 Logs.Src.set_level Requests.One.src (Some Logs.Info);
17161818- traceln "=== Testing Connection Pooling ===\n";
1717+ traceln "=== Testing One Stateless API ===\n";
1818+ traceln "The One API creates fresh connections for each request (no pooling)\n";
19192020- (* Create a client with connection pooling *)
2121- let client = Requests.One.create
2222- ~sw
2323- ~clock:env#clock
2424- ~net:env#net
2525- ~max_connections_per_host:5
2626- ~connection_idle_timeout:30.0
2727- ~verify_tls:true
2828- ()
2929- in
3030-3131- traceln "Client created with connection pool";
3232- traceln "Making 10 requests to example.com...\n";
3333-3434- (* Make multiple requests to the same host *)
2020+ (* Make multiple requests to the same host using stateless One API *)
3521 let start_time = Unix.gettimeofday () in
36223723 for i = 1 to 10 do
3824 traceln "Request %d:" i;
3939- let response = Requests.One.get ~sw ~client "http://example.com" in
2525+ let response = Requests.One.get ~sw
2626+ ~clock:env#clock ~net:env#net
2727+ "http://example.com"
2828+ in
40294130 traceln " Status: %d" (Requests.Response.status_code response);
4231 traceln " Content-Length: %s"
···4433 | Some len -> Int64.to_string len
4534 | None -> "unknown");
46354747- (* Body already drained - connection automatically returned to pool *)
3636+ (* Connection is fresh for each request - no pooling *)
4837 traceln ""
4938 done;
5039···56455746let () =
5847 try
5959- test_connection_pooling ()
4848+ test_one_stateless ()
6049 with e ->
6150 traceln "Test failed with exception: %s" (Printexc.to_string e);
6251 Printexc.print_backtrace stdout;
+7-5
stack/requests/test/test_requests.ml
···705705 end in
706706 Test_server.start_server ~port test_env;
707707708708- let client = Requests.One.create ~sw ~clock:env#clock ~net:env#net () in
709709- let response = Requests.One.get ~sw ~client (base_url ^ "/echo") in
708708+ let response = Requests.One.get ~sw
709709+ ~clock:env#clock ~net:env#net
710710+ (base_url ^ "/echo")
711711+ in
710712711713 Alcotest.(check int) "One module status" 200 (Requests.Response.status_code response)
712714···815817816818 let req = Requests.create ~sw env in
817819818818- Requests.set_default_header req "X-Session" "session-123";
820820+ let req = Requests.set_default_header req "X-Session" "session-123" in
819821820822 let auth = Requests.Auth.bearer ~token:"test_token" in
821821- Requests.set_auth req auth;
823823+ let req = Requests.set_auth req auth in
822824823825 let response1 = Requests.get req (base_url ^ "/echo") in
824826 let body_str1 = Requests.Response.body response1 |> Eio.Flow.read_all in
···834836835837 Alcotest.(check string) "Session header persisted" "session-123" session_header;
836838837837- Requests.remove_default_header req "X-Session";
839839+ let req = Requests.remove_default_header req "X-Session" in
838840839841 let response2 = Requests.get req (base_url ^ "/echo") in
840842 let body_str2 = Requests.Response.body response2 |> Eio.Flow.read_all in
···11(** Resolve a DOI from a Zotero translation server *)
2233-module C = Cohttp
44-module CL = Cohttp_lwt
55-module CLU = Cohttp_lwt_unix.Client
63module J = Ezjsonm
7485(* From the ZTS source code: https://github.com/zotero/translation-server/blob/master/src/formats.js
···10299 | true -> Uri.of_string (base_uri ^ "import")
103100 | false -> Uri.of_string (base_uri ^ "/import")
104101105105-open Lwt.Infix
106106-107107-(* The Eio version has more in here, hence I'm just keeping this around. *)
108108-type t = {
102102+type ('clock, 'net) t = {
109103 base_uri: string;
104104+ requests_session: ('clock, 'net) Requests.t;
110105}
111106112112-let v base_uri = { base_uri }
107107+let create ~sw ~env ?requests_session base_uri =
108108+ let requests_session = match requests_session with
109109+ | Some session -> session
110110+ | None -> Requests.create ~sw env
111111+ in
112112+ { base_uri; requests_session }
113113114114-let resolve_doi { base_uri } doi =
115115- let body = "https://doi.org/" ^ doi in
116116- let doi_body = CL.Body.of_string body in
117117- let headers = C.Header.init_with "content-type" "text/plain" in
114114+let v _base_uri =
115115+ failwith "Zotero_translation.v is deprecated. Use Zotero_translation.create ~sw ~env base_uri instead"
116116+117117+let resolve_doi { base_uri; requests_session } doi =
118118+ let body_str = "https://doi.org/" ^ doi in
118119 let uri = web_endp base_uri in
119119- CLU.call ~headers ~body:doi_body `POST uri >>= fun (resp, body) ->
120120- let status = C.Response.status resp in
121121- body |> Cohttp_lwt.Body.to_string >>= fun body ->
122122- if status = `OK then begin
120120+ let body = Requests.Body.text body_str in
121121+ let response = Requests.post requests_session ~body (Uri.to_string uri) in
122122+ let status = Requests.Response.status_code response in
123123+ let body = Requests.Response.body response |> Eio.Flow.read_all in
124124+ if status = 200 then begin
123125 try
124126 let doi_json = J.from_string body in
125125- Lwt.return_ok doi_json
126126- with exn -> Lwt.return_error (`Msg (Printexc.to_string exn))
127127+ Ok doi_json
128128+ with exn -> Error (`Msg (Printexc.to_string exn))
127129 end else
128128- Lwt.return_error (`Msg (Format.asprintf "Unexpected HTTP status: %a for %s" Http.Status.pp status body))
130130+ Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
129131130130-let resolve_url { base_uri } url =
131131- let url_body = CL.Body.of_string url in
132132- let headers = C.Header.init_with "content-type" "text/plain" in
132132+let resolve_url { base_uri; requests_session } url =
133133+ let body_str = url in
133134 let uri = web_endp base_uri in
134134- CLU.call ~headers ~body:url_body `POST uri >>= fun (resp, body) ->
135135- let status = C.Response.status resp in
136136- body |> Cohttp_lwt.Body.to_string >>= fun body ->
137137- if status = `OK then begin
135135+ let body = Requests.Body.text body_str in
136136+ let response = Requests.post requests_session ~body (Uri.to_string uri) in
137137+ let status = Requests.Response.status_code response in
138138+ let body = Requests.Response.body response |> Eio.Flow.read_all in
139139+ if status = 200 then begin
138140 try
139141 let url_json = J.from_string body in
140140- Lwt.return_ok url_json
141141- with exn -> Lwt.return_error (`Msg (Printexc.to_string exn))
142142+ Ok url_json
143143+ with exn -> Error (`Msg (Printexc.to_string exn))
142144 end else
143143- Lwt.return_error (`Msg (Format.asprintf "Unexpected HTTP status: %a for %s" Http.Status.pp status body))
145145+ Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
144146145145-let search_id { base_uri} doi =
146146- let body = "https://doi.org/" ^ doi in
147147- let doi_body = CL.Body.of_string body in
148148- let headers = C.Header.init_with "content-type" "text/plain" in
147147+let search_id { base_uri; requests_session } doi =
148148+ let body_str = "https://doi.org/" ^ doi in
149149 let uri = search_endp base_uri in
150150- CLU.call ~headers ~body:doi_body `POST uri >>= fun (resp, body) ->
151151- let status = C.Response.status resp in
152152- body |> Cohttp_lwt.Body.to_string >>= fun body ->
153153- if status = `OK then begin
150150+ let body = Requests.Body.text body_str in
151151+ let response = Requests.post requests_session ~body (Uri.to_string uri) in
152152+ let status = Requests.Response.status_code response in
153153+ let body = Requests.Response.body response |> Eio.Flow.read_all in
154154+ if status = 200 then begin
154155 try
155156 let doi_json = J.from_string body in
156156- Lwt.return_ok doi_json
157157- with exn -> Lwt.return_error (`Msg (Printexc.to_string exn))
157157+ Ok doi_json
158158+ with exn -> Error (`Msg (Printexc.to_string exn))
158159 end else
159159- Lwt.return_error (`Msg (Format.asprintf "Unexpected HTTP status: %a for %s" Http.Status.pp status body))
160160+ Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
160161161161-let export {base_uri} format api =
162162- let body = CL.Body.of_string (J.to_string api) in
163163- let headers = C.Header.init_with "content-type" "application/json" in
162162+let export { base_uri; requests_session } format api =
163163+ let body_str = J.to_string api in
164164 let uri = Uri.with_query' (export_endp base_uri ) ["format", (format_to_string format)] in
165165- CLU.call ~headers ~body `POST uri >>= fun (resp, body) ->
166166- let status = C.Response.status resp in
167167- body |> Cohttp_lwt.Body.to_string >>= fun body ->
168168- if status = `OK then begin
165165+ let body = Requests.Body.of_string Requests.Mime.json body_str in
166166+ let response = Requests.post requests_session ~body (Uri.to_string uri) in
167167+ let status = Requests.Response.status_code response in
168168+ let body = Requests.Response.body response |> Eio.Flow.read_all in
169169+ if status = 200 then begin
169170 try
170171 match format with
171171- | Bibtex -> Lwt.return_ok (Astring.String.trim body)
172172- | _ -> Lwt.return_ok body
173173- with exn -> Lwt.return_error (`Msg (Printexc.to_string exn))
172172+ | Bibtex -> Ok (Astring.String.trim body)
173173+ | _ -> Ok body
174174+ with exn -> Error (`Msg (Printexc.to_string exn))
174175 end else
175175- Lwt.return_error (`Msg (Format.asprintf "Unexpected HTTP status: %a for %s" Http.Status.pp status body))
176176+ Error (`Msg (Format.asprintf "Unexpected HTTP status: %d for %s" status body))
176177177178let unescape_hex s =
178179 let buf = Buffer.create (String.length s) in
···201202 | Error e ->
202203 prerr_endline bib;
203204 Fmt.epr "%a\n%!" Bibtex.pp_error e;
204204- Lwt.fail_with "bib parse err TODO"
205205+ failwith "bib parse err TODO"
205206 | Ok [bib] ->
206207 let f = Bibtex.fields bib |> Bibtex.SM.bindings |> List.map (fun (k,v) -> k, (unescape_bibtex v)) in
207208 let ty = match Bibtex.type' bib with "inbook" -> "book" | x -> x in
208209 let v = List.fold_left (fun acc (k,v) -> (k,(`String v))::acc) ["bibtype",`String ty] f in
209209- Lwt.return v
210210- | Ok _ -> Lwt.fail_with "one bib at a time plz"
210210+ v
211211+ | Ok _ -> failwith "one bib at a time plz"
211212212213let bib_of_doi zt doi =
213214 prerr_endline ("Fetching " ^ doi);
214214- let v = resolve_doi zt doi >>= function
215215- | Ok r ->
216216- Lwt.return r
215215+ let v = match resolve_doi zt doi with
216216+ | Ok r -> r
217217 | Error (`Msg _) ->
218218 Printf.eprintf "%s failed on /web, trying to /search\n%!" doi;
219219- search_id zt doi >>= function
220220- | Error (`Msg e) -> Lwt.fail_with e
221221- | Ok r ->
222222- Lwt.return r
219219+ match search_id zt doi with
220220+ | Error (`Msg e) -> failwith e
221221+ | Ok r -> r
223222 in
224224- v >>= fun v ->
225225- export zt Bibtex v >>= function
226226- | Error (`Msg e) -> Lwt.fail_with e
223223+ match export zt Bibtex v with
224224+ | Error (`Msg e) -> failwith e
227225 | Ok r ->
228226 print_endline r;
229229- Lwt.return r
227227+ r
230228231229let split_authors keys =
232230 let authors =
···270268 fun bib -> J.update y ["bib"] (Some (`String bib))
271269272270let json_of_doi zt ~slug doi =
273273- bib_of_doi zt doi >>= fun x ->
274274- fields_of_bib x >>= fun x ->
275275- Lwt.return (split_authors x |> add_bibtex ~slug)
271271+ let x = bib_of_doi zt doi in
272272+ let x = fields_of_bib x in
273273+ split_authors x |> add_bibtex ~slug
+25-7
stack/zotero-translation/zotero_translation.mli
···11(** {1 Interface to the Zotero Translation Server} *)
2233-type t
33+type ('clock, 'net) t
4455type format =
66 | Bibtex
···2424val format_to_string: format -> string
2525val format_of_string: string -> format option
26262727-val v : string -> t
2727+(** Create a Zotero Translation client.
2828+ @param requests_session Optional Requests session for connection pooling.
2929+ If not provided, a new session is created. *)
3030+val create :
3131+ sw:Eio.Switch.t ->
3232+ env:< clock: ([> float Eio.Time.clock_ty ] as 'clock) Eio.Resource.t;
3333+ net: ([> [> `Generic ] Eio.Net.ty ] as 'net) Eio.Resource.t;
3434+ fs: Eio.Fs.dir_ty Eio.Path.t; .. > ->
3535+ ?requests_session:('clock Eio.Resource.t, 'net Eio.Resource.t) Requests.t ->
3636+ string -> ('clock Eio.Resource.t, 'net Eio.Resource.t) t
28372929-val resolve_doi: t -> string -> ([>Ezjsonm.t], [>`Msg of string]) Lwt_result.t
3838+(** Deprecated: use [create] instead *)
3939+val v : string -> (_, _) t
4040+ [@@deprecated "Use create ~sw ~env base_uri instead"]
30413131-val resolve_url: t -> string -> ([>Ezjsonm.t], [>`Msg of string]) Lwt_result.t
4242+val resolve_doi: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
4343+ string -> (Ezjsonm.t, [>`Msg of string]) result
32443333-val search_id: t -> string -> ([>Ezjsonm.t], [>`Msg of string]) Lwt_result.t
4545+val resolve_url: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
4646+ string -> (Ezjsonm.t, [>`Msg of string]) result
34473535-val export: t -> format -> Ezjsonm.t -> (string, [>`Msg of string]) Lwt_result.t
4848+val search_id: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
4949+ string -> (Ezjsonm.t, [>`Msg of string]) result
36503737-val json_of_doi : t -> slug:string -> string -> Ezjsonm.value Lwt.t
5151+val export: ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
5252+ format -> Ezjsonm.t -> (string, [>`Msg of string]) result
5353+5454+val json_of_doi : ([> float Eio.Time.clock_ty ] Eio.Resource.t, [> [> `Generic ] Eio.Net.ty ] Eio.Resource.t) t ->
5555+ slug:string -> string -> Ezjsonm.value