Full backend service with: - FastAPI REST API with CRUD, search, reprocess endpoints - PostgreSQL + pgvector for items and semantic search - Redis + RQ for background job processing - Meilisearch for fast keyword/filter search - Browserless/Chrome for JS rendering and screenshots - OpenAI structured output for AI classification - Local file storage with S3-ready abstraction - Gateway auth via X-Gateway-User-Id header - Own docker-compose stack (6 containers) Classification: fixed folders (Home/Family/Work/Travel/Knowledge/Faith/Projects) and fixed tags (28 predefined). AI assigns exactly 1 folder, 2-3 tags, title, summary, and confidence score per item. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
82 lines
2.1 KiB
Python
82 lines
2.1 KiB
Python
"""File storage abstraction — local disk first, S3-ready interface."""
|
|
|
|
import os
|
|
import shutil
|
|
from abc import ABC, abstractmethod
|
|
from pathlib import Path
|
|
|
|
from app.config import STORAGE_BACKEND, STORAGE_LOCAL_PATH
|
|
|
|
|
|
class StorageBackend(ABC):
|
|
@abstractmethod
|
|
def save(self, item_id: str, asset_type: str, filename: str, data: bytes) -> str:
|
|
"""Save file, return relative storage path."""
|
|
...
|
|
|
|
@abstractmethod
|
|
def read(self, path: str) -> bytes:
|
|
...
|
|
|
|
@abstractmethod
|
|
def delete(self, path: str) -> None:
|
|
...
|
|
|
|
@abstractmethod
|
|
def exists(self, path: str) -> bool:
|
|
...
|
|
|
|
@abstractmethod
|
|
def url(self, path: str) -> str:
|
|
"""Return a URL or local path for serving."""
|
|
...
|
|
|
|
|
|
class LocalStorage(StorageBackend):
|
|
def __init__(self, base_path: str):
|
|
self.base = Path(base_path)
|
|
self.base.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _full_path(self, path: str) -> Path:
|
|
return self.base / path
|
|
|
|
def save(self, item_id: str, asset_type: str, filename: str, data: bytes) -> str:
|
|
rel = f"{item_id}/{asset_type}/{filename}"
|
|
full = self._full_path(rel)
|
|
full.parent.mkdir(parents=True, exist_ok=True)
|
|
full.write_bytes(data)
|
|
return rel
|
|
|
|
def read(self, path: str) -> bytes:
|
|
return self._full_path(path).read_bytes()
|
|
|
|
def delete(self, path: str) -> None:
|
|
full = self._full_path(path)
|
|
if full.exists():
|
|
full.unlink()
|
|
# Clean empty parent dirs
|
|
parent = full.parent
|
|
while parent != self.base:
|
|
try:
|
|
parent.rmdir()
|
|
parent = parent.parent
|
|
except OSError:
|
|
break
|
|
|
|
def exists(self, path: str) -> bool:
|
|
return self._full_path(path).exists()
|
|
|
|
def url(self, path: str) -> str:
|
|
return f"/storage/{path}"
|
|
|
|
|
|
# Future: S3Storage class implementing the same interface
|
|
|
|
def _create_storage() -> StorageBackend:
|
|
if STORAGE_BACKEND == "local":
|
|
return LocalStorage(STORAGE_LOCAL_PATH)
|
|
raise ValueError(f"Unknown storage backend: {STORAGE_BACKEND}")
|
|
|
|
|
|
storage = _create_storage()
|