------- 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
}
-----------------
