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,154 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/art"
|
||||
"github.com/cdnutter/playlink/internal/engine"
|
||||
)
|
||||
|
||||
func (s *Server) handleArtSettings(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.eng.ArtSettings(r.Context())
|
||||
writeJSON(w, 200, map[string]any{"settings": cfg, "all_sources": art.AllSources})
|
||||
}
|
||||
|
||||
func (s *Server) handlePutArtSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg engine.ArtSettings
|
||||
if !readJSON(w, r, &cfg) {
|
||||
return
|
||||
}
|
||||
if err := s.eng.SetArtSettings(r.Context(), cfg); err != nil {
|
||||
writeErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
s.handleArtSettings(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleArtSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
query := art.Query{Artist: q.Get("artist"), Album: q.Get("album"), Text: q.Get("q")}
|
||||
if strings.TrimSpace(query.Artist+query.Album+query.Text) == "" {
|
||||
writeErr(w, 400, errors.New("enter an artist, album or search text"))
|
||||
return
|
||||
}
|
||||
var sources []string
|
||||
if src := q.Get("sources"); src != "" {
|
||||
sources = strings.Split(src, ",")
|
||||
}
|
||||
res, errs, err := s.eng.SearchArt(r.Context(), query, sources)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"results": res, "errors": errs})
|
||||
}
|
||||
|
||||
func (s *Server) handleArtPrepare(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if !readJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
p, err := s.eng.PrepareArtURL(r.Context(), body.URL)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleArtUpload(w http.ResponseWriter, r *http.Request) {
|
||||
f, _, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
writeErr(w, 400, errors.New("choose an image file"))
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
p, err := s.eng.PrepareArtUpload(r.Context(), f)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleArtPrepared(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.eng.PreparedArtPath(chi.URLParam(r, "token"))
|
||||
if err != nil {
|
||||
writeErr(w, 404, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
http.ServeFile(w, r, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleCover(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
q := r.URL.Query()
|
||||
b, err := s.eng.Cover(r.Context(), id, q.Get("side"), q.Get("path"))
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
if len(b) == 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", http.DetectContentType(b))
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, "cover", time.Time{}, strings.NewReader(string(b)))
|
||||
}
|
||||
|
||||
func (s *Server) handleAlbum(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
q := r.URL.Query()
|
||||
a, err := s.eng.AlbumSongs(r.Context(), id, q.Get("side"), q.Get("path"))
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, a)
|
||||
}
|
||||
|
||||
func (s *Server) handleArtAuto(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
var m engine.ArtMatch
|
||||
if !readJSON(w, r, &m) {
|
||||
return
|
||||
}
|
||||
if !s.preAction(w, r, id, "auto-match album art") {
|
||||
return
|
||||
}
|
||||
j, err := s.eng.StartArtAutoMatch(id, m)
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
// handleArtThumb shows a search result's thumbnail through the server, so
|
||||
// the page keeps its strict content policy and the browser never contacts
|
||||
// the image hosts directly. Fetching uses the local-network-safe client.
|
||||
func (s *Server) handleArtThumb(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := s.eng.FetchArtThumb(r.Context(), r.URL.Query().Get("u"))
|
||||
if err != nil {
|
||||
writeErr(w, 502, err)
|
||||
return
|
||||
}
|
||||
ct := http.DetectContentType(b)
|
||||
if !strings.HasPrefix(ct, "image/") {
|
||||
writeErr(w, 502, errors.New("not an image"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||
w.Write(b)
|
||||
}
|
||||
@@ -58,6 +58,9 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
limit := int64(1 << 20)
|
||||
if strings.HasSuffix(r.URL.Path, "/art/upload") {
|
||||
limit = 21 << 20
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/backups/restore-upload") {
|
||||
limit = 20 << 30 // backup zips can be large
|
||||
}
|
||||
@@ -80,6 +83,13 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Put("/settings", s.handlePutSettings)
|
||||
r.Get("/browse", s.handleBrowse)
|
||||
r.Get("/naming", s.handleNamingInfo)
|
||||
r.Get("/art/settings", s.handleArtSettings)
|
||||
r.Put("/art/settings", s.handlePutArtSettings)
|
||||
r.Get("/art/search", s.handleArtSearch)
|
||||
r.Post("/art/prepare", s.handleArtPrepare)
|
||||
r.Post("/art/upload", s.handleArtUpload)
|
||||
r.Get("/art/prepared/{token}", s.handleArtPrepared)
|
||||
r.Get("/art/thumb", s.handleArtThumb)
|
||||
r.Post("/naming/examples", s.handleNamingExamples)
|
||||
if s.cfg.Backups != nil {
|
||||
s.backupRoutes(r)
|
||||
@@ -101,6 +111,9 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Post("/bulk/preview", s.handleBulkPreview)
|
||||
r.Post("/bulk", s.handleBulk)
|
||||
r.Get("/files", s.handleFiles)
|
||||
r.Get("/cover", s.handleCover)
|
||||
r.Get("/album", s.handleAlbum)
|
||||
r.Post("/art/auto", s.handleArtAuto)
|
||||
r.Post("/tags/read", s.handleReadTags)
|
||||
r.Post("/tags", s.handleEditTags)
|
||||
r.Post("/organize/preview", s.handleOrganizePreview)
|
||||
|
||||
Reference in new issue
Block a user