Ajoute un cache pour les couvertures

This commit is contained in:
2025-09-09 09:07:48 +02:00
parent 78c3dfa686
commit cd01b5b2ca
17 changed files with 1018 additions and 34 deletions

129
pmocover/cache.go Normal file
View File

@@ -0,0 +1,129 @@
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 lURL
func pkFromURL(url string) string {
h := sha1.Sum([]byte(url))
return hex.EncodeToString(h[:8])
}

118
pmocover/db.go Normal file
View File

@@ -0,0 +1,118 @@
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 nexiste 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
}

62
pmocover/handler.go Normal file
View File

@@ -0,0 +1,62 @@
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, err := c.db.GetAll()
if err != nil {
http.Error(w, "cannot retrieve stats", 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
})
}

16
pmocover/pmocover.go Normal file
View File

@@ -0,0 +1,16 @@
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) {
config := pmoconfig.GetConfig()
dir := config.GetCoverCacheDir()
size := config.GetCoverCacheSize()
cache, err := NewCache(dir, size)
if err != nil {
return nil, err
}
return cache, nil
}

87
pmocover/webp.go Normal file
View File

@@ -0,0 +1,87 @@
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
}