all repos

rss-tools @ 62a30de517ceff8bd84533d1a7c054a0321c0bb9

get rss feed from sources that(i need and) dont provide one
5 files changed, 891 insertions(+), 6 deletions(-)
add musicfeed
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-05-23 18:18:59 +0300
Authored at: 2026-05-23 17:02:58 +0300
Change ID: wpopytxxkxmlwvqmomnmmzuvvlrtnkus
Parent: 7f0afbb
M app/config.go
···
        6
        6
         )

      
        7
        7
         

      
        8
        8
         type Config struct {

      
        9
        
        -	Port              int      `json:"port"`

      
        10
        
        -	AuthToken         string   `json:"auth_token"`

      
        11
        
        -	TGUserID          int64    `json:"tg_userid"`

      
        12
        
        -	TGToken           string   `json:"tg_token"`

      
        13
        
        -	MoviefeedAPIKey   string   `json:"moviefeed_api_key"`

      
        14
        
        -	MoviefeedShows    []string `json:"moviefeed_shows"`

      
        
        9
        +	Port            int      `json:"port"`

      
        
        10
        +	AuthToken       string   `json:"auth_token"`

      
        
        11
        +	TGUserID        int64    `json:"tg_userid"`

      
        
        12
        +	TGToken         string   `json:"tg_token"`

      
        
        13
        +	MoviefeedAPIKey string   `json:"moviefeed_api_key"`

      
        
        14
        +	MoviefeedShows  []string `json:"moviefeed_shows"`

      
        
        15
        +	MusicArtists    []string `json:"music_artists"`

      
        
        16
        +	MusicMaxAgeDays int      `json:"music_max_age_days"`

      
        15
        17
         }

      
        16
        18
         

      
        17
        19
         func NewConfig(fpath string) (*Config, error) {

      
M main.go
···
        7
        7
         	"go.etcd.io/bbolt"

      
        8
        8
         	"olexsmir.xyz/rss-tools/app"

      
        9
        9
         	"olexsmir.xyz/rss-tools/sources/moviefeed"

      
        
        10
        +	"olexsmir.xyz/rss-tools/sources/musicfeed"

      
        10
        11
         	"olexsmir.xyz/rss-tools/sources/telegram"

      
        11
        12
         	"olexsmir.xyz/rss-tools/sources/weather"

      
        12
        13
         	"olexsmir.xyz/rss-tools/sources/ztoe"

      ···
        39
        40
         	_ = ztoe.Register(app)

      
        40
        41
         	_ = telegram.Register(app)

      
        41
        42
         	_ = moviefeed.Register(app)

      
        
        43
        +	_ = musicfeed.Register(app)

      
        42
        44
         	_ = weather.Register(app)

      
        43
        45
         

      
        44
        46
         	return app.Start(ctx)

      
A sources/musicfeed/musicbrainz.go
···
        
        1
        +package musicfeed

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"encoding/json"

      
        
        6
        +	"fmt"

      
        
        7
        +	"log/slog"

      
        
        8
        +	"net/http"

      
        
        9
        +	"net/url"

      
        
        10
        +	"sync"

      
        
        11
        +	"time"

      
        
        12
        +)

      
        
        13
        +

      
        
        14
        +const (

      
        
        15
        +	mbBaseURL   = "https://musicbrainz.org/ws/2"

      
        
        16
        +	caaBaseURL  = "https://coverartarchive.org"

      
        
        17
        +	mbUserAgent = "rss-tools/1.0 ( https://github.com/olexsmir/rss-tools )"

      
        
        18
        +)

      
        
        19
        +

      
        
        20
        +type mbRelease struct {

      
        
        21
        +	ID           string `json:"id"`

      
        
        22
        +	Title        string `json:"title"`

      
        
        23
        +	Date         string `json:"date"`

      
        
        24
        +	Status       string `json:"status"`

      
        
        25
        +	ArtistCredit []struct {

      
        
        26
        +		Name string `json:"name"`

      
        
        27
        +	} `json:"artist-credit"`

      
        
        28
        +	ReleaseGroup struct {

      
        
        29
        +		ID          string `json:"id"`

      
        
        30
        +		PrimaryType string `json:"primary-type"`

      
        
        31
        +	} `json:"release-group"`

      
        
        32
        +	CoverArtArchive struct {

      
        
        33
        +		Artwork bool `json:"artwork"`

      
        
        34
        +	} `json:"cover-art-archive"`

      
        
        35
        +}

      
        
        36
        +

      
        
        37
        +type mbReleaseResponse struct {

      
        
        38
        +	Releases []mbRelease `json:"releases"`

      
        
        39
        +}

      
        
        40
        +

      
        
        41
        +type mbArtistSearchResponse struct {

      
        
        42
        +	Artists []struct {

      
        
        43
        +		ID             string `json:"id"`

      
        
        44
        +		Name           string `json:"name"`

      
        
        45
        +		Disambiguation string `json:"disambiguation,omitempty"`

      
        
        46
        +		Score          int    `json:"score"`

      
        
        47
        +	} `json:"artists"`

      
        
        48
        +}

      
        
        49
        +

      
        
        50
        +type mbArtistResponse struct {

      
        
        51
        +	ID   string `json:"id"`

      
        
        52
        +	Name string `json:"name"`

      
        
        53
        +}

      
        
        54
        +

      
        
        55
        +const mbRequestGap = 200 * time.Millisecond

      
        
        56
        +

      
        
        57
        +type musicbrainzAPI struct {

      
        
        58
        +	client  *http.Client

      
        
        59
        +	mu      sync.Mutex

      
        
        60
        +	lastReq time.Time

      
        
        61
        +}

      
        
        62
        +

      
        
        63
        +func newMusicBrainzAPI(client *http.Client) *musicbrainzAPI {

      
        
        64
        +	return &musicbrainzAPI{

      
        
        65
        +		client: client,

      
        
        66
        +	}

      
        
        67
        +}

      
        
        68
        +

      
        
        69
        +func (a *musicbrainzAPI) throttle(ctx context.Context) error {

      
        
        70
        +	a.mu.Lock()

      
        
        71
        +	if a.lastReq.IsZero() {

      
        
        72
        +		a.lastReq = time.Now()

      
        
        73
        +		a.mu.Unlock()

      
        
        74
        +		return nil

      
        
        75
        +	}

      
        
        76
        +

      
        
        77
        +	scheduled := a.lastReq.Add(mbRequestGap)

      
        
        78
        +	a.lastReq = scheduled

      
        
        79
        +	a.mu.Unlock()

      
        
        80
        +

      
        
        81
        +	wait := time.Until(scheduled)

      
        
        82
        +	if wait > 0 {

      
        
        83
        +		select {

      
        
        84
        +		case <-time.After(wait):

      
        
        85
        +		case <-ctx.Done():

      
        
        86
        +			return ctx.Err()

      
        
        87
        +		}

      
        
        88
        +	}

      
        
        89
        +	return nil

      
        
        90
        +}

      
        
        91
        +

      
        
        92
        +func (a *musicbrainzAPI) doRequest(ctx context.Context, urlStr string) (*http.Response, error) {

      
        
        93
        +	if err := a.throttle(ctx); err != nil {

      
        
        94
        +		return nil, err

      
        
        95
        +	}

      
        
        96
        +

      
        
        97
        +	req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)

      
        
        98
        +	if err != nil {

      
        
        99
        +		return nil, err

      
        
        100
        +	}

      
        
        101
        +	req.Header.Set("User-Agent", mbUserAgent)

      
        
        102
        +

      
        
        103
        +	slog.Info("musicbrainz API request", "url", urlStr)

      
        
        104
        +	return a.client.Do(req)

      
        
        105
        +}

      
        
        106
        +

      
        
        107
        +func (a *musicbrainzAPI) searchArtist(ctx context.Context, name string) (string, string, error) {

      
        
        108
        +	q := url.QueryEscape(name)

      
        
        109
        +	u := fmt.Sprintf("%s/artist?query=artist:%s&limit=5&fmt=json", mbBaseURL, q)

      
        
        110
        +	resp, err := a.doRequest(ctx, u)

      
        
        111
        +	if err != nil {

      
        
        112
        +		return "", "", fmt.Errorf("search artist %q: %w", name, err)

      
        
        113
        +	}

      
        
        114
        +	defer resp.Body.Close()

      
        
        115
        +

      
        
        116
        +	var result mbArtistSearchResponse

      
        
        117
        +	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {

      
        
        118
        +		return "", "", fmt.Errorf("decode artist search: %w", err)

      
        
        119
        +	}

      
        
        120
        +

      
        
        121
        +	if len(result.Artists) == 0 {

      
        
        122
        +		return "", "", fmt.Errorf("no artist found for %q", name)

      
        
        123
        +	}

      
        
        124
        +

      
        
        125
        +	best := result.Artists[0]

      
        
        126
        +	slog.Info("resolved artist",

      
        
        127
        +		"query", name,

      
        
        128
        +		"match", best.Name,

      
        
        129
        +		"disambiguation", best.Disambiguation,

      
        
        130
        +		"score", best.Score,

      
        
        131
        +		"mbid", best.ID,

      
        
        132
        +	)

      
        
        133
        +	return best.ID, best.Name, nil

      
        
        134
        +}

      
        
        135
        +

      
        
        136
        +func (a *musicbrainzAPI) fetchArtist(ctx context.Context, mbid string) (string, error) {

      
        
        137
        +	u := fmt.Sprintf("%s/artist/%s?fmt=json", mbBaseURL, mbid)

      
        
        138
        +	resp, err := a.doRequest(ctx, u)

      
        
        139
        +	if err != nil {

      
        
        140
        +		return "", fmt.Errorf("fetch artist %s: %w", mbid, err)

      
        
        141
        +	}

      
        
        142
        +	defer resp.Body.Close()

      
        
        143
        +

      
        
        144
        +	var result mbArtistResponse

      
        
        145
        +	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {

      
        
        146
        +		return "", fmt.Errorf("decode artist: %w", err)

      
        
        147
        +	}

      
        
        148
        +	return result.Name, nil

      
        
        149
        +}

      
        
        150
        +

      
        
        151
        +func (a *musicbrainzAPI) fetchReleases(ctx context.Context, mbid string) ([]mbRelease, error) {

      
        
        152
        +	u := fmt.Sprintf(

      
        
        153
        +		"%s/release?artist=%s&inc=artist-credits+release-groups&limit=100&fmt=json",

      
        
        154
        +		mbBaseURL, mbid,

      
        
        155
        +	)

      
        
        156
        +	resp, err := a.doRequest(ctx, u)

      
        
        157
        +	if err != nil {

      
        
        158
        +		return nil, fmt.Errorf("fetch releases for %s: %w", mbid, err)

      
        
        159
        +	}

      
        
        160
        +	defer resp.Body.Close()

      
        
        161
        +

      
        
        162
        +	var result mbReleaseResponse

      
        
        163
        +	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {

      
        
        164
        +		return nil, fmt.Errorf("decode releases: %w", err)

      
        
        165
        +	}

      
        
        166
        +	return result.Releases, nil

      
        
        167
        +}

      
A sources/musicfeed/musicfeed.go
···
        
        1
        +package musicfeed

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"encoding/binary"

      
        
        6
        +	"fmt"

      
        
        7
        +	"html"

      
        
        8
        +	"log/slog"

      
        
        9
        +	"net/http"

      
        
        10
        +	"sort"

      
        
        11
        +	"strings"

      
        
        12
        +	"sync"

      
        
        13
        +	"sync/atomic"

      
        
        14
        +	"time"

      
        
        15
        +

      
        
        16
        +	"olexsmir.xyz/rss-tools/app"

      
        
        17
        +	"olexsmir.xyz/rss-tools/app/atom"

      
        
        18
        +)

      
        
        19
        +

      
        
        20
        +type artistEntry struct {

      
        
        21
        +	label string

      
        
        22
        +	mbid  string

      
        
        23
        +}

      
        
        24
        +

      
        
        25
        +type release struct {

      
        
        26
        +	id             string

      
        
        27
        +	releaseGroupID string

      
        
        28
        +	title          string

      
        
        29
        +	date           time.Time

      
        
        30
        +	releaseType    string

      
        
        31
        +	artistName     string

      
        
        32
        +	label          string

      
        
        33
        +	hasArtwork     bool

      
        
        34
        +}

      
        
        35
        +

      
        
        36
        +type releaseFetcher interface {

      
        
        37
        +	searchArtist(ctx context.Context, name string) (string, string, error)

      
        
        38
        +	fetchArtist(ctx context.Context, mbid string) (string, error)

      
        
        39
        +	fetchReleases(ctx context.Context, mbid string) ([]mbRelease, error)

      
        
        40
        +}

      
        
        41
        +

      
        
        42
        +type musicfeed struct {

      
        
        43
        +	bucket    *app.Bucket

      
        
        44
        +	artists   []string

      
        
        45
        +	api       releaseFetcher

      
        
        46
        +	maxAge    time.Duration

      
        
        47
        +	logger    *slog.Logger

      
        
        48
        +	refreshMu sync.Mutex

      
        
        49
        +	refreshed atomic.Bool

      
        
        50
        +}

      
        
        51
        +

      
        
        52
        +func Register(a *app.App) error {

      
        
        53
        +	if len(a.Config.MusicArtists) == 0 {

      
        
        54
        +		return nil

      
        
        55
        +	}

      
        
        56
        +

      
        
        57
        +	bucket, err := a.Bucket("musicfeed")

      
        
        58
        +	if err != nil {

      
        
        59
        +		return err

      
        
        60
        +	}

      
        
        61
        +

      
        
        62
        +	maxAge := time.Duration(a.Config.MusicMaxAgeDays) * 24 * time.Hour

      
        
        63
        +	if maxAge <= 0 {

      
        
        64
        +		maxAge = 30 * 24 * time.Hour

      
        
        65
        +	}

      
        
        66
        +

      
        
        67
        +	mf := &musicfeed{

      
        
        68
        +		bucket:  bucket,

      
        
        69
        +		artists: a.Config.MusicArtists,

      
        
        70
        +		api:     newMusicBrainzAPI(a.Client),

      
        
        71
        +		maxAge:  maxAge,

      
        
        72
        +		logger:  a.Logger,

      
        
        73
        +	}

      
        
        74
        +

      
        
        75
        +	a.AddWorker(mf.worker)

      
        
        76
        +	a.Route("GET /music", mf.handleMusic)

      
        
        77
        +

      
        
        78
        +	a.Logger.Info("musicfeed source registered")

      
        
        79
        +	return nil

      
        
        80
        +}

      
        
        81
        +

      
        
        82
        +func (mf *musicfeed) handleMusic(w http.ResponseWriter, r *http.Request) {

      
        
        83
        +	if !mf.refreshed.Load() {

      
        
        84
        +		mf.refreshMu.Lock()

      
        
        85
        +		if !mf.refreshed.Load() {

      
        
        86
        +			mf.refresh(r.Context())

      
        
        87
        +			mf.refreshed.Store(true)

      
        
        88
        +		}

      
        
        89
        +		mf.refreshMu.Unlock()

      
        
        90
        +	}

      
        
        91
        +

      
        
        92
        +	cached, err := mf.bucket.Get([]byte("feed"))

      
        
        93
        +	if err != nil {

      
        
        94
        +		slog.Error("failed to read cached feed", "err", err)

      
        
        95
        +		http.Error(w, "Internal server error", http.StatusInternalServerError)

      
        
        96
        +		return

      
        
        97
        +	}

      
        
        98
        +	if cached == nil {

      
        
        99
        +		http.Error(w, "feed not yet available", http.StatusServiceUnavailable)

      
        
        100
        +		return

      
        
        101
        +	}

      
        
        102
        +	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")

      
        
        103
        +	w.Write(cached)

      
        
        104
        +}

      
        
        105
        +

      
        
        106
        +func (mf *musicfeed) worker(ctx context.Context) error {

      
        
        107
        +	mf.logger.Info("starting musicfeed worker")

      
        
        108
        +

      
        
        109
        +	mf.maybeRefresh(ctx)

      
        
        110
        +

      
        
        111
        +	ticker := time.NewTicker(1 * time.Hour)

      
        
        112
        +	defer ticker.Stop()

      
        
        113
        +

      
        
        114
        +	for {

      
        
        115
        +		select {

      
        
        116
        +		case <-ctx.Done():

      
        
        117
        +			return nil

      
        
        118
        +		case <-ticker.C:

      
        
        119
        +			mf.maybeRefresh(ctx)

      
        
        120
        +		}

      
        
        121
        +	}

      
        
        122
        +}

      
        
        123
        +

      
        
        124
        +func (mf *musicfeed) maybeRefresh(ctx context.Context) {

      
        
        125
        +	now := time.Now()

      
        
        126
        +

      
        
        127
        +	mf.refreshMu.Lock()

      
        
        128
        +	defer mf.refreshMu.Unlock()

      
        
        129
        +

      
        
        130
        +	if mf.refreshed.Load() && now.Weekday() != time.Friday {

      
        
        131
        +		return

      
        
        132
        +	}

      
        
        133
        +

      
        
        134
        +	if mf.refreshed.Load() {

      
        
        135
        +		raw, err := mf.bucket.Get([]byte("refreshed_at"))

      
        
        136
        +		if err == nil && raw != nil {

      
        
        137
        +			lastRefresh := time.Unix(int64(binary.BigEndian.Uint64(raw)), 0)

      
        
        138
        +			if isSameDay(lastRefresh, now) {

      
        
        139
        +				return

      
        
        140
        +			}

      
        
        141
        +		}

      
        
        142
        +	}

      
        
        143
        +

      
        
        144
        +	mf.logger.Info("starting music feed refresh")

      
        
        145
        +	mf.refresh(ctx)

      
        
        146
        +	mf.refreshed.Store(true)

      
        
        147
        +}

      
        
        148
        +

      
        
        149
        +func isSameDay(a, b time.Time) bool {

      
        
        150
        +	ay, am, ad := a.Date()

      
        
        151
        +	by, bm, bd := b.Date()

      
        
        152
        +	return ay == by && am == bm && ad == bd

      
        
        153
        +}

      
        
        154
        +

      
        
        155
        +func (mf *musicfeed) refresh(ctx context.Context) {

      
        
        156
        +	type artistResult struct {

      
        
        157
        +		releases []release

      
        
        158
        +	}

      
        
        159
        +

      
        
        160
        +	var mu sync.Mutex

      
        
        161
        +	var all []release

      
        
        162
        +	var wg sync.WaitGroup

      
        
        163
        +	sem := make(chan struct{}, 5)

      
        
        164
        +

      
        
        165
        +	for _, raw := range mf.artists {

      
        
        166
        +		raw := raw

      
        
        167
        +		wg.Add(1)

      
        
        168
        +		sem <- struct{}{}

      
        
        169
        +

      
        
        170
        +		go func() {

      
        
        171
        +			defer wg.Done()

      
        
        172
        +			defer func() { <-sem }()

      
        
        173
        +

      
        
        174
        +			entry := parseArtistEntry(raw)

      
        
        175
        +			mbid, label := mf.resolveArtist(ctx, entry)

      
        
        176
        +			if mbid == "" {

      
        
        177
        +				mf.logger.Warn("could not resolve artist, skipping", "entry", raw)

      
        
        178
        +				return

      
        
        179
        +			}

      
        
        180
        +

      
        
        181
        +			mbReleases, err := mf.api.fetchReleases(ctx, mbid)

      
        
        182
        +			if err != nil {

      
        
        183
        +				mf.logger.Warn("failed to fetch releases", "artist", label, "err", err)

      
        
        184
        +				return

      
        
        185
        +			}

      
        
        186
        +

      
        
        187
        +			var artistReleases []release

      
        
        188
        +			for _, r := range mbReleases {

      
        
        189
        +				if r.Date == "" {

      
        
        190
        +					continue

      
        
        191
        +				}

      
        
        192
        +				date := parseMBDate(r.Date)

      
        
        193
        +				if date.IsZero() {

      
        
        194
        +					continue

      
        
        195
        +				}

      
        
        196
        +				if time.Since(date) > mf.maxAge || date.After(time.Now()) {

      
        
        197
        +					continue

      
        
        198
        +				}

      
        
        199
        +				artistName := ""

      
        
        200
        +				if len(r.ArtistCredit) > 0 {

      
        
        201
        +					artistName = r.ArtistCredit[0].Name

      
        
        202
        +				}

      
        
        203
        +				artistReleases = append(artistReleases, release{

      
        
        204
        +					id:             r.ID,

      
        
        205
        +					releaseGroupID: r.ReleaseGroup.ID,

      
        
        206
        +					title:          r.Title,

      
        
        207
        +					date:           date,

      
        
        208
        +					releaseType:    r.ReleaseGroup.PrimaryType,

      
        
        209
        +					artistName:     artistName,

      
        
        210
        +					label:          label,

      
        
        211
        +					hasArtwork:     r.CoverArtArchive.Artwork,

      
        
        212
        +				})

      
        
        213
        +			}

      
        
        214
        +

      
        
        215
        +			mu.Lock()

      
        
        216
        +			all = append(all, artistReleases...)

      
        
        217
        +			mu.Unlock()

      
        
        218
        +		}()

      
        
        219
        +	}

      
        
        220
        +

      
        
        221
        +	wg.Wait()

      
        
        222
        +

      
        
        223
        +	all = dedupeByReleaseGroup(all)

      
        
        224
        +

      
        
        225
        +	sort.Slice(all, func(i, j int) bool {

      
        
        226
        +		return all[i].date.After(all[j].date)

      
        
        227
        +	})

      
        
        228
        +

      
        
        229
        +	feed := generateFeed(all)

      
        
        230
        +	bytes, err := feed.Bytes()

      
        
        231
        +	if err != nil {

      
        
        232
        +		mf.logger.Error("failed to serialize feed", "err", err)

      
        
        233
        +		return

      
        
        234
        +	}

      
        
        235
        +

      
        
        236
        +	if err := mf.bucket.Set([]byte("feed"), bytes); err != nil {

      
        
        237
        +		mf.logger.Error("failed to cache feed", "err", err)

      
        
        238
        +	}

      
        
        239
        +

      
        
        240
        +	var ts [8]byte

      
        
        241
        +	binary.BigEndian.PutUint64(ts[:], uint64(time.Now().Unix()))

      
        
        242
        +	if err := mf.bucket.Set([]byte("refreshed_at"), ts[:]); err != nil {

      
        
        243
        +		mf.logger.Error("failed to save refresh timestamp", "err", err)

      
        
        244
        +	}

      
        
        245
        +

      
        
        246
        +	mf.logger.Info("music feed refreshed", "releases", len(all))

      
        
        247
        +}

      
        
        248
        +

      
        
        249
        +func (mf *musicfeed) resolveArtist(ctx context.Context, entry artistEntry) (string, string) {

      
        
        250
        +	if entry.mbid != "" {

      
        
        251
        +		return entry.mbid, entry.label

      
        
        252
        +	}

      
        
        253
        +

      
        
        254
        +	cached, err := mf.bucket.Get([]byte("mapping:" + entry.label))

      
        
        255
        +	if err == nil && cached != nil {

      
        
        256
        +		return string(cached), entry.label

      
        
        257
        +	}

      
        
        258
        +

      
        
        259
        +	if isMBID(entry.label) {

      
        
        260
        +		name, err := mf.api.fetchArtist(ctx, entry.label)

      
        
        261
        +		if err != nil {

      
        
        262
        +			mf.logger.Warn("failed to fetch artist name", "mbid", entry.label, "err", err)

      
        
        263
        +			return entry.label, entry.label

      
        
        264
        +		}

      
        
        265
        +		if err := mf.bucket.Set([]byte("mapping:"+name), []byte(entry.label)); err != nil {

      
        
        266
        +			mf.logger.Warn("failed to cache artist mapping", "err", err)

      
        
        267
        +		}

      
        
        268
        +		return entry.label, name

      
        
        269
        +	}

      
        
        270
        +

      
        
        271
        +	mbid, name, err := mf.api.searchArtist(ctx, entry.label)

      
        
        272
        +	if err != nil {

      
        
        273
        +		mf.logger.Warn("failed to search artist", "label", entry.label, "err", err)

      
        
        274
        +		return "", entry.label

      
        
        275
        +	}

      
        
        276
        +

      
        
        277
        +	if err := mf.bucket.Set([]byte("mapping:"+entry.label), []byte(mbid)); err != nil {

      
        
        278
        +		mf.logger.Warn("failed to cache artist mapping", "err", err)

      
        
        279
        +	}

      
        
        280
        +

      
        
        281
        +	return mbid, name

      
        
        282
        +}

      
        
        283
        +

      
        
        284
        +func parseArtistEntry(raw string) artistEntry {

      
        
        285
        +	label, mbid, found := strings.Cut(raw, "::")

      
        
        286
        +	if found {

      
        
        287
        +		return artistEntry{label: strings.TrimSpace(label), mbid: strings.TrimSpace(mbid)}

      
        
        288
        +	}

      
        
        289
        +	return artistEntry{label: strings.TrimSpace(raw)}

      
        
        290
        +}

      
        
        291
        +

      
        
        292
        +func isMBID(s string) bool {

      
        
        293
        +	if len(s) != 36 {

      
        
        294
        +		return false

      
        
        295
        +	}

      
        
        296
        +	for i, c := range s {

      
        
        297
        +		switch i {

      
        
        298
        +		case 8, 13, 18, 23:

      
        
        299
        +			if c != '-' {

      
        
        300
        +				return false

      
        
        301
        +			}

      
        
        302
        +		default:

      
        
        303
        +			if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {

      
        
        304
        +				return false

      
        
        305
        +			}

      
        
        306
        +		}

      
        
        307
        +	}

      
        
        308
        +	return true

      
        
        309
        +}

      
        
        310
        +

      
        
        311
        +func dedupeByReleaseGroup(releases []release) []release {

      
        
        312
        +	seen := make(map[string]int)

      
        
        313
        +	var out []release

      
        
        314
        +	for _, r := range releases {

      
        
        315
        +		if r.releaseGroupID == "" {

      
        
        316
        +			out = append(out, r)

      
        
        317
        +			continue

      
        
        318
        +		}

      
        
        319
        +		if idx, ok := seen[r.releaseGroupID]; ok {

      
        
        320
        +			if r.hasArtwork && !out[idx].hasArtwork {

      
        
        321
        +				out[idx] = r

      
        
        322
        +			}

      
        
        323
        +			continue

      
        
        324
        +		}

      
        
        325
        +		seen[r.releaseGroupID] = len(out)

      
        
        326
        +		out = append(out, r)

      
        
        327
        +	}

      
        
        328
        +	return out

      
        
        329
        +}

      
        
        330
        +

      
        
        331
        +func parseMBDate(s string) time.Time {

      
        
        332
        +	formats := []string{"2006-01-02", "2006-01", "2006"}

      
        
        333
        +	for _, f := range formats {

      
        
        334
        +		if t, err := time.Parse(f, s); err == nil {

      
        
        335
        +			return t

      
        
        336
        +		}

      
        
        337
        +	}

      
        
        338
        +	return time.Time{}

      
        
        339
        +}

      
        
        340
        +

      
        
        341
        +func generateFeed(releases []release) *atom.Feed {

      
        
        342
        +	feed := atom.NewFeed("New Music Releases", "musicfeed")

      
        
        343
        +	for _, r := range releases {

      
        
        344
        +		displayName := r.label

      
        
        345
        +		if displayName == "" {

      
        
        346
        +			displayName = r.artistName

      
        
        347
        +		}

      
        
        348
        +

      
        
        349
        +		links := []atom.Link{

      
        
        350
        +			{

      
        
        351
        +				Rel:  "alternate",

      
        
        352
        +				Href: fmt.Sprintf("https://musicbrainz.org/release/%s", r.id),

      
        
        353
        +			},

      
        
        354
        +		}

      
        
        355
        +

      
        
        356
        +		content, contentType := releaseContent(r, displayName)

      
        
        357
        +

      
        
        358
        +		if r.hasArtwork {

      
        
        359
        +			links = append(links, atom.Link{

      
        
        360
        +				Rel:  "enclosure",

      
        
        361
        +				Type: "image/jpeg",

      
        
        362
        +				Href: fmt.Sprintf("%s/release/%s/front-250.jpg", caaBaseURL, r.id),

      
        
        363
        +			})

      
        
        364
        +		}

      
        
        365
        +

      
        
        366
        +		releaseType := strings.TrimSpace(r.releaseType)

      
        
        367
        +		title := fmt.Sprintf("%s — %s", displayName, r.title)

      
        
        368
        +		if releaseType != "" {

      
        
        369
        +			title += fmt.Sprintf(" (%s)", releaseType)

      
        
        370
        +		}

      
        
        371
        +

      
        
        372
        +		feed.Add(&atom.Entry{

      
        
        373
        +			ID:      r.id,

      
        
        374
        +			Title:   title,

      
        
        375
        +			Content: atom.NewText(content, contentType),

      
        
        376
        +			Updated: atom.Time(r.date),

      
        
        377
        +			Link:    links,

      
        
        378
        +		})

      
        
        379
        +	}

      
        
        380
        +	return feed

      
        
        381
        +}

      
        
        382
        +

      
        
        383
        +func releaseContent(r release, displayName string) (string, string) {

      
        
        384
        +	if !r.hasArtwork {

      
        
        385
        +		releaseType := strings.TrimSpace(r.releaseType)

      
        
        386
        +		if releaseType != "" {

      
        
        387
        +			return fmt.Sprintf("%s by %s (%s)", r.title, displayName, releaseType), ""

      
        
        388
        +		}

      
        
        389
        +		return fmt.Sprintf("%s by %s", r.title, displayName), ""

      
        
        390
        +	}

      
        
        391
        +

      
        
        392
        +	imageURL := fmt.Sprintf("%s/release/%s/front-250.jpg", caaBaseURL, r.id)

      
        
        393
        +	parts := make([]string, 0, 4)

      
        
        394
        +	parts = append(parts, "<body>")

      
        
        395
        +

      
        
        396
        +	releaseType := strings.TrimSpace(r.releaseType)

      
        
        397
        +	var text string

      
        
        398
        +	if releaseType != "" {

      
        
        399
        +		text = fmt.Sprintf("%s by %s (%s)", r.title, displayName, releaseType)

      
        
        400
        +	} else {

      
        
        401
        +		text = fmt.Sprintf("%s by %s", r.title, displayName)

      
        
        402
        +	}

      
        
        403
        +	parts = append(parts, "<p>"+html.EscapeString(text)+"</p>")

      
        
        404
        +	parts = append(parts,

      
        
        405
        +		fmt.Sprintf(`<p><img src="%s" alt="%s"/></p>`, html.EscapeString(imageURL), html.EscapeString(r.title)))

      
        
        406
        +	parts = append(parts, "</body>")

      
        
        407
        +

      
        
        408
        +	return strings.Join(parts, ""), "xhtml"

      
        
        409
        +}

      
A sources/musicfeed/musicfeed_test.go
···
        
        1
        +package musicfeed

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"context"

      
        
        5
        +	"net/http"

      
        
        6
        +	"net/http/httptest"

      
        
        7
        +	"strings"

      
        
        8
        +	"testing"

      
        
        9
        +	"time"

      
        
        10
        +

      
        
        11
        +	"go.etcd.io/bbolt"

      
        
        12
        +	"olexsmir.xyz/rss-tools/app"

      
        
        13
        +	"olexsmir.xyz/x/is"

      
        
        14
        +)

      
        
        15
        +

      
        
        16
        +type fakeMusicAPI struct {

      
        
        17
        +	artists  map[string]string

      
        
        18
        +	releases map[string][]mbRelease

      
        
        19
        +	errs     map[string]error

      
        
        20
        +}

      
        
        21
        +

      
        
        22
        +func (f fakeMusicAPI) searchArtist(_ context.Context, name string) (string, string, error) {

      
        
        23
        +	if err, ok := f.errs[name]; ok {

      
        
        24
        +		return "", "", err

      
        
        25
        +	}

      
        
        26
        +	mbid, ok := f.artists[name]

      
        
        27
        +	if !ok {

      
        
        28
        +		return "", "", nil

      
        
        29
        +	}

      
        
        30
        +	return mbid, name, nil

      
        
        31
        +}

      
        
        32
        +

      
        
        33
        +func (f fakeMusicAPI) fetchArtist(_ context.Context, mbid string) (string, error) {

      
        
        34
        +	for name, id := range f.artists {

      
        
        35
        +		if id == mbid {

      
        
        36
        +			return name, nil

      
        
        37
        +		}

      
        
        38
        +	}

      
        
        39
        +	return "", nil

      
        
        40
        +}

      
        
        41
        +

      
        
        42
        +func (f fakeMusicAPI) fetchReleases(_ context.Context, mbid string) ([]mbRelease, error) {

      
        
        43
        +	if err, ok := f.errs[mbid]; ok {

      
        
        44
        +		return nil, err

      
        
        45
        +	}

      
        
        46
        +	return f.releases[mbid], nil

      
        
        47
        +}

      
        
        48
        +

      
        
        49
        +func TestParseArtistEntryLabelMbid(t *testing.T) {

      
        
        50
        +	entry := parseArtistEntry("orphan::2a9e4c32-xxxx-xxxx-xxxx-xxxxxxxxxxxx")

      
        
        51
        +	is.Equal(t, "orphan", entry.label)

      
        
        52
        +	is.Equal(t, "2a9e4c32-xxxx-xxxx-xxxx-xxxxxxxxxxxx", entry.mbid)

      
        
        53
        +}

      
        
        54
        +

      
        
        55
        +func TestParseArtistEntryPlainSlug(t *testing.T) {

      
        
        56
        +	entry := parseArtistEntry("metallica")

      
        
        57
        +	is.Equal(t, "metallica", entry.label)

      
        
        58
        +	is.Equal(t, "", entry.mbid)

      
        
        59
        +}

      
        
        60
        +

      
        
        61
        +func TestParseArtistEntryRawMBID(t *testing.T) {

      
        
        62
        +	entry := parseArtistEntry("2a9e4c32-xxxx-xxxx-xxxx-xxxxxxxxxxxx")

      
        
        63
        +	is.Equal(t, "2a9e4c32-xxxx-xxxx-xxxx-xxxxxxxxxxxx", entry.label)

      
        
        64
        +	is.Equal(t, "", entry.mbid)

      
        
        65
        +}

      
        
        66
        +

      
        
        67
        +func TestParseArtistEntryWhitespace(t *testing.T) {

      
        
        68
        +	entry := parseArtistEntry("  orphan::  2a9e4c32-xxxx  ")

      
        
        69
        +	is.Equal(t, "orphan", entry.label)

      
        
        70
        +	is.Equal(t, "2a9e4c32-xxxx", entry.mbid)

      
        
        71
        +}

      
        
        72
        +

      
        
        73
        +func TestIsMBIDValid(t *testing.T) {

      
        
        74
        +	is.Equal(t, true, isMBID("2a9e4c32-abcd-4ef8-9abc-123456789abc"))

      
        
        75
        +	is.Equal(t, true, isMBID("550e8400-e29b-41d4-a716-446655440000"))

      
        
        76
        +}

      
        
        77
        +

      
        
        78
        +func TestIsMBIDInvalid(t *testing.T) {

      
        
        79
        +	is.Equal(t, false, isMBID(""))

      
        
        80
        +	is.Equal(t, false, isMBID("metallica"))

      
        
        81
        +	is.Equal(t, false, isMBID("550e8400-e29b-41d4-a716-44665544000"))   // 35 chars

      
        
        82
        +	is.Equal(t, false, isMBID("550e8400-e29b-41d4-a716-4466554400000")) // 37 chars

      
        
        83
        +	is.Equal(t, false, isMBID("550e8400:e29b-41d4-a716-446655440000"))  // wrong separator

      
        
        84
        +}

      
        
        85
        +

      
        
        86
        +func TestParseMBDateFull(t *testing.T) {

      
        
        87
        +	d := parseMBDate("2026-04-22")

      
        
        88
        +	is.Equal(t, 2026, d.Year())

      
        
        89
        +	is.Equal(t, time.Month(4), d.Month())

      
        
        90
        +	is.Equal(t, 22, d.Day())

      
        
        91
        +}

      
        
        92
        +

      
        
        93
        +func TestParseMBDateYearMonth(t *testing.T) {

      
        
        94
        +	d := parseMBDate("2026-04")

      
        
        95
        +	is.Equal(t, 2026, d.Year())

      
        
        96
        +	is.Equal(t, time.Month(4), d.Month())

      
        
        97
        +	is.Equal(t, 1, d.Day())

      
        
        98
        +}

      
        
        99
        +

      
        
        100
        +func TestParseMBDateYearOnly(t *testing.T) {

      
        
        101
        +	d := parseMBDate("2026")

      
        
        102
        +	is.Equal(t, 2026, d.Year())

      
        
        103
        +	is.Equal(t, time.Month(1), d.Month())

      
        
        104
        +	is.Equal(t, 1, d.Day())

      
        
        105
        +}

      
        
        106
        +

      
        
        107
        +func TestParseMBDateInvalid(t *testing.T) {

      
        
        108
        +	is.Equal(t, true, parseMBDate("").IsZero())

      
        
        109
        +	is.Equal(t, true, parseMBDate("not-a-date").IsZero())

      
        
        110
        +}

      
        
        111
        +

      
        
        112
        +func TestDedupeByReleaseGroup(t *testing.T) {

      
        
        113
        +	releases := []release{

      
        
        114
        +		{id: "r1", releaseGroupID: "g1", title: "Album CD", hasArtwork: false},

      
        
        115
        +		{id: "r2", releaseGroupID: "g1", title: "Album Vinyl", hasArtwork: true},

      
        
        116
        +		{id: "r3", releaseGroupID: "g2", title: "Single A", hasArtwork: false},

      
        
        117
        +	}

      
        
        118
        +

      
        
        119
        +	deduped := dedupeByReleaseGroup(releases)

      
        
        120
        +	is.Equal(t, 2, len(deduped))

      
        
        121
        +	is.Equal(t, "Album Vinyl", deduped[0].title)

      
        
        122
        +	is.Equal(t, "Single A", deduped[1].title)

      
        
        123
        +	is.Equal(t, true, deduped[0].hasArtwork)

      
        
        124
        +}

      
        
        125
        +

      
        
        126
        +func TestDedupeByReleaseGroupNoID(t *testing.T) {

      
        
        127
        +	releases := []release{

      
        
        128
        +		{id: "r1", title: "No group", releaseGroupID: ""},

      
        
        129
        +	}

      
        
        130
        +	deduped := dedupeByReleaseGroup(releases)

      
        
        131
        +	is.Equal(t, 1, len(deduped))

      
        
        132
        +}

      
        
        133
        +

      
        
        134
        +func TestReleaseContentWithoutArtwork(t *testing.T) {

      
        
        135
        +	r := release{

      
        
        136
        +		title:       "Porcelain",

      
        
        137
        +		releaseType: "Album",

      
        
        138
        +		hasArtwork:  false,

      
        
        139
        +	}

      
        
        140
        +	content, ctype := releaseContent(r, "Orphan")

      
        
        141
        +	is.Equal(t, "", ctype)

      
        
        142
        +	is.Equal(t, "Porcelain by Orphan (Album)", content)

      
        
        143
        +}

      
        
        144
        +

      
        
        145
        +func TestReleaseContentWithoutArtworkAndType(t *testing.T) {

      
        
        146
        +	r := release{

      
        
        147
        +		title:       "Porcelain",

      
        
        148
        +		releaseType: "",

      
        
        149
        +		hasArtwork:  false,

      
        
        150
        +	}

      
        
        151
        +	content, ctype := releaseContent(r, "Orphan")

      
        
        152
        +	is.Equal(t, "", ctype)

      
        
        153
        +	is.Equal(t, "Porcelain by Orphan", content)

      
        
        154
        +}

      
        
        155
        +

      
        
        156
        +func TestReleaseContentWithArtwork(t *testing.T) {

      
        
        157
        +	r := release{

      
        
        158
        +		id:          "mbid-123",

      
        
        159
        +		title:       "Porcelain",

      
        
        160
        +		releaseType: "Album",

      
        
        161
        +		hasArtwork:  true,

      
        
        162
        +	}

      
        
        163
        +	content, ctype := releaseContent(r, "Orphan")

      
        
        164
        +	is.Equal(t, "xhtml", ctype)

      
        
        165
        +	is.Equal(t, strings.Contains(content, "<body>"), true)

      
        
        166
        +	is.Equal(t, strings.Contains(content, "Porcelain by Orphan (Album)"), true)

      
        
        167
        +	is.Equal(t, strings.Contains(content, `<img src="https://coverartarchive.org/release/mbid-123/front-250.jpg"`), true)

      
        
        168
        +	is.Equal(t, strings.Contains(content, "</body>"), true)

      
        
        169
        +}

      
        
        170
        +

      
        
        171
        +func TestGenerateFeed(t *testing.T) {

      
        
        172
        +	now := time.Now()

      
        
        173
        +	releases := []release{

      
        
        174
        +		{

      
        
        175
        +			id:          "mbid-1",

      
        
        176
        +			title:       "New Album",

      
        
        177
        +			date:        now.Add(-24 * time.Hour),

      
        
        178
        +			releaseType: "Album",

      
        
        179
        +			artistName:  "Test Band",

      
        
        180
        +			label:       "test-band",

      
        
        181
        +			hasArtwork:  false,

      
        
        182
        +		},

      
        
        183
        +		{

      
        
        184
        +			id:          "mbid-2",

      
        
        185
        +			title:       "New Single",

      
        
        186
        +			date:        now.Add(-48 * time.Hour),

      
        
        187
        +			releaseType: "Single",

      
        
        188
        +			artistName:  "Test Band",

      
        
        189
        +			label:       "",

      
        
        190
        +			hasArtwork:  false,

      
        
        191
        +		},

      
        
        192
        +	}

      
        
        193
        +

      
        
        194
        +	feed := generateFeed(releases)

      
        
        195
        +	is.Equal(t, "New Music Releases", feed.Title)

      
        
        196
        +	is.Equal(t, "musicfeed", feed.ID)

      
        
        197
        +	is.Equal(t, 2, len(feed.Entry))

      
        
        198
        +

      
        
        199
        +	is.Equal(t, "test-band — New Album (Album)", feed.Entry[0].Title)

      
        
        200
        +	is.Equal(t, "mbid-1", feed.Entry[0].ID)

      
        
        201
        +	is.Equal(t, "Test Band — New Single (Single)", feed.Entry[1].Title)

      
        
        202
        +}

      
        
        203
        +

      
        
        204
        +func TestGenerateFeedWithLabelFallback(t *testing.T) {

      
        
        205
        +	r := []release{{

      
        
        206
        +		id: "mbid-1", title: "Album", date: time.Now(),

      
        
        207
        +		releaseType: "Album", artistName: "Real Band Name", label: "",

      
        
        208
        +	}}

      
        
        209
        +	feed := generateFeed(r)

      
        
        210
        +	is.Equal(t, "Real Band Name — Album (Album)", feed.Entry[0].Title)

      
        
        211
        +}

      
        
        212
        +

      
        
        213
        +func TestGenerateFeedWithArtworkLink(t *testing.T) {

      
        
        214
        +	r := []release{{

      
        
        215
        +		id: "mbid-1", title: "Album", date: time.Now(),

      
        
        216
        +		releaseType: "Album", artistName: "Band", label: "band",

      
        
        217
        +		hasArtwork: true,

      
        
        218
        +	}}

      
        
        219
        +	feed := generateFeed(r)

      
        
        220
        +	is.Equal(t, 2, len(feed.Entry[0].Link))

      
        
        221
        +	is.Equal(t, "alternate", feed.Entry[0].Link[0].Rel)

      
        
        222
        +	is.Equal(t, "enclosure", feed.Entry[0].Link[1].Rel)

      
        
        223
        +	is.Equal(t, "image/jpeg", feed.Entry[0].Link[1].Type)

      
        
        224
        +}

      
        
        225
        +

      
        
        226
        +func TestHandleMusicServesCachedFeed(t *testing.T) {

      
        
        227
        +	bucket := newBucket(t)

      
        
        228
        +

      
        
        229
        +	err := bucket.Set([]byte("feed"), []byte("<feed>test</feed>"))

      
        
        230
        +	is.Err(t, err, nil)

      
        
        231
        +

      
        
        232
        +	mf := &musicfeed{bucket: bucket}

      
        
        233
        +	mf.refreshed.Store(true)

      
        
        234
        +	mux := http.NewServeMux()

      
        
        235
        +	mux.HandleFunc("GET /music", mf.handleMusic)

      
        
        236
        +

      
        
        237
        +	req := httptest.NewRequest(http.MethodGet, "/music", nil)

      
        
        238
        +	rr := httptest.NewRecorder()

      
        
        239
        +	mux.ServeHTTP(rr, req)

      
        
        240
        +

      
        
        241
        +	is.Equal(t, rr.Code, http.StatusOK)

      
        
        242
        +	is.Equal(t, strings.Contains(rr.Header().Get("Content-Type"), "application/atom+xml"), true)

      
        
        243
        +	is.Equal(t, "<feed>test</feed>", rr.Body.String())

      
        
        244
        +}

      
        
        245
        +

      
        
        246
        +func TestHandleMusicNoCache(t *testing.T) {

      
        
        247
        +	bucket := newBucket(t)

      
        
        248
        +	mf := &musicfeed{bucket: bucket}

      
        
        249
        +	mf.refreshed.Store(true)

      
        
        250
        +	mux := http.NewServeMux()

      
        
        251
        +	mux.HandleFunc("GET /music", mf.handleMusic)

      
        
        252
        +

      
        
        253
        +	req := httptest.NewRequest(http.MethodGet, "/music", nil)

      
        
        254
        +	rr := httptest.NewRecorder()

      
        
        255
        +	mux.ServeHTTP(rr, req)

      
        
        256
        +

      
        
        257
        +	is.Equal(t, rr.Code, http.StatusServiceUnavailable)

      
        
        258
        +}

      
        
        259
        +

      
        
        260
        +func TestResolveArtistCachedMapping(t *testing.T) {

      
        
        261
        +	bucket := newBucket(t)

      
        
        262
        +	bucket.Set([]byte("mapping:metallica"), []byte("mbid-123"))

      
        
        263
        +

      
        
        264
        +	mf := &musicfeed{

      
        
        265
        +		bucket: bucket,

      
        
        266
        +		api: fakeMusicAPI{

      
        
        267
        +			artists: map[string]string{"metallica": "mbid-999"},

      
        
        268
        +		},

      
        
        269
        +	}

      
        
        270
        +

      
        
        271
        +	mbid, label := mf.resolveArtist(context.Background(), artistEntry{label: "metallica"})

      
        
        272
        +	is.Equal(t, "mbid-123", mbid)

      
        
        273
        +	is.Equal(t, "metallica", label)

      
        
        274
        +}

      
        
        275
        +

      
        
        276
        +func TestResolveArtistWithExplicitMBID(t *testing.T) {

      
        
        277
        +	mf := &musicfeed{

      
        
        278
        +		api: fakeMusicAPI{

      
        
        279
        +			artists: map[string]string{"Orphan": "mbid-orphan"},

      
        
        280
        +		},

      
        
        281
        +	}

      
        
        282
        +

      
        
        283
        +	mbid, label := mf.resolveArtist(context.Background(), artistEntry{label: "orphan", mbid: "mbid-orphan"})

      
        
        284
        +	is.Equal(t, "mbid-orphan", mbid)

      
        
        285
        +	is.Equal(t, "orphan", label)

      
        
        286
        +}

      
        
        287
        +

      
        
        288
        +func TestIsSameDay(t *testing.T) {

      
        
        289
        +	now := time.Now()

      
        
        290
        +	is.Equal(t, true, isSameDay(now, now))

      
        
        291
        +	is.Equal(t, true, isSameDay(now, now.Add(time.Hour)))

      
        
        292
        +	is.Equal(t, false, isSameDay(now, now.Add(24*time.Hour)))

      
        
        293
        +}

      
        
        294
        +

      
        
        295
        +func newBucket(t *testing.T) *app.Bucket {

      
        
        296
        +	t.Helper()

      
        
        297
        +	db, err := bbolt.Open(t.TempDir()+"/test.db", 0o600, nil)

      
        
        298
        +	is.Err(t, err, nil)

      
        
        299
        +	t.Cleanup(func() { db.Close() })

      
        
        300
        +

      
        
        301
        +	a := app.New(&app.Config{}, db)

      
        
        302
        +	bucket, err := a.Bucket("musicfeed")

      
        
        303
        +	is.Err(t, err, nil)

      
        
        304
        +	return bucket

      
        
        305
        +}