5 files changed,
891 insertions(+),
6 deletions(-)
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 +}