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(version_id) = id.strip_prefix("cv:") {
17 match mold_catalog::live::fetch_civitai_version(
18 state.catalog_live_civitai_base.as_str(),
19 version_id,
20 std::env::var("CIVITAI_TOKEN").ok().as_deref(),
21 )
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 mold_catalog::live::fetch_hf_repo(
32 "https://huggingface.co",
33 repo_id,
34 std::env::var("HF_TOKEN").ok().as_deref(),
35 )
36 .await
37 {
38 Ok(e) => e,
39 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
40 return (StatusCode::NOT_FOUND, "not found").into_response();
41 }
42 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
43 }
44 } else {
45 return (
46 StatusCode::BAD_REQUEST,
47 "id must be `cv:` or `hf:` prefixed",
48 )
49 .into_response();
50 };
51
52 Json(live_entry_to_wire(&entry, &models_dir)).into_response()
53}
54
55pub async fn post_catalog_dispatch(
59 State(state): State<crate::state::AppState>,
60 Path(rest): Path<String>,
61) -> impl IntoResponse {
62 if let Some(id) = rest.strip_suffix("/download") {
63 post_catalog_download(State(state), Path(id.to_string()))
64 .await
65 .into_response()
66 } else {
67 (StatusCode::NOT_FOUND, "unknown catalog action").into_response()
68 }
69}
70
71pub async fn list_families(State(_state): State<crate::state::AppState>) -> impl IntoResponse {
72 use mold_catalog::families::{Family, ALL_FAMILIES};
76 let merged: Vec<serde_json::Value> = ALL_FAMILIES
77 .iter()
78 .map(|f: &Family| serde_json::json!({ "family": f.as_str() }))
79 .collect();
80 Json(serde_json::json!({ "families": merged })).into_response()
81}
82
83#[derive(Clone, Debug, serde::Serialize)]
86pub struct CompanionJob {
87 pub name: String,
91 pub job_id: String,
93}
94
95pub(crate) async fn enqueue_missing_companions(
109 companion_names: &[String],
110 models_dir: &std::path::Path,
111 queue: &crate::downloads::DownloadQueue,
112 catalog_id: Option<&str>,
113) -> Vec<CompanionJob> {
114 let manifests = mold_core::download::missing_companions(companion_names, models_dir);
115 let mut jobs = Vec::with_capacity(manifests.len());
116 for manifest in manifests {
117 let result = match catalog_id {
123 Some(id) => queue.enqueue_in_group(manifest.name.clone(), id).await,
124 None => queue.enqueue(manifest.name.clone()).await,
125 };
126 match result {
127 Ok((job_id, _, _)) => jobs.push(CompanionJob {
128 name: manifest.name.clone(),
129 job_id,
130 }),
131 Err(e) => {
132 tracing::warn!(
133 companion = %manifest.name,
134 error = %e,
135 "companion enqueue failed",
136 );
137 }
138 }
139 }
140 jobs
141}
142
143pub async fn post_catalog_download(
144 State(state): State<crate::state::AppState>,
145 Path(id): Path<String>,
146) -> impl IntoResponse {
147 let entry = if let Some(version_id) = id.strip_prefix("cv:") {
149 match mold_catalog::live::fetch_civitai_version(
150 state.catalog_live_civitai_base.as_str(),
151 version_id,
152 std::env::var("CIVITAI_TOKEN").ok().as_deref(),
153 )
154 .await
155 {
156 Ok(e) => e,
157 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
158 return (StatusCode::NOT_FOUND, "unknown catalog id").into_response();
159 }
160 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
161 }
162 } else if let Some(repo_id) = id.strip_prefix("hf:") {
163 match mold_catalog::live::fetch_hf_repo(
164 "https://huggingface.co",
165 repo_id,
166 std::env::var("HF_TOKEN").ok().as_deref(),
167 )
168 .await
169 {
170 Ok(e) => e,
171 Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
172 return (StatusCode::NOT_FOUND, "unknown catalog id").into_response();
173 }
174 Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
175 }
176 } else {
177 return (
178 StatusCode::BAD_REQUEST,
179 "id must be `cv:` or `hf:` prefixed",
180 )
181 .into_response();
182 };
183
184 if entry.engine_phase >= 6 {
185 return (
186 StatusCode::CONFLICT,
187 format!(
188 "engine_phase {} not yet supported by this build — see release notes",
189 entry.engine_phase
190 ),
191 )
192 .into_response();
193 }
194
195 let models_dir = state.config.read().await.resolved_models_dir();
201 let entry_id = entry.id.as_str().to_string();
202 let companion_jobs = enqueue_missing_companions(
203 &entry.companions,
204 &models_dir,
205 &state.downloads,
206 Some(&entry_id),
207 )
208 .await;
209
210 use mold_catalog::entry::Source;
216 let primary_job_id: Option<String> = match entry.source {
217 Source::Hf => {
218 let model = match mold_core::manifest::find_manifest(&entry.source_id) {
219 Some(m) => m.name.clone(),
220 None => entry.source_id.clone(),
221 };
222 match state.downloads.enqueue_in_group(model, &entry_id).await {
223 Ok((jid, _, _)) => Some(jid),
224 Err(crate::downloads::EnqueueError::UnknownModel(_)) => None,
225 Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
226 }
227 }
228 Source::Civitai => {
229 let auth = match entry.download_recipe.needs_token {
230 Some(mold_catalog::entry::TokenKind::Civitai) => {
231 match mold_core::download::civitai_auth_or_error(&entry_id) {
232 Ok(a) => a,
233 Err(e) => {
234 return (StatusCode::UNAUTHORIZED, e.to_string()).into_response();
235 }
236 }
237 }
238 _ => mold_core::download::RecipeAuth::None,
239 };
240 let (author, name) = match entry.source_id.split_once('/') {
241 Some((a, n)) => (a.to_string(), n.to_string()),
242 None => (String::new(), entry.source_id.clone()),
243 };
244 let files: Vec<crate::downloads::OwnedRecipeFile> = entry
245 .download_recipe
246 .files
247 .iter()
248 .map(|f| crate::downloads::OwnedRecipeFile {
249 url: f.url.clone(),
250 dest: mold_catalog::entry::render_recipe_dest(
251 &f.dest,
252 entry.family.as_str(),
253 &author,
254 &name,
255 ),
256 sha256: f.sha256.clone(),
257 size_bytes: f.size_bytes,
258 })
259 .collect();
260 if let Some(primary) = files.first() {
264 let sidecar =
265 mold_catalog::sidecar::sidecar_from_entry(&entry, primary.dest.clone());
266 let sc_path = mold_catalog::sidecar::civitai_sidecar_path(&models_dir, &entry_id);
267 if let Err(e) = mold_catalog::sidecar::write_sidecar(&sc_path, &sidecar) {
268 tracing::warn!(
269 target: "catalog.sidecar",
270 catalog_id = %entry_id,
271 error = %e,
272 "sidecar write failed; picker will need a reinstall to surface this row",
273 );
274 }
275 }
276 let payload = crate::downloads::RecipePayload {
277 catalog_id: entry_id.clone(),
278 files,
279 auth,
280 };
281 match state
282 .downloads
283 .enqueue_recipe_in_group(payload, &entry_id)
284 .await
285 {
286 Ok((jid, _, _)) => Some(jid),
287 Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
288 }
289 }
290 };
291
292 (
293 StatusCode::ACCEPTED,
294 Json(serde_json::json!({
295 "primary_job_id": primary_job_id,
296 "companion_jobs": companion_jobs,
297 })),
298 )
299 .into_response()
300}
301
302#[derive(Debug, serde::Deserialize)]
305pub struct LiveSearchQuery {
306 pub q: Option<String>,
307 pub family: Option<String>,
308 pub kind: Option<String>,
309 pub source: Option<String>,
310 pub include_nsfw: Option<bool>,
311 pub page: Option<u32>,
312 pub page_size: Option<u32>,
313}
314
315pub async fn live_search_catalog(
320 State(state): State<crate::state::AppState>,
321 Query(q): Query<LiveSearchQuery>,
322) -> impl IntoResponse {
323 let family = match q.family.as_deref().filter(|s| !s.is_empty()) {
324 Some(s) => match mold_catalog::families::Family::from_str(s) {
325 Ok(f) => Some(f),
326 Err(_) => {
327 return (StatusCode::BAD_REQUEST, format!("unknown family: {s}")).into_response()
328 }
329 },
330 None => None,
331 };
332 let kind = match q.kind.as_deref().filter(|s| !s.is_empty()) {
333 Some(s) => match parse_kind(s) {
334 Some(k) => Some(k),
335 None => return (StatusCode::BAD_REQUEST, format!("unknown kind: {s}")).into_response(),
336 },
337 None => None,
338 };
339 let source = match q.source.as_deref().filter(|s| !s.is_empty()) {
340 Some("hf") => Some(mold_catalog::entry::Source::Hf),
341 Some("civitai") => Some(mold_catalog::entry::Source::Civitai),
342 Some(other) => {
343 return (StatusCode::BAD_REQUEST, format!("unknown source: {other}")).into_response()
344 }
345 None => None,
346 };
347
348 let opts = mold_catalog::live::LiveSearchOpts {
349 q: q.q.clone(),
350 family,
351 kind,
352 source,
353 page: q.page.unwrap_or(1).max(1),
354 page_size: q.page_size.unwrap_or(20).clamp(1, 100),
355 include_nsfw: q.include_nsfw.unwrap_or(false),
356 civitai_token: std::env::var("CIVITAI_TOKEN").ok(),
357 hf_token: std::env::var("HF_TOKEN").ok(),
358 };
359
360 let cfg = state.config.read().await;
361 let models_dir = cfg.resolved_models_dir();
362 drop(cfg);
363
364 let entries = match mold_catalog::live::search(
365 state.catalog_live_civitai_base.as_str(),
366 "https://huggingface.co",
367 &state.catalog_live_cache,
368 &opts,
369 )
370 .await
371 {
372 Ok(es) => es,
373 Err(e) => {
374 tracing::warn!(target: "catalog.live", error = %e, "live search failed");
375 return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response();
376 }
377 };
378
379 let wire: Vec<serde_json::Value> = entries
380 .iter()
381 .map(|e| live_entry_to_wire(e, &models_dir))
382 .collect();
383 let total = wire.len() as i64;
384 Json(serde_json::json!({
385 "entries": wire,
386 "page": opts.page,
387 "page_size": opts.page_size,
388 "total": total,
389 }))
390 .into_response()
391}
392
393#[derive(Debug, serde::Deserialize)]
394pub struct InstalledQuery {
395 pub kind: Option<String>,
396 pub family: Option<String>,
397}
398
399pub async fn list_installed_catalog(
403 State(state): State<crate::state::AppState>,
404 Query(q): Query<InstalledQuery>,
405) -> impl IntoResponse {
406 let kind_filter = q.kind.as_deref().map(|s| s.to_string());
407 let family_filter = q.family.as_deref().map(|s| s.to_string());
408
409 let cfg = state.config.read().await;
410 let models_dir = cfg.resolved_models_dir();
411 drop(cfg);
412
413 let walked = mold_catalog::sidecar::walk_sidecars(&models_dir);
414 let mut wire = Vec::with_capacity(walked.len());
415 for (dir, sidecar) in walked {
416 if let Some(k) = kind_filter.as_deref() {
417 if sidecar.kind != k {
418 continue;
419 }
420 }
421 if let Some(f) = family_filter.as_deref() {
422 if sidecar.family != f {
423 continue;
424 }
425 }
426 let abs = mold_catalog::sidecar::primary_path_if_present(&dir, &sidecar);
427 let installed = abs.is_some();
428 let primary_path = abs.map(|p| p.to_string_lossy().into_owned());
429 wire.push(sidecar_to_wire(sidecar, installed, primary_path));
430 }
431 let total = wire.len() as i64;
432 Json(serde_json::json!({
433 "entries": wire,
434 "page": 1,
435 "page_size": total,
436 "total": total,
437 }))
438 .into_response()
439}
440
441fn parse_kind(s: &str) -> Option<mold_catalog::entry::Kind> {
442 use mold_catalog::entry::Kind::*;
443 Some(match s {
444 "checkpoint" => Checkpoint,
445 "lora" => Lora,
446 "vae" => Vae,
447 "text-encoder" => TextEncoder,
448 "control-net" => ControlNet,
449 _ => return None,
450 })
451}
452
453fn live_entry_to_wire(
454 entry: &mold_catalog::entry::CatalogEntry,
455 models_dir: &std::path::Path,
456) -> serde_json::Value {
457 let (installed, primary_path) = if matches!(entry.source, mold_catalog::entry::Source::Civitai)
465 {
466 let sc_path = mold_catalog::sidecar::civitai_sidecar_path(models_dir, entry.id.as_str());
467 match mold_catalog::sidecar::read_sidecar(&sc_path) {
468 Ok(sidecar) => match sc_path.parent() {
469 Some(parent) => {
470 match mold_catalog::sidecar::primary_path_if_present(parent, &sidecar) {
471 Some(abs) => (true, Some(abs.to_string_lossy().into_owned())),
472 None => (false, None),
473 }
474 }
475 None => (false, None),
476 },
477 Err(_) => (false, None),
478 }
479 } else {
480 (false, None)
481 };
482 serde_json::json!({
483 "id": entry.id.as_str(),
484 "source": entry.source,
485 "source_id": entry.source_id,
486 "name": entry.name,
487 "author": entry.author,
488 "family": entry.family.as_str(),
489 "family_role": entry.family_role,
490 "sub_family": entry.sub_family,
491 "modality": entry.modality,
492 "kind": entry.kind,
493 "file_format": entry.file_format,
494 "bundling": entry.bundling,
495 "size_bytes": entry.size_bytes,
496 "download_count": entry.download_count,
497 "rating": entry.rating,
498 "likes": entry.likes,
499 "nsfw": entry.nsfw,
500 "thumbnail_url": entry.thumbnail_url,
501 "description": entry.description,
502 "license": entry.license,
503 "license_flags": entry.license_flags,
504 "tags": entry.tags,
505 "companions": entry.companions,
506 "download_recipe": entry.download_recipe,
507 "engine_phase": entry.engine_phase,
508 "installed": installed,
509 "primary_path": primary_path,
510 "created_at": entry.created_at,
511 "updated_at": entry.updated_at,
512 "added_at": entry.added_at,
513 "trained_words": entry.trained_words,
514 })
515}
516
517fn sidecar_to_wire(
518 sc: mold_catalog::sidecar::CatalogSidecar,
519 installed: bool,
520 primary_path: Option<String>,
521) -> serde_json::Value {
522 serde_json::json!({
529 "id": sc.id,
530 "source": sc.source,
531 "source_id": sc.source_id,
532 "name": sc.name,
533 "author": sc.author,
534 "family": sc.family,
535 "family_role": sc.family_role,
536 "sub_family": sc.sub_family,
537 "modality": sc.modality,
538 "kind": sc.kind,
539 "file_format": "safetensors",
540 "bundling": "single-file",
541 "size_bytes": sc.size_bytes,
542 "download_count": 0,
543 "rating": null,
544 "likes": 0,
545 "nsfw": false,
546 "thumbnail_url": sc.thumbnail_url,
547 "description": null,
548 "license": null,
549 "license_flags": null,
550 "tags": [],
551 "companions": [],
552 "download_recipe": { "files": [], "needs_token": null },
553 "engine_phase": sc.engine_phase,
554 "installed": installed,
555 "primary_path": primary_path,
556 "created_at": null,
557 "updated_at": null,
558 "added_at": sc.written_at,
559 "trained_words": sc.trained_words,
560 })
561}
562
563#[cfg(test)]
564#[path = "catalog_live_test.rs"]
565mod catalog_live_test;