Skip to main content

mold_server/
catalog_api.rs

1//! Catalog REST surface. Live HF + Civitai proxy with a 5-min in-process
2//! cache; the bulk-scrape DB and scanner are gone.
3
4use 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
55/// POST dispatcher for `/api/catalog/*id` — routes sub-actions based on the
56/// trailing path segment.  Currently only `/download` is handled; everything
57/// else returns 404.
58pub 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    // Live search hits one family per request, so the sidebar just gets
73    // the static taxonomy. No per-family counts — that line is gone from
74    // the SPA too.
75    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/// One queued companion download surfaced in the
84/// `POST /api/catalog/:id/download` response.
85#[derive(Clone, Debug, serde::Serialize)]
86pub struct CompanionJob {
87    /// Canonical companion name from the catalog scanner
88    /// (`mold_catalog::companions::COMPANIONS`). Same string the
89    /// `companions` array on the catalog row carries.
90    pub name: String,
91    /// Job id that polls against `GET /api/downloads`.
92    pub job_id: String,
93}
94
95/// Enqueue any companions in `companion_names` that are not already
96/// fully present under `models_dir`. Returns the per-companion job ids
97/// in the order they appear in the catalog entry.
98///
99/// On-disk presence + name resolution are delegated to
100/// `mold_core::download::missing_companions`, which the CLI also
101/// consumes for `mold pull cv:<id>`'s companion-first ordering.
102///
103/// `DownloadQueue::enqueue` is idempotent against canonical manifest
104/// names: re-enqueuing a companion that's mid-flight returns the existing
105/// job id so concurrent download requests won't double-pull. That lets
106/// the on-disk presence check fall back on "missing → enqueue" without a
107/// race condition.
108pub(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        // When called for a catalog download, the companion job is
118        // registered in the catalog group so `CatalogReady` waits for
119        // it. Outside that context (no catalog id), fall back to the
120        // ungrouped enqueue — companions invoked from elsewhere don't
121        // synchronise on a group event.
122        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    // Live single-id lookup — replaces the bulk-scrape DB read.
148    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    // Phase 2: companions auto-pull before the primary entry. Civitai
196    // single-file checkpoints commonly strip their text encoders + VAE,
197    // so the catalog records `companions: ["clip-l", ...]` on those
198    // entries. Each canonical companion has a hidden synthetic manifest
199    // in `mold-core` so the queue can enqueue them by name.
200    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    // Primary entry. HF rows map onto a manifest model name (best-effort —
211    // diffusers entries with a recognised source_id flow through; the rest
212    // surface a `null` primary while companion downloads still run). `cv:`
213    // rows go through the recipe path: resolve `CIVITAI_TOKEN` if
214    // `needs_token: Civitai`, and call `DownloadQueue::enqueue_recipe`.
215    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            // Sidecar write is best-effort: it lets the LoRA picker see
261            // the entry as soon as the file lands without re-querying
262            // the live catalog. Failure is not fatal.
263            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// ── Live search + installed (replaces the bulk-scrape DB) ──────────────────
303
304#[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
315/// `GET /api/catalog/search` — live proxy to upstream catalog APIs with
316/// a 5-min in-process cache. Replaces `/api/catalog` on the read path
317/// for the SPA's catalog tab. The bulk-scrape DB is retained until a
318/// follow-up release.
319pub 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
399/// `GET /api/catalog/installed` — enumerate installed catalog entries
400/// from the per-install sidecar files under `models_dir`. The LoRA
401/// picker uses this as its primary data source.
402pub 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    // Installed-detection for live rows hangs off the sidecar layout: a
458    // sidecar at `{models_dir}/{sanitized}/mold-catalog.json` whose
459    // primary file actually exists on disk → installed=true. We
460    // intentionally do NOT re-walk the recipe here: that path is a
461    // legacy fallback for rows the catalog DB returned, and live rows
462    // by definition pre-date their sidecar (which gets written at
463    // download time).
464    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    // The sidecar carries fewer fields than a full CatalogEntryWire on
523    // purpose — the picker only needs id/name/family/kind/trained_words/
524    // primary_path. Empty defaults are returned for fields the SPA
525    // ignores in this code path; this keeps the wire shape uniform with
526    // `/api/catalog/search` so a single TypeScript interface works for
527    // both.
528    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;