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