Git Repository
miniaudiolib
miniaudiolib scannt lokale Musik- und Hörbuchordner, liest Audio-Metadaten und Cover, verwaltet Favoriten und Playlists und streamt Titel direkt an den Browser.
HTTPS
https://zanvex.de/git/miniaudiolib.gitinternal/musicmetadata/service.go
Zum Verzeichnispackage musicmetadata
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const userAgent = "MiniMusicLibrary/1.0 (local music library)"
type Result struct {
ID, Title, Artist, Date, PrimaryType string
Score int
}
type Service struct {
db *sql.DB
coverDir string
client *http.Client
mu sync.Mutex
lastCall time.Time
}
func New(db *sql.DB, databasePath string) *Service {
return &Service{db: db, coverDir: filepath.Join(filepath.Dir(databasePath), "musicbrainz-covers"), client: &http.Client{Timeout: 45 * time.Second}}
}
func (s *Service) Search(ctx context.Context, album, artist string) ([]Result, error) {
album, artist = strings.TrimSpace(album), strings.TrimSpace(artist)
if album == "" {
return nil, errors.New("album title is required")
}
key := "musicbrainz:release-group:" + strings.ToLower(album+"\x00"+artist)
var cached string
var fetched int64
if s.db.QueryRowContext(ctx, "SELECT payload,fetched_at FROM music_metadata_cache WHERE cache_key=?", key).Scan(&cached, &fetched) == nil && time.Since(time.Unix(fetched, 0)) < 7*24*time.Hour {
var out []Result
if json.Unmarshal([]byte(cached), &out) == nil {
return out, nil
}
}
query := `releasegroup:"` + escape(album) + `"`
if artist != "" {
query += ` AND artist:"` + escape(artist) + `"`
}
u := "https://musicbrainz.org/ws/2/release-group/?fmt=json&limit=10&query=" + url.QueryEscape(query)
s.mu.Lock()
if wait := time.Second - time.Since(s.lastCall); wait > 0 {
select {
case <-time.After(wait):
case <-ctx.Done():
s.mu.Unlock()
return nil, ctx.Err()
}
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
req.Header.Set("User-Agent", userAgent)
resp, err := s.client.Do(req)
s.lastCall = time.Now()
s.mu.Unlock()
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("MusicBrainz HTTP %s", resp.Status)
}
var body struct {
ReleaseGroups []struct {
ID string `json:"id"`
Title string `json:"title"`
FirstReleaseDate string `json:"first-release-date"`
PrimaryType string `json:"primary-type"`
Score int `json:"score"`
ArtistCredit []struct{ Name string } `json:"artist-credit"`
} `json:"release-groups"`
}
if err = json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&body); err != nil {
return nil, err
}
out := make([]Result, 0, len(body.ReleaseGroups))
for _, x := range body.ReleaseGroups {
artistName := ""
if len(x.ArtistCredit) > 0 {
artistName = x.ArtistCredit[0].Name
}
out = append(out, Result{ID: x.ID, Title: x.Title, Artist: artistName, Date: x.FirstReleaseDate, PrimaryType: x.PrimaryType, Score: x.Score})
}
b, _ := json.Marshal(out)
_, _ = s.db.ExecContext(ctx, `INSERT INTO music_metadata_cache(cache_key,payload,fetched_at) VALUES(?,?,?) ON CONFLICT(cache_key) DO UPDATE SET payload=excluded.payload,fetched_at=excluded.fetched_at`, key, string(b), time.Now().Unix())
return out, nil
}
func (s *Service) Apply(ctx context.Context, currentAlbum, currentArtist string, result Result) error {
if result.ID == "" || result.Title == "" {
return errors.New("invalid MusicBrainz result")
}
year := 0
if len(result.Date) >= 4 {
fmt.Sscanf(result.Date[:4], "%d", &year)
}
cover, _ := s.downloadCover(ctx, result.ID)
artist := result.Artist
if artist == "" {
artist = currentArtist
}
_, err := s.db.ExecContext(ctx, `UPDATE tracks SET album=?,album_artist=?,year=CASE WHEN ? > 0 THEN ? ELSE year END,cover=CASE WHEN ? <> '' THEN ? ELSE cover END WHERE album=? AND album_artist=?`, result.Title, artist, year, year, cover, cover, currentAlbum, currentArtist)
return err
}
func (s *Service) downloadCover(ctx context.Context, id string) (string, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://coverartarchive.org/release-group/"+url.PathEscape(id), nil)
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", nil
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Cover Art Archive HTTP %s", resp.Status)
}
var body struct {
Images []struct {
Front bool `json:"front"`
Image string `json:"image"`
Thumbnails map[string]string `json:"thumbnails"`
} `json:"images"`
}
if err = json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&body); err != nil {
return "", err
}
imageURL := ""
for _, x := range body.Images {
if x.Front {
imageURL = x.Thumbnails["500"]
if imageURL == "" {
imageURL = x.Image
}
break
}
}
if imageURL == "" {
return "", nil
}
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
req.Header.Set("User-Agent", userAgent)
resp, err = s.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("cover download HTTP %s", resp.Status)
}
if !strings.HasPrefix(resp.Header.Get("Content-Type"), "image/") {
return "", errors.New("cover response is not an image")
}
if err = os.MkdirAll(s.coverDir, 0755); err != nil {
return "", err
}
target := filepath.Join(s.coverDir, id+".jpg")
tmp, err := os.CreateTemp(s.coverDir, ".cover-*")
if err != nil {
return "", err
}
name := tmp.Name()
defer os.Remove(name)
written, err := io.Copy(tmp, io.LimitReader(resp.Body, 15<<20))
closeErr := tmp.Close()
if err != nil {
return "", err
}
if closeErr != nil {
return "", closeErr
}
if written >= 15<<20 {
return "", errors.New("cover is too large")
}
_ = os.Remove(target)
if err = os.Rename(name, target); err != nil {
return "", err
}
abs, _ := filepath.Abs(target)
return abs, nil
}
func escape(v string) string { return strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(v) }