d92349ca6e
Vision needs a second model this box does not have, so the shipped half is the part that works without one: an image arrives, is sniffed, is stored content-addressed, and is prepared for inference. The describing half is written and tested against a fake server, and refuses any endpoint that is not on this box. internal/media is the intake all three senses share — hearing and speaker recognition store their audio in the same place under the same retention. Blobs stay out of the sqlite store; only the derived text becomes a note, and only when the caller asks. Retention is enforced by an hourly prune loop rather than by a comment. The plan's RemoteProvider step is refused: no cloud model, inference stays on the box, and vision.NewLocal validates that at construction.
120 lines
5.4 KiB
Go
120 lines
5.4 KiB
Go
// Package media is the intake for everything Maven sees or hears that is not
|
|
// text: a photo he sends her, a meeting she was asked to record, a voice sample
|
|
// used to enrol a speaker. All three senses (vision, hearing, speaker
|
|
// recognition) share one problem — a blob arrives, it has to be stored, and
|
|
// something has to describe it — so the storing half lives here once instead of
|
|
// three times.
|
|
//
|
|
// # What this package is
|
|
//
|
|
// A content-addressed blob store on the local filesystem. Put returns a Blob
|
|
// keyed by the sha256 of its bytes, so the same photo sent twice is one file.
|
|
// Each blob gets a sidecar `.json` with its kind, mime, size, source and
|
|
// creation time; the sidecar is the whole index, because at personal scale a
|
|
// directory walk is cheaper than another sqlite table and the store has to be
|
|
// readable with `ls` when something goes wrong.
|
|
//
|
|
// Blobs are NOT in the sqlite database. The database is small, encrypted, and
|
|
// read on every tick; a 40 MB meeting recording has no business in it. What
|
|
// goes in the database is the *text* a blob produced — a transcript, a
|
|
// description — written as an ordinary note, which is the durable artefact and
|
|
// the only part worth recalling later.
|
|
//
|
|
// # Invariants (these are the point of the package, not decoration)
|
|
//
|
|
// - Nothing is captured that was not asked for. This package never records;
|
|
// it stores what a caller hands it, and every caller is an explicit act
|
|
// with a start and a stop. There is no ambient path in, and none may be
|
|
// added: see the refusal recorded in docs/plans/08-hearing.md.
|
|
// - A blob never leaves the box. No provider in this repo may upload one, and
|
|
// the vision provider refuses a non-private endpoint for exactly that
|
|
// reason (internal/vision).
|
|
// - A blob is never search input and never embedded. His photos and the audio
|
|
// of his meetings are not corpus. Only text derived from them, once he can
|
|
// see it as a note, participates in recall.
|
|
// - Storage is bounded. Retention is a config knob with a default, Prune
|
|
// enforces it, and an unpruned store is a bug: audio of people accumulating
|
|
// forever on disk is the failure mode this capability has to avoid.
|
|
//
|
|
// # Layout
|
|
//
|
|
// <dir>/<kind>/<aa>/<sha256>.<ext> the bytes
|
|
// <dir>/<kind>/<aa>/<sha256>.json the sidecar metadata
|
|
//
|
|
// `aa` is the first two hex chars of the digest — one fan-out level, enough to
|
|
// keep a directory listing usable after a few thousand blobs.
|
|
package media
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Kind — what a blob is. Two values today; the kind is a directory name and a
|
|
// retention bucket, so adding a third is additive.
|
|
type Kind string
|
|
|
|
const (
|
|
// KindImage — a still image (png / jpeg / gif / webp bytes as received).
|
|
KindImage Kind = "image"
|
|
// KindAudio — raw PCM in the canonical internal/audio format, or a WAV
|
|
// container. Meeting captures and enrolment samples both land here.
|
|
KindAudio Kind = "audio"
|
|
)
|
|
|
|
// Valid reports whether k is a kind this package will store. An unknown kind is
|
|
// refused at Put rather than creating a stray directory.
|
|
func (k Kind) Valid() bool { return k == KindImage || k == KindAudio }
|
|
|
|
// Errors callers distinguish. ErrNotFound is the only one a caller usually
|
|
// handles; the rest mean the call was wrong.
|
|
var (
|
|
// ErrNotFound — no blob with that id in this store.
|
|
ErrNotFound = errors.New("media: not found")
|
|
// ErrEmpty — Put was handed zero bytes. Storing an empty capture would
|
|
// leave a sidecar claiming a recording exists when it does not.
|
|
ErrEmpty = errors.New("media: empty payload")
|
|
// ErrTooLarge — the payload is over the store's cap. The cap exists so a
|
|
// runaway capture cannot fill the disk that mavend's database lives on.
|
|
ErrTooLarge = errors.New("media: payload too large")
|
|
// ErrBadKind — unknown Kind.
|
|
ErrBadKind = errors.New("media: unknown kind")
|
|
// ErrBadID — the id is not a 64-char lowercase hex digest, so it cannot
|
|
// have come from this store and must not be turned into a path.
|
|
ErrBadID = errors.New("media: malformed id")
|
|
)
|
|
|
|
// Blob — one stored item. ID is the sha256 of the bytes in lowercase hex, which
|
|
// makes it both the primary key and the dedupe mechanism. Path is absolute and
|
|
// local; it is a debugging affordance and the argument a subprocess (whisper,
|
|
// llama-server) is pointed at, never something handed to a network client.
|
|
type Blob struct {
|
|
ID string `json:"id"`
|
|
Kind Kind `json:"kind"`
|
|
MIME string `json:"mime"`
|
|
Size int64 `json:"size"`
|
|
Source string `json:"source"` // provenance: "telegram", "web:upload", "capture:meeting", "enroll"
|
|
Created time.Time `json:"created"` // UTC
|
|
Path string `json:"-"` // filled by the store; not part of the sidecar
|
|
}
|
|
|
|
// Age is how long ago the blob was stored, measured against now. Prune uses it;
|
|
// it is exported because the /media surface will want to show it.
|
|
func (b Blob) Age(now time.Time) time.Duration { return now.Sub(b.Created) }
|
|
|
|
// String is a one-line summary for logs. Deliberately does not include Path:
|
|
// a log line is not the place to spell out where his meeting audio lives.
|
|
func (b Blob) String() string {
|
|
return fmt.Sprintf("%s %s %dB from %s", b.Kind, shortID(b.ID), b.Size, b.Source)
|
|
}
|
|
|
|
// shortID trims a digest to something readable in a log line. Twelve hex chars
|
|
// is unambiguous at personal scale and short enough to fit next to the rest.
|
|
func shortID(id string) string {
|
|
if len(id) <= 12 {
|
|
return id
|
|
}
|
|
return id[:12]
|
|
}
|