From 5d478082717b00fe4ea8953b46bd0a4d2abb0303 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Tue, 9 Sep 2025 09:07:48 +0200 Subject: [PATCH] Ajoute un cache pour les couvertures --- .pmomusic_covers/cache.db | Bin 0 -> 12288 bytes all.txt | 415 ++++++++++++++++++++++++++++++ go.mod | 15 +- go.sum | 34 +++ pmoapp/web/src/App.tsx | 42 ++- pmoapp/web/src/Covers.tsx | 46 ++++ pmoapp/web/src/Logs.tsx | 27 +- pmoapp/web/src/index.css | 4 +- {upnp => pmoconfig}/config.go | 35 ++- {upnp => pmoconfig}/pmomusic.yaml | 5 +- pmocover/cache.go | 129 ++++++++++ pmocover/db.go | 118 +++++++++ pmocover/handler.go | 62 +++++ pmocover/pmocover.go | 16 ++ pmocover/webp.go | 87 +++++++ upnp/server.go | 12 +- upnp/srv_reg_devices.go | 5 +- 17 files changed, 1018 insertions(+), 34 deletions(-) create mode 100644 .pmomusic_covers/cache.db create mode 100644 all.txt create mode 100644 pmoapp/web/src/Covers.tsx rename {upnp => pmoconfig}/config.go (93%) rename {upnp => pmoconfig}/pmomusic.yaml (58%) create mode 100644 pmocover/cache.go create mode 100644 pmocover/db.go create mode 100644 pmocover/handler.go create mode 100644 pmocover/pmocover.go create mode 100644 pmocover/webp.go diff --git a/.pmomusic_covers/cache.db b/.pmomusic_covers/cache.db new file mode 100644 index 0000000000000000000000000000000000000000..dc96c7528a843a12152e9c549fd38b245d554ce3 GIT binary patch literal 12288 zcmeI#K}*9h6bJC6%Cv>ifrruCJ1UAOegJJX%CL&o2<}ws7PVNdYf|yLt6$A8D}GNxl!~~R&SZ1BQ{{1vlAl57;pAkvlq+e>x-m0ZN6-Aw*S4= z_kWq=N6e4>pJgwg4*>{300Izz00bZa0SG_<0ucD00`nf<7zP2KKb!2aP?PdgRl1AL zX(B=?NQO}?sH@So=ei%SB*lZIn`CqqCS$r3Ae>`pt^Z(7g3 z4FemnAOHafKmY;|fB*y_009U<00IzLRRPal4D<{9 literal 0 HcmV?d00001 diff --git a/all.txt b/all.txt new file mode 100644 index 00000000..352b2c4c --- /dev/null +++ b/all.txt @@ -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 +} +----------------- diff --git a/go.mod b/go.mod index 6dad701b..2e2ee421 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 85214551..2df94bec 100644 --- a/go.sum +++ b/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= diff --git a/pmoapp/web/src/App.tsx b/pmoapp/web/src/App.tsx index 240ae900..3cdafcfd 100644 --- a/pmoapp/web/src/App.tsx +++ b/pmoapp/web/src/App.tsx @@ -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([]); + 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 ( -
-
-

PMO-Music logs

- +
+
+

PMO-Music

+
+ + + +
- + {view === "logs" && } + {view === "covers" && }
); } diff --git a/pmoapp/web/src/Covers.tsx b/pmoapp/web/src/Covers.tsx new file mode 100644 index 00000000..25f78202 --- /dev/null +++ b/pmoapp/web/src/Covers.tsx @@ -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([]); + + useEffect(() => { + fetch("/covers/stats") + .then((res) => res.json()) + .then((data) => { + setCovers(data.top_hits || []); + }) + .catch((err) => console.error("Erreur stats covers:", err)); + }, []); + + return ( +
+

+ Top Covers +

+
+ {covers.map((cover) => ( +
+ {cover.source_url} +

+ {cover.hits} hits +

+
+ ))} +
+
+ ); +} diff --git a/pmoapp/web/src/Logs.tsx b/pmoapp/web/src/Logs.tsx index 032f44b9..586df015 100644 --- a/pmoapp/web/src/Logs.tsx +++ b/pmoapp/web/src/Logs.tsx @@ -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 ( -
+
{logs.map((log) => ( -
+
{children} diff --git a/pmoapp/web/src/index.css b/pmoapp/web/src/index.css index 08a3ac9e..51178550 100644 --- a/pmoapp/web/src/index.css +++ b/pmoapp/web/src/index.css @@ -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; } -} +} */ diff --git a/upnp/config.go b/pmoconfig/config.go similarity index 93% rename from upnp/config.go rename to pmoconfig/config.go index f25050d0..37a973b4 100644 --- a/upnp/config.go +++ b/pmoconfig/config.go @@ -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 + } +} diff --git a/upnp/pmomusic.yaml b/pmoconfig/pmomusic.yaml similarity index 58% rename from upnp/pmomusic.yaml rename to pmoconfig/pmomusic.yaml index 23a49537..c2acfad7 100644 --- a/upnp/pmomusic.yaml +++ b/pmoconfig/pmomusic.yaml @@ -1,5 +1,8 @@ host: - http_port: "1900" + http_port: "8080" + cover_cache: + directory: "./.pmomusic_covers" + size: 2000 devices: mediarenderer: mpd_renderer: diff --git a/pmocover/cache.go b/pmocover/cache.go new file mode 100644 index 00000000..467ba5cd --- /dev/null +++ b/pmocover/cache.go @@ -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]) +} diff --git a/pmocover/db.go b/pmocover/db.go new file mode 100644 index 00000000..cb06a71c --- /dev/null +++ b/pmocover/db.go @@ -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 +} diff --git a/pmocover/handler.go b/pmocover/handler.go new file mode 100644 index 00000000..2b807399 --- /dev/null +++ b/pmocover/handler.go @@ -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) + }) +} diff --git a/pmocover/pmocover.go b/pmocover/pmocover.go new file mode 100644 index 00000000..166b5a77 --- /dev/null +++ b/pmocover/pmocover.go @@ -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 +} diff --git a/pmocover/webp.go b/pmocover/webp.go new file mode 100644 index 00000000..c02e04ee --- /dev/null +++ b/pmocover/webp.go @@ -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 +} diff --git a/upnp/server.go b/upnp/server.go index 4027f3f0..81e7873c 100644 --- a/upnp/server.go +++ b/upnp/server.go @@ -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{ diff --git a/upnp/srv_reg_devices.go b/upnp/srv_reg_devices.go index d6813be5..39929f9c 100644 --- a/upnp/srv_reg_devices.go +++ b/upnp/srv_reg_devices.go @@ -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)