Files
unslothai--unsloth/studio/backend/hub/services/models/local_inventory.py
T
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

716 lines
24 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Local model, HF cache, LM Studio, and Ollama inventory services.
Ollama logic lives in :mod:`hub.services.models.ollama`; this module
orchestrates all on-device sources and exposes the route handlers.
"""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import List, Optional
from fastapi import HTTPException
from loggers import get_logger
from hub.schemas.inventory import LocalModelInfo, LocalModelListResponse, ModelFormat
from hub.storage.scan_folders import (
add_scan_folder,
list_scan_folders,
remove_scan_folder,
)
from hub.utils import inventory_scan as hf_cache_scan
from hub.utils.paths import (
hf_default_cache_dir,
legacy_hf_cache_dir,
lmstudio_model_dirs,
normalize_path,
ollama_model_dirs,
outputs_root,
path_is_same_or_child,
studio_root,
)
from hub.services.models import common as model_common
from hub.services.models.ollama import scan_ollama_dir
logger = get_logger(__name__)
_MAX_MODELS_PER_CUSTOM_FOLDER = 200
_MAX_CUSTOM_FOLDER_ENTRIES = 2000
_MODEL_SIGNAL_PROBE_LIMIT = 200
# Local aliases keep the extracted code close to the original implementation.
_is_model_directory = model_common._is_model_directory
_local_inventory_id = model_common._local_inventory_id
_local_model_info = model_common._local_model_info
_capabilities_for_format = model_common._capabilities_for_format
_apply_format_aware_partial = model_common._apply_format_aware_partial
_classify_local_path = model_common._classify_local_path
_is_main_gguf_filename = model_common._is_main_gguf_filename
_is_transformers_bin_weight_file = model_common._is_transformers_bin_weight_file
_prefer_complete_larger = model_common._prefer_complete_larger
_gguf_variant_state_summary = model_common._gguf_variant_state_summary
def _is_immediate_model_weight_file(path: Path) -> bool:
suffix = path.suffix.lower()
if suffix == ".safetensors":
return True
if suffix == ".gguf":
return _is_main_gguf_filename(path.name)
if suffix == ".bin":
return _is_transformers_bin_weight_file(path)
return False
def _has_immediate_model_weight(
path: Path, *, probe_limit: int = _MODEL_SIGNAL_PROBE_LIMIT
) -> bool:
try:
for index, entry in enumerate(path.iterdir(), start = 1):
if index > probe_limit:
break
try:
if entry.is_file() and _is_immediate_model_weight_file(entry):
return True
except OSError:
continue
except OSError:
return False
return False
def _has_immediate_model_signal(
path: Path, *, probe_limit: int = _MODEL_SIGNAL_PROBE_LIMIT
) -> bool:
try:
if (path / "config.json").exists() or (path / "adapter_config.json").exists():
return True
except OSError:
return False
return _has_immediate_model_weight(path, probe_limit = probe_limit)
def _is_model_directory_for_scan(path: Path, *, entry_limit: int | None) -> bool:
if entry_limit is None:
return _is_model_directory(path)
try:
has_config = (path / "config.json").exists() or (path / "adapter_config.json").exists()
except OSError:
return False
return has_config and _has_immediate_model_weight(path)
def _resolve_hf_cache_dir() -> Path:
try:
from huggingface_hub.constants import HF_HUB_CACHE
return Path(HF_HUB_CACHE)
except Exception:
return Path.home() / ".cache" / "huggingface" / "hub"
def _scan_models_dir(
models_dir: Path,
*,
limit: int | None = None,
entry_limit: int | None = None,
) -> List[LocalModelInfo]:
if not models_dir.exists() or not models_dir.is_dir():
return []
_is_self_model = _is_model_directory_for_scan(
models_dir,
entry_limit = entry_limit,
)
if _is_self_model:
try:
updated_at = models_dir.stat().st_mtime
except OSError:
updated_at = None
return _classify_local_path(
models_dir,
"models_dir",
updated_at = updated_at,
)
found: List[LocalModelInfo] = []
visited = 0
try:
children = models_dir.iterdir()
except OSError:
return found
for child in children:
if limit is not None and len(found) >= limit:
break
visited += 1
if entry_limit is not None and visited > entry_limit:
break
try:
is_dir = child.is_dir()
is_gguf_file = not is_dir and child.suffix.lower() == ".gguf" and child.is_file()
if not is_dir and not is_gguf_file:
continue
has_model_files = is_gguf_file or _has_immediate_model_signal(child)
except OSError:
# Skip individual children that are unreadable (permissions, broken
# symlinks, etc.) rather than failing the entire scan.
continue
if not has_model_files:
continue
try:
updated_at = child.stat().st_mtime
except OSError:
updated_at = None
rows = _classify_local_path(
child,
"models_dir",
updated_at = updated_at,
)
if limit is not None:
rows = rows[: max(0, limit - len(found))]
found.extend(rows)
return found
def _safe_is_dir(path: Path) -> bool:
"""``Path.is_dir()`` treating an unreadable path (``PermissionError`` /
``OSError`` on a restricted ``~/.cache/huggingface/hub``) as "not a
directory", so the inventory skips that source instead of 500ing the Hub page.
"""
try:
return path.is_dir()
except OSError:
return False
def _hf_repo_dir_has_content(repo_dir: Path) -> bool:
blobs_dir = repo_dir / "blobs"
if not blobs_dir.is_dir():
return False
try:
for entry in blobs_dir.iterdir():
if entry.is_file() or entry.is_symlink():
return True
except OSError:
return False
return False
def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[LocalModelInfo]:
if not _safe_is_dir(cache_dir):
return []
discovered: List[tuple[Path, str, Optional[float]]] = []
visited = 0
try:
entries = cache_dir.iterdir()
except OSError:
return []
for repo_dir in entries:
visited += 1
if entry_limit is not None and visited > entry_limit:
break
if not repo_dir.name.startswith("models--"):
continue
if not repo_dir.is_dir():
continue
if not _hf_repo_dir_has_content(repo_dir):
continue
repo_name = repo_dir.name[len("models--") :]
if not repo_name:
continue
model_id = repo_name.replace("--", "/")
try:
updated_at = repo_dir.stat().st_mtime
except OSError:
updated_at = None
discovered.append((repo_dir, model_id, updated_at))
found: list[LocalModelInfo] = []
for repo_dir, model_id, updated_at in discovered:
snapshot_partial = hf_cache_scan.is_snapshot_partial(
"model",
model_id,
repo_dir,
)
gguf_partial = hf_cache_scan.is_gguf_repo_partial(model_id, repo_dir)
has_gguf_variant_state, gguf_variant_state_size = _gguf_variant_state_summary(model_id)
snapshot_partial_transport = (
hf_cache_scan.partial_transport_for(
"model",
model_id,
repo_cache_dir = repo_dir,
)
if snapshot_partial
else None
)
resolved = hf_cache_scan.resolve_hf_cache_realpath(repo_dir)
scan_path = Path(resolved) if resolved else repo_dir
# partial=False here; _apply_format_aware_partial below rewrites per-row
# so a hybrid repo's gguf row doesn't taint its safetensors row.
rows = _classify_local_path(
scan_path,
"hf_cache",
load_path = repo_dir,
display_name = model_id.split("/")[-1],
model_id = model_id,
updated_at = updated_at,
partial = False,
)
if not rows:
if has_gguf_variant_state and gguf_partial:
rows = [
_local_model_info(
scan_path = repo_dir,
load_path = repo_dir,
source = "hf_cache",
model_format = "gguf",
display_name = model_id.split("/")[-1],
model_id = model_id,
updated_at = updated_at,
partial = True,
requires_variant = True,
size_bytes = gguf_variant_state_size,
)
]
else:
# Fallback row's model_format is "unknown"; either signal
# applies because we can't dispatch to a specific predicate.
rows = [
_local_model_info(
scan_path = repo_dir,
load_path = repo_dir,
source = "hf_cache",
model_format = "unknown",
display_name = model_id.split("/")[-1],
model_id = model_id,
updated_at = updated_at,
partial = snapshot_partial or gguf_partial,
)
]
elif (
has_gguf_variant_state
and gguf_partial
and not any(row.model_format == "gguf" for row in rows)
):
rows.append(
_local_model_info(
scan_path = repo_dir,
load_path = repo_dir,
source = "hf_cache",
model_format = "gguf",
display_name = model_id.split("/")[-1],
model_id = model_id,
updated_at = updated_at,
partial = True,
requires_variant = True,
size_bytes = gguf_variant_state_size,
)
)
rows = _apply_format_aware_partial(
rows,
snapshot_partial = snapshot_partial,
gguf_partial = gguf_partial,
snapshot_partial_transport = snapshot_partial_transport,
)
found.extend(rows)
return found
def _scan_lmstudio_dir(lm_dir: Path, *, entry_limit: int | None = None) -> List[LocalModelInfo]:
"""Scan an LM Studio models dir (``publisher/model-name`` folders of GGUFs, or top-level standalone GGUFs)."""
if not lm_dir.exists() or not lm_dir.is_dir():
return []
# If the dir is itself a model dir (config + weights), it's not an LM Studio
# publisher structure -- return it as a single entry rather than descend.
if _is_model_directory(lm_dir):
try:
updated_at = lm_dir.stat().st_mtime
except OSError:
updated_at = None
return _classify_local_path(
lm_dir,
"lmstudio",
updated_at = updated_at,
)
found: List[LocalModelInfo] = []
visited = 0
exhausted = False
def _consume_visit() -> bool:
nonlocal visited
visited += 1
return entry_limit is not None and visited > entry_limit
try:
children = lm_dir.iterdir()
except OSError:
return found
for child in children:
if _consume_visit():
break
try:
if not child.is_dir():
if child.suffix == ".gguf" and child.is_file():
try:
updated_at = child.stat().st_mtime
except OSError:
updated_at = None
found.extend(
_classify_local_path(
child,
"lmstudio",
updated_at = updated_at,
)
)
continue
# Child is itself a model dir: surface it directly, not as a publisher.
if _is_model_directory(child):
try:
updated_at = child.stat().st_mtime
except OSError:
updated_at = None
found.extend(
_classify_local_path(
child,
"lmstudio",
updated_at = updated_at,
)
)
continue
# child is a publisher directory -- scan its sub-directories
for model_dir in child.iterdir():
if _consume_visit():
exhausted = True
break
try:
if model_dir.is_dir():
has_model = _has_immediate_model_signal(model_dir)
if not has_model:
continue
model_id = f"{child.name}/{model_dir.name}"
try:
updated_at = model_dir.stat().st_mtime
except OSError:
updated_at = None
found.extend(
_classify_local_path(
model_dir,
"lmstudio",
display_name = model_dir.name,
model_id = model_id,
updated_at = updated_at,
)
)
elif model_dir.suffix == ".gguf" and model_dir.is_file():
try:
updated_at = model_dir.stat().st_mtime
except OSError:
updated_at = None
found.extend(
_classify_local_path(
model_dir,
"lmstudio",
model_id = f"{child.name}/{model_dir.stem}",
updated_at = updated_at,
)
)
except OSError:
continue
if exhausted:
break
except OSError:
continue
return found
def _resolve_allowed_models_dir(models_dir: str, allowed_roots: list[Path]) -> Path:
"""Resolve a requested model scan directory without widening subpaths."""
if not models_dir or not models_dir.strip():
raise ValueError("Directory not allowed")
requested = Path(os.path.realpath(os.path.expanduser(normalize_path(models_dir.strip()))))
if any(path_is_same_or_child(requested, root) for root in allowed_roots):
return requested
raise ValueError("Directory not allowed")
def _coerce_scan_folder_path(raw_path: str) -> str:
"""Normalize a scan registration target; the registry stores directories, so a pasted weight-file path is reduced to its parent folder."""
if not raw_path or not raw_path.strip():
raise ValueError("Path cannot be empty")
raw = raw_path.strip()
if "\x00" in raw:
raise ValueError("Path cannot contain null bytes")
def normalize(value: str) -> Path:
return Path(os.path.realpath(os.path.expanduser(normalize_path(value))))
try:
normalized = normalize(raw)
except (OSError, ValueError) as e:
raise ValueError(f"Path is not readable: {e}") from e
try:
exists = normalized.exists()
is_dir = normalized.is_dir()
is_file = normalized.is_file()
except (OSError, ValueError) as e:
raise ValueError(f"Path is not readable: {e}") from e
if not exists and "\\" in raw:
try:
slash_normalized = normalize(raw.replace("\\", "/"))
slash_exists = slash_normalized.exists()
except (OSError, ValueError) as e:
raise ValueError(f"Path is not readable: {e}") from e
if slash_exists:
normalized = slash_normalized
try:
is_dir = normalized.is_dir()
is_file = normalized.is_file()
except (OSError, ValueError) as e:
raise ValueError(f"Path is not readable: {e}") from e
exists = True
if not exists:
return str(normalized)
if is_dir:
return str(normalized)
if is_file:
suffix = normalized.suffix.lower()
if suffix not in {".gguf", ".safetensors", ".bin"}:
raise ValueError("Path must be a folder or model weight file")
return str(normalized.parent)
return str(normalized)
async def _scan_source(label: str, scanner, path: Path) -> List[LocalModelInfo]:
try:
return await asyncio.to_thread(scanner, path)
except Exception as e:
logger.warning("Skipping %s scan for %s: %s", label, path, e)
return []
async def _collect_models_from_default_sources(
models_root: Path,
hf_cache_dir: Path,
legacy_hf: Path,
hf_default: Path,
lm_dirs: list[Path],
ollama_dirs: list[Path],
) -> List[LocalModelInfo]:
local_models = await _scan_source("models directory", _scan_models_dir, models_root)
local_models += await _scan_source("HF cache", _scan_hf_cache, hf_cache_dir)
if _safe_is_dir(legacy_hf) and legacy_hf.resolve() != hf_cache_dir.resolve():
local_models += await _scan_source("legacy HF cache", _scan_hf_cache, legacy_hf)
if (
_safe_is_dir(hf_default)
and hf_default.resolve() != hf_cache_dir.resolve()
and hf_default.resolve() != legacy_hf.resolve()
):
local_models += await _scan_source("default HF cache", _scan_hf_cache, hf_default)
for lm_dir in lm_dirs:
local_models += await _scan_source("LM Studio", _scan_lmstudio_dir, lm_dir)
for ollama_dir in ollama_dirs:
local_models += await _scan_source("Ollama", scan_ollama_dir, ollama_dir)
return local_models
def _scan_custom_folder(folder_path: Path) -> List[LocalModelInfo]:
supported_formats: set[ModelFormat] = {"gguf", "safetensors", "adapter"}
generic = [
m
for m in (
_scan_models_dir(
folder_path,
limit = _MAX_MODELS_PER_CUSTOM_FOLDER,
entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES,
)
+ _scan_hf_cache(folder_path, entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES)
+ _scan_lmstudio_dir(folder_path, entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES)
)
if m.model_format in supported_formats
if not any(p in (".studio_links", "ollama_links") for p in Path(m.path).parts)
]
return generic[:_MAX_MODELS_PER_CUSTOM_FOLDER]
def _promote_to_custom_source(model: LocalModelInfo) -> LocalModelInfo:
if model.source == "hf_cache":
return model
return model.model_copy(
update = {
"source": "custom",
"model_id": None,
"inventory_id": _local_inventory_id(
"custom",
model.model_format,
model.path,
model.format_variant,
),
"capabilities": _capabilities_for_format(
model.model_format,
"custom",
partial = model.partial,
requires_variant = model.capabilities.requires_variant,
),
}
)
async def _collect_models_from_custom_folders() -> List[LocalModelInfo]:
try:
custom_folders = await asyncio.to_thread(list_scan_folders)
except Exception as e:
logger.warning("Could not load custom scan folders: %s", e)
return []
local_models: List[LocalModelInfo] = []
for folder in custom_folders:
folder_path = Path(normalize_path(folder["path"])).expanduser()
try:
custom_models = await asyncio.to_thread(_scan_custom_folder, folder_path)
except Exception as e:
logger.warning("Skipping unreadable scan folder %s: %s", folder_path, e)
continue
local_models.extend(_promote_to_custom_source(m) for m in custom_models)
return local_models
def _dedupe_local_models(local_models: List[LocalModelInfo]) -> list[LocalModelInfo]:
deduped: dict[str, LocalModelInfo] = {}
for model in local_models:
if model.source == "hf_cache" and model.model_id:
key = "\x00".join(
(
"hf_cache",
model.model_id.strip().lower(),
model.model_format,
model.format_variant or "",
)
)
else:
row_key = model.inventory_id or model.id
key = f"{row_key}\x00custom" if model.source == "custom" else row_key
existing = deduped.get(key)
if existing is None or _prefer_complete_larger(
model.partial,
model.size_bytes,
existing.partial,
existing.size_bytes,
):
deduped[key] = model
return sorted(
deduped.values(),
key = lambda item: (item.updated_at or 0),
reverse = True,
)
async def list_local_models_response(models_dir: str = "./models") -> LocalModelListResponse:
"""List local model candidates from every supported on-device source."""
hf_cache_dir = _resolve_hf_cache_dir()
legacy_hf = legacy_hf_cache_dir()
hf_default = hf_default_cache_dir()
lm_dirs = lmstudio_model_dirs()
ollama_dirs = ollama_model_dirs()
allowed_roots: list[Path] = [Path("./models").resolve(), hf_cache_dir]
if _safe_is_dir(legacy_hf):
allowed_roots.append(legacy_hf)
if _safe_is_dir(hf_default):
allowed_roots.append(hf_default)
allowed_roots.extend([studio_root(), outputs_root()])
try:
models_root = _resolve_allowed_models_dir(models_dir, allowed_roots)
except ValueError:
raise HTTPException(status_code = 403, detail = "Directory not allowed")
try:
local_models = await _collect_models_from_default_sources(
models_root,
hf_cache_dir,
legacy_hf,
hf_default,
lm_dirs,
ollama_dirs,
)
local_models += await _collect_models_from_custom_folders()
models = _dedupe_local_models(local_models)
return LocalModelListResponse(
models_dir = str(models_root),
hf_cache_dir = str(hf_cache_dir),
lmstudio_dirs = [str(d) for d in lm_dirs],
ollama_dirs = [str(d) for d in ollama_dirs],
models = models,
)
except Exception as e:
logger.error(f"Error listing local models: {e}", exc_info = True)
raise HTTPException(
status_code = 500,
detail = f"Failed to list local models: {str(e)}",
)
def get_models_folder_response() -> dict:
"""Return the directory where downloaded models are stored.
This is the active HF hub cache (honors ``HF_HOME`` / ``HF_HUB_CACHE``);
the desktop app reveals it in the OS file manager.
"""
path = _resolve_hf_cache_dir()
# Create it if missing so "Open folder" works before the first download:
# HF builds the cache lazily, and studio only pre-creates the *default*
# dir, not a user's explicit HF_HOME / HF_HUB_CACHE.
try:
path.mkdir(parents = True, exist_ok = True)
except OSError as e:
raise HTTPException(
status_code = 500,
detail = f"Failed to create models folder: {path}: {e}",
) from e
if not path.is_dir():
raise HTTPException(
status_code = 500,
detail = f"Models folder path is not a directory: {path}",
)
return {"path": str(path)}
def get_scan_folders_response() -> dict:
return {"folders": list_scan_folders()}
def add_scan_folder_response(path: str) -> dict:
try:
folder = add_scan_folder(_coerce_scan_folder_path(path))
except ValueError as e:
logger.warning("Scan folder rejected: %s (path=%s)", e, path)
raise HTTPException(status_code = 400, detail = str(e))
logger.info("Scan folder added: %s", folder.get("path"))
return folder
def remove_scan_folder_response(folder_id: int) -> dict:
remove_scan_folder(folder_id)
logger.info("Scan folder removed: id=%s", folder_id)
return {"ok": True}