1use axum::extract::{Path, Query, State};
5use axum::http::{HeaderMap, 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 headers: HeaderMap,
12) -> impl IntoResponse {
13 let forwarded = ForwardedCatalogCredentials::from_headers(&headers);
14 let cfg_guard = state.config.read().await;
15 let models_dir = cfg_guard.resolved_models_dir();
16 drop(cfg_guard);
17
18 let entry = if let Some(entry) = mold_catalog::live::companion_entry_for_id(&id) {
19 entry
20 } else if let Some(version_id) = id.strip_prefix("cv:") {
21 match fetch_civitai_version_with_fallback(&state, version_id, forwarded.civitai.as_deref())
22 .await
23 {
24 Ok((e, _)) => e,
25 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
26 return (StatusCode::NOT_FOUND, "not found").into_response();
27 }
28 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
29 }
30 } else if let Some(repo_id) = id.strip_prefix("hf:") {
31 match fetch_hf_repo_with_fallback(repo_id, forwarded.hf.as_deref()).await {
32 Ok((e, _)) => e,
33 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
34 return (StatusCode::NOT_FOUND, "not found").into_response();
35 }
36 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
37 }
38 } else {
39 return (
40 StatusCode::BAD_REQUEST,
41 "id must be `cv:` or `hf:` prefixed",
42 )
43 .into_response();
44 };
45
46 Json(live_entry_to_wire(&entry, &models_dir)).into_response()
47}
48
49pub async fn post_catalog_dispatch(
53 State(state): State<crate::state::AppState>,
54 Path(rest): Path<String>,
55 headers: HeaderMap,
56) -> impl IntoResponse {
57 if let Some(id) = rest.strip_suffix("/download") {
58 post_catalog_download(State(state), Path(id.to_string()), headers)
59 .await
60 .into_response()
61 } else {
62 (StatusCode::NOT_FOUND, "unknown catalog action").into_response()
63 }
64}
65
66const FORWARDED_HF_TOKEN_HEADER: &str = "x-mold-hf-token";
67const FORWARDED_CIVITAI_TOKEN_HEADER: &str = "x-mold-civitai-token";
68
69#[derive(Clone, Debug, Default)]
70struct ForwardedCatalogCredentials {
71 hf: Option<String>,
72 civitai: Option<String>,
73}
74
75impl ForwardedCatalogCredentials {
76 fn from_headers(headers: &HeaderMap) -> Self {
77 let read = |name: &'static str| {
78 headers
79 .get(name)
80 .and_then(|value| value.to_str().ok())
81 .map(str::trim)
82 .filter(|value| !value.is_empty())
83 .map(str::to_string)
84 };
85 Self {
86 hf: read(FORWARDED_HF_TOKEN_HEADER),
87 civitai: read(FORWARDED_CIVITAI_TOKEN_HEADER),
88 }
89 }
90}
91
92fn credential_candidates(server: Option<String>, forwarded: Option<&str>) -> Vec<Option<String>> {
93 let server = server.filter(|value| !value.trim().is_empty());
94 let forwarded = forwarded
95 .map(str::trim)
96 .filter(|value| !value.is_empty())
97 .map(str::to_string);
98 match (server, forwarded) {
99 (Some(server), Some(forwarded)) if server != forwarded => {
100 vec![Some(server), Some(forwarded)]
101 }
102 (Some(server), _) => vec![Some(server)],
103 (None, Some(forwarded)) => vec![Some(forwarded)],
104 (None, None) => vec![None],
105 }
106}
107
108fn env_catalog_token(name: &str) -> Option<String> {
109 std::env::var(name)
110 .ok()
111 .map(|value| value.trim().to_string())
112 .filter(|value| !value.is_empty())
113}
114
115fn is_auth_error(error: &mold_catalog::live::LiveSearchError) -> bool {
116 matches!(
117 error,
118 mold_catalog::live::LiveSearchError::Upstream {
119 status: 401 | 403,
120 ..
121 }
122 )
123}
124
125fn replace_failed_search_credential(
126 error: &mold_catalog::live::LiveSearchError,
127 opts: &mut mold_catalog::live::LiveSearchOpts,
128 forwarded: &ForwardedCatalogCredentials,
129) -> bool {
130 let mold_catalog::live::LiveSearchError::Upstream {
131 host,
132 status: 401 | 403,
133 ..
134 } = error
135 else {
136 return false;
137 };
138 let (current, forwarded) = if *host == "civitai.com" {
139 (&mut opts.civitai_token, forwarded.civitai.as_ref())
140 } else {
141 (&mut opts.hf_token, forwarded.hf.as_ref())
142 };
143 if current.is_none() {
144 return false;
145 }
146 if let Some(replacement) = forwarded.filter(|replacement| current.as_ref() != Some(replacement))
147 {
148 *current = Some(replacement.clone());
149 return true;
150 }
151 *current = None;
152 true
153}
154
155async fn fetch_civitai_version_with_fallback(
156 state: &crate::state::AppState,
157 version_id: &str,
158 forwarded: Option<&str>,
159) -> Result<(mold_catalog::entry::CatalogEntry, Option<String>), mold_catalog::live::LiveSearchError>
160{
161 let candidates = credential_candidates(env_catalog_token("CIVITAI_TOKEN"), forwarded);
162 let mut last_error = None;
163 for (index, token) in candidates.iter().enumerate() {
164 match mold_catalog::live::fetch_civitai_version(
165 state.catalog_live_civitai_base.as_str(),
166 version_id,
167 token.as_deref(),
168 )
169 .await
170 {
171 Ok(entry) => return Ok((entry, token.clone())),
172 Err(error) if is_auth_error(&error) && index + 1 < candidates.len() => {
173 last_error = Some(error)
174 }
175 Err(error) => return Err(error),
176 }
177 }
178 Err(last_error.expect("credential candidates are never empty"))
179}
180
181async fn fetch_hf_repo_with_fallback(
182 repo_id: &str,
183 forwarded: Option<&str>,
184) -> Result<(mold_catalog::entry::CatalogEntry, Option<String>), mold_catalog::live::LiveSearchError>
185{
186 let candidates = credential_candidates(env_catalog_token("HF_TOKEN"), forwarded);
187 let mut last_error = None;
188 for (index, token) in candidates.iter().enumerate() {
189 match mold_catalog::live::fetch_hf_repo("https://huggingface.co", repo_id, token.as_deref())
190 .await
191 {
192 Ok(entry) => return Ok((entry, token.clone())),
193 Err(error) if is_auth_error(&error) && index + 1 < candidates.len() => {
194 last_error = Some(error)
195 }
196 Err(error) => return Err(error),
197 }
198 }
199 Err(last_error.expect("credential candidates are never empty"))
200}
201
202fn rendered_primary_recipe_dest(
203 entry: &mold_catalog::entry::CatalogEntry,
204 author: &str,
205 name: &str,
206) -> Option<String> {
207 entry
208 .download_recipe
209 .files
210 .iter()
211 .find(|file| file.role.is_none())
212 .or_else(|| entry.download_recipe.files.first())
213 .map(|file| {
214 mold_catalog::entry::render_recipe_dest(&file.dest, entry.family.as_str(), author, name)
215 })
216}
217
218pub(crate) async fn enqueue_catalog_primary_repair(
219 state: &crate::state::AppState,
220 id: &str,
221) -> Result<Option<String>, (StatusCode, String)> {
222 let models_dir = state.config.read().await.resolved_models_dir();
223 if let Some(version_id) = id.strip_prefix("cv:") {
224 let sc_path = mold_catalog::sidecar::civitai_sidecar_path(&models_dir, id);
225 if let Ok(sidecar) = mold_catalog::sidecar::read_sidecar(&sc_path) {
226 if let Some(parent) = sc_path.parent() {
227 if mold_catalog::sidecar::primary_path_if_present(parent, &sidecar).is_some() {
228 return Ok(None);
229 }
230 }
231 }
232
233 let entry = mold_catalog::live::fetch_civitai_version(
234 state.catalog_live_civitai_base.as_str(),
235 version_id,
236 std::env::var("CIVITAI_TOKEN").ok().as_deref(),
237 )
238 .await
239 .map_err(|e| match e {
240 mold_catalog::live::LiveSearchError::Upstream { status: 404, .. } => (
241 StatusCode::NOT_FOUND,
242 format!("{id}: upstream returned 404"),
243 ),
244 other => (StatusCode::BAD_GATEWAY, format!("upstream: {other}")),
245 })?;
246
247 let auth = match entry.download_recipe.needs_token {
248 Some(mold_catalog::entry::TokenKind::Civitai) => {
249 mold_core::download::civitai_auth_or_error(id)
250 .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?
251 }
252 _ => mold_core::download::RecipeAuth::None,
253 };
254 let (author, name) = match entry.source_id.split_once('/') {
255 Some((a, n)) => (a.to_string(), n.to_string()),
256 None => (String::new(), entry.source_id.clone()),
257 };
258 let files: Vec<crate::downloads::OwnedRecipeFile> = entry
259 .download_recipe
260 .files
261 .iter()
262 .map(|f| crate::downloads::OwnedRecipeFile {
263 url: f.url.clone(),
264 dest: mold_catalog::entry::render_recipe_dest(
265 &f.dest,
266 entry.family.as_str(),
267 &author,
268 &name,
269 ),
270 sha256: f.sha256.clone(),
271 size_bytes: f.size_bytes,
272 })
273 .collect();
274 if let Some(primary_dest) = rendered_primary_recipe_dest(&entry, &author, &name) {
275 let sidecar = mold_catalog::sidecar::sidecar_from_entry(&entry, primary_dest);
276 if let Err(e) = mold_catalog::sidecar::write_sidecar(&sc_path, &sidecar) {
277 tracing::warn!(
278 target: "catalog.sidecar",
279 catalog_id = %id,
280 error = %e,
281 "sidecar write failed during primary repair",
282 );
283 }
284 }
285 let payload = crate::downloads::RecipePayload {
286 catalog_id: id.to_string(),
287 files,
288 auth,
289 };
290 let (job_id, _, _) = state
291 .downloads
292 .enqueue_recipe_in_group(payload, id)
293 .await
294 .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
295 return Ok(Some(job_id));
296 }
297
298 if let Some(repo_id) = id.strip_prefix("hf:") {
299 let entry = mold_catalog::live::fetch_hf_repo(
300 "https://huggingface.co",
301 repo_id,
302 std::env::var("HF_TOKEN").ok().as_deref(),
303 )
304 .await
305 .map_err(|e| match e {
306 mold_catalog::live::LiveSearchError::Upstream { status: 404, .. } => (
307 StatusCode::NOT_FOUND,
308 format!("{id}: upstream returned 404"),
309 ),
310 other => (StatusCode::BAD_GATEWAY, format!("upstream: {other}")),
311 })?;
312 let model = match mold_core::manifest::find_manifest(&entry.source_id) {
313 Some(m) => m.name.clone(),
314 None => entry.source_id.clone(),
315 };
316 let (job_id, _, _) = state
317 .downloads
318 .enqueue_in_group(model, id)
319 .await
320 .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
321 return Ok(Some(job_id));
322 }
323
324 Ok(None)
325}
326
327pub async fn list_families(State(_state): State<crate::state::AppState>) -> impl IntoResponse {
328 use mold_catalog::families::{Family, ALL_FAMILIES};
332 let merged: Vec<serde_json::Value> = ALL_FAMILIES
333 .iter()
334 .map(|f: &Family| serde_json::json!({ "family": f.as_str() }))
335 .collect();
336 Json(serde_json::json!({ "families": merged })).into_response()
337}
338
339#[derive(Clone, Debug, serde::Serialize)]
342pub struct CompanionJob {
343 pub name: String,
347 pub job_id: String,
349}
350
351pub(crate) async fn enqueue_missing_companions(
365 companion_names: &[String],
366 models_dir: &std::path::Path,
367 queue: &crate::downloads::DownloadQueue,
368 catalog_id: Option<&str>,
369 hf_fallback_token: Option<String>,
370) -> Vec<CompanionJob> {
371 let manifests = mold_core::download::missing_companions(companion_names, models_dir);
372 let mut jobs = Vec::with_capacity(manifests.len());
373 for manifest in manifests {
374 let result = match catalog_id {
380 Some(id) => {
381 queue
382 .enqueue_in_group_with_hf_fallback(
383 manifest.name.clone(),
384 id,
385 hf_fallback_token.clone(),
386 )
387 .await
388 }
389 None => {
390 queue
391 .enqueue_with_hf_fallback(manifest.name.clone(), hf_fallback_token.clone())
392 .await
393 }
394 };
395 match result {
396 Ok((job_id, _, _)) => jobs.push(CompanionJob {
397 name: manifest.name.clone(),
398 job_id,
399 }),
400 Err(e) => {
401 tracing::warn!(
402 companion = %manifest.name,
403 error = %e,
404 "companion enqueue failed",
405 );
406 }
407 }
408 }
409 jobs
410}
411
412pub async fn post_catalog_download(
413 State(state): State<crate::state::AppState>,
414 Path(id): Path<String>,
415 headers: HeaderMap,
416) -> impl IntoResponse {
417 let forwarded = ForwardedCatalogCredentials::from_headers(&headers);
418 if let Some(companion_name) = mold_catalog::live::companion_name_for_catalog_id(&id) {
420 let models_dir = state.config.read().await.resolved_models_dir();
421 let companion_jobs = enqueue_missing_companions(
422 &[companion_name.to_string()],
423 &models_dir,
424 &state.downloads,
425 Some(&id),
426 forwarded.hf.clone(),
427 )
428 .await;
429 let primary_job_id = if companion_jobs.is_empty() {
430 None
431 } else {
432 Some(companion_jobs[0].job_id.clone())
433 };
434 return (
435 StatusCode::ACCEPTED,
436 Json(serde_json::json!({
437 "primary_job_id": primary_job_id,
438 "companion_jobs": [],
439 })),
440 )
441 .into_response();
442 }
443
444 let (entry, resolved_token) = if let Some(version_id) = id.strip_prefix("cv:") {
445 match fetch_civitai_version_with_fallback(&state, version_id, forwarded.civitai.as_deref())
446 .await
447 {
448 Ok(e) => e,
449 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
450 return (StatusCode::NOT_FOUND, "unknown catalog id").into_response();
451 }
452 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
453 }
454 } else if let Some(repo_id) = id.strip_prefix("hf:") {
455 match fetch_hf_repo_with_fallback(repo_id, forwarded.hf.as_deref()).await {
456 Ok(e) => e,
457 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
458 return (StatusCode::NOT_FOUND, "unknown catalog id").into_response();
459 }
460 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
461 }
462 } else {
463 return (
464 StatusCode::BAD_REQUEST,
465 "id must be `cv:` or `hf:` prefixed",
466 )
467 .into_response();
468 };
469
470 if entry.engine_phase >= 6 {
471 return (
472 StatusCode::CONFLICT,
473 format!(
474 "engine_phase {} not yet supported by this build — see release notes",
475 entry.engine_phase
476 ),
477 )
478 .into_response();
479 }
480
481 let models_dir = state.config.read().await.resolved_models_dir();
487 let entry_id = entry.id.as_str().to_string();
488 let companion_jobs = enqueue_missing_companions(
489 &entry.companions,
490 &models_dir,
491 &state.downloads,
492 Some(&entry_id),
493 forwarded.hf.clone(),
494 )
495 .await;
496
497 use mold_catalog::entry::{Kind, Source};
502 let primary_job_id: Option<String> = match entry.source {
503 Source::Hf => {
504 if let Some(manifest) = mold_core::manifest::find_manifest(&entry.source_id) {
505 match state
506 .downloads
507 .enqueue_in_group_with_hf_fallback(
508 manifest.name.clone(),
509 &entry_id,
510 resolved_token.clone(),
511 )
512 .await
513 {
514 Ok((jid, _, _)) => Some(jid),
515 Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
516 }
517 } else if entry.kind == Kind::Lora {
518 let auth = match entry.download_recipe.needs_token {
519 Some(mold_catalog::entry::TokenKind::Hf) => match resolved_token.clone() {
520 Some(token) => mold_core::download::RecipeAuth::Bearer(token),
521 None => {
522 return (
523 StatusCode::UNAUTHORIZED,
524 "this Hugging Face repository requires an HF token",
525 )
526 .into_response();
527 }
528 },
529 _ => mold_core::download::RecipeAuth::None,
530 };
531 let (author, name) = match entry.source_id.split_once('/') {
532 Some((a, n)) => (a.to_string(), n.to_string()),
533 None => (String::new(), entry.source_id.clone()),
534 };
535 let files = entry
536 .download_recipe
537 .files
538 .iter()
539 .map(|file| crate::downloads::OwnedRecipeFile {
540 url: file.url.clone(),
541 dest: mold_catalog::entry::render_recipe_dest(
542 &file.dest,
543 entry.family.as_str(),
544 &author,
545 &name,
546 ),
547 sha256: file.sha256.clone(),
548 size_bytes: file.size_bytes,
549 })
550 .collect();
551 if let Some(primary_dest) = rendered_primary_recipe_dest(&entry, &author, &name) {
552 let sidecar = mold_catalog::sidecar::sidecar_from_entry(&entry, primary_dest);
553 let path = mold_catalog::sidecar::civitai_sidecar_path(&models_dir, &entry_id);
554 if let Err(error) = mold_catalog::sidecar::write_sidecar(&path, &sidecar) {
555 tracing::warn!(
556 target: "catalog.sidecar",
557 catalog_id = %entry_id,
558 %error,
559 "HF LoRA sidecar write failed",
560 );
561 }
562 }
563 let payload = crate::downloads::RecipePayload {
564 catalog_id: entry_id.clone(),
565 files,
566 auth,
567 };
568 match state
569 .downloads
570 .enqueue_recipe_in_group(payload, &entry_id)
571 .await
572 {
573 Ok((jid, _, _)) => Some(jid),
574 Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
575 }
576 } else {
577 return (
578 StatusCode::BAD_REQUEST,
579 "this Hugging Face catalog entry is not a supported built-in model or LoRA",
580 )
581 .into_response();
582 }
583 }
584 Source::Civitai => {
585 let auth = match entry.download_recipe.needs_token {
586 Some(mold_catalog::entry::TokenKind::Civitai) => match resolved_token {
587 Some(token) => mold_core::download::RecipeAuth::Bearer(token),
588 None => match mold_core::download::civitai_auth_or_error(&entry_id) {
589 Ok(auth) => auth,
590 Err(e) => {
591 return (StatusCode::UNAUTHORIZED, e.to_string()).into_response();
592 }
593 },
594 },
595 _ => mold_core::download::RecipeAuth::None,
596 };
597 let (author, name) = match entry.source_id.split_once('/') {
598 Some((a, n)) => (a.to_string(), n.to_string()),
599 None => (String::new(), entry.source_id.clone()),
600 };
601 let files: Vec<crate::downloads::OwnedRecipeFile> = entry
602 .download_recipe
603 .files
604 .iter()
605 .map(|f| crate::downloads::OwnedRecipeFile {
606 url: f.url.clone(),
607 dest: mold_catalog::entry::render_recipe_dest(
608 &f.dest,
609 entry.family.as_str(),
610 &author,
611 &name,
612 ),
613 sha256: f.sha256.clone(),
614 size_bytes: f.size_bytes,
615 })
616 .collect();
617 if let Some(primary_dest) = rendered_primary_recipe_dest(&entry, &author, &name) {
621 let sidecar = mold_catalog::sidecar::sidecar_from_entry(&entry, primary_dest);
622 let sc_path = mold_catalog::sidecar::civitai_sidecar_path(&models_dir, &entry_id);
623 if let Err(e) = mold_catalog::sidecar::write_sidecar(&sc_path, &sidecar) {
624 tracing::warn!(
625 target: "catalog.sidecar",
626 catalog_id = %entry_id,
627 error = %e,
628 "sidecar write failed; picker will need a reinstall to surface this row",
629 );
630 }
631 }
632 let payload = crate::downloads::RecipePayload {
633 catalog_id: entry_id.clone(),
634 files,
635 auth,
636 };
637 match state
638 .downloads
639 .enqueue_recipe_in_group(payload, &entry_id)
640 .await
641 {
642 Ok((jid, _, _)) => Some(jid),
643 Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
644 }
645 }
646 };
647
648 (
649 StatusCode::ACCEPTED,
650 Json(serde_json::json!({
651 "primary_job_id": primary_job_id,
652 "companion_jobs": companion_jobs,
653 })),
654 )
655 .into_response()
656}
657
658#[derive(Debug, serde::Deserialize)]
661pub struct LiveSearchQuery {
662 pub q: Option<String>,
663 pub family: Option<String>,
664 pub kind: Option<String>,
665 pub source: Option<String>,
666 pub include_nsfw: Option<bool>,
667 pub page: Option<u32>,
668 pub page_size: Option<u32>,
669 pub sort: Option<String>,
674}
675
676pub async fn live_search_catalog(
681 State(state): State<crate::state::AppState>,
682 Query(q): Query<LiveSearchQuery>,
683 headers: HeaderMap,
684) -> impl IntoResponse {
685 let forwarded = ForwardedCatalogCredentials::from_headers(&headers);
686 let family = match q.family.as_deref().filter(|s| !s.is_empty()) {
687 Some(s) => match mold_catalog::families::Family::from_str(s) {
688 Ok(f) => Some(f),
689 Err(_) => {
690 return (StatusCode::BAD_REQUEST, format!("unknown family: {s}")).into_response()
691 }
692 },
693 None => None,
694 };
695 let kind = match q.kind.as_deref().filter(|s| !s.is_empty()) {
696 Some(s) => match parse_kind(s) {
697 Some(k) => Some(k),
698 None => return (StatusCode::BAD_REQUEST, format!("unknown kind: {s}")).into_response(),
699 },
700 None => None,
701 };
702 let source = match q.source.as_deref().filter(|s| !s.is_empty()) {
703 Some("hf") => Some(mold_catalog::entry::Source::Hf),
704 Some("civitai") => Some(mold_catalog::entry::Source::Civitai),
705 Some(other) => {
706 return (StatusCode::BAD_REQUEST, format!("unknown source: {other}")).into_response()
707 }
708 None => None,
709 };
710 let sort = match q.sort.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
711 Some(s) => match mold_catalog::live::CatalogSort::from_wire(s) {
712 Some(sort) => sort,
713 None => {
714 return (
715 StatusCode::UNPROCESSABLE_ENTITY,
716 format!(
717 "unknown sort: {s} (allowed values: {})",
718 mold_catalog::live::CatalogSort::WIRE_VALUES.join(", ")
719 ),
720 )
721 .into_response()
722 }
723 },
724 None => mold_catalog::live::CatalogSort::default(),
725 };
726
727 let server_civitai = env_catalog_token("CIVITAI_TOKEN");
728 let server_hf = env_catalog_token("HF_TOKEN");
729 let mut opts = mold_catalog::live::LiveSearchOpts {
730 q: q.q.clone(),
731 family,
732 kind,
733 source,
734 page: q.page.unwrap_or(1).max(1),
735 page_size: q.page_size.unwrap_or(20).clamp(1, 100),
736 include_nsfw: q.include_nsfw.unwrap_or(true),
737 sort,
738 civitai_token: server_civitai.clone().or_else(|| forwarded.civitai.clone()),
739 hf_token: server_hf.clone().or_else(|| forwarded.hf.clone()),
740 };
741
742 let cfg = state.config.read().await;
743 let models_dir = cfg.resolved_models_dir();
744 drop(cfg);
745
746 let search_result = loop {
747 let result = mold_catalog::live::search(
748 state.catalog_live_civitai_base.as_str(),
749 "https://huggingface.co",
750 &state.catalog_live_cache,
751 &opts,
752 )
753 .await;
754 match result {
755 Err(ref error) if replace_failed_search_credential(error, &mut opts, &forwarded) => {
756 continue;
757 }
758 result => break result,
759 }
760 };
761 let entries = match search_result {
762 Ok(es) => es,
763 Err(e) => {
764 tracing::warn!(target: "catalog.live", error = %e, "live search failed");
765 return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response();
766 }
767 };
768
769 let wire: Vec<serde_json::Value> = entries
770 .iter()
771 .map(|e| live_entry_to_wire(e, &models_dir))
772 .collect();
773 let total = wire.len() as i64;
774 Json(serde_json::json!({
775 "entries": wire,
776 "page": opts.page,
777 "page_size": opts.page_size,
778 "total": total,
779 }))
780 .into_response()
781}
782
783#[derive(Debug, serde::Deserialize)]
784pub struct InstalledQuery {
785 pub kind: Option<String>,
786 pub family: Option<String>,
787}
788
789#[derive(Debug, serde::Deserialize)]
790pub struct ListLorasQuery {
791 pub model: Option<String>,
792}
793
794pub async fn list_installed_catalog(
798 State(state): State<crate::state::AppState>,
799 Query(q): Query<InstalledQuery>,
800) -> impl IntoResponse {
801 let kind_filter = q.kind.as_deref().map(|s| s.to_string());
802 let family_filter = q.family.as_deref().map(|s| s.to_string());
803 let compatible_lora_families = match kind_filter.as_deref() {
804 Some("lora") => family_filter.as_deref().map(compatible_lora_families),
805 _ => None,
806 };
807
808 let cfg = state.config.read().await;
809 let models_dir = cfg.resolved_models_dir();
810 drop(cfg);
811
812 let walked = mold_catalog::sidecar::walk_sidecars(&models_dir);
813 let mut wire = Vec::with_capacity(walked.len());
814 for (dir, sidecar) in walked {
815 if let Some(k) = kind_filter.as_deref() {
816 if sidecar.kind != k {
817 continue;
818 }
819 }
820 if let Some(f) = family_filter.as_deref() {
821 let family_matches = compatible_lora_families
822 .as_ref()
823 .is_some_and(|families| families.contains(&sidecar.family))
824 || sidecar.family == f;
825 if !family_matches {
826 continue;
827 }
828 }
829 let abs = mold_catalog::sidecar::primary_path_if_present(&dir, &sidecar);
830 let installed = abs.is_some();
831 let primary_path = abs.map(|p| p.to_string_lossy().into_owned());
832 wire.push(sidecar_to_wire(sidecar, installed, primary_path));
833 }
834 let total = wire.len() as i64;
835 Json(mold_core::catalog_wire::InstalledCatalogResponse {
836 entries: wire,
837 page: 1,
838 page_size: total,
839 total,
840 })
841 .into_response()
842}
843
844#[utoipa::path(
848 get,
849 path = "/api/loras",
850 tag = "models",
851 params(
852 ("model" = Option<String>, Query, description = "Optional model name used to filter LoRAs by compatible family")
853 ),
854 responses(
855 (status = 200, description = "Installed LoRAs", body = Vec<mold_core::LoraInfo>),
856 (status = 400, description = "Unknown model")
857 )
858)]
859pub async fn list_loras(
860 State(state): State<crate::state::AppState>,
861 Query(q): Query<ListLorasQuery>,
862) -> Result<Json<Vec<mold_core::LoraInfo>>, crate::routes::ApiError> {
863 let family_filter = match q.model.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
864 Some(model) => {
865 let family = lora_family_for_model(&state, model).await.ok_or_else(|| {
866 crate::routes::ApiError::unknown_model(format!(
867 "unknown model '{model}'; cannot resolve compatible LoRAs"
868 ))
869 })?;
870 if !mold_core::family_supports_lora(&family) {
871 return Ok(Json(Vec::new()));
872 }
873 Some(compatible_lora_families(&family))
874 }
875 None => None,
876 };
877
878 let cfg = state.config.read().await;
879 let models_dir = cfg.resolved_models_dir();
880 drop(cfg);
881
882 let mut loras = mold_catalog::sidecar::walk_sidecars(&models_dir)
883 .into_iter()
884 .filter_map(|(dir, sidecar)| {
885 if sidecar.kind != "lora" {
886 return None;
887 }
888 if let Some(families) = family_filter.as_ref() {
889 if !families.contains(&sidecar.family) {
890 return None;
891 }
892 }
893 sidecar_to_lora_info(&dir, sidecar)
894 })
895 .collect::<Vec<_>>();
896
897 loras.sort_by(|a, b| {
898 b.added_at
899 .cmp(&a.added_at)
900 .then_with(|| a.name.cmp(&b.name))
901 .then_with(|| a.id.cmp(&b.id))
902 });
903 Ok(Json(loras))
904}
905
906fn compatible_lora_families(family: &str) -> Vec<String> {
907 match family {
908 "qwen-image-edit" | "qwen_image_edit" => {
909 vec!["qwen-image".to_string(), "qwen-image-edit".to_string()]
910 }
911 other => vec![other.to_string()],
912 }
913}
914
915async fn lora_family_for_model(state: &crate::state::AppState, model: &str) -> Option<String> {
916 let canonical = mold_core::manifest::resolve_model_name(model);
917 if let Some(manifest) = mold_core::manifest::find_manifest(&canonical) {
918 return Some(manifest.family.clone());
919 }
920 if let Some(manifest) = mold_core::manifest::find_manifest(model) {
921 return Some(manifest.family.clone());
922 }
923
924 {
925 let intents = state.catalog_intents.read().await;
926 if let Some(intent) = intents.get(model).or_else(|| intents.get(&canonical)) {
927 return Some(intent.family.clone());
928 }
929 }
930
931 let config = state.config.read().await;
932 let configured = config
933 .models
934 .get(model)
935 .or_else(|| config.models.get(&canonical))
936 .and_then(|m| m.family.clone());
937 if configured.is_some() {
938 return configured;
939 }
940 let models_dir = config.resolved_models_dir();
941 drop(config);
942
943 if model.starts_with("cv:") || model.starts_with("hf:") {
944 for (_, sidecar) in mold_catalog::sidecar::walk_sidecars(&models_dir) {
945 if sidecar.id == model {
946 return Some(sidecar.family);
947 }
948 }
949 }
950
951 None
952}
953
954fn sidecar_to_lora_info(
955 dir: &std::path::Path,
956 sidecar: mold_catalog::sidecar::CatalogSidecar,
957) -> Option<mold_core::LoraInfo> {
958 let path = mold_catalog::sidecar::primary_path_if_present(dir, &sidecar)?;
959 Some(mold_core::LoraInfo {
960 id: sidecar.id,
961 name: sidecar.name,
962 family: sidecar.family,
963 author: sidecar.author,
964 path: path.to_string_lossy().into_owned(),
965 trained_words: sidecar.trained_words,
966 size_bytes: sidecar.size_bytes,
967 thumbnail_url: sidecar.thumbnail_url,
968 added_at: sidecar.written_at,
969 })
970}
971
972fn parse_kind(s: &str) -> Option<mold_catalog::entry::Kind> {
973 use mold_catalog::entry::Kind::*;
974 Some(match s {
975 "checkpoint" => Checkpoint,
976 "lora" => Lora,
977 "vae" => Vae,
978 "text-encoder" => TextEncoder,
979 "tokenizer" => Tokenizer,
980 "clip" => Clip,
981 "control-net" => ControlNet,
982 _ => return None,
983 })
984}
985
986fn live_entry_to_wire(
987 entry: &mold_catalog::entry::CatalogEntry,
988 models_dir: &std::path::Path,
989) -> serde_json::Value {
990 let (installed, primary_path) = if let Some(companion_name) =
998 mold_catalog::live::companion_name_for_catalog_id(entry.id.as_str())
999 {
1000 let installed = mold_core::manifest::find_manifest(companion_name)
1001 .map(|manifest| mold_core::download::companion_present_on_disk(models_dir, manifest))
1002 .unwrap_or(false);
1003 (installed, None)
1004 } else if matches!(entry.source, mold_catalog::entry::Source::Civitai) {
1005 let sc_path = mold_catalog::sidecar::civitai_sidecar_path(models_dir, entry.id.as_str());
1006 match mold_catalog::sidecar::read_sidecar(&sc_path) {
1007 Ok(sidecar) => match sc_path.parent() {
1008 Some(parent) => {
1009 match mold_catalog::sidecar::primary_path_if_present(parent, &sidecar) {
1010 Some(abs) => (true, Some(abs.to_string_lossy().into_owned())),
1011 None => (false, None),
1012 }
1013 }
1014 None => (false, None),
1015 },
1016 Err(_) => (false, None),
1017 }
1018 } else {
1019 (false, None)
1020 };
1021 let companion_details = entry
1022 .companions
1023 .iter()
1024 .filter_map(|name| {
1025 mold_catalog::companions::COMPANIONS
1026 .iter()
1027 .find(|companion| companion.canonical_name == name)
1028 .map(|companion| {
1029 serde_json::json!({
1030 "name": companion.canonical_name,
1031 "kind": companion.kind,
1032 "repo": companion.repo,
1033 "size_bytes": companion.size_bytes,
1034 })
1035 })
1036 })
1037 .collect::<Vec<_>>();
1038
1039 serde_json::json!({
1040 "id": entry.id.as_str(),
1041 "source": entry.source,
1042 "source_id": entry.source_id,
1043 "name": entry.name,
1044 "author": entry.author,
1045 "family": entry.family.as_str(),
1046 "family_role": entry.family_role,
1047 "sub_family": entry.sub_family,
1048 "modality": entry.modality,
1049 "kind": entry.kind,
1050 "file_format": entry.file_format,
1051 "bundling": entry.bundling,
1052 "size_bytes": entry.size_bytes,
1053 "download_count": entry.download_count,
1054 "rating": entry.rating,
1055 "likes": entry.likes,
1056 "nsfw": entry.nsfw,
1057 "thumbnail_url": entry.thumbnail_url,
1058 "description": entry.description,
1059 "license": entry.license,
1060 "license_flags": entry.license_flags,
1061 "tags": entry.tags,
1062 "companions": entry.companions,
1063 "companion_details": companion_details,
1064 "download_recipe": entry.download_recipe,
1065 "engine_phase": entry.engine_phase,
1066 "installed": installed,
1067 "primary_path": primary_path,
1068 "created_at": entry.created_at,
1069 "updated_at": entry.updated_at,
1070 "added_at": entry.added_at,
1071 "trained_words": entry.trained_words,
1072 "page_url": entry.page_url,
1073 })
1074}
1075
1076pub(crate) fn sidecar_to_wire(
1077 sc: mold_catalog::sidecar::CatalogSidecar,
1078 installed: bool,
1079 primary_path: Option<String>,
1080) -> mold_core::catalog_wire::InstalledCatalogEntry {
1081 mold_core::catalog_wire::InstalledCatalogEntry {
1089 id: sc.id,
1090 source: sc.source,
1091 source_id: sc.source_id,
1092 name: sc.name,
1093 author: sc.author,
1094 family: sc.family,
1095 family_role: sc.family_role,
1096 sub_family: sc.sub_family,
1097 modality: sc.modality,
1098 kind: sc.kind,
1099 file_format: "safetensors".into(),
1100 bundling: "single-file".into(),
1101 size_bytes: sc.size_bytes,
1102 download_count: 0,
1103 rating: None,
1104 likes: 0,
1105 nsfw: false,
1106 thumbnail_url: sc.thumbnail_url,
1107 description: None,
1108 license: None,
1109 license_flags: None,
1110 tags: Vec::new(),
1111 companions: Vec::new(),
1112 companion_details: Vec::new(),
1113 download_recipe: mold_core::catalog_wire::DownloadRecipeWire::default(),
1114 engine_phase: sc.engine_phase,
1115 installed,
1116 primary_path,
1117 created_at: None,
1118 updated_at: None,
1119 added_at: sc.written_at,
1120 trained_words: sc.trained_words,
1121 }
1122}
1123
1124#[cfg(test)]
1125#[path = "catalog_live_test.rs"]
1126mod catalog_live_test;