48e3418c7f
First ingest path beyond manual upload (plan §1C). Source abstraction +
the first concrete backend, so a homelab can index an existing library.
- domain: SourceBackend/IndexableSource ports + SourceInfo/SourceFile shapes
- infrastructure/sources: LocalFolderSource (walks a mounted dir, idempotent
source_id = relative path) + registry built from settings
- application: LibraryImportService — batch sibling of UploadService; dedup on
(source, source_id), copy into storage, minimal track (metadata_status=pending,
enrichment fills the rest in 1D), per-file failures isolated
- workers: scan_local_folder arq task (registered) + enqueue helper (503 if
Redis down)
- api: GET /sources, POST /sources/{source}/scan (admin, enqueues), /health
- config: LOCAL_MEDIA_IMPORT_PATH; README + .env.example documented
- tests: scanner, registry, import service (fakes) + DB-gated sources API path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
42 lines
1.6 KiB
Python
42 lines
1.6 KiB
Python
"""Source registry — selection + enumeration of configured backends.
|
|
|
|
Built from settings at the composition root. Only sources that are configured
|
|
are registered (e.g. ``local`` appears only when ``LOCAL_MEDIA_IMPORT_PATH`` is
|
|
set), so enumeration reflects what the instance can actually use.
|
|
"""
|
|
|
|
from typing import cast
|
|
|
|
from app.core.config import Settings
|
|
from app.domain.errors import NotFoundError, ValidationError
|
|
from app.domain.ports import IndexableSource, SourceBackend
|
|
from app.domain.sources import SourceInfo
|
|
from app.infrastructure.sources.local_folder import LocalFolderSource
|
|
|
|
|
|
class SourceRegistry:
|
|
def __init__(self, backends: list[SourceBackend]) -> None:
|
|
self._by_name = {backend.name: backend for backend in backends}
|
|
|
|
def get(self, name: str) -> SourceBackend:
|
|
backend = self._by_name.get(name)
|
|
if backend is None:
|
|
raise NotFoundError(f"Source {name!r} is not configured.")
|
|
return backend
|
|
|
|
def indexable(self, name: str) -> IndexableSource:
|
|
backend = self.get(name)
|
|
if not hasattr(backend, "scan"):
|
|
raise ValidationError(f"Source {name!r} cannot be indexed.")
|
|
return cast(IndexableSource, backend)
|
|
|
|
def infos(self) -> list[SourceInfo]:
|
|
return [backend.info() for backend in self._by_name.values()]
|
|
|
|
|
|
def build_source_registry(settings: Settings) -> SourceRegistry:
|
|
backends: list[SourceBackend] = []
|
|
if settings.local_media_import_path is not None:
|
|
backends.append(LocalFolderSource(settings.local_media_import_path))
|
|
return SourceRegistry(backends)
|