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::{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
49/// POST dispatcher for `/api/catalog/*id` — routes sub-actions based on the
50/// trailing path segment.  Currently only `/download` is handled; everything
51/// else returns 404.
52pub 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    // Live search hits one family per request, so the sidebar just gets
329    // the static taxonomy. No per-family counts — that line is gone from
330    // the SPA too.
331    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/// One queued companion download surfaced in the
340/// `POST /api/catalog/:id/download` response.
341#[derive(Clone, Debug, serde::Serialize)]
342pub struct CompanionJob {
343    /// Canonical companion name from the catalog scanner
344    /// (`mold_catalog::companions::COMPANIONS`). Same string the
345    /// `companions` array on the catalog row carries.
346    pub name: String,
347    /// Job id that polls against `GET /api/downloads`.
348    pub job_id: String,
349}
350
351/// Enqueue any companions in `companion_names` that are not already
352/// fully present under `models_dir`. Returns the per-companion job ids
353/// in the order they appear in the catalog entry.
354///
355/// On-disk presence + name resolution are delegated to
356/// `mold_core::download::missing_companions`, which the CLI also
357/// consumes for `mold pull cv:<id>`'s companion-first ordering.
358///
359/// `DownloadQueue::enqueue` is idempotent against canonical manifest
360/// names: re-enqueuing a companion that's mid-flight returns the existing
361/// job id so concurrent download requests won't double-pull. That lets
362/// the on-disk presence check fall back on "missing → enqueue" without a
363/// race condition.
364pub(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        // When called for a catalog download, the companion job is
375        // registered in the catalog group so `CatalogReady` waits for
376        // it. Outside that context (no catalog id), fall back to the
377        // ungrouped enqueue — companions invoked from elsewhere don't
378        // synchronise on a group event.
379        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    // Live single-id lookup — replaces the bulk-scrape DB read.
419    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    // Phase 2: companions auto-pull before the primary entry. Civitai
482    // single-file checkpoints commonly strip their text encoders + VAE,
483    // so the catalog records `companions: ["clip-l", ...]` on those
484    // entries. Each canonical companion has a hidden synthetic manifest
485    // in `mold-core` so the queue can enqueue them by name.
486    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    // Primary entry. Built-in HF models use their canonical manifest. Live HF
498    // LoRA rows use the normalized single-file recipe, just like Civitai rows,
499    // so remote hosts can pull catalog-only adapters. Unsupported HF repos now
500    // return a real error instead of a successful response with no queued job.
501    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            // Sidecar write is best-effort: it lets the LoRA picker see
618            // the entry as soon as the file lands without re-querying
619            // the live catalog. Failure is not fatal.
620            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// ── Live search + installed (replaces the bulk-scrape DB) ──────────────────
659
660#[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    /// `downloads` (default) / `recent` / `rating` — see
670    /// [`mold_catalog::live::CatalogSort::WIRE_VALUES`]. Anything else is
671    /// a 422: silently ignoring an unknown sort is exactly the bug that
672    /// shipped when this endpoint had no sort parameter at all.
673    pub sort: Option<String>,
674}
675
676/// `GET /api/catalog/search` — live proxy to upstream catalog APIs with
677/// a 5-min in-process cache. Replaces `/api/catalog` on the read path
678/// for the SPA's catalog tab. The bulk-scrape DB is retained until a
679/// follow-up release.
680pub 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
794/// `GET /api/catalog/installed` — enumerate installed catalog entries
795/// from the per-install sidecar files under `models_dir`. The LoRA
796/// picker uses this as its primary data source.
797pub 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/// `GET /api/loras` — list installed LoRA adapters, optionally filtered
845/// by model compatibility. This is a small MCP-friendly view over the
846/// catalog sidecars used by the web picker.
847#[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    // Installed-detection for live rows hangs off the sidecar layout: a
991    // sidecar at `{models_dir}/{sanitized}/mold-catalog.json` whose
992    // primary file actually exists on disk → installed=true. We
993    // intentionally do NOT re-walk the recipe here: that path is a
994    // legacy fallback for rows the catalog DB returned, and live rows
995    // by definition pre-date their sidecar (which gets written at
996    // download time).
997    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    // The sidecar carries fewer fields than a full CatalogEntryWire on
1082    // purpose — the picker only needs id/name/family/kind/trained_words/
1083    // primary_path. Empty defaults are returned for fields the SPA
1084    // ignores in this code path; the shared wire struct keeps the shape
1085    // uniform with `/api/catalog/search` so a single TypeScript
1086    // interface works for both, and keeps the client deserializer in
1087    // `mold_core::client` from drifting.
1088    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;