Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ UPLOAD_MAX_SIZE=4g
# large video; short enough that a leaked URL stops working. Default 1 hour.
MEDIA_URL_TTL_SECONDS=3600

# ─── Disk space ──────────────────────────────────────────────────────────────
#
# Free space, in GB, on the disk holding media (and, if separate, the database and the
# container's temp space, where uploads and imports land first). Below the first, the
# header shows a warning; below the second, uploads, imports, generations and clip
# extractions are refused until space is freed. Settings → Storage shows the numbers.
DISK_WARN_FREE_GB=20
DISK_RESERVE_GB=5

# ─── Background jobs ─────────────────────────────────────────────────────────

JOB_HEARTBEAT_SECONDS=30
Expand Down Expand Up @@ -85,7 +94,8 @@ URL_IMPORT_COOKIES_FILE=

# ─── Administrators ──────────────────────────────────────────────────────────

# Who may edit the AI generation model catalogue: Gecko Notes user ids or usernames,
# Who may edit the AI generation model catalogue, and see the server's disk space and
# clear stray files under Settings → Storage: Gecko Notes user ids or usernames,
# comma-separated, matched exactly (case included). Prefer ids: Notes lets anyone rename
# themselves, so a username somebody gives up can be taken by somebody else. Notes'
# token carries no admin flag, so this is the only way anyone becomes an admin here.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ user row. The changes that makes possible are specified in
images, with the prompt and base assets recorded so a result stays reproducible.
- **Cost visibility** — an estimate before anything paid runs, and the real cost tracked
per asset and per library afterwards.
- **Disk space** — what the library takes up and what the disk has left, a header
warning before it runs out, and new writes refused before a full disk can damage
anything. See [`docs/deployment.md`](docs/deployment.md#disk-space).

## Tech stack

Expand Down
14 changes: 14 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ class Settings(BaseSettings):
# video, short enough that a leaked URL stops working.
media_url_ttl_seconds: int = 3600

# ─── disk space ──────────────────────────────────────────────────────────
# Absolute sizes, not percentages of the disk. What fills it is single files whose
# size has nothing to do with the disk's: a ninety-minute 1080p import is 1-2 GB on
# a 100 GB VPS or a 10 TB array alike, and one generation may download up to
# `generation_max_download_mb`. Ten percent of an 8 TB disk would cry wolf at
# 800 GB free.
#
# Below `disk_warn_free_gb` the header shows a warning. Below `disk_reserve_gb`
# uploads, imports, generations and extractions are refused, which keeps enough
# room for the writes already in flight and for SQLite, which does not recover
# gracefully from a disk that fills under it.
disk_warn_free_gb: float = 20
disk_reserve_gb: float = 5

# ─── http ────────────────────────────────────────────────────────────────
cors_origin: str = ""

Expand Down
6 changes: 5 additions & 1 deletion backend/app/enrichment/extract_subvideo.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from app.ingest.probe import probe
from app.models.asset import Asset
from app.services import assets as asset_service
from app.storage import build_storage, new_key, thumb_key_for
from app.storage import build_storage, capacity, new_key, thumb_key_for

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -72,6 +72,10 @@ def run(
except (KeyError, TypeError, ValueError) as exc:
raise SubvideoExtractionError("Missing or invalid in/out points") from exc

# Again, although the router already did: the job may have waited in the queue.
if not capacity.has_room():
raise SubvideoExtractionError(capacity.DISK_FULL_MESSAGE)

storage = build_storage()
progress("Extracting", 10, "")

Expand Down
7 changes: 6 additions & 1 deletion backend/app/enrichment/import_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
from app.services import suggestions as suggestion_service
from app.services import tags as tag_service
from app.settings_store import load_deepgram_key
from app.storage import StorageError, build_storage, thumb_key_for
from app.storage import StorageError, build_storage, capacity, thumb_key_for

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -112,6 +112,11 @@ def run(session: Session, job: EnrichmentJob, progress: Progress) -> ImportResul
except HTTPException as exc:
raise UrlImportError(readable_error(exc)) from exc

# Again, although the router already did: a job can sit queued behind others while
# the disk fills, and every playlist child arrives here without passing the router.
if not capacity.has_room():
raise UrlImportError(capacity.DISK_FULL_MESSAGE)

with tempfile.TemporaryDirectory(prefix="gam-import-") as workdir:
work = Path(workdir)

Expand Down
7 changes: 6 additions & 1 deletion backend/app/generation/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
from app.providers.base import ProviderError
from app.services import assets as asset_service
from app.settings_store import load_fal_key
from app.storage import StorageError, build_storage
from app.storage import StorageError, build_storage, capacity
from app.usage import events as usage_events

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -166,6 +166,11 @@ def run(session: Session, job: EnrichmentJob, progress: Progress) -> GenerationR

if state.result is None:
if state.queued is None:
# Before submitting, and only then. fal bills when it runs the request, so this
# is the last moment refusing costs nothing; a request already submitted has
# been paid for, and is let try to save its output rather than thrown away.
if not capacity.has_room():
raise GenerationError(capacity.DISK_FULL_MESSAGE)
body = _request_body(session, job, request, progress)
progress(STAGE_SUBMITTING, 10, "")
state.queued = fal.submit(request.endpoint_id, body, api_key=api_key)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from app.routers import providers as providers_router
from app.routers import search as search_router
from app.routers import settings as settings_router
from app.routers import storage as storage_router
from app.routers import tags as tags_router
from app.routers import usage as usage_router
from app.routers import transcripts as transcripts_router
Expand Down Expand Up @@ -186,6 +187,7 @@ def me(
app.include_router(providers_router.router, prefix="/api/providers", tags=["providers"])
app.include_router(tags_router.router, prefix="/api/tags", tags=["tags"])
app.include_router(usage_router.router, prefix="/api/usage", tags=["usage"])
app.include_router(storage_router.router, prefix="/api/storage", tags=["storage"])
# Not under /api: these URLs go straight into <img src> and <video src>, and the
# signature in the query string is what authorises them.
app.include_router(media_router.router, prefix="/media", tags=["media"])
19 changes: 18 additions & 1 deletion backend/app/routers/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from app.schemas_tags import AssetTagsWrite, BulkTagsResult, BulkTagsWrite, TagRead
from app.services import assets as service
from app.services import tags as tag_service
from app.storage import LocalStorage, build_storage
from app.storage import LocalStorage, build_storage, capacity

router = APIRouter()

Expand Down Expand Up @@ -80,6 +80,19 @@ async def upload_assets(
for upload in files:
filename = upload.filename or "unnamed"
try:
# Per file, not once for the batch, in keeping with the partial success
# above: the files that fit are stored and the rest say why they were not.
# The bytes are already spooled to temp space by now, so what is checked is
# the copy into media — which is where `has_room` counts the size.
if not capacity.has_room(upload.size or 0):
rejected.append(
UploadRejection(
filename=filename,
code=capacity.DISK_FULL_CODE,
message=capacity.DISK_FULL_MESSAGE,
)
)
continue
asset = await service.ingest_upload(
session,
storage,
Expand Down Expand Up @@ -112,6 +125,10 @@ async def upload_assets(
if not created and rejected:
# Nothing stored at all is a failed request, not a 201 describing an empty
# result — the client should be able to tell without inspecting the body.
if all(r.code == capacity.DISK_FULL_CODE for r in rejected):
# Said as itself rather than as "none of the 3 files could be stored": the
# reason is the same for every file and is the one thing worth reading.
raise capacity.disk_full_error()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
Expand Down
4 changes: 3 additions & 1 deletion backend/app/routers/clips.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from app.schemas_jobs import ActivityJobRead
from app.services import assets as asset_service
from app.services import tags as tag_service
from app.storage import LocalStorage
from app.storage import LocalStorage, capacity

router = APIRouter()

Expand Down Expand Up @@ -146,6 +146,7 @@ def start_subvideo_extraction(
detail={"code": "bad_request", "message": "The out point must be after the in point"},
)
_require_no_active_extraction(session, asset.id)
capacity.require_room()

job_payload = json.dumps(
{
Expand Down Expand Up @@ -178,6 +179,7 @@ def promote_clip(
detail={"code": "not_a_clip", "message": "This asset is not a clip"},
)
_require_no_active_extraction(session, clip.id)
capacity.require_room()

job = enrichment_jobs.submit(
session, clip, KIND_EXTRACT_SUBVIDEO, payload=json.dumps({"mode": "promote"})
Expand Down
4 changes: 3 additions & 1 deletion backend/app/routers/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
from app.schemas_jobs import ActivityJobRead
from app.services.assets import generation_of
from app.settings_store import FAL_API_KEY, has_setting
from app.storage import LocalStorage, StorageError
from app.storage import LocalStorage, StorageError, capacity

router = APIRouter()

Expand Down Expand Up @@ -330,6 +330,8 @@ def _checked_sources(


def _queue(session: Session, user_id: str, request: GenerationRequest) -> ActivityJobRead:
# Here, after every other check, so a bad request is still told what is wrong with it.
capacity.require_room()
job = enrichment_jobs.submit_library(
session,
user_id,
Expand Down
2 changes: 2 additions & 0 deletions backend/app/routers/imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from app.models.job import KIND_IMPORT_URL
from app.schemas import DataResponse
from app.schemas_jobs import ActivityJobRead
from app.storage import capacity

router = APIRouter()

Expand Down Expand Up @@ -58,6 +59,7 @@ def start_url_import(
it rather than downloading it twice.
"""
url = normalise_url(payload.url)
capacity.require_room()
request = ImportRequest(
url=url,
audio_only=payload.audio_only,
Expand Down
70 changes: 70 additions & 0 deletions backend/app/routers/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""How much disk GAM uses, how much is left, and clearing what nothing refers to.

Two audiences. Anybody signed in sees their own library's share — what they could free
by deleting things. The server's disk, every user's total and the stray-file sweep are
for administrators: how full one person's server is, and what everybody else keeps on
it, is not every account's business.
"""

from __future__ import annotations

from fastapi import APIRouter, Depends
from sqlmodel import Session

from app.auth import AdminUser, CurrentUser, is_admin
from app.database import get_session
from app.models.user import User
from app.routers.assets import get_storage
from app.schemas import DataResponse
from app.schemas_storage import CleanupResult, StorageStatus, StorageSummary
from app.services import storage_usage
from app.storage import LocalStorage, capacity

router = APIRouter()


@router.get("", response_model=DataResponse[StorageSummary])
def read_storage(
user: CurrentUser,
session: Session = Depends(get_session),
storage: LocalStorage = Depends(get_storage),
) -> DataResponse[StorageSummary]:
admin = is_admin(user, session.get(User, user.id))
volumes = capacity.volumes()
return DataResponse(
data=StorageSummary(
level=storage_usage.level(volumes),
library=storage_usage.library_usage(session, storage, user.id),
server=storage_usage.server_usage(session, storage, volumes) if admin else None,
)
)


@router.get("/status", response_model=DataResponse[StorageStatus])
def read_status(
user: CurrentUser, session: Session = Depends(get_session)
) -> DataResponse[StorageStatus]:
"""What the header polls. `statvfs` only — no walk, no table scan.

The level is everybody's, because a refused upload is everybody's problem and the
warning is what explains it before it happens. The numbers are an admin's.
"""
volumes = capacity.volumes()
level = storage_usage.level(volumes)
tightest = capacity.tightest(volumes)
if tightest is None or not is_admin(user, session.get(User, user.id)):
return DataResponse(data=StorageStatus(level=level))
return DataResponse(
data=StorageStatus(
level=level, free_bytes=tightest.free_bytes, total_bytes=tightest.total_bytes
)
)


@router.post("/cleanup", response_model=DataResponse[CleanupResult])
def clean_up_strays(
user: AdminUser,
session: Session = Depends(get_session),
storage: LocalStorage = Depends(get_storage),
) -> DataResponse[CleanupResult]:
return DataResponse(data=storage_usage.remove_strays(session, storage))
81 changes: 81 additions & 0 deletions backend/app/schemas_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""What `/api/storage` returns."""

from __future__ import annotations

from typing import List, Optional

from pydantic import BaseModel


class FileGroup(BaseModel):
files: int = 0
bytes: int = 0


class TypeUsage(BaseModel):
asset_type: str
files: int
bytes: int


class LargestAsset(BaseModel):
id: str
name: str
asset_type: str
size_bytes: int


class LibraryUsage(BaseModel):
"""One user's share: what they could free by deleting things."""

total_bytes: int
files: int
by_type: List[TypeUsage]
thumbnails: FileGroup
largest: List[LargestAsset]


class VolumeRead(BaseModel):
# media | database | working — every role GAM puts on this one filesystem.
roles: List[str]
total_bytes: int
used_bytes: int
free_bytes: int
level: str
# Whether this volume's level counts. The working volume alone does not; see
# `storage/capacity.py::Volume.guarded`.
guarded: bool


class ServerUsage(BaseModel):
"""Admin only. The whole server, every user included."""

volumes: List[VolumeRead]
# Every file under the media root: all users, thumbnails and strays included.
media: FileGroup
# None when the database is not a local SQLite file.
database_bytes: Optional[int] = None
stray: FileGroup
warn_free_bytes: int
reserve_bytes: int


class StorageSummary(BaseModel):
# Everybody's: the header's level, so the panel can say why an upload was refused
# without telling a non-admin how full the server is.
level: str
library: LibraryUsage
server: Optional[ServerUsage] = None


class StorageStatus(BaseModel):
level: str
# Admin only; null for everyone else, who is told the level and not the numbers.
free_bytes: Optional[int] = None
total_bytes: Optional[int] = None


class CleanupResult(BaseModel):
removed: FileGroup
# Strays the sweep found and could not delete. Logged with the reason.
skipped: int = 0
Loading
Loading