Version fonctionnelle du cache #15
BIN
.pmomusic_covers/cache.db
Normal file
BIN
.pmomusic_covers/cache.db
Normal file
Binary file not shown.
415
all.txt
Normal file
415
all.txt
Normal file
@@ -0,0 +1,415 @@
|
||||
------- 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
|
||||
}
|
||||
-----------------
|
||||
15
go.mod
15
go.mod
@@ -10,6 +10,19 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
github.com/chai2010/webp v1.4.0 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.38.2 // indirect
|
||||
)
|
||||
|
||||
34
go.sum
34
go.sum
@@ -1,24 +1,58 @@
|
||||
github.com/beevik/etree v1.5.1 h1:TC3zyxYp+81wAmbsi8SWUpZCurbxa6S8RITYRSkNRwo=
|
||||
github.com/beevik/etree v1.5.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
|
||||
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
|
||||
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/globusdigital/soap v1.4.0 h1:nQDpWelZr3zKg6Oe1e5SqWd4PfiUvsl5/44oWUXy3II=
|
||||
github.com/globusdigital/soap v1.4.0/go.mod h1:p8hjOZ4FmK0jXBTcIZ6e5M2QBfcsxBUKWBYsHM2eZRw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
// App.tsx
|
||||
import { useEffect, useState } from "react";
|
||||
import { Logs, type LogEntry } from "./Logs";
|
||||
import { Covers } from "./Covers";
|
||||
import "./App.css";
|
||||
|
||||
export default function App() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("dark");
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [view, setView] = useState<"logs" | "covers">("logs");
|
||||
|
||||
const toggleTheme = () =>
|
||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
|
||||
// Fonction pour transformer le SSE en LogEntry
|
||||
const mapSSEToLogEntry = (sseData: any, index: number): LogEntry => ({
|
||||
id: sseData.time || `log-${index}`,
|
||||
level: sseData.level || "info",
|
||||
@@ -37,17 +38,36 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={theme}>
|
||||
<header className="flex items-center justify-between p-4 bg-neutral-800">
|
||||
<h1 className="text-xl font-bold">PMO-Music logs</h1>
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-600 rounded hover:bg-blue-500"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
Theme: {theme}
|
||||
</button>
|
||||
<div className={theme === "dark" ? "dark bg-neutral-900 text-white" : "bg-neutral-50 text-black"}>
|
||||
<header className="flex items-center justify-between p-4 bg-neutral-800 text-white">
|
||||
<h1 className="text-xl font-bold">PMO-Music</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-600 rounded hover:bg-blue-500"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
Theme: {theme}
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded ${
|
||||
view === "logs" ? "bg-neutral-700" : "bg-neutral-600"
|
||||
}`}
|
||||
onClick={() => setView("logs")}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded ${
|
||||
view === "covers" ? "bg-neutral-700" : "bg-neutral-600"
|
||||
}`}
|
||||
onClick={() => setView("covers")}
|
||||
>
|
||||
Covers
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Logs logs={logs} theme={theme} />
|
||||
{view === "logs" && <Logs logs={logs} theme={theme} />}
|
||||
{view === "covers" && <Covers />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
46
pmoapp/web/src/Covers.tsx
Normal file
46
pmoapp/web/src/Covers.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// Covers.tsx
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type CoverEntry = {
|
||||
pk: string;
|
||||
source_url: string;
|
||||
hits: number;
|
||||
};
|
||||
|
||||
export function Covers() {
|
||||
const [covers, setCovers] = useState<CoverEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/covers/stats")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setCovers(data.top_hits || []);
|
||||
})
|
||||
.catch((err) => console.error("Erreur stats covers:", err));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl font-bold mb-4 text-neutral-900 dark:text-neutral-100">
|
||||
Top Covers
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
{covers.map((cover) => (
|
||||
<div
|
||||
key={cover.pk}
|
||||
className="bg-white dark:bg-neutral-800 rounded-xl shadow p-3 flex flex-col items-center"
|
||||
>
|
||||
<img
|
||||
src={`/covers/${cover.pk}/256`}
|
||||
alt={cover.source_url}
|
||||
className="w-32 h-32 object-cover rounded-lg shadow"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-300 truncate w-32 text-center">
|
||||
{cover.hits} hits
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,21 +13,19 @@ type LogsProps = {
|
||||
theme: "light" | "dark";
|
||||
};
|
||||
|
||||
|
||||
|
||||
export function Logs({ logs, theme }: LogsProps) {
|
||||
export function Logs({ logs }: LogsProps) {
|
||||
const levelClass = (level: LogEntry["level"]) => {
|
||||
switch (level) {
|
||||
case "info":
|
||||
return "info";
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
|
||||
case "warn":
|
||||
return "warning";
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
|
||||
case "error":
|
||||
return "error";
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
|
||||
case "debug":
|
||||
return "debug";
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
|
||||
default:
|
||||
return "info";
|
||||
return "bg-gray-100 dark:bg-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,19 +36,20 @@ export function Logs({ logs, theme }: LogsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`log-container ${theme}`}>
|
||||
<div className="p-4 space-y-2">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className={`log-entry ${levelClass(log.level)}`}>
|
||||
<div
|
||||
key={log.id}
|
||||
className={`rounded-lg shadow p-3 ${levelClass(log.level)}`}
|
||||
>
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
children={decodeMessage(log.message)}
|
||||
components={{
|
||||
code({ node, className, children, ...props }) {
|
||||
code({ className, children, ...props }) {
|
||||
return (
|
||||
<code
|
||||
className={`bg-gray-700 text-white px-1 py-0.5 rounded ${
|
||||
className || ""
|
||||
}`}
|
||||
className={`bg-black/20 px-1 py-0.5 rounded ${className || ""}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -54,7 +54,7 @@ button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
/* @media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
@@ -65,4 +65,4 @@ button:focus-visible {
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
} */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package upnp
|
||||
package pmoconfig
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
@@ -366,7 +366,7 @@ func (conf *Config) GetHTTPPort() int {
|
||||
return 1900
|
||||
}
|
||||
|
||||
func (conf *Config) GetDeviceUDN(devtype DeviceType, name string) string {
|
||||
func (conf *Config) GetDeviceUDN(devtype, name string) string {
|
||||
udn, error := conf.GetValue([]string{"devices", string(devtype), name, "udn"})
|
||||
|
||||
if error != nil {
|
||||
@@ -377,3 +377,34 @@ func (conf *Config) GetDeviceUDN(devtype DeviceType, name string) string {
|
||||
|
||||
return udn.(string)
|
||||
}
|
||||
|
||||
func (conf *Config) GetCoverCacheDir() string {
|
||||
cachedir, _ := conf.GetValue([]string{"host", "cover_cache", "directory"})
|
||||
if cachedir == nil {
|
||||
cachedir = "./.pmomusic_covers"
|
||||
}
|
||||
|
||||
sdir, ok := cachedir.(string)
|
||||
|
||||
if ok && sdir != "" {
|
||||
return sdir
|
||||
}
|
||||
|
||||
return "./.pmomusic_covers"
|
||||
}
|
||||
|
||||
func (conf *Config) GetCoverCacheSize() int {
|
||||
size, _ := conf.GetValue([]string{"host", "cover_cache", "size"})
|
||||
if size == nil {
|
||||
return 2000
|
||||
}
|
||||
|
||||
switch v := size.(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
default:
|
||||
return 2000
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
host:
|
||||
http_port: "1900"
|
||||
http_port: "8080"
|
||||
cover_cache:
|
||||
directory: "./.pmomusic_covers"
|
||||
size: 2000
|
||||
devices:
|
||||
mediarenderer:
|
||||
mpd_renderer:
|
||||
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
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/netutils"
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmoapp"
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmoconfig"
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmocover"
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmolog"
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/ssdp"
|
||||
)
|
||||
@@ -35,7 +37,7 @@ type Server struct {
|
||||
}
|
||||
|
||||
func NewServer(name string, opts ...ServerOption) *Server {
|
||||
config := GetConfig()
|
||||
config := pmoconfig.GetConfig()
|
||||
|
||||
baseURL := config.GetBaseURL()
|
||||
httpPort := config.GetHTTPPort()
|
||||
@@ -79,6 +81,14 @@ func (s *Server) Start() error {
|
||||
|
||||
s.mu.RLock()
|
||||
|
||||
cover_cache, err := pmocover.GetCoverCache()
|
||||
if err != nil {
|
||||
log.Panicf("❌ Cannot initialize the Cover Cache")
|
||||
}
|
||||
|
||||
log.Info("✅ Cover cache activated")
|
||||
|
||||
cover_cache.ServeMux(mux)
|
||||
pmoapp.Handler(mux)
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package upnp
|
||||
|
||||
import (
|
||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmoconfig"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -16,8 +17,8 @@ func (s *Server) RegisterDevice(name string, d *Device) {
|
||||
name = d.Name()
|
||||
}
|
||||
|
||||
config := GetConfig()
|
||||
udn := config.GetDeviceUDN(d.DeviceType(), name)
|
||||
config := pmoconfig.GetConfig()
|
||||
udn := config.GetDeviceUDN(string(d.DeviceType()), name)
|
||||
|
||||
instance := d.NewInstance(s, udn)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user