Ajoute un cache pour les couvertures
This commit is contained in:
129
pmocover/cache.go
Normal file
129
pmocover/cache.go
Normal 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 l’URL
|
||||
func pkFromURL(url string) string {
|
||||
h := sha1.Sum([]byte(url))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
118
pmocover/db.go
Normal file
118
pmocover/db.go
Normal 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 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
|
||||
}
|
||||
62
pmocover/handler.go
Normal file
62
pmocover/handler.go
Normal 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
16
pmocover/pmocover.go
Normal 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
87
pmocover/webp.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user