416 lines
9.1 KiB
Plaintext
416 lines
9.1 KiB
Plaintext
------- 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
|
||
}
|
||
-----------------
|