A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(deezer): backfill track/disc number on top match candidates

The /search endpoint omits track_position and disk_number, so matches
were always missing them. Deep-fetch the full track for the top 5
ranked matches to backfill trackNumber/discNumber in the response.

+89 -25
+67 -12
deezer/service/deezer/deezer.go
··· 282 282 } 283 283 284 284 // toMatch converts a scored Deezer track into the lightweight Match shape. 285 + // TrackNumber / DiscNumber are only present when the seed track has already 286 + // been deep-fetched (the /search endpoint omits them); hydrateMatches fills 287 + // them in afterwards. 285 288 func toMatch(c rankedCandidate) Match { 286 289 t := c.track 287 290 return Match{ 288 - ID: t.ID, 289 - Title: t.Title, 290 - Artist: t.Artist.Name, 291 - Album: t.Album.Title, 292 - AlbumArt: bestCover(t.Album), 293 - ISRC: t.ISRC, 294 - DurationMs: int64(t.Duration) * 1000, 295 - Link: t.Link, 296 - Preview: t.Preview, 297 - Rank: t.Rank, 298 - Explicit: t.ExplicitLyrics, 299 - Score: c.score, 291 + ID: t.ID, 292 + Title: t.Title, 293 + Artist: t.Artist.Name, 294 + Album: t.Album.Title, 295 + AlbumArt: bestCover(t.Album), 296 + ISRC: t.ISRC, 297 + DurationMs: int64(t.Duration) * 1000, 298 + TrackNumber: t.TrackPosition, 299 + DiscNumber: t.DiskNumber, 300 + Link: t.Link, 301 + Preview: t.Preview, 302 + Rank: t.Rank, 303 + Explicit: t.ExplicitLyrics, 304 + Score: c.score, 305 + } 306 + } 307 + 308 + // matchHydrateConcurrency bounds how many match deep-fetches run at once. The 309 + // Deezer limiter still governs the overall request rate; this just caps the 310 + // number of in-flight goroutines. 311 + const matchHydrateConcurrency = 5 312 + 313 + // maxHydratedMatches caps how many top-ranked matches we deep-fetch to backfill 314 + // track position / disc number. Each deep-fetch costs an extra API call, so we 315 + // only spend them on the most relevant candidates. 316 + const maxHydratedMatches = 5 317 + 318 + // hydrateMatches deep-fetches the full track for the top maxHydratedMatches 319 + // matches to fill in the track position and disc number, which the /search 320 + // endpoint does not return. GetTrack is cached, so repeated calls (e.g. the top 321 + // match that Enrich also hydrates) are cheap. Failures leave the match's 322 + // numbers at 0. 323 + func (s *DeezerService) hydrateMatches(ctx context.Context, matches []Match) { 324 + if len(matches) == 0 { 325 + return 326 + } 327 + 328 + limit := min(len(matches), maxHydratedMatches) 329 + sem := make(chan struct{}, matchHydrateConcurrency) 330 + var wg sync.WaitGroup 331 + for i := range matches[:limit] { 332 + if matches[i].ID == 0 || (matches[i].TrackNumber != 0 && matches[i].DiscNumber != 0) { 333 + continue 334 + } 335 + wg.Add(1) 336 + sem <- struct{}{} 337 + go func(i int) { 338 + defer wg.Done() 339 + defer func() { <-sem }() 340 + full, err := s.GetTrack(ctx, matches[i].ID) 341 + if err != nil || full == nil { 342 + if err != nil { 343 + s.logger.Printf("match deep-fetch failed for id=%d: %v", matches[i].ID, err) 344 + } 345 + return 346 + } 347 + matches[i].TrackNumber = full.TrackPosition 348 + matches[i].DiscNumber = full.DiskNumber 349 + }(i) 300 350 } 351 + wg.Wait() 301 352 } 302 353 303 354 // Enrich searches for the track, ranks candidates, deep-fetches the best one to ··· 318 369 } 319 370 matches = append(matches, toMatch(c)) 320 371 } 372 + 373 + // Search results omit track_position / disk_number, so deep-fetch each match 374 + // to backfill them before returning. 375 + s.hydrateMatches(ctx, matches) 321 376 322 377 resp := &EnrichResponse{Matches: matches} 323 378 if len(ranked) == 0 {
+6
deezer/service/deezer/deezer_test.go
··· 332 332 if resp.Matches[0].DurationMs != 369000 { 333 333 t.Errorf("match duration should be ms: %d", resp.Matches[0].DurationMs) 334 334 } 335 + // Matches are deep-fetched so track position / disc number are backfilled 336 + // from the full track (the /search endpoint omits them). 337 + if resp.Matches[0].TrackNumber != 8 || resp.Matches[0].DiscNumber != 1 { 338 + t.Errorf("match track/disc not backfilled: track=%d disc=%d", 339 + resp.Matches[0].TrackNumber, resp.Matches[0].DiscNumber) 340 + } 335 341 } 336 342 337 343 func TestEnrichNoResults(t *testing.T) {
+16 -13
deezer/service/deezer/types.go
··· 98 98 99 99 // Match is a lightweight, ranked candidate returned to callers alongside the 100 100 // enriched track. Duration is expressed in milliseconds to match the rest of 101 - // the Rocksky pipeline. 101 + // the Rocksky pipeline. TrackNumber and DiscNumber are only populated after a 102 + // deep-fetch of the full track (the /search endpoint omits them). 102 103 type Match struct { 103 - ID int64 `json:"id"` 104 - Title string `json:"title"` 105 - Artist string `json:"artist"` 106 - Album string `json:"album"` 107 - AlbumArt string `json:"albumArt,omitempty"` 108 - ISRC string `json:"isrc,omitempty"` 109 - DurationMs int64 `json:"durationMs"` 110 - Link string `json:"link,omitempty"` 111 - Preview string `json:"preview,omitempty"` 112 - Rank int `json:"rank,omitempty"` 113 - Explicit bool `json:"explicit,omitempty"` 114 - Score float64 `json:"score"` 104 + ID int64 `json:"id"` 105 + Title string `json:"title"` 106 + Artist string `json:"artist"` 107 + Album string `json:"album"` 108 + AlbumArt string `json:"albumArt,omitempty"` 109 + ISRC string `json:"isrc,omitempty"` 110 + DurationMs int64 `json:"durationMs"` 111 + TrackNumber int `json:"trackNumber,omitempty"` 112 + DiscNumber int `json:"discNumber,omitempty"` 113 + Link string `json:"link,omitempty"` 114 + Preview string `json:"preview,omitempty"` 115 + Rank int `json:"rank,omitempty"` 116 + Explicit bool `json:"explicit,omitempty"` 117 + Score float64 `json:"score"` 115 118 } 116 119 117 120 // EnrichedTrack is the fully hydrated, normalized track metadata returned to