1use axum::extract::{Path, Query, State};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Json};
7
8pub async fn get_catalog_entry(
9 State(state): State<crate::state::AppState>,
10 Path(id): Path<String>,
11) -> impl IntoResponse {
12 let cfg_guard = state.config.read().await;
13 let models_dir = cfg_guard.resolved_models_dir();
14 drop(cfg_guard);
15
16 let entry = if let Some(entry) = mold_catalog::live::companion_entry_for_id(&id) {
17 entry
18 } else if let Some(version_id) = id.strip_prefix("cv:") {
19 match mold_catalog::live::fetch_civitai_version(
20 state.catalog_live_civitai_base.as_str(),
21 version_id,
22 std::env::var("CIVITAI_TOKEN").ok().as_deref(),
23 )
24 .await
25 {
26 Ok(e) => e,
27 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
28 return (StatusCode::NOT_FOUND, "not found").into_response();
29 }
30 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
31 }
32 } else if let Some(repo_id) = id.strip_prefix("hf:") {
33 match mold_catalog::live::fetch_hf_repo(
34 "https://huggingface.co",
35 repo_id,
36 std::env::var("HF_TOKEN").ok().as_deref(),
37 )
38 .await
39 {
40 Ok(e) => e,
41 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
42 return (StatusCode::NOT_FOUND, "not found").into_response();
43 }
44 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
45 }
46 } else {
47 return (
48 StatusCode::BAD_REQUEST,
49 "id must be `cv:` or `hf:` prefixed",
50 )
51 .into_response();
52 };
53
54 Json(live_entry_to_wire(&entry, &models_dir)).into_response()
55}
56
57pub async fn post_catalog_dispatch(
61 State(state): State<crate::state::AppState>,
62 Path(rest): Path<String>,
63) -> impl IntoResponse {
64 if let Some(id) = rest.strip_suffix("/download") {
65 post_catalog_download(State(state), Path(id.to_string()))
66 .await
67 .into_response()
68 } else {
69 (StatusCode::NOT_FOUND, "unknown catalog action").into_response()
70 }
71}
72
73fn rendered_primary_recipe_dest(
74 entry: &mold_catalog::entry::CatalogEntry,
75 author: &str,
76 name: &str,
77) -> Option<String> {
78 entry
79 .download_recipe
80 .files
81 .iter()
82 .find(|file| file.role.is_none())
83 .or_else(|| entry.download_recipe.files.first())
84 .map(|file| {
85 mold_catalog::entry::render_recipe_dest(&file.dest, entry.family.as_str(), author, name)
86 })
87}
88
89pub(crate) async fn enqueue_catalog_primary_repair(
90 state: &crate::state::AppState,
91 id: &str,
92) -> Result<Option<String>, (StatusCode, String)> {
93 let models_dir = state.config.read().await.resolved_models_dir();
94 if let Some(version_id) = id.strip_prefix("cv:") {
95 let sc_path = mold_catalog::sidecar::civitai_sidecar_path(&models_dir, id);
96 if let Ok(sidecar) = mold_catalog::sidecar::read_sidecar(&sc_path) {
97 if let Some(parent) = sc_path.parent() {
98 if mold_catalog::sidecar::primary_path_if_present(parent, &sidecar).is_some() {
99 return Ok(None);
100 }
101 }
102 }
103
104 let entry = mold_catalog::live::fetch_civitai_version(
105 state.catalog_live_civitai_base.as_str(),
106 version_id,
107 std::env::var("CIVITAI_TOKEN").ok().as_deref(),
108 )
109 .await
110 .map_err(|e| match e {
111 mold_catalog::live::LiveSearchError::Upstream { status: 404, .. } => (
112 StatusCode::NOT_FOUND,
113 format!("{id}: upstream returned 404"),
114 ),
115 other => (StatusCode::BAD_GATEWAY, format!("upstream: {other}")),
116 })?;
117
118 let auth = match entry.download_recipe.needs_token {
119 Some(mold_catalog::entry::TokenKind::Civitai) => {
120 mold_core::download::civitai_auth_or_error(id)
121 .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?
122 }
123 _ => mold_core::download::RecipeAuth::None,
124 };
125 let (author, name) = match entry.source_id.split_once('/') {
126 Some((a, n)) => (a.to_string(), n.to_string()),
127 None => (String::new(), entry.source_id.clone()),
128 };
129 let files: Vec<crate::downloads::OwnedRecipeFile> = entry
130 .download_recipe
131 .files
132 .iter()
133 .map(|f| crate::downloads::OwnedRecipeFile {
134 url: f.url.clone(),
135 dest: mold_catalog::entry::render_recipe_dest(
136 &f.dest,
137 entry.family.as_str(),
138 &author,
139 &name,
140 ),
141 sha256: f.sha256.clone(),
142 size_bytes: f.size_bytes,
143 })
144 .collect();
145 if let Some(primary_dest) = rendered_primary_recipe_dest(&entry, &author, &name) {
146 let sidecar = mold_catalog::sidecar::sidecar_from_entry(&entry, primary_dest);
147 if let Err(e) = mold_catalog::sidecar::write_sidecar(&sc_path, &sidecar) {
148 tracing::warn!(
149 target: "catalog.sidecar",
150 catalog_id = %id,
151 error = %e,
152 "sidecar write failed during primary repair",
153 );
154 }
155 }
156 let payload = crate::downloads::RecipePayload {
157 catalog_id: id.to_string(),
158 files,
159 auth,
160 };
161 let (job_id, _, _) = state
162 .downloads
163 .enqueue_recipe_in_group(payload, id)
164 .await
165 .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
166 return Ok(Some(job_id));
167 }
168
169 if let Some(repo_id) = id.strip_prefix("hf:") {
170 let entry = mold_catalog::live::fetch_hf_repo(
171 "https://huggingface.co",
172 repo_id,
173 std::env::var("HF_TOKEN").ok().as_deref(),
174 )
175 .await
176 .map_err(|e| match e {
177 mold_catalog::live::LiveSearchError::Upstream { status: 404, .. } => (
178 StatusCode::NOT_FOUND,
179 format!("{id}: upstream returned 404"),
180 ),
181 other => (StatusCode::BAD_GATEWAY, format!("upstream: {other}")),
182 })?;
183 let model = match mold_core::manifest::find_manifest(&entry.source_id) {
184 Some(m) => m.name.clone(),
185 None => entry.source_id.clone(),
186 };
187 let (job_id, _, _) = state
188 .downloads
189 .enqueue_in_group(model, id)
190 .await
191 .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
192 return Ok(Some(job_id));
193 }
194
195 Ok(None)
196}
197
198pub async fn list_families(State(_state): State<crate::state::AppState>) -> impl IntoResponse {
199 use mold_catalog::families::{Family, ALL_FAMILIES};
203 let merged: Vec<serde_json::Value> = ALL_FAMILIES
204 .iter()
205 .map(|f: &Family| serde_json::json!({ "family": f.as_str() }))
206 .collect();
207 Json(serde_json::json!({ "families": merged })).into_response()
208}
209
210#[derive(Clone, Debug, serde::Serialize)]
213pub struct CompanionJob {
214 pub name: String,
218 pub job_id: String,
220}
221
222pub(crate) async fn enqueue_missing_companions(
236 companion_names: &[String],
237 models_dir: &std::path::Path,
238 queue: &crate::downloads::DownloadQueue,
239 catalog_id: Option<&str>,
240) -> Vec<CompanionJob> {
241 let manifests = mold_core::download::missing_companions(companion_names, models_dir);
242 let mut jobs = Vec::with_capacity(manifests.len());
243 for manifest in manifests {
244 let result = match catalog_id {
250 Some(id) => queue.enqueue_in_group(manifest.name.clone(), id).await,
251 None => queue.enqueue(manifest.name.clone()).await,
252 };
253 match result {
254 Ok((job_id, _, _)) => jobs.push(CompanionJob {
255 name: manifest.name.clone(),
256 job_id,
257 }),
258 Err(e) => {
259 tracing::warn!(
260 companion = %manifest.name,
261 error = %e,
262 "companion enqueue failed",
263 );
264 }
265 }
266 }
267 jobs
268}
269
270pub async fn post_catalog_download(
271 State(state): State<crate::state::AppState>,
272 Path(id): Path<String>,
273) -> impl IntoResponse {
274 if let Some(companion_name) = mold_catalog::live::companion_name_for_catalog_id(&id) {
276 let models_dir = state.config.read().await.resolved_models_dir();
277 let companion_jobs = enqueue_missing_companions(
278 &[companion_name.to_string()],
279 &models_dir,
280 &state.downloads,
281 Some(&id),
282 )
283 .await;
284 let primary_job_id = if companion_jobs.is_empty() {
285 None
286 } else {
287 Some(companion_jobs[0].job_id.clone())
288 };
289 return (
290 StatusCode::ACCEPTED,
291 Json(serde_json::json!({
292 "primary_job_id": primary_job_id,
293 "companion_jobs": [],
294 })),
295 )
296 .into_response();
297 }
298
299 let entry = if let Some(version_id) = id.strip_prefix("cv:") {
300 match mold_catalog::live::fetch_civitai_version(
301 state.catalog_live_civitai_base.as_str(),
302 version_id,
303 std::env::var("CIVITAI_TOKEN").ok().as_deref(),
304 )
305 .await
306 {
307 Ok(e) => e,
308 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
309 return (StatusCode::NOT_FOUND, "unknown catalog id").into_response();
310 }
311 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
312 }
313 } else if let Some(repo_id) = id.strip_prefix("hf:") {
314 match mold_catalog::live::fetch_hf_repo(
315 "https://huggingface.co",
316 repo_id,
317 std::env::var("HF_TOKEN").ok().as_deref(),
318 )
319 .await
320 {
321 Ok(e) => e,
322 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
323 return (StatusCode::NOT_FOUND, "unknown catalog id").into_response();
324 }
325 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
326 }
327 } else {
328 return (
329 StatusCode::BAD_REQUEST,
330 "id must be `cv:` or `hf:` prefixed",
331 )
332 .into_response();
333 };
334
335 if entry.engine_phase >= 6 {
336 return (
337 StatusCode::CONFLICT,
338 format!(
339 "engine_phase {} not yet supported by this build — see release notes",
340 entry.engine_phase
341 ),
342 )
343 .into_response();
344 }
345
346 let models_dir = state.config.read().await.resolved_models_dir();
352 let entry_id = entry.id.as_str().to_string();
353 let companion_jobs = enqueue_missing_companions(
354 &entry.companions,
355 &models_dir,
356 &state.downloads,
357 Some(&entry_id),
358 )
359 .await;
360
361 use mold_catalog::entry::Source;
367 let primary_job_id: Option<String> = match entry.source {
368 Source::Hf => {
369 let model = match mold_core::manifest::find_manifest(&entry.source_id) {
370 Some(m) => m.name.clone(),
371 None => entry.source_id.clone(),
372 };
373 match state.downloads.enqueue_in_group(model, &entry_id).await {
374 Ok((jid, _, _)) => Some(jid),
375 Err(crate::downloads::EnqueueError::UnknownModel(_)) => None,
376 Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
377 }
378 }
379 Source::Civitai => {
380 let auth = match entry.download_recipe.needs_token {
381 Some(mold_catalog::entry::TokenKind::Civitai) => {
382 match mold_core::download::civitai_auth_or_error(&entry_id) {
383 Ok(a) => a,
384 Err(e) => {
385 return (StatusCode::UNAUTHORIZED, e.to_string()).into_response();
386 }
387 }
388 }
389 _ => mold_core::download::RecipeAuth::None,
390 };
391 let (author, name) = match entry.source_id.split_once('/') {
392 Some((a, n)) => (a.to_string(), n.to_string()),
393 None => (String::new(), entry.source_id.clone()),
394 };
395 let files: Vec<crate::downloads::OwnedRecipeFile> = entry
396 .download_recipe
397 .files
398 .iter()
399 .map(|f| crate::downloads::OwnedRecipeFile {
400 url: f.url.clone(),
401 dest: mold_catalog::entry::render_recipe_dest(
402 &f.dest,
403 entry.family.as_str(),
404 &author,
405 &name,
406 ),
407 sha256: f.sha256.clone(),
408 size_bytes: f.size_bytes,
409 })
410 .collect();
411 if let Some(primary_dest) = rendered_primary_recipe_dest(&entry, &author, &name) {
415 let sidecar = mold_catalog::sidecar::sidecar_from_entry(&entry, primary_dest);
416 let sc_path = mold_catalog::sidecar::civitai_sidecar_path(&models_dir, &entry_id);
417 if let Err(e) = mold_catalog::sidecar::write_sidecar(&sc_path, &sidecar) {
418 tracing::warn!(
419 target: "catalog.sidecar",
420 catalog_id = %entry_id,
421 error = %e,
422 "sidecar write failed; picker will need a reinstall to surface this row",
423 );
424 }
425 }
426 let payload = crate::downloads::RecipePayload {
427 catalog_id: entry_id.clone(),
428 files,
429 auth,
430 };
431 match state
432 .downloads
433 .enqueue_recipe_in_group(payload, &entry_id)
434 .await
435 {
436 Ok((jid, _, _)) => Some(jid),
437 Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
438 }
439 }
440 };
441
442 (
443 StatusCode::ACCEPTED,
444 Json(serde_json::json!({
445 "primary_job_id": primary_job_id,
446 "companion_jobs": companion_jobs,
447 })),
448 )
449 .into_response()
450}
451
452#[derive(Debug, serde::Deserialize)]
455pub struct LiveSearchQuery {
456 pub q: Option<String>,
457 pub family: Option<String>,
458 pub kind: Option<String>,
459 pub source: Option<String>,
460 pub include_nsfw: Option<bool>,
461 pub page: Option<u32>,
462 pub page_size: Option<u32>,
463}
464
465pub async fn live_search_catalog(
470 State(state): State<crate::state::AppState>,
471 Query(q): Query<LiveSearchQuery>,
472) -> impl IntoResponse {
473 let family = match q.family.as_deref().filter(|s| !s.is_empty()) {
474 Some(s) => match mold_catalog::families::Family::from_str(s) {
475 Ok(f) => Some(f),
476 Err(_) => {
477 return (StatusCode::BAD_REQUEST, format!("unknown family: {s}")).into_response()
478 }
479 },
480 None => None,
481 };
482 let kind = match q.kind.as_deref().filter(|s| !s.is_empty()) {
483 Some(s) => match parse_kind(s) {
484 Some(k) => Some(k),
485 None => return (StatusCode::BAD_REQUEST, format!("unknown kind: {s}")).into_response(),
486 },
487 None => None,
488 };
489 let source = match q.source.as_deref().filter(|s| !s.is_empty()) {
490 Some("hf") => Some(mold_catalog::entry::Source::Hf),
491 Some("civitai") => Some(mold_catalog::entry::Source::Civitai),
492 Some(other) => {
493 return (StatusCode::BAD_REQUEST, format!("unknown source: {other}")).into_response()
494 }
495 None => None,
496 };
497
498 let opts = mold_catalog::live::LiveSearchOpts {
499 q: q.q.clone(),
500 family,
501 kind,
502 source,
503 page: q.page.unwrap_or(1).max(1),
504 page_size: q.page_size.unwrap_or(20).clamp(1, 100),
505 include_nsfw: q.include_nsfw.unwrap_or(true),
506 civitai_token: std::env::var("CIVITAI_TOKEN").ok(),
507 hf_token: std::env::var("HF_TOKEN").ok(),
508 };
509
510 let cfg = state.config.read().await;
511 let models_dir = cfg.resolved_models_dir();
512 drop(cfg);
513
514 let entries = match mold_catalog::live::search(
515 state.catalog_live_civitai_base.as_str(),
516 "https://huggingface.co",
517 &state.catalog_live_cache,
518 &opts,
519 )
520 .await
521 {
522 Ok(es) => es,
523 Err(e) => {
524 tracing::warn!(target: "catalog.live", error = %e, "live search failed");
525 return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response();
526 }
527 };
528
529 let wire: Vec<serde_json::Value> = entries
530 .iter()
531 .map(|e| live_entry_to_wire(e, &models_dir))
532 .collect();
533 let total = wire.len() as i64;
534 Json(serde_json::json!({
535 "entries": wire,
536 "page": opts.page,
537 "page_size": opts.page_size,
538 "total": total,
539 }))
540 .into_response()
541}
542
543#[derive(Debug, serde::Deserialize)]
544pub struct InstalledQuery {
545 pub kind: Option<String>,
546 pub family: Option<String>,
547}
548
549#[derive(Debug, serde::Deserialize)]
550pub struct ListLorasQuery {
551 pub model: Option<String>,
552}
553
554pub async fn list_installed_catalog(
558 State(state): State<crate::state::AppState>,
559 Query(q): Query<InstalledQuery>,
560) -> impl IntoResponse {
561 let kind_filter = q.kind.as_deref().map(|s| s.to_string());
562 let family_filter = q.family.as_deref().map(|s| s.to_string());
563 let compatible_lora_families = match kind_filter.as_deref() {
564 Some("lora") => family_filter.as_deref().map(compatible_lora_families),
565 _ => None,
566 };
567
568 let cfg = state.config.read().await;
569 let models_dir = cfg.resolved_models_dir();
570 drop(cfg);
571
572 let walked = mold_catalog::sidecar::walk_sidecars(&models_dir);
573 let mut wire = Vec::with_capacity(walked.len());
574 for (dir, sidecar) in walked {
575 if let Some(k) = kind_filter.as_deref() {
576 if sidecar.kind != k {
577 continue;
578 }
579 }
580 if let Some(f) = family_filter.as_deref() {
581 let family_matches = compatible_lora_families
582 .as_ref()
583 .is_some_and(|families| families.contains(&sidecar.family))
584 || sidecar.family == f;
585 if !family_matches {
586 continue;
587 }
588 }
589 let abs = mold_catalog::sidecar::primary_path_if_present(&dir, &sidecar);
590 let installed = abs.is_some();
591 let primary_path = abs.map(|p| p.to_string_lossy().into_owned());
592 wire.push(sidecar_to_wire(sidecar, installed, primary_path));
593 }
594 let total = wire.len() as i64;
595 Json(serde_json::json!({
596 "entries": wire,
597 "page": 1,
598 "page_size": total,
599 "total": total,
600 }))
601 .into_response()
602}
603
604#[utoipa::path(
608 get,
609 path = "/api/loras",
610 tag = "models",
611 params(
612 ("model" = Option<String>, Query, description = "Optional model name used to filter LoRAs by compatible family")
613 ),
614 responses(
615 (status = 200, description = "Installed LoRAs", body = Vec<mold_core::LoraInfo>),
616 (status = 400, description = "Unknown model")
617 )
618)]
619pub async fn list_loras(
620 State(state): State<crate::state::AppState>,
621 Query(q): Query<ListLorasQuery>,
622) -> Result<Json<Vec<mold_core::LoraInfo>>, crate::routes::ApiError> {
623 let family_filter = match q.model.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
624 Some(model) => {
625 let family = lora_family_for_model(&state, model).await.ok_or_else(|| {
626 crate::routes::ApiError::unknown_model(format!(
627 "unknown model '{model}'; cannot resolve compatible LoRAs"
628 ))
629 })?;
630 if !mold_core::family_supports_lora(&family) {
631 return Ok(Json(Vec::new()));
632 }
633 Some(compatible_lora_families(&family))
634 }
635 None => None,
636 };
637
638 let cfg = state.config.read().await;
639 let models_dir = cfg.resolved_models_dir();
640 drop(cfg);
641
642 let mut loras = mold_catalog::sidecar::walk_sidecars(&models_dir)
643 .into_iter()
644 .filter_map(|(dir, sidecar)| {
645 if sidecar.kind != "lora" {
646 return None;
647 }
648 if let Some(families) = family_filter.as_ref() {
649 if !families.contains(&sidecar.family) {
650 return None;
651 }
652 }
653 sidecar_to_lora_info(&dir, sidecar)
654 })
655 .collect::<Vec<_>>();
656
657 loras.sort_by(|a, b| {
658 b.added_at
659 .cmp(&a.added_at)
660 .then_with(|| a.name.cmp(&b.name))
661 .then_with(|| a.id.cmp(&b.id))
662 });
663 Ok(Json(loras))
664}
665
666fn compatible_lora_families(family: &str) -> Vec<String> {
667 match family {
668 "qwen-image-edit" | "qwen_image_edit" => {
669 vec!["qwen-image".to_string(), "qwen-image-edit".to_string()]
670 }
671 other => vec![other.to_string()],
672 }
673}
674
675async fn lora_family_for_model(state: &crate::state::AppState, model: &str) -> Option<String> {
676 let canonical = mold_core::manifest::resolve_model_name(model);
677 if let Some(manifest) = mold_core::manifest::find_manifest(&canonical) {
678 return Some(manifest.family.clone());
679 }
680 if let Some(manifest) = mold_core::manifest::find_manifest(model) {
681 return Some(manifest.family.clone());
682 }
683
684 {
685 let intents = state.catalog_intents.read().await;
686 if let Some(intent) = intents.get(model).or_else(|| intents.get(&canonical)) {
687 return Some(intent.family.clone());
688 }
689 }
690
691 let config = state.config.read().await;
692 let configured = config
693 .models
694 .get(model)
695 .or_else(|| config.models.get(&canonical))
696 .and_then(|m| m.family.clone());
697 if configured.is_some() {
698 return configured;
699 }
700 let models_dir = config.resolved_models_dir();
701 drop(config);
702
703 if model.starts_with("cv:") || model.starts_with("hf:") {
704 for (_, sidecar) in mold_catalog::sidecar::walk_sidecars(&models_dir) {
705 if sidecar.id == model {
706 return Some(sidecar.family);
707 }
708 }
709 }
710
711 None
712}
713
714fn sidecar_to_lora_info(
715 dir: &std::path::Path,
716 sidecar: mold_catalog::sidecar::CatalogSidecar,
717) -> Option<mold_core::LoraInfo> {
718 let path = mold_catalog::sidecar::primary_path_if_present(dir, &sidecar)?;
719 Some(mold_core::LoraInfo {
720 id: sidecar.id,
721 name: sidecar.name,
722 family: sidecar.family,
723 author: sidecar.author,
724 path: path.to_string_lossy().into_owned(),
725 trained_words: sidecar.trained_words,
726 size_bytes: sidecar.size_bytes,
727 thumbnail_url: sidecar.thumbnail_url,
728 added_at: sidecar.written_at,
729 })
730}
731
732fn parse_kind(s: &str) -> Option<mold_catalog::entry::Kind> {
733 use mold_catalog::entry::Kind::*;
734 Some(match s {
735 "checkpoint" => Checkpoint,
736 "lora" => Lora,
737 "vae" => Vae,
738 "text-encoder" => TextEncoder,
739 "tokenizer" => Tokenizer,
740 "clip" => Clip,
741 "control-net" => ControlNet,
742 _ => return None,
743 })
744}
745
746fn live_entry_to_wire(
747 entry: &mold_catalog::entry::CatalogEntry,
748 models_dir: &std::path::Path,
749) -> serde_json::Value {
750 let (installed, primary_path) = if let Some(companion_name) =
758 mold_catalog::live::companion_name_for_catalog_id(entry.id.as_str())
759 {
760 let installed = mold_core::manifest::find_manifest(companion_name)
761 .map(|manifest| mold_core::download::companion_present_on_disk(models_dir, manifest))
762 .unwrap_or(false);
763 (installed, None)
764 } else if matches!(entry.source, mold_catalog::entry::Source::Civitai) {
765 let sc_path = mold_catalog::sidecar::civitai_sidecar_path(models_dir, entry.id.as_str());
766 match mold_catalog::sidecar::read_sidecar(&sc_path) {
767 Ok(sidecar) => match sc_path.parent() {
768 Some(parent) => {
769 match mold_catalog::sidecar::primary_path_if_present(parent, &sidecar) {
770 Some(abs) => (true, Some(abs.to_string_lossy().into_owned())),
771 None => (false, None),
772 }
773 }
774 None => (false, None),
775 },
776 Err(_) => (false, None),
777 }
778 } else {
779 (false, None)
780 };
781 let companion_details = entry
782 .companions
783 .iter()
784 .filter_map(|name| {
785 mold_catalog::companions::COMPANIONS
786 .iter()
787 .find(|companion| companion.canonical_name == name)
788 .map(|companion| {
789 serde_json::json!({
790 "name": companion.canonical_name,
791 "kind": companion.kind,
792 "repo": companion.repo,
793 "size_bytes": companion.size_bytes,
794 })
795 })
796 })
797 .collect::<Vec<_>>();
798
799 serde_json::json!({
800 "id": entry.id.as_str(),
801 "source": entry.source,
802 "source_id": entry.source_id,
803 "name": entry.name,
804 "author": entry.author,
805 "family": entry.family.as_str(),
806 "family_role": entry.family_role,
807 "sub_family": entry.sub_family,
808 "modality": entry.modality,
809 "kind": entry.kind,
810 "file_format": entry.file_format,
811 "bundling": entry.bundling,
812 "size_bytes": entry.size_bytes,
813 "download_count": entry.download_count,
814 "rating": entry.rating,
815 "likes": entry.likes,
816 "nsfw": entry.nsfw,
817 "thumbnail_url": entry.thumbnail_url,
818 "description": entry.description,
819 "license": entry.license,
820 "license_flags": entry.license_flags,
821 "tags": entry.tags,
822 "companions": entry.companions,
823 "companion_details": companion_details,
824 "download_recipe": entry.download_recipe,
825 "engine_phase": entry.engine_phase,
826 "installed": installed,
827 "primary_path": primary_path,
828 "created_at": entry.created_at,
829 "updated_at": entry.updated_at,
830 "added_at": entry.added_at,
831 "trained_words": entry.trained_words,
832 })
833}
834
835fn sidecar_to_wire(
836 sc: mold_catalog::sidecar::CatalogSidecar,
837 installed: bool,
838 primary_path: Option<String>,
839) -> serde_json::Value {
840 serde_json::json!({
847 "id": sc.id,
848 "source": sc.source,
849 "source_id": sc.source_id,
850 "name": sc.name,
851 "author": sc.author,
852 "family": sc.family,
853 "family_role": sc.family_role,
854 "sub_family": sc.sub_family,
855 "modality": sc.modality,
856 "kind": sc.kind,
857 "file_format": "safetensors",
858 "bundling": "single-file",
859 "size_bytes": sc.size_bytes,
860 "download_count": 0,
861 "rating": null,
862 "likes": 0,
863 "nsfw": false,
864 "thumbnail_url": sc.thumbnail_url,
865 "description": null,
866 "license": null,
867 "license_flags": null,
868 "tags": [],
869 "companions": [],
870 "companion_details": [],
871 "download_recipe": { "files": [], "needs_token": null },
872 "engine_phase": sc.engine_phase,
873 "installed": installed,
874 "primary_path": primary_path,
875 "created_at": null,
876 "updated_at": null,
877 "added_at": sc.written_at,
878 "trained_words": sc.trained_words,
879 })
880}
881
882#[cfg(test)]
883#[path = "catalog_live_test.rs"]
884mod catalog_live_test;