Maintenant capter les URL des documents didl parser et les stocker dans le cache...
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,3 +6,7 @@
|
|||||||
xxx
|
xxx
|
||||||
/dcai/
|
/dcai/
|
||||||
***/.pmomusic.yml
|
***/.pmomusic.yml
|
||||||
|
***/.pmomusic_covers/***
|
||||||
|
***/.DS_Strore/***
|
||||||
|
.DS_Strore/***
|
||||||
|
.pmomusic_covers/***
|
||||||
BIN
.pmomusic_covers/bd89518e78a247d3.128.webp
Normal file
BIN
.pmomusic_covers/bd89518e78a247d3.128.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
.pmomusic_covers/bd89518e78a247d3.256.webp
Normal file
BIN
.pmomusic_covers/bd89518e78a247d3.256.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
BIN
.pmomusic_covers/bd89518e78a247d3.64.webp
Normal file
BIN
.pmomusic_covers/bd89518e78a247d3.64.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
BIN
.pmomusic_covers/bd89518e78a247d3.orig.webp
Normal file
BIN
.pmomusic_covers/bd89518e78a247d3.orig.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 496 KiB |
Binary file not shown.
@@ -5,12 +5,17 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
|
_ "image/jpeg" // support JPEG
|
||||||
|
_ "image/png" // support PNG
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
_ "golang.org/x/image/webp" // support WebP
|
||||||
)
|
)
|
||||||
|
|
||||||
// CacheEntry représente une image stockée (original + dérivés)
|
// CacheEntry représente une image stockée (original + dérivés)
|
||||||
@@ -65,6 +70,34 @@ func (c *Cache) AddFromURL(url string) (string, error) {
|
|||||||
return c.Add(url, data)
|
return c.Add(url, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureFromURL vérifie si l'URL est déjà dans le cache et que le fichier existe.
|
||||||
|
func (c *Cache) EnsureFromURL(url string) (string, error) {
|
||||||
|
pk := pkFromURL(url)
|
||||||
|
|
||||||
|
// Vérifie si une entrée existe déjà en base
|
||||||
|
_, err := c.db.Get(pk)
|
||||||
|
if err == nil {
|
||||||
|
// Vérifie aussi que le fichier original existe
|
||||||
|
origPath := filepath.Join(c.dir, pk+".orig.webp")
|
||||||
|
if _, statErr := os.Stat(origPath); statErr == nil {
|
||||||
|
return pk, nil
|
||||||
|
}
|
||||||
|
// Si le fichier a disparu → on retélécharge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sinon, on télécharge et on ajoute
|
||||||
|
return c.AddFromURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeImage essaie de décoder une image depuis un []byte en utilisant les formats connus
|
||||||
|
func decodeImage(data []byte) (image.Image, string, error) {
|
||||||
|
img, format, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("échec de décodage image: %w", err)
|
||||||
|
}
|
||||||
|
return img, format, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Add ajoute une image déjà téléchargée
|
// Add ajoute une image déjà téléchargée
|
||||||
func (c *Cache) Add(url string, data []byte) (string, error) {
|
func (c *Cache) Add(url string, data []byte) (string, error) {
|
||||||
pk := pkFromURL(url)
|
pk := pkFromURL(url)
|
||||||
@@ -74,10 +107,12 @@ func (c *Cache) Add(url string, data []byte) (string, error) {
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if _, err := os.Stat(origPath); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(origPath); errors.Is(err, os.ErrNotExist) {
|
||||||
img, _, err := image.Decode(bytes.NewReader(data))
|
img, format, err := decodeImage(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
fmt.Printf("Décodage réussi : format %s depuis %s\n", format, url)
|
||||||
|
|
||||||
buf, err := encodeWebP(img)
|
buf, err := encodeWebP(img)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -127,3 +162,48 @@ func pkFromURL(url string) string {
|
|||||||
h := sha1.Sum([]byte(url))
|
h := sha1.Sum([]byte(url))
|
||||||
return hex.EncodeToString(h[:8])
|
return hex.EncodeToString(h[:8])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consolidate parcourt la base et corrige les incohérences
|
||||||
|
func (c *Cache) Consolidate() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
entries, err := c.db.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie chaque entrée de la base
|
||||||
|
for _, e := range entries {
|
||||||
|
origPath := filepath.Join(c.dir, e.PK+".orig.webp")
|
||||||
|
if _, err := os.Stat(origPath); os.IsNotExist(err) {
|
||||||
|
// Fichier absent → on retente un download
|
||||||
|
resp, err := http.Get(e.SourceURL)
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
// Impossible de retélécharger → suppression de l’entrée
|
||||||
|
_ = c.db.Delete(e.PK)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
if _, err := c.Add(e.SourceURL, data); err != nil {
|
||||||
|
// Si on échoue quand même → suppression
|
||||||
|
_ = c.db.Delete(e.PK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie les fichiers orphelins (qui n’ont pas d’entrée en DB)
|
||||||
|
files, _ := filepath.Glob(filepath.Join(c.dir, "*.orig.webp"))
|
||||||
|
for _, f := range files {
|
||||||
|
pk := filepath.Base(f)
|
||||||
|
pk = pk[:len(pk)-len(".orig.webp")]
|
||||||
|
_, err := c.db.Get(pk)
|
||||||
|
if err != nil {
|
||||||
|
// Pas d’entrée en DB → supprimer le fichier
|
||||||
|
_ = os.Remove(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,3 +116,9 @@ func (db *DB) GetAll() ([]*CacheEntry, error) {
|
|||||||
}
|
}
|
||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete supprime une entrée spécifique
|
||||||
|
func (db *DB) Delete(pk string) error {
|
||||||
|
_, err := db.conn.Exec(`DELETE FROM covers WHERE pk = ?`, pk)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
281
pmodidl/didilite.go
Normal file
281
pmodidl/didilite.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package pmodidl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"iter"
|
||||||
|
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmocover"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Méthodes pour DIDLLite
|
||||||
|
|
||||||
|
// CacheAllCoverArts met en cache toutes les images de couverture et stocke les clés primaires
|
||||||
|
func (d *DIDLLite) CacheAllCoverArts(cache *pmocover.Cache) error {
|
||||||
|
itemsWithCovers := Filter(d.AllItems(), func(item *Item) bool {
|
||||||
|
return item.AlbumArt != ""
|
||||||
|
})
|
||||||
|
|
||||||
|
for item := range itemsWithCovers {
|
||||||
|
pk, err := cache.EnsureFromURL(item.AlbumArt)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("❌ Erreur lors de la mise en cache de %s: %v", item.AlbumArt, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Infof("✅ Mise en cache de %s : pk[%s]", item.AlbumArt, pk)
|
||||||
|
item.AlbumArtPk = pk
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllContainers retourne un itérateur sur tous les containers de manière récursive
|
||||||
|
func (d *DIDLLite) AllContainers() iter.Seq[*Container] {
|
||||||
|
return func(yield func(*Container) bool) {
|
||||||
|
for _, container := range d.Containers {
|
||||||
|
if !yield(&container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for child := range container.AllContainers() {
|
||||||
|
if !yield(child) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllItems retourne un itérateur sur tous les items de manière récursive
|
||||||
|
func (d *DIDLLite) AllItems() iter.Seq[*Item] {
|
||||||
|
return func(yield func(*Item) bool) {
|
||||||
|
for _, item := range d.Items {
|
||||||
|
if !yield(&item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for container := range d.AllContainers() {
|
||||||
|
for _, item := range container.Items {
|
||||||
|
if !yield(&item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContainerByID retourne un itérateur sur les containers avec l'ID spécifié
|
||||||
|
func (d *DIDLLite) GetContainerByID(id string) iter.Seq[*Container] {
|
||||||
|
return func(yield func(*Container) bool) {
|
||||||
|
for container := range d.AllContainers() {
|
||||||
|
if container.ID == id {
|
||||||
|
if !yield(container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetItemByID retourne un itérateur sur les items avec l'ID spécifié
|
||||||
|
func (d *DIDLLite) GetItemByID(id string) iter.Seq[*Item] {
|
||||||
|
return func(yield func(*Item) bool) {
|
||||||
|
for item := range d.AllItems() {
|
||||||
|
if item.ID == id {
|
||||||
|
if !yield(item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterContainers filtre les containers selon un prédicat
|
||||||
|
func (d *DIDLLite) FilterContainers(predicate func(*Container) bool) iter.Seq[*Container] {
|
||||||
|
return func(yield func(*Container) bool) {
|
||||||
|
for container := range d.AllContainers() {
|
||||||
|
if predicate(container) && !yield(container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterItems filtre les items selon un prédicat
|
||||||
|
func (d *DIDLLite) FilterItems(predicate func(*Item) bool) iter.Seq[*Item] {
|
||||||
|
return func(yield func(*Item) bool) {
|
||||||
|
for item := range d.AllItems() {
|
||||||
|
if predicate(item) && !yield(item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthodes pour Container
|
||||||
|
|
||||||
|
// AllContainers retourne un itérateur sur tous les containers enfants de manière récursive
|
||||||
|
func (c *Container) AllContainers() iter.Seq[*Container] {
|
||||||
|
return func(yield func(*Container) bool) {
|
||||||
|
if !yield(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, child := range c.Containers {
|
||||||
|
for container := range child.AllContainers() {
|
||||||
|
if !yield(container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllItems retourne un itérateur sur tous les items du container et de ses enfants
|
||||||
|
func (c *Container) AllItems() iter.Seq[*Item] {
|
||||||
|
return func(yield func(*Item) bool) {
|
||||||
|
for _, item := range c.Items {
|
||||||
|
if !yield(&item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range c.Containers {
|
||||||
|
for item := range child.AllItems() {
|
||||||
|
if !yield(item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChildContainers retourne un itérateur sur les containers enfants directs
|
||||||
|
func (c *Container) GetChildContainers() iter.Seq[*Container] {
|
||||||
|
return func(yield func(*Container) bool) {
|
||||||
|
for _, container := range c.Containers {
|
||||||
|
if !yield(&container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChildItems retourne un itérateur sur les items directs du container
|
||||||
|
func (c *Container) GetChildItems() iter.Seq[*Item] {
|
||||||
|
return func(yield func(*Item) bool) {
|
||||||
|
for _, item := range c.Items {
|
||||||
|
if !yield(&item) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthodes pour Item
|
||||||
|
|
||||||
|
// GetAudioResources retourne un itérateur sur les ressources audio
|
||||||
|
func (i *Item) GetAudioResources() iter.Seq[Res] {
|
||||||
|
return func(yield func(Res) bool) {
|
||||||
|
for _, res := range i.Ress {
|
||||||
|
if strings.HasPrefix(res.ProtocolInfo, "http-get:*:audio/") {
|
||||||
|
if !yield(res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrimaryResource retourne la ressource principale (première disponible)
|
||||||
|
func (i *Item) GetPrimaryResource() iter.Seq[Res] {
|
||||||
|
return func(yield func(Res) bool) {
|
||||||
|
if len(i.Ress) > 0 {
|
||||||
|
if !yield(i.Ress[0]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata retourne un itérateur sur les métadonnées sous forme de paires clé-valeur
|
||||||
|
func (i *Item) GetMetadata() iter.Seq2[string, string] {
|
||||||
|
return func(yield func(string, string) bool) {
|
||||||
|
if i.Title != "" && !yield("title", i.Title) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i.Artist != "" && !yield("artist", i.Artist) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i.Album != "" && !yield("album", i.Album) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i.Genre != "" && !yield("genre", i.Genre) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i.Date != "" && !yield("date", i.Date) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i.OriginalTrackNumber != "" && !yield("trackNumber", i.OriginalTrackNumber) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, desc := range i.Descs {
|
||||||
|
if desc.TrackGain != "" && !yield("replayGain", desc.TrackGain) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if desc.TrackPeak != "" && !yield("replayPeak", desc.TrackPeak) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonctions utilitaires
|
||||||
|
|
||||||
|
// Filter filtre une séquence selon un prédicat
|
||||||
|
func Filter[T any](seq iter.Seq[T], predicate func(T) bool) iter.Seq[T] {
|
||||||
|
return func(yield func(T) bool) {
|
||||||
|
for value := range seq {
|
||||||
|
if predicate(value) && !yield(value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map transforme une séquence en appliquant une fonction
|
||||||
|
func Map[T, U any](seq iter.Seq[T], f func(T) U) iter.Seq[U] {
|
||||||
|
return func(yield func(U) bool) {
|
||||||
|
for value := range seq {
|
||||||
|
if !yield(f(value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect collecte tous les éléments d'une séquence dans une slice
|
||||||
|
func Collect[T any](seq iter.Seq[T]) []T {
|
||||||
|
return slices.Collect(seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First retourne le premier élément d'une séquence
|
||||||
|
func First[T any](seq iter.Seq[T]) (T, bool) {
|
||||||
|
var zero T
|
||||||
|
next, stop := iter.Pull(seq)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
value, ok := next()
|
||||||
|
if !ok {
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count compte le nombre d'éléments dans une séquence
|
||||||
|
func Count[T any](seq iter.Seq[T]) int {
|
||||||
|
count := 0
|
||||||
|
for range seq {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package didl
|
package pmodidl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package didl
|
package pmodidl
|
||||||
|
|
||||||
import "encoding/xml"
|
import "encoding/xml"
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ type Item struct {
|
|||||||
Album string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ album,omitempty"`
|
Album string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ album,omitempty"`
|
||||||
Genre string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ genre,omitempty"`
|
Genre string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ genre,omitempty"`
|
||||||
AlbumArt string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ albumArtURI,omitempty"`
|
AlbumArt string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ albumArtURI,omitempty"`
|
||||||
|
AlbumArtPk string
|
||||||
Date string `xml:"http://purl.org/dc/elements/1.1/ date,omitempty"`
|
Date string `xml:"http://purl.org/dc/elements/1.1/ date,omitempty"`
|
||||||
OriginalTrackNumber string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ originalTrackNumber,omitempty"`
|
OriginalTrackNumber string `xml:"urn:schemas-upnp-org:metadata-1-0/upnp/ originalTrackNumber,omitempty"`
|
||||||
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package didl
|
package pmodidl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmocover"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Parse(metadata string) (*DIDLLite, error) {
|
func Parse(metadata string) (*DIDLLite, error) {
|
||||||
@@ -13,5 +15,12 @@ func Parse(metadata string) (*DIDLLite, error) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache, err := pmocover.GetCoverCache()
|
||||||
|
if err != nil {
|
||||||
|
return &didl, fmt.Errorf("failed to get cover cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
didl.CacheAllCoverArts(cache)
|
||||||
|
|
||||||
return &didl, nil
|
return &didl, nil
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
package avtransport
|
package avtransport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gargoton.petite-maison-orange.fr/eric/pmomusic/didl"
|
"gargoton.petite-maison-orange.fr/eric/pmomusic/pmodidl"
|
||||||
sv "gargoton.petite-maison-orange.fr/eric/pmomusic/pmoupnp/devices/services/statevariables"
|
sv "gargoton.petite-maison-orange.fr/eric/pmomusic/pmoupnp/devices/services/statevariables"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func _AVTransportURIMetaDataParser(value string) (interface{}, error) {
|
func _AVTransportURIMetaDataParser(value string) (interface{}, error) {
|
||||||
log.Debug("[avtransport] Parsing AVTransport)")
|
log.Debug("[avtransport] Parsing AVTransport)")
|
||||||
didl, err := didl.Parse(value)
|
didl, err := pmodidl.Parse(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return value, err
|
return value, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user