------- cache.go ------ package pmocover import ( "bytes" "crypto/sha1" "encoding/hex" "errors" "image" "io" "net/http" "os" "path/filepath" "sync" ) // CacheEntry représente une image stockée (original + dérivés) type CacheEntry struct { PK string `json:"pk"` SourceURL string `json:"source_url"` Hits int `json:"hits"` LastUsed string `json:"last_used"` // ISO8601 } // Cache persistant avec SQLite type Cache struct { dir string limit int db *DB mu sync.Mutex } // NewCache ouvre ou crée un cache persistant avec SQLite func NewCache(dir string, limit int) (*Cache, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } db, err := InitDB(dir) if err != nil { return nil, err } return &Cache{ dir: dir, limit: limit, db: db, }, nil } // AddFromURL télécharge une image et la met en cache func (c *Cache) AddFromURL(url string) (string, error) { resp, err := http.Get(url) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", errors.New("bad status") } data, err := io.ReadAll(resp.Body) if err != nil { return "", err } return c.Add(url, data) } // Add ajoute une image déjà téléchargée func (c *Cache) Add(url string, data []byte) (string, error) { pk := pkFromURL(url) origPath := filepath.Join(c.dir, pk+".orig.webp") c.mu.Lock() defer c.mu.Unlock() if _, err := os.Stat(origPath); errors.Is(err, os.ErrNotExist) { img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return "", err } buf, err := encodeWebP(img) if err != nil { return "", err } if err := os.WriteFile(origPath, buf, 0o644); err != nil { return "", err } } _ = c.db.Add(pk, url) // ajoute ou met à jour la base return pk, nil } // Get renvoie le chemin du fichier original et met à jour hits/last_used func (c *Cache) Get(pk string) (string, error) { c.mu.Lock() defer c.mu.Unlock() _, err := c.db.Get(pk) if err != nil { return "", err } _ = c.db.UpdateHit(pk) origPath := filepath.Join(c.dir, pk+".orig.webp") if _, err := os.Stat(origPath); err != nil { return "", err } return origPath, nil } // Purge vide complètement le cache func (c *Cache) Purge() error { c.mu.Lock() defer c.mu.Unlock() files, _ := filepath.Glob(filepath.Join(c.dir, "*")) for _, f := range files { os.Remove(f) } return c.db.Purge() } // pkFromURL → hash stable basé sur l’URL func pkFromURL(url string) string { h := sha1.Sum([]byte(url)) return hex.EncodeToString(h[:8]) } ----------------- ------- db.go ------ package pmocover import ( "database/sql" "path/filepath" "time" _ "modernc.org/sqlite" ) // DB représente la base SQLite du cache type DB struct { conn *sql.DB } // InitDB ouvre ou crée la base SQLite dans le répertoire dir func InitDB(dir string) (*DB, error) { path := filepath.Join(dir, "cache.db") conn, err := sql.Open("sqlite", path) if err != nil { return nil, err } db := &DB{conn: conn} if err := db.initTables(); err != nil { conn.Close() return nil, err } return db, nil } // initTables crée la table covers si elle n’existe pas func (db *DB) initTables() error { _, err := db.conn.Exec(` CREATE TABLE IF NOT EXISTS covers ( pk TEXT PRIMARY KEY, source_url TEXT, hits INTEGER DEFAULT 0, last_used TEXT ); `) return err } // Add ajoute ou met à jour une entrée cover func (db *DB) Add(pk, url string) error { _, err := db.conn.Exec(` INSERT INTO covers(pk, source_url, hits, last_used) VALUES(?, ?, 0, ?) ON CONFLICT(pk) DO UPDATE SET source_url=excluded.source_url, last_used=excluded.last_used; `, pk, url, time.Now().UTC().Format(time.RFC3339)) return err } // Get récupère une entrée func (db *DB) Get(pk string) (*CacheEntry, error) { row := db.conn.QueryRow(` SELECT pk, source_url, hits, last_used FROM covers WHERE pk = ? `, pk) entry := &CacheEntry{} var lastUsed sql.NullString err := row.Scan(&entry.PK, &entry.SourceURL, &entry.Hits, &lastUsed) if err != nil { return nil, err } if lastUsed.Valid { entry.LastUsed = lastUsed.String } return entry, nil } // UpdateHit incrémente hits et met à jour last_used func (db *DB) UpdateHit(pk string) error { _, err := db.conn.Exec(` UPDATE covers SET hits = hits + 1, last_used = ? WHERE pk = ? `, time.Now().UTC().Format(time.RFC3339), pk) return err } // Purge supprime toutes les entrées de la base func (db *DB) Purge() error { _, err := db.conn.Exec(`DELETE FROM covers`) return err } // GetAll récupère toutes les entrées triées par hits décroissant func (db *DB) GetAll() ([]*CacheEntry, error) { rows, err := db.conn.Query(` SELECT pk, source_url, hits, last_used FROM covers ORDER BY hits DESC `) if err != nil { return nil, err } defer rows.Close() var entries []*CacheEntry for rows.Next() { entry := &CacheEntry{} var lastUsed sql.NullString if err := rows.Scan(&entry.PK, &entry.SourceURL, &entry.Hits, &lastUsed); err != nil { continue } if lastUsed.Valid { entry.LastUsed = lastUsed.String } entries = append(entries, entry) } return entries, nil } ----------------- ------- handler.go ------ package pmocover import ( "encoding/json" "net/http" "os" "path/filepath" "strconv" "strings" ) // ServeMux branche les routes /covers func (c *Cache) ServeMux(mux *http.ServeMux) { // Images mux.HandleFunc("/covers/images/", func(w http.ResponseWriter, r *http.Request) { parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/covers/images/"), "/") if len(parts) == 0 || parts[0] == "" { http.Error(w, "missing pk", 400) return } pk := parts[0] if len(parts) == 1 { path, err := c.Get(pk) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/webp") http.ServeFile(w, r, path) return } size, err := strconv.Atoi(parts[1]) if err != nil { http.Error(w, "invalid size", 400) return } variantPath := filepath.Join(c.dir, pk+"."+parts[1]+".webp") if _, err := os.Stat(variantPath); os.IsNotExist(err) { data, err := c.generateVariant(pk, size) if err != nil { http.Error(w, "cannot generate", 500) return } w.Header().Set("Content-Type", "image/webp") w.Write(data) return } w.Header().Set("Content-Type", "image/webp") http.ServeFile(w, r, variantPath) }) // Stats mux.HandleFunc("/covers/stats", func(w http.ResponseWriter, r *http.Request) { entries, _ := c.db.GetAll() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(entries) }) } ----------------- ------- pmocover.go ------ package pmocover import "gargoton.petite-maison-orange.fr/eric/pmomusic/pmoconfig" // GetCoverCache récupère le cache, crée le dossier et la base si nécessaire func GetCoverCache() (*Cache, error) { dir := pmoconfig.GetConfig().GetCoverCacheDir() cache, err := NewCache(dir, 2000) if err != nil { return nil, err } return cache, nil } ----------------- ------- webp.go ------ package pmocover import ( "bytes" "image" "image/draw" "os" "path/filepath" "strconv" "github.com/chai2010/webp" xdraw "golang.org/x/image/draw" ) // encodeWebP convertit une image.Image en WebP avec compression sans perte. func encodeWebP(img image.Image) ([]byte, error) { var buf bytes.Buffer if err := webp.Encode(&buf, img, &webp.Options{Lossless: true}); err != nil { return nil, err } return buf.Bytes(), nil } // ensureSquare génère une image carrée paddée transparente func ensureSquare(img image.Image, size int) image.Image { dst := image.NewRGBA(image.Rect(0, 0, size, size)) draw.Draw(dst, dst.Bounds(), &image.Uniform{C: image.Transparent}, image.Point{}, draw.Src) srcBounds := img.Bounds() srcW, srcH := srcBounds.Dx(), srcBounds.Dy() var scale float64 if srcW > srcH { scale = float64(size) / float64(srcW) } else { scale = float64(size) / float64(srcH) } newW, newH := int(float64(srcW)*scale), int(float64(srcH)*scale) // redimensionnement avec Catmull-Rom tmp := image.NewRGBA(image.Rect(0, 0, newW, newH)) xdraw.CatmullRom.Scale(tmp, tmp.Bounds(), img, srcBounds, xdraw.Over, nil) // centrer l'image redimensionnée dans le carré offset := image.Pt((size-newW)/2, (size-newH)/2) draw.Draw(dst, tmp.Bounds().Add(offset), tmp, image.Point{}, draw.Over) return dst } // generateVariant génère une version carrée en WebP pour une taille donnée. // Si le fichier existe déjà, il est renvoyé directement. func (c *Cache) generateVariant(pk string, size int) ([]byte, error) { variantPath := filepath.Join(c.dir, pk+"."+strconv.Itoa(size)+".webp") // si le variant existe déjà, le renvoyer if data, err := os.ReadFile(variantPath); err == nil { return data, nil } // lire l'image originale origPath := filepath.Join(c.dir, pk+".orig.webp") data, err := os.ReadFile(origPath) if err != nil { return nil, err } img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil, err } // générer une image carrée sq := ensureSquare(img, size) // encoder en WebP buf, err := encodeWebP(sq) if err != nil { return nil, err } // sauvegarder le variant if err := os.WriteFile(variantPath, buf, 0o644); err != nil { return nil, err } return buf, nil } -----------------