Add album art search, auto-match and a whole-album tag editor
Search iTunes, Deezer, MusicBrainz, DuckDuckGo and Google for square covers, embed them and/or save cover.jpg, auto-match covers for albums, and edit an album's shared tags and track list in one place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5fb195f0d6
commit
d8ac25a55d
21 files changed
+2719
-38
No files matched your search
@@ -0,0 +1,565 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/art"
|
||||
"github.com/cdnutter/playlink/internal/fsops"
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
"github.com/cdnutter/playlink/internal/tags"
|
||||
)
|
||||
|
||||
// FolderCoverName is the album-folder image playlink writes (read by Plex,
|
||||
// Jellyfin, Navidrome and most players).
|
||||
const FolderCoverName = "cover.jpg"
|
||||
|
||||
var folderCoverNames = []string{"cover.jpg", "folder.jpg", "cover.png", "folder.png", "front.jpg"}
|
||||
|
||||
// SetArt enables album art search and preparation.
|
||||
func (e *Engine) SetArt(c *art.Client, s *art.Store) { e.artClient, e.artStore = c, s }
|
||||
|
||||
func (e *Engine) preparedArt(token string) (string, error) {
|
||||
if e.artStore == nil {
|
||||
return "", errors.New("album art is not available")
|
||||
}
|
||||
return e.artStore.Path(token)
|
||||
}
|
||||
|
||||
// ---- settings ----
|
||||
|
||||
// ArtSettings are the album art options (Settings -> Album art).
|
||||
type ArtSettings struct {
|
||||
Sources []string `json:"sources"`
|
||||
GoogleKey string `json:"google_key"`
|
||||
GoogleCX string `json:"google_cx"`
|
||||
MaxSize int `json:"max_size"` // longest side of prepared covers; 0 keeps the original
|
||||
MinSize int `json:"min_size"` // smallest search result shown
|
||||
}
|
||||
|
||||
func (e *Engine) ArtSettings(ctx context.Context) ArtSettings {
|
||||
s := ArtSettings{
|
||||
GoogleKey: e.st.Setting(ctx, "art_google_key", ""),
|
||||
GoogleCX: e.st.Setting(ctx, "art_google_cx", ""),
|
||||
}
|
||||
src := e.st.Setting(ctx, "art_sources", strings.Join([]string{art.ITunes, art.Deezer, art.MusicBrainz, art.DuckDuckGo, art.Google}, ","))
|
||||
for _, x := range strings.Split(src, ",") {
|
||||
if x != "" {
|
||||
s.Sources = append(s.Sources, x)
|
||||
}
|
||||
}
|
||||
s.MaxSize, _ = strconv.Atoi(e.st.Setting(ctx, "art_max_size", "1000"))
|
||||
s.MinSize, _ = strconv.Atoi(e.st.Setting(ctx, "art_min_size", "500"))
|
||||
return s
|
||||
}
|
||||
|
||||
func (e *Engine) SetArtSettings(ctx context.Context, s ArtSettings) error {
|
||||
known := map[string]bool{}
|
||||
for _, x := range art.AllSources {
|
||||
known[x] = true
|
||||
}
|
||||
var src []string
|
||||
for _, x := range s.Sources {
|
||||
if !known[x] {
|
||||
return fmt.Errorf("unknown art source %q", x)
|
||||
}
|
||||
src = append(src, x)
|
||||
}
|
||||
if s.MaxSize != 0 && (s.MaxSize < 300 || s.MaxSize > 4000) {
|
||||
return errors.New("cover size must be 300-4000 pixels, or 0 to keep the original")
|
||||
}
|
||||
if s.MinSize < 100 || s.MinSize > 3000 {
|
||||
return errors.New("minimum search result size must be 100-3000 pixels")
|
||||
}
|
||||
for k, v := range map[string]string{"art_sources": strings.Join(src, ","), "art_google_key": strings.TrimSpace(s.GoogleKey),
|
||||
"art_google_cx": strings.TrimSpace(s.GoogleCX), "art_max_size": strconv.Itoa(s.MaxSize), "art_min_size": strconv.Itoa(s.MinSize)} {
|
||||
if err := e.st.SetSetting(ctx, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) artConfig(ctx context.Context, sources []string) art.Config {
|
||||
s := e.ArtSettings(ctx)
|
||||
enabled := map[string]bool{}
|
||||
for _, x := range s.Sources {
|
||||
enabled[x] = true
|
||||
}
|
||||
var use []string
|
||||
for _, x := range sources {
|
||||
if enabled[x] {
|
||||
use = append(use, x)
|
||||
}
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
use = s.Sources
|
||||
}
|
||||
return art.Config{Sources: use, GoogleKey: s.GoogleKey, GoogleCX: s.GoogleCX, MinSize: s.MinSize}
|
||||
}
|
||||
|
||||
// SearchArt searches album art (square images only).
|
||||
func (e *Engine) SearchArt(ctx context.Context, q art.Query, sources []string) ([]art.Result, map[string]string, error) {
|
||||
if e.artClient == nil {
|
||||
return nil, nil, errors.New("album art is not available")
|
||||
}
|
||||
res, errs := e.artClient.Search(ctx, q, e.artConfig(ctx, sources))
|
||||
if res == nil {
|
||||
res = []art.Result{}
|
||||
}
|
||||
return res, errs, nil
|
||||
}
|
||||
|
||||
// PrepareArtURL downloads and prepares an image for applying.
|
||||
func (e *Engine) PrepareArtURL(ctx context.Context, u string) (art.Prepared, error) {
|
||||
if e.artClient == nil || e.artStore == nil {
|
||||
return art.Prepared{}, errors.New("album art is not available")
|
||||
}
|
||||
b, err := e.artClient.FetchImage(ctx, u)
|
||||
if err != nil {
|
||||
return art.Prepared{}, err
|
||||
}
|
||||
return e.artStore.Prepare(ctx, b, e.ArtSettings(ctx).MaxSize)
|
||||
}
|
||||
|
||||
// PrepareArtUpload prepares an uploaded image.
|
||||
func (e *Engine) PrepareArtUpload(ctx context.Context, r io.Reader) (art.Prepared, error) {
|
||||
if e.artStore == nil {
|
||||
return art.Prepared{}, errors.New("album art is not available")
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(r, art.MaxImageBytes+1))
|
||||
if err != nil {
|
||||
return art.Prepared{}, err
|
||||
}
|
||||
if len(b) > art.MaxImageBytes {
|
||||
return art.Prepared{}, errors.New("the image is larger than 20 MB")
|
||||
}
|
||||
return e.artStore.Prepare(ctx, b, e.ArtSettings(ctx).MaxSize)
|
||||
}
|
||||
|
||||
func (e *Engine) PreparedArtPath(token string) (string, error) { return e.preparedArt(token) }
|
||||
|
||||
// FetchArtThumb downloads a search result thumbnail for display.
|
||||
func (e *Engine) FetchArtThumb(ctx context.Context, u string) ([]byte, error) {
|
||||
if e.artClient == nil {
|
||||
return nil, errors.New("album art is not available")
|
||||
}
|
||||
return e.artClient.FetchImage(ctx, u)
|
||||
}
|
||||
|
||||
// ---- reading covers ----
|
||||
|
||||
// Cover returns the art for a song (embedded) or an album folder (its image
|
||||
// file), as image bytes.
|
||||
func (e *Engine) Cover(ctx context.Context, pairID int64, side, rel string) ([]byte, error) {
|
||||
l, err := e.loadPair(ctx, pairID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := sideRoot(l, side)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
abs := root.Path
|
||||
if rel != "" {
|
||||
if abs, err = root.Resolve(rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
fi, err := os.Lstat(abs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fi.IsDir() {
|
||||
for _, n := range folderCoverNames {
|
||||
p := filepath.Join(abs, n)
|
||||
if in, err := fsops.RegularFile(p); err == nil && in.Size < art.MaxImageBytes {
|
||||
return os.ReadFile(p)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return nil, fsops.ErrNotRegular
|
||||
}
|
||||
return e.tagTools.Cover(ctx, abs)
|
||||
}
|
||||
|
||||
// AlbumSong is one song in the album editor.
|
||||
type AlbumSong struct {
|
||||
TagFile
|
||||
HasCover bool `json:"has_cover"`
|
||||
EmbedCover bool `json:"embed_cover"` // the format can hold embedded art
|
||||
}
|
||||
|
||||
// Album is an album folder with its songs (disc folders included).
|
||||
type Album struct {
|
||||
Folder string `json:"folder"`
|
||||
Songs []AlbumSong `json:"songs"`
|
||||
FolderCover string `json:"folder_cover,omitempty"` // name of the folder image, if any
|
||||
}
|
||||
|
||||
func (e *Engine) AlbumSongs(ctx context.Context, pairID int64, side, folder string) (Album, error) {
|
||||
a := Album{Folder: folder, Songs: []AlbumSong{}}
|
||||
if !e.TagEditing() {
|
||||
return a, ErrNoTagTools
|
||||
}
|
||||
l, err := e.loadPair(ctx, pairID)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
root, err := sideRoot(l, side)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
dir := root.Path
|
||||
if folder != "" {
|
||||
if dir, err = root.Resolve(folder); err != nil {
|
||||
return a, err
|
||||
}
|
||||
}
|
||||
for _, n := range folderCoverNames {
|
||||
if _, err := fsops.RegularFile(filepath.Join(dir, n)); err == nil {
|
||||
a.FolderCover = n
|
||||
break
|
||||
}
|
||||
}
|
||||
var rels []string
|
||||
collect := func(d, prefix string) {
|
||||
ents, _ := os.ReadDir(d)
|
||||
for _, en := range ents {
|
||||
if en.Type().IsRegular() && l.hasExt(en.Name()) && !strings.HasPrefix(en.Name(), ".") {
|
||||
rels = append(rels, filepath.Join(prefix, en.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(dir, folder)
|
||||
ents, _ := os.ReadDir(dir)
|
||||
for _, en := range ents {
|
||||
if en.IsDir() && discFolder.MatchString(en.Name()) {
|
||||
collect(filepath.Join(dir, en.Name()), filepath.Join(folder, en.Name()))
|
||||
}
|
||||
}
|
||||
if len(rels) == 0 {
|
||||
return a, nil
|
||||
}
|
||||
if len(rels) > maxTagFiles {
|
||||
return a, fmt.Errorf("%w: the folder has more than %d songs", ErrInvalidBulk, maxTagFiles)
|
||||
}
|
||||
files, err := e.ReadTags(ctx, pairID, side, rels)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
for _, f := range files {
|
||||
s := AlbumSong{TagFile: f}
|
||||
if fm, ok := tags.FormatOf(f.Rel); ok {
|
||||
s.EmbedCover = fm.EmbedsCover()
|
||||
}
|
||||
if f.Editable {
|
||||
if abs, err := root.Resolve(f.Rel); err == nil {
|
||||
s.HasCover = e.tagTools.HasCover(ctx, abs)
|
||||
}
|
||||
}
|
||||
a.Songs = append(a.Songs, s)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ---- folder image ----
|
||||
|
||||
type coverDetail struct {
|
||||
Side string `json:"side"`
|
||||
Existed bool `json:"existed"`
|
||||
Size int64 `json:"size"`
|
||||
Mtime int64 `json:"mtime"`
|
||||
}
|
||||
|
||||
// writeFolderCover writes cover.jpg into an album folder. An existing one is
|
||||
// kept in quarantine first; the operation can be undone.
|
||||
func (e *Engine) writeFolderCover(ctx context.Context, l *libs, side, folder, src string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
root, _ := sideRoot(l, side)
|
||||
rel := filepath.Join(folder, FolderCoverName)
|
||||
abs, err := root.Resolve(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o := store.Operation{PairID: l.pair.ID, Action: store.ActCover, QuarantineSide: side, Trigger: "art"}
|
||||
setSidePath(&o, side, rel)
|
||||
old, oldErr := fsops.RegularFile(abs)
|
||||
d := coverDetail{Side: side, Existed: oldErr == nil}
|
||||
o.Detail, _ = json.Marshal(d)
|
||||
if err := e.begin(ctx, &o); err != nil {
|
||||
return err
|
||||
}
|
||||
if o.DryRun {
|
||||
return nil
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(abs), fsops.TmpPrefix+"cover.jpg")
|
||||
_ = os.Remove(tmp)
|
||||
err = func() error {
|
||||
if d.Existed {
|
||||
q := quarantinePath(l, o.ID, side, rel)
|
||||
if err := fsops.MkdirAllLike(filepath.Dir(q)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fsops.LinkNew(abs, old, q); err != nil {
|
||||
return fmt.Errorf("quarantine: %w", err)
|
||||
}
|
||||
e.setQuarantine(ctx, o.ID, q)
|
||||
}
|
||||
if err := fsops.CopyNew(src, tmp, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, abs); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
in, _ := fsops.Lstat(abs)
|
||||
d.Size, d.Mtime = in.Size, in.MtimeNs
|
||||
det, _ := json.Marshal(d)
|
||||
_, _ = e.st.DB.ExecContext(ctx, `UPDATE operations SET detail = ? WHERE id = ?`, string(det), o.ID)
|
||||
return fsops.SyncDir(filepath.Dir(abs))
|
||||
}()
|
||||
return e.finish(ctx, &o, err)
|
||||
}
|
||||
|
||||
// undoCover restores the previous folder image, or removes the new one.
|
||||
func (e *Engine) undoCover(l *libs, o store.Operation) error {
|
||||
var d coverDetail
|
||||
if err := json.Unmarshal(o.Detail, &d); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := sideRoot(l, d.Side)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel := o.RelSlave
|
||||
if d.Side == SideMaster {
|
||||
rel = o.RelMaster
|
||||
}
|
||||
abs, err := root.Resolve(rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, err := fsops.RegularFile(abs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cur.Unchanged(d.Size, d.Mtime) {
|
||||
return fmt.Errorf("%s changed after it was written; refusing to undo", rel)
|
||||
}
|
||||
if d.Existed {
|
||||
qi, err := e.quarantined(l, o.QuarantinePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fsops.ReplaceWithLink(o.QuarantinePath, qi, abs); err != nil {
|
||||
return err
|
||||
}
|
||||
dropQuarantine(l, o.QuarantinePath, qi)
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(abs); err != nil {
|
||||
return err
|
||||
}
|
||||
return fsops.SyncDir(filepath.Dir(abs))
|
||||
}
|
||||
|
||||
// ---- automatic matching ----
|
||||
|
||||
// ArtMatch are the options of an automatic match.
|
||||
type ArtMatch struct {
|
||||
Side string `json:"side"`
|
||||
Folder string `json:"folder"`
|
||||
OnlyMissing bool `json:"only_missing"`
|
||||
Embed bool `json:"embed"`
|
||||
FolderFile bool `json:"folder_file"`
|
||||
}
|
||||
|
||||
// matchKey normalizes an artist or album name for comparison: case,
|
||||
// punctuation, a leading "The" and release labels such as "(Deluxe)" are
|
||||
// ignored.
|
||||
func matchKey(s string) string {
|
||||
s = stripRelease(s)
|
||||
s = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(s)), "the ")
|
||||
var b strings.Builder
|
||||
for _, r := range accentFold.Replace(s) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// bestArt picks a confident match: artist and album must both closely match.
|
||||
func bestArt(results []art.Result, artist, album string) (art.Result, float64, bool) {
|
||||
ak, bk := matchKey(artist), matchKey(album)
|
||||
var best art.Result
|
||||
bestScore := 0.0
|
||||
for _, r := range results {
|
||||
if r.Artist == "" || r.Title == "" {
|
||||
continue
|
||||
}
|
||||
sa := levenshteinRatio(matchKey(r.Artist), ak)
|
||||
sb := levenshteinRatio(matchKey(r.Title), bk)
|
||||
if sa < 0.85 || sb < 0.85 {
|
||||
continue
|
||||
}
|
||||
score := sa*0.4 + sb*0.6 + float64(min(r.Width, 3000))/100000
|
||||
if score > bestScore {
|
||||
best, bestScore = r, score
|
||||
}
|
||||
}
|
||||
return best, bestScore, bestScore > 0
|
||||
}
|
||||
|
||||
// StartArtAutoMatch finds and applies covers for the albums in a folder.
|
||||
func (e *Engine) StartArtAutoMatch(pairID int64, m ArtMatch) (*Job, error) {
|
||||
if !e.TagEditing() {
|
||||
return nil, ErrNoTagTools
|
||||
}
|
||||
if e.artClient == nil || e.artStore == nil {
|
||||
return nil, errors.New("album art is not available")
|
||||
}
|
||||
if m.Side != SideMaster && m.Side != SideSlave {
|
||||
return nil, fmt.Errorf("%w: side must be master or slave", ErrInvalidBulk)
|
||||
}
|
||||
if !m.Embed && !m.FolderFile {
|
||||
return nil, fmt.Errorf("%w: choose where the art goes", ErrInvalidBulk)
|
||||
}
|
||||
return e.startJob(pairID, "art-auto-match", func(ctx context.Context, j *Job) error {
|
||||
l, err := e.loadPair(ctx, pairID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, _ := sideRoot(l, m.Side)
|
||||
start := root
|
||||
if m.Folder != "" {
|
||||
abs, err := root.Resolve(m.Folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if start, err = fsops.NewRoot(abs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
files := map[string]fsops.Info{}
|
||||
if _, err := walk(ctx, l, start, files); err != nil {
|
||||
return err
|
||||
}
|
||||
type album struct {
|
||||
artist, title string
|
||||
rels []string
|
||||
}
|
||||
albums := map[string]*album{}
|
||||
var order []string
|
||||
j.phase("Reading tags", int64(len(files)))
|
||||
for _, rel := range sortedKeys(files) {
|
||||
j.tick()
|
||||
full := filepath.Join(m.Folder, rel)
|
||||
fields, err := e.tagCache.get(ctx, e.tagTools, filepath.Join(start.Path, rel), files[rel])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
v := valuesOf(fields)
|
||||
if v.artist == "" || v.album == "" {
|
||||
j.skip(full + ": missing artist or album tag")
|
||||
continue
|
||||
}
|
||||
key := matchKey(v.artist) + "|" + matchKey(v.album)
|
||||
if albums[key] == nil {
|
||||
albums[key] = &album{artist: v.artist, title: v.album}
|
||||
order = append(order, key)
|
||||
}
|
||||
albums[key].rels = append(albums[key].rels, full)
|
||||
}
|
||||
j.phase("Finding covers", int64(len(order)))
|
||||
for _, key := range order {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
a := albums[key]
|
||||
label := a.artist + " – " + a.title
|
||||
j.tick()
|
||||
need := a.rels
|
||||
if m.OnlyMissing {
|
||||
need = nil
|
||||
for _, rel := range a.rels {
|
||||
abs, _ := root.Resolve(rel)
|
||||
if fm, ok := tags.FormatOf(rel); ok && fm.EmbedsCover() && m.Embed && !e.tagTools.HasCover(ctx, abs) {
|
||||
need = append(need, rel)
|
||||
}
|
||||
}
|
||||
folderMissing := false
|
||||
if m.FolderFile {
|
||||
for _, f := range albumFolders(a.rels) {
|
||||
if abs, err := root.Resolve(f); err == nil && !hasFolderCover(abs) {
|
||||
folderMissing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(need) == 0 && !folderMissing {
|
||||
j.count("already have art")
|
||||
continue
|
||||
}
|
||||
}
|
||||
res, _ := e.artClient.Search(ctx, art.Query{Artist: a.artist, Album: a.title}, e.artConfig(ctx, art.MatchSources))
|
||||
best, _, ok := bestArt(res, a.artist, a.title)
|
||||
if !ok {
|
||||
j.skip(label + ": no confident match")
|
||||
continue
|
||||
}
|
||||
b, err := e.artClient.FetchImage(ctx, best.URL)
|
||||
if err != nil {
|
||||
j.skip(label + ": download failed: " + err.Error())
|
||||
continue
|
||||
}
|
||||
p, err := e.artStore.Prepare(ctx, b, e.ArtSettings(ctx).MaxSize)
|
||||
if err != nil {
|
||||
j.skip(label + ": " + err.Error())
|
||||
continue
|
||||
}
|
||||
cover, _ := e.artStore.Path(p.Token)
|
||||
if m.Embed {
|
||||
for _, rel := range need {
|
||||
if err := e.editTagsOne(ctx, j, l, m.Side, rel, nil, cover, false); err != nil {
|
||||
j.skip(rel + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.FolderFile {
|
||||
for _, f := range albumFolders(a.rels) {
|
||||
abs, _ := root.Resolve(f)
|
||||
if m.OnlyMissing && hasFolderCover(abs) {
|
||||
continue
|
||||
}
|
||||
if err := e.writeFolderCover(ctx, l, m.Side, f, cover); err != nil {
|
||||
j.skip(f + " cover.jpg: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
j.count("albums matched")
|
||||
j.note(fmt.Sprintf("%s: %s (%dx%d)", label, best.Source, p.Width, p.Height))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func hasFolderCover(dir string) bool {
|
||||
for _, n := range folderCoverNames {
|
||||
if _, err := fsops.RegularFile(filepath.Join(dir, n)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user