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.

Projektseite ↗
HTTPShttps://zanvex.de/git/miniaudiolib.git

internal/musicmetadata/service.go

Zum Verzeichnis
package 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) }