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 }