bbolt instead of a tree of hash-named JPEGs: pure Go, so the binary stays static; one file per machine; an entry carries where the preview came from (engine, codec, size, when) beside the picture, and a count rides along so the tab can say what it holds without walking anything. A miss never creates the file, so a machine nobody browses with previews on never has one; a cache that cannot be opened is logged and previews are still made and served, just not kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
219 lines
5.5 KiB
Go
219 lines
5.5 KiB
Go
package thumbs
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
bolt "go.etcd.io/bbolt"
|
|
)
|
|
|
|
// Cache keeps finished previews in one database file under Ham Lab's own data directory —
|
|
// thumbs.db, a bbolt key/value store: pure Go, so the binary stays static, and one file
|
|
// per machine rather than a tree of hash-named JPEGs. An entry is one file's preview keyed
|
|
// by its path, size and mtime, so a file that changes gets a new preview and an unchanged
|
|
// one is never decoded twice. A file ffmpeg could not decode is kept the same way, with
|
|
// ffmpeg's reason and no picture, so a folder of odd files does not re-run ffmpeg on every
|
|
// visit. Nothing expires: a preview is a few kilobytes, and only files somebody actually
|
|
// scrolled past are ever made. The file is opened the first time something is stored, so a
|
|
// machine nobody browses with previews on never has one.
|
|
type Cache struct {
|
|
Path string
|
|
mu sync.Mutex
|
|
db *bolt.DB
|
|
err error
|
|
}
|
|
|
|
// Entry is one cached preview, or the remembered reason there is none.
|
|
type Entry struct {
|
|
Path string `json:"path"`
|
|
Engine string `json:"engine,omitempty"`
|
|
Codec string `json:"codec,omitempty"`
|
|
Width int `json:"width,omitempty"`
|
|
Height int `json:"height,omitempty"`
|
|
Made time.Time `json:"made"`
|
|
Failure string `json:"failure,omitempty"` // set instead of JPEG when ffmpeg refused the file
|
|
JPEG []byte `json:"-"`
|
|
}
|
|
|
|
var (
|
|
bucketThumbs = []byte("thumbs")
|
|
bucketMeta = []byte("meta")
|
|
keyCount = []byte("count")
|
|
)
|
|
|
|
// Key identifies one file's preview. "v1" is the preview format: bump it to retire every
|
|
// cached preview at once.
|
|
func Key(path string, size, mtime int64) string {
|
|
h := sha256.Sum256([]byte("v1\x00" + path + "\x00" + strconv.FormatInt(size, 10) + "\x00" + strconv.FormatInt(mtime, 10)))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func NewCache(path string) *Cache { return &Cache{Path: path} }
|
|
|
|
// open the database once; an open that failed is not retried, and previews still get made
|
|
// and served — they are simply not kept, and the log says why.
|
|
func (c *Cache) open() (*bolt.DB, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.db != nil || c.err != nil {
|
|
return c.db, c.err
|
|
}
|
|
db, err := bolt.Open(c.Path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
|
|
if err != nil {
|
|
c.err = err
|
|
return nil, err
|
|
}
|
|
if err := db.Update(func(tx *bolt.Tx) error {
|
|
if _, err := tx.CreateBucketIfNotExists(bucketThumbs); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.CreateBucketIfNotExists(bucketMeta)
|
|
return err
|
|
}); err != nil {
|
|
db.Close()
|
|
c.err = err
|
|
return nil, err
|
|
}
|
|
c.db = db
|
|
return db, nil
|
|
}
|
|
|
|
// Close releases the file. Previews after it are made but not kept.
|
|
func (c *Cache) Close() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.db == nil {
|
|
return nil
|
|
}
|
|
err := c.db.Close()
|
|
c.db, c.err = nil, errors.New("cache closed")
|
|
return err
|
|
}
|
|
|
|
// Get returns the entry for a key. A database that does not exist yet is a miss, not a reason
|
|
// to make one.
|
|
func (c *Cache) Get(key string) (Entry, bool, error) {
|
|
c.mu.Lock()
|
|
db := c.db
|
|
c.mu.Unlock()
|
|
if db == nil {
|
|
if _, err := os.Stat(c.Path); err != nil {
|
|
return Entry{}, false, nil
|
|
}
|
|
var err error
|
|
if db, err = c.open(); err != nil {
|
|
return Entry{}, false, err
|
|
}
|
|
}
|
|
var e Entry
|
|
found := false
|
|
err := db.View(func(tx *bolt.Tx) error {
|
|
v := tx.Bucket(bucketThumbs).Get([]byte(key))
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
var err error
|
|
e, err = decode(v) // copies: a value is only valid inside the transaction
|
|
found = err == nil
|
|
return err
|
|
})
|
|
return e, found, err
|
|
}
|
|
|
|
// Put stores an entry, replacing any under the same key.
|
|
func (c *Cache) Put(key string, e Entry) error {
|
|
if len(e.JPEG) == 0 && e.Failure == "" {
|
|
return errors.New("empty preview")
|
|
}
|
|
if e.Made.IsZero() {
|
|
e.Made = time.Now()
|
|
}
|
|
db, err := c.open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v, err := encode(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return db.Update(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket(bucketThumbs)
|
|
k := []byte(key)
|
|
fresh := b.Get(k) == nil
|
|
if err := b.Put(k, v); err != nil {
|
|
return err
|
|
}
|
|
if fresh {
|
|
m := tx.Bucket(bucketMeta)
|
|
return m.Put(keyCount, be64(count(m)+1))
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Stats is how many previews are kept and how big the file is.
|
|
func (c *Cache) Stats() (entries int64, bytes int64) {
|
|
st, err := os.Stat(c.Path)
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
bytes = st.Size()
|
|
db, err := c.open()
|
|
if err != nil {
|
|
return 0, bytes
|
|
}
|
|
_ = db.View(func(tx *bolt.Tx) error { entries = count(tx.Bucket(bucketMeta)); return nil })
|
|
return entries, bytes
|
|
}
|
|
|
|
func count(m *bolt.Bucket) int64 {
|
|
if v := m.Get(keyCount); len(v) == 8 {
|
|
return int64(binary.BigEndian.Uint64(v))
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func be64(n int64) []byte {
|
|
b := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(b, uint64(n))
|
|
return b
|
|
}
|
|
|
|
// A value is a 4-byte header length, the header as JSON, then the JPEG bytes.
|
|
func encode(e Entry) ([]byte, error) {
|
|
hdr, err := json.Marshal(e)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
v := make([]byte, 4+len(hdr)+len(e.JPEG))
|
|
binary.BigEndian.PutUint32(v, uint32(len(hdr)))
|
|
copy(v[4:], hdr)
|
|
copy(v[4+len(hdr):], e.JPEG)
|
|
return v, nil
|
|
}
|
|
|
|
func decode(v []byte) (Entry, error) {
|
|
if len(v) < 4 {
|
|
return Entry{}, errors.New("short value")
|
|
}
|
|
n := int(binary.BigEndian.Uint32(v))
|
|
if 4+n > len(v) {
|
|
return Entry{}, errors.New("bad header length")
|
|
}
|
|
var e Entry
|
|
if err := json.Unmarshal(v[4:4+n], &e); err != nil {
|
|
return Entry{}, err
|
|
}
|
|
if rest := v[4+n:]; len(rest) > 0 {
|
|
e.JPEG = append([]byte(nil), rest...)
|
|
}
|
|
return e, nil
|
|
}
|