all repos

rss-tools @ 05391f1ceeba6d6f8ff2adc080b7e254f645cd6d

get rss feed from sources that(i need and) dont provide one

rss-tools/sources/musicfeed/musicfeed.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
musicfeed: include spotify and youtube links, 1 month ago
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
	spotifyURL     string
35
	youtubeURL     string
36
}
37
38
type releaseFetcher interface {
39
	searchArtist(ctx context.Context, name string) (string, string, error)
40
	fetchArtist(ctx context.Context, mbid string) (string, error)
41
	fetchReleases(ctx context.Context, mbid string) ([]mbRelease, error)
42
}
43
44
type musicfeed struct {
45
	bucket    *app.Bucket
46
	artists   []string
47
	api       releaseFetcher
48
	maxAge    time.Duration
49
	logger    *slog.Logger
50
	refreshMu sync.Mutex
51
	refreshed atomic.Bool
52
}
53
54
func Register(a *app.App) error {
55
	if len(a.Config.MusicArtists) == 0 {
56
		return nil
57
	}
58
59
	bucket, err := a.Bucket("musicfeed")
60
	if err != nil {
61
		return err
62
	}
63
64
	maxAge := time.Duration(a.Config.MusicMaxAgeDays) * 24 * time.Hour
65
	if maxAge <= 0 {
66
		maxAge = 30 * 24 * time.Hour
67
	}
68
69
	mf := &musicfeed{
70
		bucket:  bucket,
71
		artists: a.Config.MusicArtists,
72
		api:     newMusicBrainzAPI(a.Client),
73
		maxAge:  maxAge,
74
		logger:  a.Logger,
75
	}
76
77
	a.AddWorker(mf.worker)
78
	a.Route("GET /music", mf.handleMusic)
79
80
	a.Logger.Info("musicfeed source registered")
81
	return nil
82
}
83
84
func (mf *musicfeed) handleMusic(w http.ResponseWriter, r *http.Request) {
85
	if !mf.refreshed.Load() && (time.Now().Weekday() == time.Friday || mf.cacheMissing()) {
86
		mf.refreshMu.Lock()
87
		if !mf.refreshed.Load() {
88
			mf.refresh(r.Context())
89
			mf.refreshed.Store(true)
90
		}
91
		mf.refreshMu.Unlock()
92
	}
93
94
	cached, err := mf.bucket.Get([]byte("feed"))
95
	if err != nil {
96
		slog.Error("failed to read cached feed", "err", err)
97
		http.Error(w, "Internal server error", http.StatusInternalServerError)
98
		return
99
	}
100
	if cached == nil {
101
		http.Error(w, "feed not yet available", http.StatusServiceUnavailable)
102
		return
103
	}
104
	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
105
	w.Write(cached)
106
}
107
108
func (mf *musicfeed) worker(ctx context.Context) error {
109
	mf.logger.Info("starting musicfeed worker")
110
111
	// Only refresh on Fridays — releases drop on Friday, so we fetch once weekly
112
	// to avoid rate-limiting MusicBrainz.
113
	if time.Now().Weekday() == time.Friday {
114
		mf.maybeRefresh(ctx)
115
	}
116
117
	for {
118
		next := nextFridayRefresh(time.Now())
119
		dur := time.Until(next)
120
		mf.logger.Info("next music feed refresh", "at", next.Format("2006-01-02 15:04"), "in", dur.Round(time.Second))
121
122
		select {
123
		case <-ctx.Done():
124
			return nil
125
		case <-time.After(dur):
126
			mf.maybeRefresh(ctx)
127
		}
128
	}
129
}
130
131
func nextFridayRefresh(after time.Time) time.Time {
132
	const targetHour = 19
133
	y, m, d := after.Date()
134
	loc := after.Location()
135
136
	// If today is Friday before target hour, return today at target hour.
137
	if after.Weekday() == time.Friday {
138
		target := time.Date(y, m, d, targetHour, 0, 0, 0, loc)
139
		if after.Before(target) {
140
			return target
141
		}
142
	}
143
144
	// Otherwise advance to next Friday at target hour.
145
	next := time.Date(y, m, d, 0, 0, 0, 0, loc).Add(24 * time.Hour)
146
	for next.Weekday() != time.Friday {
147
		next = next.Add(24 * time.Hour)
148
	}
149
	return time.Date(next.Year(), next.Month(), next.Day(), targetHour, 0, 0, 0, loc)
150
}
151
152
func (mf *musicfeed) cacheMissing() bool {
153
	_, err := mf.bucket.Get([]byte("feed"))
154
	return err != nil
155
}
156
157
func (mf *musicfeed) maybeRefresh(ctx context.Context) {
158
	mf.refreshMu.Lock()
159
	defer mf.refreshMu.Unlock()
160
161
	if mf.refreshed.Load() {
162
		raw, err := mf.bucket.Get([]byte("refreshed_at"))
163
		if err == nil && raw != nil {
164
			lastRefresh := time.Unix(int64(binary.BigEndian.Uint64(raw)), 0)
165
			if isSameDay(lastRefresh, time.Now()) {
166
				return
167
			}
168
		}
169
	}
170
171
	mf.logger.Info("starting music feed refresh")
172
	mf.refresh(ctx)
173
	mf.refreshed.Store(true)
174
}
175
176
func isSameDay(a, b time.Time) bool {
177
	ay, am, ad := a.Date()
178
	by, bm, bd := b.Date()
179
	return ay == by && am == bm && ad == bd
180
}
181
182
func (mf *musicfeed) refresh(ctx context.Context) {
183
	type artistResult struct {
184
		releases []release
185
	}
186
187
	var mu sync.Mutex
188
	var all []release
189
	var wg sync.WaitGroup
190
	sem := make(chan struct{}, 5)
191
192
	for _, raw := range mf.artists {
193
		wg.Add(1)
194
		sem <- struct{}{}
195
196
		go func() {
197
			defer wg.Done()
198
			defer func() { <-sem }()
199
200
			entry := parseArtistEntry(raw)
201
			mbid, label := mf.resolveArtist(ctx, entry)
202
			if mbid == "" {
203
				mf.logger.Warn("could not resolve artist, skipping", "entry", raw)
204
				return
205
			}
206
207
			mbReleases, err := mf.api.fetchReleases(ctx, mbid)
208
			if err != nil {
209
				mf.logger.Warn("failed to fetch releases", "artist", label, "err", err)
210
				return
211
			}
212
213
			var artistReleases []release
214
			for _, r := range mbReleases {
215
				if r.Date == "" {
216
					continue
217
				}
218
				date := parseMBDate(r.Date)
219
				if date.IsZero() {
220
					continue
221
				}
222
				if time.Since(date) > mf.maxAge || date.After(time.Now()) {
223
					continue
224
				}
225
				artistName := ""
226
				if len(r.ArtistCredit) > 0 {
227
					artistName = r.ArtistCredit[0].Name
228
				}
229
				spotifyURL, youtubeURL := extractExtURLs(r.Relations)
230
				artistReleases = append(artistReleases, release{
231
					id:             r.ID,
232
					releaseGroupID: r.ReleaseGroup.ID,
233
					title:          r.Title,
234
					date:           date,
235
					releaseType:    r.ReleaseGroup.PrimaryType,
236
					artistName:     artistName,
237
					label:          label,
238
					hasArtwork:     r.CoverArtArchive.Artwork,
239
					spotifyURL:     spotifyURL,
240
					youtubeURL:     youtubeURL,
241
				})
242
			}
243
244
			mu.Lock()
245
			all = append(all, artistReleases...)
246
			mu.Unlock()
247
		}()
248
	}
249
250
	wg.Wait()
251
252
	all = dedupeByReleaseGroup(all)
253
254
	sort.Slice(all, func(i, j int) bool {
255
		return all[i].date.After(all[j].date)
256
	})
257
258
	feed := generateFeed(all)
259
	bytes, err := feed.Bytes()
260
	if err != nil {
261
		mf.logger.Error("failed to serialize feed", "err", err)
262
		return
263
	}
264
265
	if err := mf.bucket.Set([]byte("feed"), bytes); err != nil {
266
		mf.logger.Error("failed to cache feed", "err", err)
267
	}
268
269
	var ts [8]byte
270
	binary.BigEndian.PutUint64(ts[:], uint64(time.Now().Unix()))
271
	if err := mf.bucket.Set([]byte("refreshed_at"), ts[:]); err != nil {
272
		mf.logger.Error("failed to save refresh timestamp", "err", err)
273
	}
274
275
	mf.logger.Info("music feed refreshed", "releases", len(all))
276
}
277
278
func (mf *musicfeed) resolveArtist(ctx context.Context, entry artistEntry) (string, string) {
279
	if entry.mbid != "" {
280
		return entry.mbid, entry.label
281
	}
282
283
	cached, err := mf.bucket.Get([]byte("mapping:" + entry.label))
284
	if err == nil && cached != nil {
285
		return string(cached), entry.label
286
	}
287
288
	if isMBID(entry.label) {
289
		name, ferr := mf.api.fetchArtist(ctx, entry.label)
290
		if ferr != nil {
291
			mf.logger.Warn("failed to fetch artist name", "mbid", entry.label, "err", ferr)
292
			return entry.label, entry.label
293
		}
294
		if berr := mf.bucket.Set([]byte("mapping:"+name), []byte(entry.label)); berr != nil {
295
			mf.logger.Warn("failed to cache artist mapping", "err", berr)
296
		}
297
		return entry.label, name
298
	}
299
300
	mbid, name, err := mf.api.searchArtist(ctx, entry.label)
301
	if err != nil {
302
		mf.logger.Warn("failed to search artist", "label", entry.label, "err", err)
303
		return "", entry.label
304
	}
305
306
	if err := mf.bucket.Set([]byte("mapping:"+entry.label), []byte(mbid)); err != nil {
307
		mf.logger.Warn("failed to cache artist mapping", "err", err)
308
	}
309
310
	return mbid, name
311
}
312
313
func parseArtistEntry(raw string) artistEntry {
314
	label, mbid, found := strings.Cut(raw, "::")
315
	if found {
316
		return artistEntry{label: strings.TrimSpace(label), mbid: strings.TrimSpace(mbid)}
317
	}
318
	return artistEntry{label: strings.TrimSpace(raw)}
319
}
320
321
func isMBID(s string) bool {
322
	if len(s) != 36 {
323
		return false
324
	}
325
	for i, c := range s {
326
		switch i {
327
		case 8, 13, 18, 23:
328
			if c != '-' {
329
				return false
330
			}
331
		default:
332
			if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
333
				return false
334
			}
335
		}
336
	}
337
	return true
338
}
339
340
func dedupeByReleaseGroup(releases []release) []release {
341
	seen := make(map[string]int)
342
	var out []release
343
	for _, r := range releases {
344
		if r.releaseGroupID == "" {
345
			out = append(out, r)
346
			continue
347
		}
348
		if idx, ok := seen[r.releaseGroupID]; ok {
349
			if r.hasArtwork && !out[idx].hasArtwork {
350
				out[idx] = r
351
			}
352
			continue
353
		}
354
		seen[r.releaseGroupID] = len(out)
355
		out = append(out, r)
356
	}
357
	return out
358
}
359
360
func parseMBDate(s string) time.Time {
361
	formats := []string{"2006-01-02", "2006-01", "2006"}
362
	for _, f := range formats {
363
		if t, err := time.Parse(f, s); err == nil {
364
			return t
365
		}
366
	}
367
	return time.Time{}
368
}
369
370
func extractExtURLs(rels []mbRelation) (spotifyURL, youtubeURL string) {
371
	for _, rel := range rels {
372
		if rel.Direction != "forward" {
373
			continue
374
		}
375
		resource := rel.URL.Resource
376
		switch {
377
		case strings.Contains(resource, "open.spotify.com"):
378
			spotifyURL = resource
379
		case strings.Contains(resource, "youtube.com") || strings.Contains(resource, "youtu.be"):
380
			youtubeURL = resource
381
		}
382
	}
383
	return
384
}
385
386
func generateFeed(releases []release) *atom.Feed {
387
	feed := atom.NewFeed("New Music Releases", "musicfeed")
388
	for _, r := range releases {
389
		displayName := r.label
390
		if displayName == "" {
391
			displayName = r.artistName
392
		}
393
394
		links := []atom.Link{{
395
			Rel:  "alternate",
396
			Href: fmt.Sprintf("https://musicbrainz.org/release/%s", r.id),
397
		}}
398
399
		content, contentType := releaseContent(r, displayName)
400
401
		if r.hasArtwork {
402
			links = append(links, atom.Link{
403
				Rel:  "enclosure",
404
				Type: "image/jpeg",
405
				Href: fmt.Sprintf("%s/release/%s/front-250.jpg", caaBaseURL, r.id),
406
			})
407
		}
408
409
		if r.spotifyURL != "" {
410
			links = append(links, atom.Link{
411
				Rel:   "related",
412
				Href:  r.spotifyURL,
413
				Title: "Spotify",
414
			})
415
		}
416
		if r.youtubeURL != "" {
417
			links = append(links, atom.Link{
418
				Rel:   "related",
419
				Href:  r.youtubeURL,
420
				Title: "YouTube",
421
			})
422
		}
423
424
		releaseType := strings.TrimSpace(r.releaseType)
425
		title := fmt.Sprintf("%s — %s", displayName, r.title)
426
		if releaseType != "" {
427
			title += fmt.Sprintf(" (%s)", releaseType)
428
		}
429
430
		feed.Add(&atom.Entry{
431
			ID:      r.id,
432
			Title:   title,
433
			Content: atom.NewText(content, contentType),
434
			Updated: atom.Time(r.date),
435
			Link:    links,
436
		})
437
	}
438
	return feed
439
}
440
441
func releaseContent(r release, displayName string) (string, string) {
442
	if !r.hasArtwork {
443
		releaseType := strings.TrimSpace(r.releaseType)
444
		if releaseType != "" {
445
			return fmt.Sprintf("%s by %s (%s)", r.title, displayName, releaseType), ""
446
		}
447
		return fmt.Sprintf("%s by %s", r.title, displayName), ""
448
	}
449
450
	imageURL := fmt.Sprintf("%s/release/%s/front-250.jpg", caaBaseURL, r.id)
451
	parts := make([]string, 0, 6)
452
	parts = append(parts, "<body>")
453
454
	releaseType := strings.TrimSpace(r.releaseType)
455
	var text string
456
	if releaseType != "" {
457
		text = fmt.Sprintf("%s by %s (%s)", r.title, displayName, releaseType)
458
	} else {
459
		text = fmt.Sprintf("%s by %s", r.title, displayName)
460
	}
461
	parts = append(parts, "<p>"+html.EscapeString(text)+"</p>")
462
	parts = append(parts,
463
		fmt.Sprintf(`<p><img src="%s" alt="%s"/></p>`, html.EscapeString(imageURL), html.EscapeString(r.title)))
464
465
	if r.spotifyURL != "" {
466
		parts = append(parts, fmt.Sprintf(`<p><a href="%s">Spotify</a></p>`, html.EscapeString(r.spotifyURL)))
467
	}
468
	if r.youtubeURL != "" {
469
		parts = append(parts, fmt.Sprintf(`<p><a href="%s">YouTube</a></p>`, html.EscapeString(r.youtubeURL)))
470
	}
471
472
	parts = append(parts, "</body>")
473
474
	return strings.Join(parts, ""), "xhtml"
475
}