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(entry) = mold_catalog::live::companion_entry_for_id(&id) {
17        entry
18    } else if let Some(version_id) = id.strip_prefix("cv:") {
19        match mold_catalog::live::fetch_civitai_version(
20            state.catalog_live_civitai_base.as_str(),
21            version_id,
22            std::env::var("CIVITAI_TOKEN").ok().as_deref(),
23        )
24        .await
25        {
26            Ok(e) => e,
27            Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
28                return (StatusCode::NOT_FOUND, "not found").into_response();
29            }
30            Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
31        }
32    } else if let Some(repo_id) = id.strip_prefix("hf:") {
33        match mold_catalog::live::fetch_hf_repo(
34            "https://huggingface.co",
35            repo_id,
36            std::env::var("HF_TOKEN").ok().as_deref(),
37        )
38        .await
39        {
40            Ok(e) => e,
41            Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
42                return (StatusCode::NOT_FOUND, "not found").into_response();
43            }
44            Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
45        }
46    } else {
47        return (
48            StatusCode::BAD_REQUEST,
49            "id must be `cv:` or `hf:` prefixed",
50        )
51            .into_response();
52    };
53
54    Json(live_entry_to_wire(&entry, &models_dir)).into_response()
55}
56
57/// POST dispatcher for `/api/catalog/*id` — routes sub-actions based on the
58/// trailing path segment.  Currently only `/download` is handled; everything
59/// else returns 404.
60pub async fn post_catalog_dispatch(
61    State(state): State<crate::state::AppState>,
62    Path(rest): Path<String>,
63) -> impl IntoResponse {
64    if let Some(id) = rest.strip_suffix("/download") {
65        post_catalog_download(State(state), Path(id.to_string()))
66            .await
67            .into_response()
68    } else {
69        (StatusCode::NOT_FOUND, "unknown catalog action").into_response()
70    }
71}
72
73fn rendered_primary_recipe_dest(
74    entry: &mold_catalog::entry::CatalogEntry,
75    author: &str,
76    name: &str,
77) -> Option<String> {
78    entry
79        .download_recipe
80        .files
81        .iter()
82        .find(|file| file.role.is_none())
83        .or_else(|| entry.download_recipe.files.first())
84        .map(|file| {
85            mold_catalog::entry::render_recipe_dest(&file.dest, entry.family.as_str(), author, name)
86        })
87}
88
89pub(crate) async fn enqueue_catalog_primary_repair(
90    state: &crate::state::AppState,
91    id: &str,
92) -> Result<Option<String>, (StatusCode, String)> {
93    let models_dir = state.config.read().await.resolved_models_dir();
94    if let Some(version_id) = id.strip_prefix("cv:") {
95        let sc_path = mold_catalog::sidecar::civitai_sidecar_path(&models_dir, id);
96        if let Ok(sidecar) = mold_catalog::sidecar::read_sidecar(&sc_path) {
97            if let Some(parent) = sc_path.parent() {
98                if mold_catalog::sidecar::primary_path_if_present(parent, &sidecar).is_some() {
99                    return Ok(None);
100                }
101            }
102        }
103
104        let entry = mold_catalog::live::fetch_civitai_version(
105            state.catalog_live_civitai_base.as_str(),
106            version_id,
107            std::env::var("CIVITAI_TOKEN").ok().as_deref(),
108        )
109        .await
110        .map_err(|e| match e {
111            mold_catalog::live::LiveSearchError::Upstream { status: 404, .. } => (
112                StatusCode::NOT_FOUND,
113                format!("{id}: upstream returned 404"),
114            ),
115            other => (StatusCode::BAD_GATEWAY, format!("upstream: {other}")),
116        })?;
117
118        let auth = match entry.download_recipe.needs_token {
119            Some(mold_catalog::entry::TokenKind::Civitai) => {
120                mold_core::download::civitai_auth_or_error(id)
121                    .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?
122            }
123            _ => mold_core::download::RecipeAuth::None,
124        };
125        let (author, name) = match entry.source_id.split_once('/') {
126            Some((a, n)) => (a.to_string(), n.to_string()),
127            None => (String::new(), entry.source_id.clone()),
128        };
129        let files: Vec<crate::downloads::OwnedRecipeFile> = entry
130            .download_recipe
131            .files
132            .iter()
133            .map(|f| crate::downloads::OwnedRecipeFile {
134                url: f.url.clone(),
135                dest: mold_catalog::entry::render_recipe_dest(
136                    &f.dest,
137                    entry.family.as_str(),
138                    &author,
139                    &name,
140                ),
141                sha256: f.sha256.clone(),
142                size_bytes: f.size_bytes,
143            })
144            .collect();
145        if let Some(primary_dest) = rendered_primary_recipe_dest(&entry, &author, &name) {
146            let sidecar = mold_catalog::sidecar::sidecar_from_entry(&entry, primary_dest);
147            if let Err(e) = mold_catalog::sidecar::write_sidecar(&sc_path, &sidecar) {
148                tracing::warn!(
149                    target: "catalog.sidecar",
150                    catalog_id = %id,
151                    error = %e,
152                    "sidecar write failed during primary repair",
153                );
154            }
155        }
156        let payload = crate::downloads::RecipePayload {
157            catalog_id: id.to_string(),
158            files,
159            auth,
160        };
161        let (job_id, _, _) = state
162            .downloads
163            .enqueue_recipe_in_group(payload, id)
164            .await
165            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
166        return Ok(Some(job_id));
167    }
168
169    if let Some(repo_id) = id.strip_prefix("hf:") {
170        let entry = mold_catalog::live::fetch_hf_repo(
171            "https://huggingface.co",
172            repo_id,
173            std::env::var("HF_TOKEN").ok().as_deref(),
174        )
175        .await
176        .map_err(|e| match e {
177            mold_catalog::live::LiveSearchError::Upstream { status: 404, .. } => (
178                StatusCode::NOT_FOUND,
179                format!("{id}: upstream returned 404"),
180            ),
181            other => (StatusCode::BAD_GATEWAY, format!("upstream: {other}")),
182        })?;
183        let model = match mold_core::manifest::find_manifest(&entry.source_id) {
184            Some(m) => m.name.clone(),
185            None => entry.source_id.clone(),
186        };
187        let (job_id, _, _) = state
188            .downloads
189            .enqueue_in_group(model, id)
190            .await
191            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
192        return Ok(Some(job_id));
193    }
194
195    Ok(None)
196}
197
198pub async fn list_families(State(_state): State<crate::state::AppState>) -> impl IntoResponse {
199    // Live search hits one family per request, so the sidebar just gets
200    // the static taxonomy. No per-family counts — that line is gone from
201    // the SPA too.
202    use mold_catalog::families::{Family, ALL_FAMILIES};
203    let merged: Vec<serde_json::Value> = ALL_FAMILIES
204        .iter()
205        .map(|f: &Family| serde_json::json!({ "family": f.as_str() }))
206        .collect();
207    Json(serde_json::json!({ "families": merged })).into_response()
208}
209
210/// One queued companion download surfaced in the
211/// `POST /api/catalog/:id/download` response.
212#[derive(Clone, Debug, serde::Serialize)]
213pub struct CompanionJob {
214    /// Canonical companion name from the catalog scanner
215    /// (`mold_catalog::companions::COMPANIONS`). Same string the
216    /// `companions` array on the catalog row carries.
217    pub name: String,
218    /// Job id that polls against `GET /api/downloads`.
219    pub job_id: String,
220}
221
222/// Enqueue any companions in `companion_names` that are not already
223/// fully present under `models_dir`. Returns the per-companion job ids
224/// in the order they appear in the catalog entry.
225///
226/// On-disk presence + name resolution are delegated to
227/// `mold_core::download::missing_companions`, which the CLI also
228/// consumes for `mold pull cv:<id>`'s companion-first ordering.
229///
230/// `DownloadQueue::enqueue` is idempotent against canonical manifest
231/// names: re-enqueuing a companion that's mid-flight returns the existing
232/// job id so concurrent download requests won't double-pull. That lets
233/// the on-disk presence check fall back on "missing → enqueue" without a
234/// race condition.
235pub(crate) async fn enqueue_missing_companions(
236    companion_names: &[String],
237    models_dir: &std::path::Path,
238    queue: &crate::downloads::DownloadQueue,
239    catalog_id: Option<&str>,
240) -> Vec<CompanionJob> {
241    let manifests = mold_core::download::missing_companions(companion_names, models_dir);
242    let mut jobs = Vec::with_capacity(manifests.len());
243    for manifest in manifests {
244        // When called for a catalog download, the companion job is
245        // registered in the catalog group so `CatalogReady` waits for
246        // it. Outside that context (no catalog id), fall back to the
247        // ungrouped enqueue — companions invoked from elsewhere don't
248        // synchronise on a group event.
249        let result = match catalog_id {
250            Some(id) => queue.enqueue_in_group(manifest.name.clone(), id).await,
251            None => queue.enqueue(manifest.name.clone()).await,
252        };
253        match result {
254            Ok((job_id, _, _)) => jobs.push(CompanionJob {
255                name: manifest.name.clone(),
256                job_id,
257            }),
258            Err(e) => {
259                tracing::warn!(
260                    companion = %manifest.name,
261                    error = %e,
262                    "companion enqueue failed",
263                );
264            }
265        }
266    }
267    jobs
268}
269
270pub async fn post_catalog_download(
271    State(state): State<crate::state::AppState>,
272    Path(id): Path<String>,
273) -> impl IntoResponse {
274    // Live single-id lookup — replaces the bulk-scrape DB read.
275    if let Some(companion_name) = mold_catalog::live::companion_name_for_catalog_id(&id) {
276        let models_dir = state.config.read().await.resolved_models_dir();
277        let companion_jobs = enqueue_missing_companions(
278            &[companion_name.to_string()],
279            &models_dir,
280            &state.downloads,
281            Some(&id),
282        )
283        .await;
284        let primary_job_id = if companion_jobs.is_empty() {
285            None
286        } else {
287            Some(companion_jobs[0].job_id.clone())
288        };
289        return (
290            StatusCode::ACCEPTED,
291            Json(serde_json::json!({
292                "primary_job_id": primary_job_id,
293                "companion_jobs": [],
294            })),
295        )
296            .into_response();
297    }
298
299    let entry = if let Some(version_id) = id.strip_prefix("cv:") {
300        match mold_catalog::live::fetch_civitai_version(
301            state.catalog_live_civitai_base.as_str(),
302            version_id,
303            std::env::var("CIVITAI_TOKEN").ok().as_deref(),
304        )
305        .await
306        {
307            Ok(e) => e,
308            Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
309                return (StatusCode::NOT_FOUND, "unknown catalog id").into_response();
310            }
311            Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
312        }
313    } else if let Some(repo_id) = id.strip_prefix("hf:") {
314        match mold_catalog::live::fetch_hf_repo(
315            "https://huggingface.co",
316            repo_id,
317            std::env::var("HF_TOKEN").ok().as_deref(),
318        )
319        .await
320        {
321            Ok(e) => e,
322            Err(mold_catalog::live::LiveSearchError::Upstream { status: 404, .. }) => {
323                return (StatusCode::NOT_FOUND, "unknown catalog id").into_response();
324            }
325            Err(e) => return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response(),
326        }
327    } else {
328        return (
329            StatusCode::BAD_REQUEST,
330            "id must be `cv:` or `hf:` prefixed",
331        )
332            .into_response();
333    };
334
335    if entry.engine_phase >= 6 {
336        return (
337            StatusCode::CONFLICT,
338            format!(
339                "engine_phase {} not yet supported by this build — see release notes",
340                entry.engine_phase
341            ),
342        )
343            .into_response();
344    }
345
346    // Phase 2: companions auto-pull before the primary entry. Civitai
347    // single-file checkpoints commonly strip their text encoders + VAE,
348    // so the catalog records `companions: ["clip-l", ...]` on those
349    // entries. Each canonical companion has a hidden synthetic manifest
350    // in `mold-core` so the queue can enqueue them by name.
351    let models_dir = state.config.read().await.resolved_models_dir();
352    let entry_id = entry.id.as_str().to_string();
353    let companion_jobs = enqueue_missing_companions(
354        &entry.companions,
355        &models_dir,
356        &state.downloads,
357        Some(&entry_id),
358    )
359    .await;
360
361    // Primary entry. HF rows map onto a manifest model name (best-effort —
362    // diffusers entries with a recognised source_id flow through; the rest
363    // surface a `null` primary while companion downloads still run). `cv:`
364    // rows go through the recipe path: resolve `CIVITAI_TOKEN` if
365    // `needs_token: Civitai`, and call `DownloadQueue::enqueue_recipe`.
366    use mold_catalog::entry::Source;
367    let primary_job_id: Option<String> = match entry.source {
368        Source::Hf => {
369            let model = match mold_core::manifest::find_manifest(&entry.source_id) {
370                Some(m) => m.name.clone(),
371                None => entry.source_id.clone(),
372            };
373            match state.downloads.enqueue_in_group(model, &entry_id).await {
374                Ok((jid, _, _)) => Some(jid),
375                Err(crate::downloads::EnqueueError::UnknownModel(_)) => None,
376                Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
377            }
378        }
379        Source::Civitai => {
380            let auth = match entry.download_recipe.needs_token {
381                Some(mold_catalog::entry::TokenKind::Civitai) => {
382                    match mold_core::download::civitai_auth_or_error(&entry_id) {
383                        Ok(a) => a,
384                        Err(e) => {
385                            return (StatusCode::UNAUTHORIZED, e.to_string()).into_response();
386                        }
387                    }
388                }
389                _ => mold_core::download::RecipeAuth::None,
390            };
391            let (author, name) = match entry.source_id.split_once('/') {
392                Some((a, n)) => (a.to_string(), n.to_string()),
393                None => (String::new(), entry.source_id.clone()),
394            };
395            let files: Vec<crate::downloads::OwnedRecipeFile> = entry
396                .download_recipe
397                .files
398                .iter()
399                .map(|f| crate::downloads::OwnedRecipeFile {
400                    url: f.url.clone(),
401                    dest: mold_catalog::entry::render_recipe_dest(
402                        &f.dest,
403                        entry.family.as_str(),
404                        &author,
405                        &name,
406                    ),
407                    sha256: f.sha256.clone(),
408                    size_bytes: f.size_bytes,
409                })
410                .collect();
411            // Sidecar write is best-effort: it lets the LoRA picker see
412            // the entry as soon as the file lands without re-querying
413            // the live catalog. Failure is not fatal.
414            if let Some(primary_dest) = rendered_primary_recipe_dest(&entry, &author, &name) {
415                let sidecar = mold_catalog::sidecar::sidecar_from_entry(&entry, primary_dest);
416                let sc_path = mold_catalog::sidecar::civitai_sidecar_path(&models_dir, &entry_id);
417                if let Err(e) = mold_catalog::sidecar::write_sidecar(&sc_path, &sidecar) {
418                    tracing::warn!(
419                        target: "catalog.sidecar",
420                        catalog_id = %entry_id,
421                        error = %e,
422                        "sidecar write failed; picker will need a reinstall to surface this row",
423                    );
424                }
425            }
426            let payload = crate::downloads::RecipePayload {
427                catalog_id: entry_id.clone(),
428                files,
429                auth,
430            };
431            match state
432                .downloads
433                .enqueue_recipe_in_group(payload, &entry_id)
434                .await
435            {
436                Ok((jid, _, _)) => Some(jid),
437                Err(e) => return (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
438            }
439        }
440    };
441
442    (
443        StatusCode::ACCEPTED,
444        Json(serde_json::json!({
445            "primary_job_id": primary_job_id,
446            "companion_jobs": companion_jobs,
447        })),
448    )
449        .into_response()
450}
451
452// ── Live search + installed (replaces the bulk-scrape DB) ──────────────────
453
454#[derive(Debug, serde::Deserialize)]
455pub struct LiveSearchQuery {
456    pub q: Option<String>,
457    pub family: Option<String>,
458    pub kind: Option<String>,
459    pub source: Option<String>,
460    pub include_nsfw: Option<bool>,
461    pub page: Option<u32>,
462    pub page_size: Option<u32>,
463}
464
465/// `GET /api/catalog/search` — live proxy to upstream catalog APIs with
466/// a 5-min in-process cache. Replaces `/api/catalog` on the read path
467/// for the SPA's catalog tab. The bulk-scrape DB is retained until a
468/// follow-up release.
469pub async fn live_search_catalog(
470    State(state): State<crate::state::AppState>,
471    Query(q): Query<LiveSearchQuery>,
472) -> impl IntoResponse {
473    let family = match q.family.as_deref().filter(|s| !s.is_empty()) {
474        Some(s) => match mold_catalog::families::Family::from_str(s) {
475            Ok(f) => Some(f),
476            Err(_) => {
477                return (StatusCode::BAD_REQUEST, format!("unknown family: {s}")).into_response()
478            }
479        },
480        None => None,
481    };
482    let kind = match q.kind.as_deref().filter(|s| !s.is_empty()) {
483        Some(s) => match parse_kind(s) {
484            Some(k) => Some(k),
485            None => return (StatusCode::BAD_REQUEST, format!("unknown kind: {s}")).into_response(),
486        },
487        None => None,
488    };
489    let source = match q.source.as_deref().filter(|s| !s.is_empty()) {
490        Some("hf") => Some(mold_catalog::entry::Source::Hf),
491        Some("civitai") => Some(mold_catalog::entry::Source::Civitai),
492        Some(other) => {
493            return (StatusCode::BAD_REQUEST, format!("unknown source: {other}")).into_response()
494        }
495        None => None,
496    };
497
498    let opts = mold_catalog::live::LiveSearchOpts {
499        q: q.q.clone(),
500        family,
501        kind,
502        source,
503        page: q.page.unwrap_or(1).max(1),
504        page_size: q.page_size.unwrap_or(20).clamp(1, 100),
505        include_nsfw: q.include_nsfw.unwrap_or(true),
506        civitai_token: std::env::var("CIVITAI_TOKEN").ok(),
507        hf_token: std::env::var("HF_TOKEN").ok(),
508    };
509
510    let cfg = state.config.read().await;
511    let models_dir = cfg.resolved_models_dir();
512    drop(cfg);
513
514    let entries = match mold_catalog::live::search(
515        state.catalog_live_civitai_base.as_str(),
516        "https://huggingface.co",
517        &state.catalog_live_cache,
518        &opts,
519    )
520    .await
521    {
522        Ok(es) => es,
523        Err(e) => {
524            tracing::warn!(target: "catalog.live", error = %e, "live search failed");
525            return (StatusCode::BAD_GATEWAY, format!("upstream: {e}")).into_response();
526        }
527    };
528
529    let wire: Vec<serde_json::Value> = entries
530        .iter()
531        .map(|e| live_entry_to_wire(e, &models_dir))
532        .collect();
533    let total = wire.len() as i64;
534    Json(serde_json::json!({
535        "entries": wire,
536        "page": opts.page,
537        "page_size": opts.page_size,
538        "total": total,
539    }))
540    .into_response()
541}
542
543#[derive(Debug, serde::Deserialize)]
544pub struct InstalledQuery {
545    pub kind: Option<String>,
546    pub family: Option<String>,
547}
548
549#[derive(Debug, serde::Deserialize)]
550pub struct ListLorasQuery {
551    pub model: Option<String>,
552}
553
554/// `GET /api/catalog/installed` — enumerate installed catalog entries
555/// from the per-install sidecar files under `models_dir`. The LoRA
556/// picker uses this as its primary data source.
557pub async fn list_installed_catalog(
558    State(state): State<crate::state::AppState>,
559    Query(q): Query<InstalledQuery>,
560) -> impl IntoResponse {
561    let kind_filter = q.kind.as_deref().map(|s| s.to_string());
562    let family_filter = q.family.as_deref().map(|s| s.to_string());
563    let compatible_lora_families = match kind_filter.as_deref() {
564        Some("lora") => family_filter.as_deref().map(compatible_lora_families),
565        _ => None,
566    };
567
568    let cfg = state.config.read().await;
569    let models_dir = cfg.resolved_models_dir();
570    drop(cfg);
571
572    let walked = mold_catalog::sidecar::walk_sidecars(&models_dir);
573    let mut wire = Vec::with_capacity(walked.len());
574    for (dir, sidecar) in walked {
575        if let Some(k) = kind_filter.as_deref() {
576            if sidecar.kind != k {
577                continue;
578            }
579        }
580        if let Some(f) = family_filter.as_deref() {
581            let family_matches = compatible_lora_families
582                .as_ref()
583                .is_some_and(|families| families.contains(&sidecar.family))
584                || sidecar.family == f;
585            if !family_matches {
586                continue;
587            }
588        }
589        let abs = mold_catalog::sidecar::primary_path_if_present(&dir, &sidecar);
590        let installed = abs.is_some();
591        let primary_path = abs.map(|p| p.to_string_lossy().into_owned());
592        wire.push(sidecar_to_wire(sidecar, installed, primary_path));
593    }
594    let total = wire.len() as i64;
595    Json(mold_core::catalog_wire::InstalledCatalogResponse {
596        entries: wire,
597        page: 1,
598        page_size: total,
599        total,
600    })
601    .into_response()
602}
603
604/// `GET /api/loras` — list installed LoRA adapters, optionally filtered
605/// by model compatibility. This is a small MCP-friendly view over the
606/// catalog sidecars used by the web picker.
607#[utoipa::path(
608    get,
609    path = "/api/loras",
610    tag = "models",
611    params(
612        ("model" = Option<String>, Query, description = "Optional model name used to filter LoRAs by compatible family")
613    ),
614    responses(
615        (status = 200, description = "Installed LoRAs", body = Vec<mold_core::LoraInfo>),
616        (status = 400, description = "Unknown model")
617    )
618)]
619pub async fn list_loras(
620    State(state): State<crate::state::AppState>,
621    Query(q): Query<ListLorasQuery>,
622) -> Result<Json<Vec<mold_core::LoraInfo>>, crate::routes::ApiError> {
623    let family_filter = match q.model.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
624        Some(model) => {
625            let family = lora_family_for_model(&state, model).await.ok_or_else(|| {
626                crate::routes::ApiError::unknown_model(format!(
627                    "unknown model '{model}'; cannot resolve compatible LoRAs"
628                ))
629            })?;
630            if !mold_core::family_supports_lora(&family) {
631                return Ok(Json(Vec::new()));
632            }
633            Some(compatible_lora_families(&family))
634        }
635        None => None,
636    };
637
638    let cfg = state.config.read().await;
639    let models_dir = cfg.resolved_models_dir();
640    drop(cfg);
641
642    let mut loras = mold_catalog::sidecar::walk_sidecars(&models_dir)
643        .into_iter()
644        .filter_map(|(dir, sidecar)| {
645            if sidecar.kind != "lora" {
646                return None;
647            }
648            if let Some(families) = family_filter.as_ref() {
649                if !families.contains(&sidecar.family) {
650                    return None;
651                }
652            }
653            sidecar_to_lora_info(&dir, sidecar)
654        })
655        .collect::<Vec<_>>();
656
657    loras.sort_by(|a, b| {
658        b.added_at
659            .cmp(&a.added_at)
660            .then_with(|| a.name.cmp(&b.name))
661            .then_with(|| a.id.cmp(&b.id))
662    });
663    Ok(Json(loras))
664}
665
666fn compatible_lora_families(family: &str) -> Vec<String> {
667    match family {
668        "qwen-image-edit" | "qwen_image_edit" => {
669            vec!["qwen-image".to_string(), "qwen-image-edit".to_string()]
670        }
671        other => vec![other.to_string()],
672    }
673}
674
675async fn lora_family_for_model(state: &crate::state::AppState, model: &str) -> Option<String> {
676    let canonical = mold_core::manifest::resolve_model_name(model);
677    if let Some(manifest) = mold_core::manifest::find_manifest(&canonical) {
678        return Some(manifest.family.clone());
679    }
680    if let Some(manifest) = mold_core::manifest::find_manifest(model) {
681        return Some(manifest.family.clone());
682    }
683
684    {
685        let intents = state.catalog_intents.read().await;
686        if let Some(intent) = intents.get(model).or_else(|| intents.get(&canonical)) {
687            return Some(intent.family.clone());
688        }
689    }
690
691    let config = state.config.read().await;
692    let configured = config
693        .models
694        .get(model)
695        .or_else(|| config.models.get(&canonical))
696        .and_then(|m| m.family.clone());
697    if configured.is_some() {
698        return configured;
699    }
700    let models_dir = config.resolved_models_dir();
701    drop(config);
702
703    if model.starts_with("cv:") || model.starts_with("hf:") {
704        for (_, sidecar) in mold_catalog::sidecar::walk_sidecars(&models_dir) {
705            if sidecar.id == model {
706                return Some(sidecar.family);
707            }
708        }
709    }
710
711    None
712}
713
714fn sidecar_to_lora_info(
715    dir: &std::path::Path,
716    sidecar: mold_catalog::sidecar::CatalogSidecar,
717) -> Option<mold_core::LoraInfo> {
718    let path = mold_catalog::sidecar::primary_path_if_present(dir, &sidecar)?;
719    Some(mold_core::LoraInfo {
720        id: sidecar.id,
721        name: sidecar.name,
722        family: sidecar.family,
723        author: sidecar.author,
724        path: path.to_string_lossy().into_owned(),
725        trained_words: sidecar.trained_words,
726        size_bytes: sidecar.size_bytes,
727        thumbnail_url: sidecar.thumbnail_url,
728        added_at: sidecar.written_at,
729    })
730}
731
732fn parse_kind(s: &str) -> Option<mold_catalog::entry::Kind> {
733    use mold_catalog::entry::Kind::*;
734    Some(match s {
735        "checkpoint" => Checkpoint,
736        "lora" => Lora,
737        "vae" => Vae,
738        "text-encoder" => TextEncoder,
739        "tokenizer" => Tokenizer,
740        "clip" => Clip,
741        "control-net" => ControlNet,
742        _ => return None,
743    })
744}
745
746fn live_entry_to_wire(
747    entry: &mold_catalog::entry::CatalogEntry,
748    models_dir: &std::path::Path,
749) -> serde_json::Value {
750    // Installed-detection for live rows hangs off the sidecar layout: a
751    // sidecar at `{models_dir}/{sanitized}/mold-catalog.json` whose
752    // primary file actually exists on disk → installed=true. We
753    // intentionally do NOT re-walk the recipe here: that path is a
754    // legacy fallback for rows the catalog DB returned, and live rows
755    // by definition pre-date their sidecar (which gets written at
756    // download time).
757    let (installed, primary_path) = if let Some(companion_name) =
758        mold_catalog::live::companion_name_for_catalog_id(entry.id.as_str())
759    {
760        let installed = mold_core::manifest::find_manifest(companion_name)
761            .map(|manifest| mold_core::download::companion_present_on_disk(models_dir, manifest))
762            .unwrap_or(false);
763        (installed, None)
764    } else if matches!(entry.source, mold_catalog::entry::Source::Civitai) {
765        let sc_path = mold_catalog::sidecar::civitai_sidecar_path(models_dir, entry.id.as_str());
766        match mold_catalog::sidecar::read_sidecar(&sc_path) {
767            Ok(sidecar) => match sc_path.parent() {
768                Some(parent) => {
769                    match mold_catalog::sidecar::primary_path_if_present(parent, &sidecar) {
770                        Some(abs) => (true, Some(abs.to_string_lossy().into_owned())),
771                        None => (false, None),
772                    }
773                }
774                None => (false, None),
775            },
776            Err(_) => (false, None),
777        }
778    } else {
779        (false, None)
780    };
781    let companion_details = entry
782        .companions
783        .iter()
784        .filter_map(|name| {
785            mold_catalog::companions::COMPANIONS
786                .iter()
787                .find(|companion| companion.canonical_name == name)
788                .map(|companion| {
789                    serde_json::json!({
790                        "name": companion.canonical_name,
791                        "kind": companion.kind,
792                        "repo": companion.repo,
793                        "size_bytes": companion.size_bytes,
794                    })
795                })
796        })
797        .collect::<Vec<_>>();
798
799    serde_json::json!({
800        "id": entry.id.as_str(),
801        "source": entry.source,
802        "source_id": entry.source_id,
803        "name": entry.name,
804        "author": entry.author,
805        "family": entry.family.as_str(),
806        "family_role": entry.family_role,
807        "sub_family": entry.sub_family,
808        "modality": entry.modality,
809        "kind": entry.kind,
810        "file_format": entry.file_format,
811        "bundling": entry.bundling,
812        "size_bytes": entry.size_bytes,
813        "download_count": entry.download_count,
814        "rating": entry.rating,
815        "likes": entry.likes,
816        "nsfw": entry.nsfw,
817        "thumbnail_url": entry.thumbnail_url,
818        "description": entry.description,
819        "license": entry.license,
820        "license_flags": entry.license_flags,
821        "tags": entry.tags,
822        "companions": entry.companions,
823        "companion_details": companion_details,
824        "download_recipe": entry.download_recipe,
825        "engine_phase": entry.engine_phase,
826        "installed": installed,
827        "primary_path": primary_path,
828        "created_at": entry.created_at,
829        "updated_at": entry.updated_at,
830        "added_at": entry.added_at,
831        "trained_words": entry.trained_words,
832    })
833}
834
835pub(crate) fn sidecar_to_wire(
836    sc: mold_catalog::sidecar::CatalogSidecar,
837    installed: bool,
838    primary_path: Option<String>,
839) -> mold_core::catalog_wire::InstalledCatalogEntry {
840    // The sidecar carries fewer fields than a full CatalogEntryWire on
841    // purpose — the picker only needs id/name/family/kind/trained_words/
842    // primary_path. Empty defaults are returned for fields the SPA
843    // ignores in this code path; the shared wire struct keeps the shape
844    // uniform with `/api/catalog/search` so a single TypeScript
845    // interface works for both, and keeps the client deserializer in
846    // `mold_core::client` from drifting.
847    mold_core::catalog_wire::InstalledCatalogEntry {
848        id: sc.id,
849        source: sc.source,
850        source_id: sc.source_id,
851        name: sc.name,
852        author: sc.author,
853        family: sc.family,
854        family_role: sc.family_role,
855        sub_family: sc.sub_family,
856        modality: sc.modality,
857        kind: sc.kind,
858        file_format: "safetensors".into(),
859        bundling: "single-file".into(),
860        size_bytes: sc.size_bytes,
861        download_count: 0,
862        rating: None,
863        likes: 0,
864        nsfw: false,
865        thumbnail_url: sc.thumbnail_url,
866        description: None,
867        license: None,
868        license_flags: None,
869        tags: Vec::new(),
870        companions: Vec::new(),
871        companion_details: Vec::new(),
872        download_recipe: mold_core::catalog_wire::DownloadRecipeWire::default(),
873        engine_phase: sc.engine_phase,
874        installed,
875        primary_path,
876        created_at: None,
877        updated_at: None,
878        added_at: sc.written_at,
879        trained_words: sc.trained_words,
880    }
881}
882
883#[cfg(test)]
884#[path = "catalog_live_test.rs"]
885mod catalog_live_test;