Version fonctionnelle du cache #15

Closed
eric wants to merge 60 commits from push-zvytllwrlyvt into main
17 changed files with 1018 additions and 34 deletions
Showing only changes of commit cd01b5b2ca - Show all commits

BIN
.pmomusic_covers/cache.db Normal file

Binary file not shown.

415
all.txt Normal file
View 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 lURL
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 nexiste 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
View File

@@ -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
View File

@@ -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=

View File

@@ -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
View 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>
);
}

View File

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

View File

@@ -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;
}
}
} */

View File

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

View File

@@ -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
View 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 lURL
func pkFromURL(url string) string {
h := sha1.Sum([]byte(url))
return hex.EncodeToString(h[:8])
}

118
pmocover/db.go Normal file
View 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 nexiste 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
View 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
View 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
View 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
}

View File

@@ -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{

View File

@@ -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)