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): add Deezer enrichment service + wire into match pipeline

Add a standalone Deezer Go microservice (deezer/) modeled on musicbrainz/
that takes title+artist+optional album and returns fully enriched track
metadata plus a ranked list of best matches. It owns rate limiting
(50 req / 5s) and an in-memory TTL cache, and deep-fetches the top candidate
to fill every available field (ISRC, UPC, label, genres, release date/year,
track/disc number, hi-res album art + artist picture).

When Spotify search fails or returns partial data we end up with incomplete
metadata; Deezer now fills the gaps (or builds the track from scratch):
- apps/api matchSong: ctx.deezer client + DEEZER_URL env; fallback after the
Spotify block. Adds an additive, non-breaking `matches` list to
songViewDetailed (pkl -> lexicon JSON -> generated TS). Score scaled 0-100.
- Rust scrobbler: DeezerClient + try_deezer_enrich fallback across scrobble,
scrobble_v1 and scrobble_listenbrainz.

Duration unit: Deezer returns seconds; the service x1000 to durationMs to
align with Spotify's native duration_ms (verified against the live API).

Tests mock the real Deezer API with httptest; a DEEZER_SMOKE-gated live smoke
test exercises the real endpoint (off in CI). Adds systemd unit, root npm
script, and a gated GitHub Actions job for the Go tests.

+2302
+22
.github/workflows/tests.yml
··· 23 23 ts_sdk: ${{ steps.filter.outputs.ts_sdk }} 24 24 go_sdk: ${{ steps.filter.outputs.go_sdk }} 25 25 rust_sdk: ${{ steps.filter.outputs.rust_sdk }} 26 + deezer: ${{ steps.filter.outputs.deezer }} 26 27 steps: 27 28 - uses: actions/checkout@v4 28 29 - uses: dorny/paths-filter@v3 ··· 40 41 - '.github/workflows/tests.yml' 41 42 rust_sdk: 42 43 - 'sdk/rust/**' 44 + - '.github/workflows/tests.yml' 45 + deezer: 46 + - 'deezer/**' 43 47 - '.github/workflows/tests.yml' 44 48 45 49 cli: ··· 106 110 workspaces: sdk/rust 107 111 - name: Run Rust SDK tests 108 112 run: cargo test 113 + 114 + deezer: 115 + needs: changes 116 + if: needs.changes.outputs.deezer == 'true' 117 + runs-on: ubuntu-latest 118 + defaults: 119 + run: 120 + working-directory: deezer 121 + steps: 122 + - uses: actions/checkout@v4 123 + - uses: actions/setup-go@v5 124 + with: 125 + go-version-file: deezer/go.mod 126 + cache-dependency-path: deezer/go.sum 127 + # The live Deezer smoke test is gated behind DEEZER_SMOKE and stays off in 128 + # CI so the suite never depends on the external API or its rate limits. 129 + - name: Run Deezer service tests 130 + run: go test ./...
+68
apps/api/lexicons/song/defs.json
··· 191 191 "type": "ref", 192 192 "description": "The first scrobble of this song on Rocksky.", 193 193 "ref": "app.rocksky.song.defs#firstScrobbleView" 194 + }, 195 + "matches": { 196 + "type": "array", 197 + "description": "Ranked list of candidate matches from external metadata providers (e.g. Deezer). Additive field returned by matchSong; may be empty.", 198 + "items": { 199 + "type": "ref", 200 + "ref": "app.rocksky.song.defs#songMatchView" 201 + } 202 + } 203 + } 204 + }, 205 + "songMatchView": { 206 + "type": "object", 207 + "description": "A ranked candidate match for a song from an external metadata provider.", 208 + "properties": { 209 + "id": { 210 + "type": "integer", 211 + "description": "The provider's numeric identifier for the matched track." 212 + }, 213 + "title": { 214 + "type": "string", 215 + "description": "The title of the matched track." 216 + }, 217 + "artist": { 218 + "type": "string", 219 + "description": "The artist of the matched track." 220 + }, 221 + "album": { 222 + "type": "string", 223 + "description": "The album of the matched track." 224 + }, 225 + "albumArt": { 226 + "type": "string", 227 + "description": "The URL of the matched track's album art image.", 228 + "format": "uri" 229 + }, 230 + "isrc": { 231 + "type": "string", 232 + "description": "The International Standard Recording Code (ISRC) of the matched track." 233 + }, 234 + "durationMs": { 235 + "type": "integer", 236 + "description": "The duration of the matched track in milliseconds.", 237 + "minimum": 0 238 + }, 239 + "link": { 240 + "type": "string", 241 + "description": "A URL to the matched track on the provider.", 242 + "format": "uri" 243 + }, 244 + "preview": { 245 + "type": "string", 246 + "description": "A URL to a short audio preview of the matched track.", 247 + "format": "uri" 248 + }, 249 + "rank": { 250 + "type": "integer", 251 + "description": "The provider's popularity rank for the matched track." 252 + }, 253 + "explicit": { 254 + "type": "boolean", 255 + "description": "Whether the matched track has explicit lyrics." 256 + }, 257 + "score": { 258 + "type": "integer", 259 + "description": "Match confidence score in the range 0-100 (higher is better).", 260 + "maximum": 100, 261 + "minimum": 0 194 262 } 195 263 } 196 264 },
+68
apps/api/pkl/defs/song/defs.pkl
··· 192 192 ref = "app.rocksky.song.defs#firstScrobbleView" 193 193 description = "The first scrobble of this song on Rocksky." 194 194 } 195 + ["matches"] = new Array { 196 + type = "array" 197 + description = "Ranked list of candidate matches from external metadata providers (e.g. Deezer). Additive field returned by matchSong; may be empty." 198 + items = new Ref { 199 + ref = "app.rocksky.song.defs#songMatchView" 200 + } 201 + } 202 + } 203 + } 204 + 205 + ["songMatchView"] { 206 + type = "object" 207 + description = "A ranked candidate match for a song from an external metadata provider." 208 + properties { 209 + ["id"] = new IntegerType { 210 + type = "integer" 211 + description = "The provider's numeric identifier for the matched track." 212 + } 213 + ["title"] = new StringType { 214 + type = "string" 215 + description = "The title of the matched track." 216 + } 217 + ["artist"] = new StringType { 218 + type = "string" 219 + description = "The artist of the matched track." 220 + } 221 + ["album"] = new StringType { 222 + type = "string" 223 + description = "The album of the matched track." 224 + } 225 + ["albumArt"] = new StringType { 226 + type = "string" 227 + format = "uri" 228 + description = "The URL of the matched track's album art image." 229 + } 230 + ["isrc"] = new StringType { 231 + type = "string" 232 + description = "The International Standard Recording Code (ISRC) of the matched track." 233 + } 234 + ["durationMs"] = new IntegerType { 235 + type = "integer" 236 + minimum = 0 237 + description = "The duration of the matched track in milliseconds." 238 + } 239 + ["link"] = new StringType { 240 + type = "string" 241 + format = "uri" 242 + description = "A URL to the matched track on the provider." 243 + } 244 + ["preview"] = new StringType { 245 + type = "string" 246 + format = "uri" 247 + description = "A URL to a short audio preview of the matched track." 248 + } 249 + ["rank"] = new IntegerType { 250 + type = "integer" 251 + description = "The provider's popularity rank for the matched track." 252 + } 253 + ["explicit"] = new BooleanType { 254 + type = "boolean" 255 + description = "Whether the matched track has explicit lyrics." 256 + } 257 + ["score"] = new IntegerType { 258 + type = "integer" 259 + minimum = 0 260 + maximum = 100 261 + description = "Match confidence score in the range 0-100 (higher is better)." 262 + } 195 263 } 196 264 } 197 265
+1
apps/api/src/context.ts
··· 34 34 googledrive: axios.create({ baseURL: env.GOOGLE_DRIVE }), 35 35 tracklist: axios.create({ baseURL: env.TRACKLIST }), 36 36 musicbrainz: axios.create({ baseURL: env.MUSICBRAINZ_URL }), 37 + deezer: axios.create({ baseURL: env.DEEZER_URL }), 37 38 redis: await redis 38 39 .createClient({ url: env.REDIS_URL }) 39 40 .on("error", (err) => {
+74
apps/api/src/lexicon/lexicons.ts
··· 7544 7544 description: "The first scrobble of this song on Rocksky.", 7545 7545 ref: "lex:app.rocksky.song.defs#firstScrobbleView", 7546 7546 }, 7547 + matches: { 7548 + type: "array", 7549 + description: 7550 + "Ranked list of candidate matches from external metadata providers (e.g. Deezer). Additive field returned by matchSong; may be empty.", 7551 + items: { 7552 + type: "ref", 7553 + ref: "lex:app.rocksky.song.defs#songMatchView", 7554 + }, 7555 + }, 7556 + }, 7557 + }, 7558 + songMatchView: { 7559 + type: "object", 7560 + description: 7561 + "A ranked candidate match for a song from an external metadata provider.", 7562 + properties: { 7563 + id: { 7564 + type: "integer", 7565 + description: 7566 + "The provider's numeric identifier for the matched track.", 7567 + }, 7568 + title: { 7569 + type: "string", 7570 + description: "The title of the matched track.", 7571 + }, 7572 + artist: { 7573 + type: "string", 7574 + description: "The artist of the matched track.", 7575 + }, 7576 + album: { 7577 + type: "string", 7578 + description: "The album of the matched track.", 7579 + }, 7580 + albumArt: { 7581 + type: "string", 7582 + description: "The URL of the matched track's album art image.", 7583 + format: "uri", 7584 + }, 7585 + isrc: { 7586 + type: "string", 7587 + description: 7588 + "The International Standard Recording Code (ISRC) of the matched track.", 7589 + }, 7590 + durationMs: { 7591 + type: "integer", 7592 + description: "The duration of the matched track in milliseconds.", 7593 + minimum: 0, 7594 + }, 7595 + link: { 7596 + type: "string", 7597 + description: "A URL to the matched track on the provider.", 7598 + format: "uri", 7599 + }, 7600 + preview: { 7601 + type: "string", 7602 + description: "A URL to a short audio preview of the matched track.", 7603 + format: "uri", 7604 + }, 7605 + rank: { 7606 + type: "integer", 7607 + description: 7608 + "The provider's popularity rank for the matched track.", 7609 + }, 7610 + explicit: { 7611 + type: "boolean", 7612 + description: "Whether the matched track has explicit lyrics.", 7613 + }, 7614 + score: { 7615 + type: "integer", 7616 + description: 7617 + "Match confidence score in the range 0-100 (higher is better).", 7618 + maximum: 100, 7619 + minimum: 0, 7620 + }, 7547 7621 }, 7548 7622 }, 7549 7623 recentListenerView: {
+43
apps/api/src/lexicon/types/app/rocksky/song/defs.ts
··· 100 100 createdAt?: string; 101 101 artists?: AppRockskyArtistDefs.ArtistViewBasic[]; 102 102 firstScrobble?: FirstScrobbleView; 103 + /** Ranked list of candidate matches from external metadata providers (e.g. Deezer). Additive field returned by matchSong; may be empty. */ 104 + matches?: SongMatchView[]; 103 105 [k: string]: unknown; 104 106 } 105 107 ··· 113 115 114 116 export function validateSongViewDetailed(v: unknown): ValidationResult { 115 117 return lexicons.validate("app.rocksky.song.defs#songViewDetailed", v); 118 + } 119 + 120 + /** A ranked candidate match for a song from an external metadata provider. */ 121 + export interface SongMatchView { 122 + /** The provider's numeric identifier for the matched track. */ 123 + id?: number; 124 + /** The title of the matched track. */ 125 + title?: string; 126 + /** The artist of the matched track. */ 127 + artist?: string; 128 + /** The album of the matched track. */ 129 + album?: string; 130 + /** The URL of the matched track's album art image. */ 131 + albumArt?: string; 132 + /** The International Standard Recording Code (ISRC) of the matched track. */ 133 + isrc?: string; 134 + /** The duration of the matched track in milliseconds. */ 135 + durationMs?: number; 136 + /** A URL to the matched track on the provider. */ 137 + link?: string; 138 + /** A URL to a short audio preview of the matched track. */ 139 + preview?: string; 140 + /** The provider's popularity rank for the matched track. */ 141 + rank?: number; 142 + /** Whether the matched track has explicit lyrics. */ 143 + explicit?: boolean; 144 + /** Match confidence score in the range 0-100 (higher is better). */ 145 + score?: number; 146 + [k: string]: unknown; 147 + } 148 + 149 + export function isSongMatchView(v: unknown): v is SongMatchView { 150 + return ( 151 + isObj(v) && 152 + hasProp(v, "$type") && 153 + v.$type === "app.rocksky.song.defs#songMatchView" 154 + ); 155 + } 156 + 157 + export function validateSongMatchView(v: unknown): ValidationResult { 158 + return lexicons.validate("app.rocksky.song.defs#songMatchView", v); 116 159 } 117 160 118 161 export interface RecentListenerView {
+1
apps/api/src/lib/env.ts
··· 36 36 TRACKLIST: str({ default: "http://localhost:7884" }), 37 37 REDIS_URL: str({ default: "redis://localhost:6379" }), 38 38 MUSICBRAINZ_URL: str({ default: "http://localhost:8088" }), 39 + DEEZER_URL: str({ default: "http://localhost:8089" }), 39 40 PRIVATE_KEY_1: str({}), 40 41 PRIVATE_KEY_2: str({}), 41 42 PRIVATE_KEY_3: str({}),
+104
apps/api/src/xrpc/app/rocksky/song/matchSong.ts
··· 12 12 import type { 13 13 Album, 14 14 Artist, 15 + DeezerEnrichResponse, 16 + DeezerMatch, 15 17 MusicBrainzArtist, 16 18 SearchResponse, 17 19 Track, ··· 128 130 artistPicture = null, 129 131 genres = null, 130 132 mbArtists: MusicBrainzArtist[] | null = null; 133 + let deezerMatches: DeezerMatch[] = []; 131 134 132 135 // Skip Spotify if record is found and album art is already present 133 136 const needsSpotify = !record || !track?.albumArt; ··· 247 250 } 248 251 } 249 252 253 + // Deezer enrichment fallback: when Spotify search fails (or returns 254 + // partial data) we end up with incomplete metadata. Query Deezer to fill 255 + // every missing field it can provide, and always surface a ranked list of 256 + // candidate matches. Deezer can also build the track from scratch when no 257 + // other source matched. 258 + const deezerData = await searchOnDeezer( 259 + ctx, 260 + params.title, 261 + params.artist, 262 + track?.album, 263 + ); 264 + 265 + if (deezerData) { 266 + deezerMatches = deezerData.matches ?? []; 267 + const d = deezerData.track; 268 + 269 + if (d) { 270 + if (!track) { 271 + track = { 272 + id: "", 273 + title: d.title, 274 + artist: d.artist, 275 + albumArtist: d.albumArtist ?? d.artist, 276 + albumArt: d.albumArt ?? null, 277 + album: d.album, 278 + trackNumber: d.trackNumber ?? null, 279 + duration: d.durationMs ?? 0, 280 + mbId: null, 281 + isrc: d.isrc ?? null, 282 + genre: d.genres?.length ? d.genres.join(", ") : null, 283 + youtubeLink: null, 284 + spotifyLink: null, 285 + appleMusicLink: null, 286 + tidalLink: null, 287 + sha256: null, 288 + discNumber: d.discNumber ?? null, 289 + lyrics: null, 290 + composer: null, 291 + label: d.label ?? null, 292 + copyrightMessage: null, 293 + uri: null, 294 + albumUri: null, 295 + artistUri: null, 296 + createdAt: new Date(), 297 + updatedAt: new Date(), 298 + xataVersion: 0, 299 + }; 300 + } else { 301 + if (!track.albumArt && d.albumArt) track.albumArt = d.albumArt; 302 + if (!track.isrc && d.isrc) track.isrc = d.isrc; 303 + if (!track.duration && d.durationMs) track.duration = d.durationMs; 304 + if (!track.trackNumber && d.trackNumber) 305 + track.trackNumber = d.trackNumber; 306 + if (!track.discNumber && d.discNumber) 307 + track.discNumber = d.discNumber; 308 + if (!track.label && d.label) track.label = d.label; 309 + if (!track.genre && d.genres?.length) 310 + track.genre = d.genres.join(", "); 311 + } 312 + 313 + if (!releaseDate && d.releaseDate) releaseDate = d.releaseDate; 314 + if (!year && d.year) year = d.year; 315 + if (!artistPicture && d.artistPicture) 316 + artistPicture = d.artistPicture; 317 + if ((!genres || genres.length === 0) && d.genres?.length) 318 + genres = d.genres; 319 + } 320 + } 321 + 250 322 if (track && !track.mbId) { 251 323 try { 252 324 const mbTrack = await searchOnMusicBrainz(ctx, track, params.mbId); ··· 278 350 Promise.resolve(artistPicture), 279 351 Promise.resolve(genres), 280 352 Promise.resolve(mbArtists), 353 + Promise.resolve(deezerMatches), 281 354 ]); 282 355 }, 283 356 catch: (error) => new Error(`Failed to retrieve artist: ${error}`), ··· 293 366 artistPicture, 294 367 genres, 295 368 mbArtists, 369 + matches, 296 370 ]: [ 297 371 SelectTrack, 298 372 number, ··· 302 376 string | null, 303 377 string[] | null, 304 378 MusicBrainzArtist[] | null, 379 + DeezerMatch[], 305 380 ]): Effect.Effect<SongViewDetailed, never> => { 306 381 return Effect.sync(() => ({ 307 382 ...track, ··· 310 385 artistPicture, 311 386 genres, 312 387 mbArtists, 388 + // Ranked list of candidate matches from Deezer. Additive field — existing 389 + // consumers of the flattened track view are unaffected. The provider score 390 + // (0-1) is scaled to an integer 0-100 for the API (lexicons have no float). 391 + matches: matches.map((m) => ({ 392 + ...m, 393 + score: Math.round(m.score * 100), 394 + })), 313 395 playCount, 314 396 uniqueListeners, 315 397 createdAt: track.createdAt.toISOString(), ··· 546 628 } 547 629 548 630 return track; 631 + }; 632 + 633 + // searchOnDeezer asks the Deezer enrichment service for the best canonical 634 + // track metadata plus a ranked list of candidate matches. It is the fallback 635 + // metadata provider used when Spotify search fails or returns partial data. 636 + const searchOnDeezer = async ( 637 + ctx: Context, 638 + title: string, 639 + artist: string, 640 + album?: string, 641 + ): Promise<DeezerEnrichResponse | undefined> => { 642 + try { 643 + const { data } = await ctx.deezer.post<DeezerEnrichResponse>("/enrich", { 644 + title, 645 + artist, 646 + album, 647 + }); 648 + return data; 649 + } catch (error) { 650 + consola.error("Error fetching Deezer enrichment:", error); 651 + return undefined; 652 + } 549 653 }; 550 654 551 655 const searchOnMusicBrainz = async (
+46
apps/api/src/xrpc/app/rocksky/song/types.ts
··· 93 93 mbid: string; 94 94 name: string; 95 95 } 96 + 97 + // Mirrors the response of the Deezer enrichment service (deezer/ Go service, 98 + // POST /enrich). Durations are already normalized to milliseconds. 99 + export interface DeezerEnrichedTrack { 100 + title: string; 101 + artist: string; 102 + albumArtist?: string; 103 + album: string; 104 + albumArt?: string; 105 + isrc?: string; 106 + upc?: string; 107 + durationMs: number; 108 + trackNumber?: number; 109 + discNumber?: number; 110 + releaseDate?: string; 111 + year?: number; 112 + label?: string; 113 + genres?: string[]; 114 + artistPicture?: string; 115 + deezerLink?: string; 116 + preview?: string; 117 + explicit?: boolean; 118 + deezerTrackId?: number; 119 + deezerAlbumId?: number; 120 + deezerArtistId?: number; 121 + } 122 + 123 + export interface DeezerMatch { 124 + id: number; 125 + title: string; 126 + artist: string; 127 + album: string; 128 + albumArt?: string; 129 + isrc?: string; 130 + durationMs: number; 131 + link?: string; 132 + preview?: string; 133 + rank?: number; 134 + explicit?: boolean; 135 + score: number; 136 + } 137 + 138 + export interface DeezerEnrichResponse { 139 + track: DeezerEnrichedTrack | null; 140 + matches: DeezerMatch[]; 141 + }
+141
crates/scrobbler/src/deezer/client.rs
··· 1 + use std::{env, time::Duration}; 2 + 3 + use anyhow::{Context, Error}; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + /// Default location of the Rocksky Deezer enrichment service (the `deezer/` Go 7 + /// microservice). Overridable via the `DEEZER_URL` environment variable. 8 + pub const DEFAULT_BASE_URL: &str = "http://localhost:8089"; 9 + 10 + const REQUEST_TIMEOUT_SECS: u64 = 10; 11 + 12 + /// Thin HTTP client for the Rocksky Deezer enrichment service. The service 13 + /// itself owns rate limiting (50 req / 5 s) and TTL caching, so this client is 14 + /// intentionally stateless and cheap to construct. 15 + #[derive(Clone)] 16 + pub struct DeezerClient { 17 + http: reqwest::Client, 18 + base_url: String, 19 + } 20 + 21 + #[derive(Debug, Serialize)] 22 + struct EnrichRequest<'a> { 23 + title: &'a str, 24 + artist: &'a str, 25 + #[serde(skip_serializing_if = "Option::is_none")] 26 + album: Option<&'a str>, 27 + } 28 + 29 + /// Response of `POST /enrich`: the best enriched track (absent when nothing 30 + /// matched) plus a ranked list of candidate matches. 31 + #[derive(Debug, Deserialize, Default)] 32 + pub struct EnrichResponse { 33 + pub track: Option<EnrichedTrack>, 34 + #[serde(default)] 35 + pub matches: Vec<Match>, 36 + } 37 + 38 + /// Fully hydrated, normalized track metadata. Durations are milliseconds. 39 + #[derive(Debug, Deserialize, Clone, Default)] 40 + #[serde(rename_all = "camelCase")] 41 + pub struct EnrichedTrack { 42 + pub title: String, 43 + pub artist: String, 44 + pub album_artist: Option<String>, 45 + pub album: String, 46 + pub album_art: Option<String>, 47 + pub isrc: Option<String>, 48 + pub upc: Option<String>, 49 + #[serde(default)] 50 + pub duration_ms: u64, 51 + pub track_number: Option<u32>, 52 + pub disc_number: Option<u32>, 53 + pub release_date: Option<String>, 54 + pub year: Option<u32>, 55 + pub label: Option<String>, 56 + pub genres: Option<Vec<String>>, 57 + pub artist_picture: Option<String>, 58 + pub deezer_link: Option<String>, 59 + pub preview: Option<String>, 60 + #[serde(default)] 61 + pub explicit: bool, 62 + #[serde(default)] 63 + pub deezer_track_id: i64, 64 + #[serde(default)] 65 + pub deezer_album_id: i64, 66 + #[serde(default)] 67 + pub deezer_artist_id: i64, 68 + } 69 + 70 + /// A ranked candidate match. Duration is milliseconds; score is in [0,1]. 71 + #[derive(Debug, Deserialize, Clone, Default)] 72 + #[serde(rename_all = "camelCase")] 73 + pub struct Match { 74 + pub id: i64, 75 + pub title: String, 76 + pub artist: String, 77 + pub album: String, 78 + pub album_art: Option<String>, 79 + pub isrc: Option<String>, 80 + #[serde(default)] 81 + pub duration_ms: u64, 82 + pub link: Option<String>, 83 + pub preview: Option<String>, 84 + #[serde(default)] 85 + pub rank: i64, 86 + #[serde(default)] 87 + pub explicit: bool, 88 + #[serde(default)] 89 + pub score: f64, 90 + } 91 + 92 + impl DeezerClient { 93 + /// Builds a client pointed at `DEEZER_URL` (or the default localhost port). 94 + pub fn from_env() -> Result<Self, Error> { 95 + let base_url = env::var("DEEZER_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()); 96 + Self::new(base_url) 97 + } 98 + 99 + pub fn new(base_url: impl Into<String>) -> Result<Self, Error> { 100 + let http = reqwest::Client::builder() 101 + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) 102 + .build() 103 + .context("build deezer http client")?; 104 + Ok(DeezerClient { 105 + http, 106 + base_url: base_url.into().trim_end_matches('/').to_string(), 107 + }) 108 + } 109 + 110 + /// Asks the enrichment service for the best canonical track metadata plus a 111 + /// ranked list of candidate matches for the given title/artist/album. 112 + pub async fn enrich( 113 + &self, 114 + title: &str, 115 + artist: &str, 116 + album: Option<&str>, 117 + ) -> Result<EnrichResponse, Error> { 118 + let url = format!("{}/enrich", self.base_url); 119 + let album = album.map(str::trim).filter(|a| !a.is_empty()); 120 + 121 + let resp = self 122 + .http 123 + .post(&url) 124 + .json(&EnrichRequest { 125 + title, 126 + artist, 127 + album, 128 + }) 129 + .send() 130 + .await 131 + .context("send deezer enrich request")? 132 + .error_for_status() 133 + .context("deezer enrich returned error status")?; 134 + 135 + let body = resp 136 + .json::<EnrichResponse>() 137 + .await 138 + .context("decode deezer enrich response")?; 139 + Ok(body) 140 + } 141 + }
+102
crates/scrobbler/src/deezer/mod.rs
··· 1 + pub mod client; 2 + 3 + use crate::types::Track; 4 + use client::EnrichedTrack; 5 + 6 + impl From<EnrichedTrack> for Track { 7 + fn from(t: EnrichedTrack) -> Self { 8 + // Derive a year from the release date when Deezer didn't provide one. 9 + let year = t.year.or_else(|| { 10 + t.release_date 11 + .as_deref() 12 + .and_then(|d| d.split('-').next()) 13 + .and_then(|y| y.parse::<u32>().ok()) 14 + }); 15 + 16 + Track { 17 + title: t.title, 18 + album: t.album, 19 + artist: t.artist.clone(), 20 + album_artist: t.album_artist.or(Some(t.artist)), 21 + duration: t.duration_ms as u32, 22 + mbid: None, 23 + isrc: t.isrc.filter(|s| !s.is_empty()), 24 + track_number: t.track_number.unwrap_or(0), 25 + release_date: t.release_date, 26 + year, 27 + disc_number: t.disc_number.unwrap_or(0), 28 + album_art: t.album_art, 29 + spotify_link: None, 30 + label: t.label, 31 + artist_picture: t.artist_picture, 32 + timestamp: None, 33 + genres: t.genres.filter(|g| !g.is_empty()), 34 + } 35 + } 36 + } 37 + 38 + impl From<&EnrichedTrack> for Track { 39 + fn from(t: &EnrichedTrack) -> Self { 40 + Track::from(t.clone()) 41 + } 42 + } 43 + 44 + #[cfg(test)] 45 + mod tests { 46 + use super::*; 47 + 48 + #[test] 49 + fn enriched_track_maps_to_track() { 50 + let enriched = EnrichedTrack { 51 + title: "Get Lucky".into(), 52 + artist: "Daft Punk".into(), 53 + album: "Random Access Memories".into(), 54 + album_art: Some("https://cdn/art.jpg".into()), 55 + isrc: Some("GBDUW1300109".into()), 56 + duration_ms: 369_000, 57 + track_number: Some(8), 58 + disc_number: Some(1), 59 + release_date: Some("2013-05-17".into()), 60 + label: Some("Columbia".into()), 61 + genres: Some(vec!["Dance".into(), "Pop".into()]), 62 + artist_picture: Some("https://cdn/artist.jpg".into()), 63 + ..Default::default() 64 + }; 65 + 66 + let track: Track = enriched.into(); 67 + 68 + assert_eq!(track.title, "Get Lucky"); 69 + assert_eq!(track.album_artist.as_deref(), Some("Daft Punk")); 70 + assert_eq!(track.duration, 369_000); 71 + assert_eq!(track.isrc.as_deref(), Some("GBDUW1300109")); 72 + assert_eq!(track.track_number, 8); 73 + assert_eq!(track.disc_number, 1); 74 + assert_eq!(track.release_date.as_deref(), Some("2013-05-17")); 75 + // Year derived from the release date when absent. 76 + assert_eq!(track.year, Some(2013)); 77 + assert_eq!(track.label.as_deref(), Some("Columbia")); 78 + assert_eq!( 79 + track.genres.as_deref(), 80 + Some(&["Dance".to_string(), "Pop".to_string()][..]) 81 + ); 82 + // Deezer never populates the Spotify link / MBID. 83 + assert!(track.spotify_link.is_none()); 84 + assert!(track.mbid.is_none()); 85 + } 86 + 87 + #[test] 88 + fn empty_isrc_and_genres_become_none() { 89 + let enriched = EnrichedTrack { 90 + title: "x".into(), 91 + artist: "y".into(), 92 + album: "z".into(), 93 + isrc: Some(String::new()), 94 + genres: Some(vec![]), 95 + ..Default::default() 96 + }; 97 + let track: Track = enriched.into(); 98 + assert!(track.isrc.is_none()); 99 + assert!(track.genres.is_none()); 100 + assert_eq!(track.year, None); 101 + } 102 + }
+1
crates/scrobbler/src/lib.rs
··· 1 1 pub mod auth; 2 2 pub mod cache; 3 3 pub mod crypto; 4 + pub mod deezer; 4 5 pub mod events; 5 6 pub mod handlers; 6 7 pub mod listenbrainz;
+64
crates/scrobbler/src/scrobbler.rs
··· 8 8 auth::{decode_token, extract_did}, 9 9 cache::Cache, 10 10 crypto::decrypt_aes_256_ctr, 11 + deezer::client::DeezerClient, 11 12 listenbrainz::types::SubmitListensRequest, 12 13 musicbrainz::{ 13 14 client::MusicbrainzClient, get_best_release_from_recordings, recording::Recording, ··· 97 98 Ok(result) 98 99 } 99 100 101 + /// Fallback enrichment via the Deezer service. Used when Spotify search fails 102 + /// to resolve the track, so we don't end up scrobbling with incomplete 103 + /// metadata. Returns `true` when Deezer produced a track and the scrobble was 104 + /// submitted; `false` (never an error) when Deezer had no match or was 105 + /// unreachable, so the caller can continue to the next fallback. 106 + async fn try_deezer_enrich( 107 + deezer_client: &DeezerClient, 108 + cache: &Cache, 109 + did: &str, 110 + scrobble: &mut Scrobble, 111 + ) -> Result<bool, Error> { 112 + let resp = match deezer_client 113 + .enrich(&scrobble.track, &scrobble.artist, scrobble.album.as_deref()) 114 + .await 115 + { 116 + Ok(resp) => resp, 117 + Err(e) => { 118 + tracing::warn!( 119 + artist = %scrobble.artist, 120 + track = %scrobble.track, 121 + error = %e, 122 + "Deezer enrichment failed, continuing" 123 + ); 124 + return Ok(false); 125 + } 126 + }; 127 + 128 + if let Some(enriched) = resp.track { 129 + tracing::info!(artist = %scrobble.artist, track = %scrobble.track, "Deezer (track)"); 130 + let track: Track = enriched.into(); 131 + scrobble.album = Some(track.album.clone()); 132 + rocksky::scrobble(cache, did, track, scrobble.timestamp).await?; 133 + tokio::time::sleep(std::time::Duration::from_secs(1)).await; 134 + return Ok(true); 135 + } 136 + 137 + Ok(false) 138 + } 139 + 100 140 pub async fn scrobble( 101 141 pool: &Pool<Postgres>, 102 142 cache: &Cache, ··· 116 156 if spofity_tokens.is_empty() { 117 157 return Err(Error::msg("No Spotify tokens found")); 118 158 } 159 + 160 + let deezer_client = DeezerClient::from_env()?; 119 161 120 162 for scrobble in &mut scrobbles { 121 163 /* ··· 269 311 270 312 rocksky::scrobble(cache, &did, track.into(), scrobble.timestamp).await?; 271 313 tokio::time::sleep(std::time::Duration::from_secs(1)).await; 314 + continue; 315 + } 316 + 317 + // Spotify search failed to resolve the track — fall back to Deezer to 318 + // fill in the missing metadata before trying MusicBrainz. 319 + if try_deezer_enrich(&deezer_client, cache, &did, scrobble).await? { 272 320 continue; 273 321 } 274 322 ··· 336 384 337 385 let did = user.did.clone(); 338 386 387 + let deezer_client = DeezerClient::from_env()?; 388 + 339 389 /* 340 390 0. check if scrobble is cached 341 391 1. if mbid is present, check if it exists in the database ··· 516 566 tokio::time::sleep(std::time::Duration::from_secs(1)).await; 517 567 return Ok(()); 518 568 } 569 + } 570 + 571 + // Spotify search failed to resolve the track — fall back to Deezer to fill 572 + // in the missing metadata before trying MusicBrainz. 573 + if try_deezer_enrich(&deezer_client, cache, &did, &mut scrobble).await? { 574 + return Ok(()); 519 575 } 520 576 521 577 let query = format!( ··· 643 699 ignored: None, 644 700 }; 645 701 702 + let deezer_client = DeezerClient::from_env()?; 703 + 646 704 /* 647 705 0. check if scrobble is cached 648 706 1. if mbid is present, check if it exists in the database ··· 822 880 tokio::time::sleep(std::time::Duration::from_secs(1)).await; 823 881 return Ok(()); 824 882 } 883 + } 884 + 885 + // Spotify search failed to resolve the track — fall back to Deezer to fill 886 + // in the missing metadata before trying MusicBrainz. 887 + if try_deezer_enrich(&deezer_client, cache, &did, &mut scrobble).await? { 888 + return Ok(()); 825 889 } 826 890 827 891 let query = format!(
+5
deezer/.gitignore
··· 1 + # Compiled binary produced by `go build` (named after the module directory). 2 + # Anchored to this directory so it does not also match the service/deezer/ 3 + # source package. 4 + /deezer 5 + *.log
+20
deezer/go.mod
··· 1 + module github.com/tsirysndr/rocksky/deezer 2 + 3 + go 1.25.1 4 + 5 + require ( 6 + github.com/labstack/echo/v4 v4.13.3 7 + golang.org/x/text v0.21.0 8 + golang.org/x/time v0.11.0 9 + ) 10 + 11 + require ( 12 + github.com/labstack/gommon v0.4.2 // indirect 13 + github.com/mattn/go-colorable v0.1.13 // indirect 14 + github.com/mattn/go-isatty v0.0.20 // indirect 15 + github.com/valyala/bytebufferpool v1.0.0 // indirect 16 + github.com/valyala/fasttemplate v1.2.2 // indirect 17 + golang.org/x/crypto v0.31.0 // indirect 18 + golang.org/x/net v0.33.0 // indirect 19 + golang.org/x/sys v0.28.0 // indirect 20 + )
+33
deezer/go.sum
··· 1 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 + github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 4 + github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 5 + github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 6 + github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 7 + github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 8 + github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 9 + github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 10 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 11 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 12 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 15 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 16 + github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 17 + github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 18 + github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 19 + github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 20 + golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 21 + golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 22 + golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 23 + golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 24 + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 25 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 26 + golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 27 + golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 28 + golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 29 + golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 30 + golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 31 + golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 32 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 33 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+91
deezer/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + "os/signal" 9 + "strconv" 10 + "time" 11 + 12 + "github.com/labstack/echo/v4" 13 + "github.com/labstack/echo/v4/middleware" 14 + "github.com/tsirysndr/rocksky/deezer/service/deezer" 15 + ) 16 + 17 + type Server struct { 18 + deezer *deezer.DeezerService 19 + } 20 + 21 + func main() { 22 + srv := &Server{ 23 + deezer: deezer.NewDeezerService(), 24 + } 25 + 26 + e := echo.New() 27 + e.HideBanner = true 28 + e.Use(middleware.Recover()) 29 + 30 + e.GET("/health", func(c echo.Context) error { 31 + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) 32 + }) 33 + e.POST("/search", srv.searchHandler) 34 + e.POST("/enrich", srv.enrichHandler) 35 + e.GET("/track/:id", srv.trackHandler) 36 + 37 + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 38 + defer stop() 39 + 40 + go func() { 41 + port := os.Getenv("PORT") 42 + if port == "" { 43 + port = "8089" 44 + } 45 + if err := e.Start(fmt.Sprintf(":%s", port)); err != nil && err != http.ErrServerClosed { 46 + e.Logger.Fatal(err) 47 + } 48 + }() 49 + 50 + <-ctx.Done() 51 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 52 + defer cancel() 53 + _ = e.Shutdown(shutdownCtx) 54 + } 55 + 56 + // searchHandler returns the enriched best track plus the ranked match list. 57 + func (s *Server) searchHandler(c echo.Context) error { 58 + return s.enrichHandler(c) 59 + } 60 + 61 + // enrichHandler takes { title, artist, album? } and returns the enriched track 62 + // with all metadata Deezer can provide, plus a list of best matches. 63 + func (s *Server) enrichHandler(c echo.Context) error { 64 + var req deezer.SearchParams 65 + if err := c.Bind(&req); err != nil { 66 + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) 67 + } 68 + if req.Title == "" && req.Artist == "" { 69 + return c.JSON(http.StatusBadRequest, map[string]string{"error": "title or artist is required"}) 70 + } 71 + 72 + resp, err := s.deezer.Enrich(c.Request().Context(), req) 73 + if err != nil { 74 + return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()}) 75 + } 76 + return c.JSON(http.StatusOK, resp) 77 + } 78 + 79 + // trackHandler returns a single fully-hydrated track by Deezer ID. 80 + func (s *Server) trackHandler(c echo.Context) error { 81 + id, err := strconv.ParseInt(c.Param("id"), 10, 64) 82 + if err != nil { 83 + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid track id"}) 84 + } 85 + 86 + track, err := s.deezer.GetTrack(c.Request().Context(), id) 87 + if err != nil { 88 + return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()}) 89 + } 90 + return c.JSON(http.StatusOK, track) 91 + }
+494
deezer/service/deezer/deezer.go
··· 1 + package deezer 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "log" 9 + "net/http" 10 + "net/url" 11 + "os" 12 + "sort" 13 + "strconv" 14 + "strings" 15 + "sync" 16 + "time" 17 + 18 + "golang.org/x/time/rate" 19 + ) 20 + 21 + const ( 22 + // defaultBaseURL is the public Deezer API root. 23 + defaultBaseURL = "https://api.deezer.com" 24 + 25 + // Deezer rate limit: 50 requests per 5 seconds. We model this as a steady 26 + // 10 req/s with a burst of 50 so short spikes are allowed while the 27 + // sustained rate stays within quota. 28 + rateLimitWindow = 5 * time.Second 29 + rateLimitRequests = 50 30 + 31 + // defaultCacheTTL is how long search / lookup responses stay cached. 32 + defaultCacheTTL = 1 * time.Hour 33 + 34 + // maxMatches caps how many ranked candidates we return to callers. 35 + maxMatches = 10 36 + 37 + // enrichCandidates is how many top-scoring candidates we deep-fetch to 38 + // enrich (each deep fetch costs extra API calls, so keep it small). 39 + enrichScoreFloor = 0.5 40 + ) 41 + 42 + // cacheEntry holds an arbitrary decoded payload with its expiration. 43 + type cacheEntry struct { 44 + value any 45 + expiresAt time.Time 46 + } 47 + 48 + // DeezerService talks to the Deezer API with rate limiting and an in-memory 49 + // TTL cache. It is safe for concurrent use. 50 + type DeezerService struct { 51 + baseURL string 52 + httpClient *http.Client 53 + limiter *rate.Limiter 54 + cache map[string]cacheEntry 55 + cacheMutex sync.RWMutex 56 + cacheTTL time.Duration 57 + logger *log.Logger 58 + } 59 + 60 + // Option customizes a DeezerService. 61 + type Option func(*DeezerService) 62 + 63 + // WithBaseURL overrides the Deezer API root (used in tests to point at a mock). 64 + func WithBaseURL(baseURL string) Option { 65 + return func(s *DeezerService) { s.baseURL = strings.TrimRight(baseURL, "/") } 66 + } 67 + 68 + // WithCacheTTL overrides the cache time-to-live. 69 + func WithCacheTTL(ttl time.Duration) Option { 70 + return func(s *DeezerService) { s.cacheTTL = ttl } 71 + } 72 + 73 + // WithHTTPClient overrides the HTTP client. 74 + func WithHTTPClient(c *http.Client) Option { 75 + return func(s *DeezerService) { s.httpClient = c } 76 + } 77 + 78 + // WithLimiter overrides the rate limiter (used in tests to disable throttling). 79 + func WithLimiter(l *rate.Limiter) Option { 80 + return func(s *DeezerService) { s.limiter = l } 81 + } 82 + 83 + // NewDeezerService creates a new service with rate limiting and caching. 84 + func NewDeezerService(opts ...Option) *DeezerService { 85 + s := &DeezerService{ 86 + baseURL: defaultBaseURL, 87 + httpClient: &http.Client{ 88 + Timeout: 10 * time.Second, 89 + }, 90 + limiter: rate.NewLimiter(rate.Every(rateLimitWindow/rateLimitRequests), rateLimitRequests), 91 + cache: make(map[string]cacheEntry), 92 + cacheTTL: defaultCacheTTL, 93 + logger: log.New(os.Stdout, "deezer: ", log.LstdFlags|log.Lmsgprefix), 94 + } 95 + for _, opt := range opts { 96 + opt(s) 97 + } 98 + return s 99 + } 100 + 101 + // cacheGet returns a cached, non-expired value. 102 + func (s *DeezerService) cacheGet(key string) (any, bool) { 103 + s.cacheMutex.RLock() 104 + entry, found := s.cache[key] 105 + s.cacheMutex.RUnlock() 106 + if found && time.Now().UTC().Before(entry.expiresAt) { 107 + return entry.value, true 108 + } 109 + return nil, false 110 + } 111 + 112 + // cacheSet stores a value with the configured TTL. 113 + func (s *DeezerService) cacheSet(key string, value any) { 114 + s.cacheMutex.Lock() 115 + s.cache[key] = cacheEntry{value: value, expiresAt: time.Now().UTC().Add(s.cacheTTL)} 116 + s.cacheMutex.Unlock() 117 + } 118 + 119 + // get performs a rate-limited, cached GET against the Deezer API and decodes 120 + // the JSON body into out. Deezer signals errors with an "error" envelope and a 121 + // 200 status, so we sniff for that too. 122 + func (s *DeezerService) get(ctx context.Context, path string, out any) error { 123 + endpoint := s.baseURL + path 124 + 125 + if err := s.limiter.Wait(ctx); err != nil { 126 + if ctx.Err() != nil { 127 + return fmt.Errorf("context cancelled during rate limiter wait: %w", ctx.Err()) 128 + } 129 + return fmt.Errorf("rate limiter error: %w", err) 130 + } 131 + 132 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 133 + if err != nil { 134 + return fmt.Errorf("failed to create request: %w", err) 135 + } 136 + req.Header.Set("User-Agent", "rocksky-deezer/0.1.0 ( https://github.com/tsirysndr/rocksky )") 137 + req.Header.Set("Accept", "application/json") 138 + 139 + resp, err := s.httpClient.Do(req) 140 + if err != nil { 141 + if ctx.Err() != nil { 142 + return fmt.Errorf("context error during request execution: %w", ctx.Err()) 143 + } 144 + return fmt.Errorf("failed to execute request to %s: %w", endpoint, err) 145 + } 146 + defer resp.Body.Close() 147 + 148 + if resp.StatusCode == http.StatusTooManyRequests { 149 + return fmt.Errorf("deezer rate limit exceeded (429) for %s", endpoint) 150 + } 151 + if resp.StatusCode != http.StatusOK { 152 + return fmt.Errorf("deezer API request to %s returned status %d", endpoint, resp.StatusCode) 153 + } 154 + 155 + body, err := io.ReadAll(resp.Body) 156 + if err != nil { 157 + return fmt.Errorf("failed to read response body from %s: %w", endpoint, err) 158 + } 159 + 160 + // Deezer returns { "error": {...} } with HTTP 200 for quota / bad requests. 161 + var apiErr DeezerAPIError 162 + if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Error != nil { 163 + return fmt.Errorf("deezer API error (code %d, type %s): %s", 164 + apiErr.Error.Code, apiErr.Error.Type, apiErr.Error.Message) 165 + } 166 + 167 + if err := json.Unmarshal(body, out); err != nil { 168 + return fmt.Errorf("failed to decode response from %s: %w", endpoint, err) 169 + } 170 + return nil 171 + } 172 + 173 + // Search queries the Deezer catalogue for candidate tracks. Results are cached 174 + // by their normalized query key. 175 + func (s *DeezerService) Search(ctx context.Context, params SearchParams) ([]DeezerTrack, error) { 176 + if strings.TrimSpace(params.Title) == "" && strings.TrimSpace(params.Artist) == "" { 177 + return nil, fmt.Errorf("at least one of title or artist must be provided") 178 + } 179 + 180 + query := buildSearchQuery(params) 181 + cacheKey := "search:" + query 182 + 183 + if cached, ok := s.cacheGet(cacheKey); ok { 184 + s.logger.Printf("cache hit for search: %q", query) 185 + return cached.([]DeezerTrack), nil 186 + } 187 + s.logger.Printf("cache miss for search: %q", query) 188 + 189 + var result DeezerSearchResponse 190 + path := "/search?q=" + url.QueryEscape(query) 191 + if err := s.get(ctx, path, &result); err != nil { 192 + return nil, err 193 + } 194 + 195 + // Deezer's advanced query is strict; if it returns nothing, retry with a 196 + // looser free-text query so misspelled/decorated titles still match. 197 + if len(result.Data) == 0 { 198 + loose := strings.TrimSpace(params.Title + " " + params.Artist) 199 + var loosely DeezerSearchResponse 200 + if err := s.get(ctx, "/search?q="+url.QueryEscape(loose), &loosely); err == nil { 201 + result.Data = loosely.Data 202 + } 203 + } 204 + 205 + s.cacheSet(cacheKey, result.Data) 206 + return result.Data, nil 207 + } 208 + 209 + // GetTrack fetches the full track object by Deezer ID (includes ISRC, disk / 210 + // track position, release date and contributors). 211 + func (s *DeezerService) GetTrack(ctx context.Context, id int64) (*DeezerTrack, error) { 212 + cacheKey := "track:" + strconv.FormatInt(id, 10) 213 + if cached, ok := s.cacheGet(cacheKey); ok { 214 + t := cached.(DeezerTrack) 215 + return &t, nil 216 + } 217 + 218 + var track DeezerTrack 219 + if err := s.get(ctx, "/track/"+strconv.FormatInt(id, 10), &track); err != nil { 220 + return nil, err 221 + } 222 + s.cacheSet(cacheKey, track) 223 + return &track, nil 224 + } 225 + 226 + // GetAlbum fetches the full album object by Deezer ID (includes label, genres 227 + // and UPC). 228 + func (s *DeezerService) GetAlbum(ctx context.Context, id int64) (*DeezerAlbum, error) { 229 + cacheKey := "album:" + strconv.FormatInt(id, 10) 230 + if cached, ok := s.cacheGet(cacheKey); ok { 231 + a := cached.(DeezerAlbum) 232 + return &a, nil 233 + } 234 + 235 + var album DeezerAlbum 236 + if err := s.get(ctx, "/album/"+strconv.FormatInt(id, 10), &album); err != nil { 237 + return nil, err 238 + } 239 + s.cacheSet(cacheKey, album) 240 + return &album, nil 241 + } 242 + 243 + // GetArtist fetches the full artist object by Deezer ID (includes picture). 244 + func (s *DeezerService) GetArtist(ctx context.Context, id int64) (*DeezerArtist, error) { 245 + cacheKey := "artist:" + strconv.FormatInt(id, 10) 246 + if cached, ok := s.cacheGet(cacheKey); ok { 247 + a := cached.(DeezerArtist) 248 + return &a, nil 249 + } 250 + 251 + var artist DeezerArtist 252 + if err := s.get(ctx, "/artist/"+strconv.FormatInt(id, 10), &artist); err != nil { 253 + return nil, err 254 + } 255 + s.cacheSet(cacheKey, artist) 256 + return &artist, nil 257 + } 258 + 259 + // rankedCandidate pairs a track with its score for sorting. 260 + type rankedCandidate struct { 261 + track DeezerTrack 262 + score float64 263 + } 264 + 265 + // rankCandidates scores and sorts search results best-first. 266 + func rankCandidates(params SearchParams, tracks []DeezerTrack) []rankedCandidate { 267 + ranked := make([]rankedCandidate, 0, len(tracks)) 268 + for _, t := range tracks { 269 + ranked = append(ranked, rankedCandidate{track: t, score: scoreCandidate(params, t)}) 270 + } 271 + sort.SliceStable(ranked, func(i, j int) bool { 272 + if ranked[i].score != ranked[j].score { 273 + return ranked[i].score > ranked[j].score 274 + } 275 + // Tie-break on Deezer rank (popularity), then ID for determinism. 276 + if ranked[i].track.Rank != ranked[j].track.Rank { 277 + return ranked[i].track.Rank > ranked[j].track.Rank 278 + } 279 + return ranked[i].track.ID < ranked[j].track.ID 280 + }) 281 + return ranked 282 + } 283 + 284 + // toMatch converts a scored Deezer track into the lightweight Match shape. 285 + func toMatch(c rankedCandidate) Match { 286 + t := c.track 287 + 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, 300 + } 301 + } 302 + 303 + // Enrich searches for the track, ranks candidates, deep-fetches the best one to 304 + // hydrate its full metadata, and returns both the enriched track and the ranked 305 + // match list. 306 + func (s *DeezerService) Enrich(ctx context.Context, params SearchParams) (*EnrichResponse, error) { 307 + tracks, err := s.Search(ctx, params) 308 + if err != nil { 309 + return nil, err 310 + } 311 + 312 + ranked := rankCandidates(params, tracks) 313 + 314 + matches := make([]Match, 0, min(len(ranked), maxMatches)) 315 + for i, c := range ranked { 316 + if i >= maxMatches { 317 + break 318 + } 319 + matches = append(matches, toMatch(c)) 320 + } 321 + 322 + resp := &EnrichResponse{Matches: matches} 323 + if len(ranked) == 0 { 324 + return resp, nil 325 + } 326 + 327 + best := ranked[0] 328 + // Only spend deep-fetch API calls when the top candidate is plausible. 329 + if best.score < enrichScoreFloor { 330 + resp.Track = s.buildEnrichedFromSearch(best.track) 331 + return resp, nil 332 + } 333 + 334 + resp.Track = s.hydrate(ctx, best.track) 335 + return resp, nil 336 + } 337 + 338 + // hydrate deep-fetches the full track + album (+ artist picture) to fill every 339 + // available field, falling back to the search-result data on any failure. 340 + func (s *DeezerService) hydrate(ctx context.Context, seed DeezerTrack) *EnrichedTrack { 341 + track := seed 342 + if full, err := s.GetTrack(ctx, seed.ID); err == nil && full != nil { 343 + track = *full 344 + // The full track's album lacks label/genres, so keep the richer album 345 + // filled in below; carry over the search cover if the full one is empty. 346 + if track.Album.Cover == "" { 347 + track.Album.Cover = seed.Album.Cover 348 + } 349 + } else if err != nil { 350 + s.logger.Printf("track deep-fetch failed for id=%d: %v", seed.ID, err) 351 + } 352 + 353 + enriched := s.buildEnrichedFromSearch(track) 354 + 355 + // Album deep-fetch for label / genres / UPC / release date. 356 + if track.Album.ID != 0 { 357 + if album, err := s.GetAlbum(ctx, track.Album.ID); err == nil && album != nil { 358 + if album.Label != "" { 359 + enriched.Label = album.Label 360 + } 361 + if album.UPC != "" { 362 + enriched.UPC = album.UPC 363 + } 364 + if len(album.Genres.Data) > 0 { 365 + genres := make([]string, 0, len(album.Genres.Data)) 366 + for _, g := range album.Genres.Data { 367 + if g.Name != "" { 368 + genres = append(genres, g.Name) 369 + } 370 + } 371 + enriched.Genres = genres 372 + } 373 + if enriched.ReleaseDate == "" && album.ReleaseDate != "" { 374 + enriched.ReleaseDate = album.ReleaseDate 375 + enriched.Year = yearFromDate(album.ReleaseDate) 376 + } 377 + if art := bestCover(*album); art != "" { 378 + enriched.AlbumArt = art 379 + } 380 + } else if err != nil { 381 + s.logger.Printf("album deep-fetch failed for id=%d: %v", track.Album.ID, err) 382 + } 383 + } 384 + 385 + // Artist picture deep-fetch when the embedded artist has none. 386 + if enriched.ArtistPicture == "" && track.Artist.ID != 0 { 387 + if artist, err := s.GetArtist(ctx, track.Artist.ID); err == nil && artist != nil { 388 + enriched.ArtistPicture = bestPicture(*artist) 389 + } 390 + } 391 + 392 + return enriched 393 + } 394 + 395 + // buildEnrichedFromSearch maps a (possibly shallow) Deezer track to the 396 + // normalized EnrichedTrack shape without extra network calls. 397 + func (s *DeezerService) buildEnrichedFromSearch(t DeezerTrack) *EnrichedTrack { 398 + enriched := &EnrichedTrack{ 399 + Title: t.Title, 400 + Artist: t.Artist.Name, 401 + AlbumArtist: t.Artist.Name, 402 + Album: t.Album.Title, 403 + AlbumArt: bestCover(t.Album), 404 + ISRC: t.ISRC, 405 + DurationMs: int64(t.Duration) * 1000, 406 + TrackNumber: t.TrackPosition, 407 + DiscNumber: t.DiskNumber, 408 + ReleaseDate: t.ReleaseDate, 409 + Label: t.Album.Label, 410 + ArtistPicture: bestPicture(t.Artist), 411 + DeezerLink: t.Link, 412 + Preview: t.Preview, 413 + Explicit: t.ExplicitLyrics, 414 + DeezerTrackID: t.ID, 415 + DeezerAlbumID: t.Album.ID, 416 + DeezerArtistID: t.Artist.ID, 417 + } 418 + if t.ReleaseDate != "" { 419 + enriched.Year = yearFromDate(t.ReleaseDate) 420 + } else if t.Album.ReleaseDate != "" { 421 + enriched.ReleaseDate = t.Album.ReleaseDate 422 + enriched.Year = yearFromDate(t.Album.ReleaseDate) 423 + } 424 + return enriched 425 + } 426 + 427 + // buildSearchQuery constructs a Deezer advanced-search query string. 428 + func buildSearchQuery(params SearchParams) string { 429 + var parts []string 430 + if t := strings.TrimSpace(params.Title); t != "" { 431 + parts = append(parts, fmt.Sprintf(`track:"%s"`, escapeQuoted(t))) 432 + } 433 + if a := strings.TrimSpace(params.Artist); a != "" { 434 + // Use only the primary artist for the strict query; combined credits 435 + // often don't match Deezer's per-track primary artist. 436 + primary := a 437 + if arts := splitArtists(a); len(arts) > 0 { 438 + primary = arts[0] 439 + } 440 + parts = append(parts, fmt.Sprintf(`artist:"%s"`, escapeQuoted(primary))) 441 + } 442 + if al := strings.TrimSpace(params.Album); al != "" { 443 + parts = append(parts, fmt.Sprintf(`album:"%s"`, escapeQuoted(al))) 444 + } 445 + return strings.Join(parts, " ") 446 + } 447 + 448 + func escapeQuoted(s string) string { 449 + return strings.ReplaceAll(s, `"`, "") 450 + } 451 + 452 + // bestCover returns the highest-resolution album cover available. 453 + func bestCover(a DeezerAlbum) string { 454 + switch { 455 + case a.CoverXL != "": 456 + return a.CoverXL 457 + case a.CoverBig != "": 458 + return a.CoverBig 459 + case a.CoverMedium != "": 460 + return a.CoverMedium 461 + case a.CoverSmall != "": 462 + return a.CoverSmall 463 + default: 464 + return a.Cover 465 + } 466 + } 467 + 468 + // bestPicture returns the highest-resolution artist picture available. 469 + func bestPicture(a DeezerArtist) string { 470 + switch { 471 + case a.PictureXL != "": 472 + return a.PictureXL 473 + case a.PictureBig != "": 474 + return a.PictureBig 475 + case a.PictureMedium != "": 476 + return a.PictureMedium 477 + case a.PictureSmall != "": 478 + return a.PictureSmall 479 + default: 480 + return a.Picture 481 + } 482 + } 483 + 484 + // yearFromDate extracts the leading YYYY from a Deezer date string. 485 + func yearFromDate(date string) int { 486 + if len(date) < 4 { 487 + return 0 488 + } 489 + y, err := strconv.Atoi(date[:4]) 490 + if err != nil { 491 + return 0 492 + } 493 + return y 494 + }
+479
deezer/service/deezer/deezer_test.go
··· 1 + package deezer 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "sync/atomic" 10 + "testing" 11 + "time" 12 + 13 + "golang.org/x/time/rate" 14 + ) 15 + 16 + // --- Mock Deezer API --------------------------------------------------------- 17 + 18 + // These payloads mirror the shape of the real Deezer API 19 + // (https://api.deezer.com) for the track "Get Lucky" by Daft Punk. 20 + 21 + const searchResponseJSON = `{ 22 + "data": [ 23 + { 24 + "id": 67238735, 25 + "title": "Get Lucky", 26 + "title_short": "Get Lucky", 27 + "title_version": "", 28 + "link": "https://www.deezer.com/track/67238735", 29 + "duration": 369, 30 + "rank": 812000, 31 + "explicit_lyrics": false, 32 + "preview": "https://cdns-preview-a.dzcdn.net/stream/get-lucky.mp3", 33 + "artist": { 34 + "id": 27, 35 + "name": "Daft Punk", 36 + "link": "https://www.deezer.com/artist/27", 37 + "picture_medium": "https://api.deezer.com/artist/27/image?size=medium", 38 + "picture_xl": "https://api.deezer.com/artist/27/image?size=xl" 39 + }, 40 + "album": { 41 + "id": 6575789, 42 + "title": "Random Access Memories", 43 + "cover": "https://api.deezer.com/album/6575789/image", 44 + "cover_medium": "https://api.deezer.com/album/6575789/image?size=medium", 45 + "cover_xl": "https://api.deezer.com/album/6575789/image?size=xl", 46 + "tracklist": "https://api.deezer.com/album/6575789/tracks" 47 + }, 48 + "type": "track" 49 + }, 50 + { 51 + "id": 999999, 52 + "title": "Get Lucky (Live)", 53 + "title_short": "Get Lucky", 54 + "link": "https://www.deezer.com/track/999999", 55 + "duration": 380, 56 + "rank": 120000, 57 + "explicit_lyrics": false, 58 + "artist": { "id": 5555, "name": "Tribute Band", "link": "" }, 59 + "album": { "id": 8888, "title": "Live Covers", "cover_medium": "https://api.deezer.com/album/8888/image" }, 60 + "type": "track" 61 + } 62 + ], 63 + "total": 2 64 + }` 65 + 66 + const trackResponseJSON = `{ 67 + "id": 67238735, 68 + "title": "Get Lucky", 69 + "title_short": "Get Lucky", 70 + "isrc": "GBDUW1300109", 71 + "link": "https://www.deezer.com/track/67238735", 72 + "duration": 369, 73 + "rank": 812000, 74 + "track_position": 8, 75 + "disk_number": 1, 76 + "release_date": "2013-05-17", 77 + "explicit_lyrics": false, 78 + "preview": "https://cdns-preview-a.dzcdn.net/stream/get-lucky.mp3", 79 + "bpm": 116.1, 80 + "gain": -8.9, 81 + "contributors": [ 82 + { "id": 27, "name": "Daft Punk" }, 83 + { "id": 141, "name": "Pharrell Williams" } 84 + ], 85 + "artist": { 86 + "id": 27, 87 + "name": "Daft Punk", 88 + "picture_xl": "https://api.deezer.com/artist/27/image?size=xl" 89 + }, 90 + "album": { 91 + "id": 6575789, 92 + "title": "Random Access Memories", 93 + "cover_xl": "https://api.deezer.com/album/6575789/image?size=xl", 94 + "release_date": "2013-05-17" 95 + } 96 + }` 97 + 98 + const albumResponseJSON = `{ 99 + "id": 6575789, 100 + "title": "Random Access Memories", 101 + "upc": "888837168526", 102 + "link": "https://www.deezer.com/album/6575789", 103 + "cover_xl": "https://api.deezer.com/album/6575789/image?size=xl", 104 + "release_date": "2013-05-17", 105 + "label": "Columbia", 106 + "nb_tracks": 13, 107 + "genres": { 108 + "data": [ 109 + { "id": 113, "name": "Dance" }, 110 + { "id": 152, "name": "Rock" } 111 + ] 112 + } 113 + }` 114 + 115 + const artistResponseJSON = `{ 116 + "id": 27, 117 + "name": "Daft Punk", 118 + "picture_xl": "https://api.deezer.com/artist/27/image?size=xl" 119 + }` 120 + 121 + const emptySearchJSON = `{ "data": [], "total": 0 }` 122 + 123 + const quotaErrorJSON = `{ "error": { "type": "Exception", "message": "Quota limit exceeded", "code": 4 } }` 124 + 125 + // mockDeezer spins up an httptest server that mimics the real Deezer API and 126 + // records how many times each endpoint was hit. 127 + type mockDeezer struct { 128 + server *httptest.Server 129 + searchHits int64 130 + trackHits int64 131 + albumHits int64 132 + artistHits int64 133 + searchBody string // override for the /search response 134 + lastSearchQ string 135 + } 136 + 137 + func newMockDeezer() *mockDeezer { 138 + m := &mockDeezer{searchBody: searchResponseJSON} 139 + mux := http.NewServeMux() 140 + 141 + mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { 142 + atomic.AddInt64(&m.searchHits, 1) 143 + m.lastSearchQ = r.URL.Query().Get("q") 144 + writeJSON(w, m.searchBody) 145 + }) 146 + mux.HandleFunc("/track/", func(w http.ResponseWriter, r *http.Request) { 147 + atomic.AddInt64(&m.trackHits, 1) 148 + writeJSON(w, trackResponseJSON) 149 + }) 150 + mux.HandleFunc("/album/", func(w http.ResponseWriter, r *http.Request) { 151 + atomic.AddInt64(&m.albumHits, 1) 152 + writeJSON(w, albumResponseJSON) 153 + }) 154 + mux.HandleFunc("/artist/", func(w http.ResponseWriter, r *http.Request) { 155 + atomic.AddInt64(&m.artistHits, 1) 156 + writeJSON(w, artistResponseJSON) 157 + }) 158 + 159 + m.server = httptest.NewServer(mux) 160 + return m 161 + } 162 + 163 + func writeJSON(w http.ResponseWriter, body string) { 164 + w.Header().Set("Content-Type", "application/json") 165 + w.WriteHeader(http.StatusOK) 166 + _, _ = w.Write([]byte(body)) 167 + } 168 + 169 + func (m *mockDeezer) close() { m.server.Close() } 170 + 171 + // newTestService returns a service pointed at the mock with throttling 172 + // effectively disabled (huge burst) so unit tests stay fast. 173 + func newTestService(m *mockDeezer, opts ...Option) *DeezerService { 174 + base := []Option{ 175 + WithBaseURL(m.server.URL), 176 + WithLimiter(rate.NewLimiter(rate.Inf, 1)), 177 + } 178 + return NewDeezerService(append(base, opts...)...) 179 + } 180 + 181 + // --- Tests ------------------------------------------------------------------- 182 + 183 + func TestSearch(t *testing.T) { 184 + m := newMockDeezer() 185 + defer m.close() 186 + svc := newTestService(m) 187 + 188 + tracks, err := svc.Search(context.Background(), SearchParams{Title: "Get Lucky", Artist: "Daft Punk"}) 189 + if err != nil { 190 + t.Fatalf("Search returned error: %v", err) 191 + } 192 + if len(tracks) != 2 { 193 + t.Fatalf("expected 2 tracks, got %d", len(tracks)) 194 + } 195 + if tracks[0].Title != "Get Lucky" || tracks[0].Artist.Name != "Daft Punk" { 196 + t.Fatalf("unexpected first track: %+v", tracks[0]) 197 + } 198 + 199 + // The advanced query should carry track: and artist: operators. 200 + if !strings.Contains(m.lastSearchQ, `track:"Get Lucky"`) || 201 + !strings.Contains(m.lastSearchQ, `artist:"Daft Punk"`) { 202 + t.Fatalf("unexpected search query: %q", m.lastSearchQ) 203 + } 204 + } 205 + 206 + func TestSearchRequiresInput(t *testing.T) { 207 + m := newMockDeezer() 208 + defer m.close() 209 + svc := newTestService(m) 210 + 211 + if _, err := svc.Search(context.Background(), SearchParams{}); err == nil { 212 + t.Fatal("expected error for empty search params") 213 + } 214 + } 215 + 216 + func TestSearchCaching(t *testing.T) { 217 + m := newMockDeezer() 218 + defer m.close() 219 + svc := newTestService(m) 220 + 221 + params := SearchParams{Title: "Get Lucky", Artist: "Daft Punk"} 222 + for i := 0; i < 3; i++ { 223 + if _, err := svc.Search(context.Background(), params); err != nil { 224 + t.Fatalf("Search #%d error: %v", i, err) 225 + } 226 + } 227 + if got := atomic.LoadInt64(&m.searchHits); got != 1 { 228 + t.Fatalf("expected 1 upstream search hit (cached), got %d", got) 229 + } 230 + } 231 + 232 + func TestCacheTTLExpiry(t *testing.T) { 233 + m := newMockDeezer() 234 + defer m.close() 235 + svc := newTestService(m, WithCacheTTL(20*time.Millisecond)) 236 + 237 + params := SearchParams{Title: "Get Lucky", Artist: "Daft Punk"} 238 + if _, err := svc.Search(context.Background(), params); err != nil { 239 + t.Fatal(err) 240 + } 241 + time.Sleep(40 * time.Millisecond) 242 + if _, err := svc.Search(context.Background(), params); err != nil { 243 + t.Fatal(err) 244 + } 245 + if got := atomic.LoadInt64(&m.searchHits); got != 2 { 246 + t.Fatalf("expected 2 upstream hits after TTL expiry, got %d", got) 247 + } 248 + } 249 + 250 + func TestEnrichFillsAllMetadata(t *testing.T) { 251 + m := newMockDeezer() 252 + defer m.close() 253 + svc := newTestService(m) 254 + 255 + resp, err := svc.Enrich(context.Background(), SearchParams{ 256 + Title: "Get Lucky", 257 + Artist: "Daft Punk", 258 + Album: "Random Access Memories", 259 + }) 260 + if err != nil { 261 + t.Fatalf("Enrich error: %v", err) 262 + } 263 + 264 + if resp.Track == nil { 265 + t.Fatal("expected an enriched track") 266 + } 267 + tr := resp.Track 268 + 269 + // From the full track endpoint. 270 + if tr.ISRC != "GBDUW1300109" { 271 + t.Errorf("ISRC not filled from full track: %q", tr.ISRC) 272 + } 273 + if tr.DurationMs != 369000 { 274 + t.Errorf("expected duration 369000ms, got %d", tr.DurationMs) 275 + } 276 + if tr.TrackNumber != 8 || tr.DiscNumber != 1 { 277 + t.Errorf("track/disc position not filled: track=%d disc=%d", tr.TrackNumber, tr.DiscNumber) 278 + } 279 + if tr.ReleaseDate != "2013-05-17" || tr.Year != 2013 { 280 + t.Errorf("release date/year not filled: %q / %d", tr.ReleaseDate, tr.Year) 281 + } 282 + // From the full album endpoint. 283 + if tr.Label != "Columbia" { 284 + t.Errorf("label not filled from album: %q", tr.Label) 285 + } 286 + if tr.UPC != "888837168526" { 287 + t.Errorf("UPC not filled from album: %q", tr.UPC) 288 + } 289 + if len(tr.Genres) != 2 || tr.Genres[0] != "Dance" { 290 + t.Errorf("genres not filled from album: %+v", tr.Genres) 291 + } 292 + // Highest-res assets preferred. 293 + if !strings.Contains(tr.AlbumArt, "size=xl") { 294 + t.Errorf("expected XL album art, got %q", tr.AlbumArt) 295 + } 296 + if !strings.Contains(tr.ArtistPicture, "size=xl") { 297 + t.Errorf("expected XL artist picture, got %q", tr.ArtistPicture) 298 + } 299 + // Deezer identifiers + link. 300 + if tr.DeezerTrackID != 67238735 || tr.DeezerAlbumID != 6575789 || tr.DeezerArtistID != 27 { 301 + t.Errorf("deezer ids not set: %+v", tr) 302 + } 303 + if tr.DeezerLink == "" { 304 + t.Error("deezer link not set") 305 + } 306 + } 307 + 308 + func TestEnrichReturnsRankedMatches(t *testing.T) { 309 + m := newMockDeezer() 310 + defer m.close() 311 + svc := newTestService(m) 312 + 313 + resp, err := svc.Enrich(context.Background(), SearchParams{Title: "Get Lucky", Artist: "Daft Punk"}) 314 + if err != nil { 315 + t.Fatalf("Enrich error: %v", err) 316 + } 317 + 318 + if len(resp.Matches) != 2 { 319 + t.Fatalf("expected 2 matches, got %d", len(resp.Matches)) 320 + } 321 + // The exact Daft Punk match should outrank the tribute-band live version. 322 + if resp.Matches[0].ID != 67238735 { 323 + t.Errorf("expected exact match first, got id=%d", resp.Matches[0].ID) 324 + } 325 + if resp.Matches[0].Score <= resp.Matches[1].Score { 326 + t.Errorf("matches not sorted by score: %v <= %v", 327 + resp.Matches[0].Score, resp.Matches[1].Score) 328 + } 329 + if resp.Matches[0].Score < 0.9 { 330 + t.Errorf("expected high score for exact match, got %v", resp.Matches[0].Score) 331 + } 332 + if resp.Matches[0].DurationMs != 369000 { 333 + t.Errorf("match duration should be ms: %d", resp.Matches[0].DurationMs) 334 + } 335 + } 336 + 337 + func TestEnrichNoResults(t *testing.T) { 338 + m := newMockDeezer() 339 + m.searchBody = emptySearchJSON 340 + defer m.close() 341 + svc := newTestService(m) 342 + 343 + resp, err := svc.Enrich(context.Background(), SearchParams{Title: "zzzzz", Artist: "nobody"}) 344 + if err != nil { 345 + t.Fatalf("Enrich error: %v", err) 346 + } 347 + if resp.Track != nil { 348 + t.Errorf("expected nil track for no results, got %+v", resp.Track) 349 + } 350 + if len(resp.Matches) != 0 { 351 + t.Errorf("expected no matches, got %d", len(resp.Matches)) 352 + } 353 + } 354 + 355 + func TestDeezerAPIErrorEnvelope(t *testing.T) { 356 + m := newMockDeezer() 357 + m.searchBody = quotaErrorJSON 358 + defer m.close() 359 + svc := newTestService(m) 360 + 361 + _, err := svc.Search(context.Background(), SearchParams{Title: "Get Lucky", Artist: "Daft Punk"}) 362 + if err == nil { 363 + t.Fatal("expected error for Deezer quota error envelope") 364 + } 365 + if !strings.Contains(err.Error(), "Quota limit exceeded") { 366 + t.Errorf("unexpected error: %v", err) 367 + } 368 + } 369 + 370 + func TestGetTrackCaching(t *testing.T) { 371 + m := newMockDeezer() 372 + defer m.close() 373 + svc := newTestService(m) 374 + 375 + for i := 0; i < 3; i++ { 376 + if _, err := svc.GetTrack(context.Background(), 67238735); err != nil { 377 + t.Fatalf("GetTrack #%d error: %v", i, err) 378 + } 379 + } 380 + if got := atomic.LoadInt64(&m.trackHits); got != 1 { 381 + t.Fatalf("expected 1 track fetch (cached), got %d", got) 382 + } 383 + } 384 + 385 + func TestRateLimiter(t *testing.T) { 386 + m := newMockDeezer() 387 + defer m.close() 388 + 389 + // 10 req/window, burst 2, over a short window: the 3rd request must wait 390 + // for a token, proving the limiter throttles. 391 + window := 100 * time.Millisecond 392 + svc := newTestService(m, WithLimiter(rate.NewLimiter(rate.Every(window/10), 2))) 393 + 394 + ctx := context.Background() 395 + start := time.Now() 396 + for i := 0; i < 3; i++ { 397 + // Distinct ids to avoid the cache short-circuiting the limiter. 398 + if _, err := svc.GetTrack(ctx, int64(1000+i)); err != nil { 399 + t.Fatalf("GetTrack error: %v", err) 400 + } 401 + } 402 + elapsed := time.Since(start) 403 + 404 + perToken := window / 10 405 + if elapsed < perToken { 406 + t.Fatalf("expected limiter to throttle 3rd request by ~%v, elapsed=%v", perToken, elapsed) 407 + } 408 + } 409 + 410 + func TestSearchFallsBackToLooseQuery(t *testing.T) { 411 + m := newMockDeezer() 412 + defer m.close() 413 + 414 + // First (advanced) query returns empty; loose free-text query returns hits. 415 + var calls int64 416 + mux := http.NewServeMux() 417 + mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { 418 + n := atomic.AddInt64(&calls, 1) 419 + if n == 1 { 420 + writeJSON(w, emptySearchJSON) 421 + return 422 + } 423 + writeJSON(w, searchResponseJSON) 424 + }) 425 + m.server.Config.Handler = mux // swap handler 426 + m.server.Close() 427 + m.server = httptest.NewServer(mux) 428 + svc := newTestService(m) 429 + 430 + tracks, err := svc.Search(context.Background(), SearchParams{Title: "Get Lucky", Artist: "Daft Punk"}) 431 + if err != nil { 432 + t.Fatalf("Search error: %v", err) 433 + } 434 + if len(tracks) == 0 { 435 + t.Fatal("expected loose-query fallback to return tracks") 436 + } 437 + if atomic.LoadInt64(&calls) != 2 { 438 + t.Fatalf("expected 2 search calls (advanced + loose), got %d", calls) 439 + } 440 + } 441 + 442 + func TestNormalize(t *testing.T) { 443 + cases := map[string]string{ 444 + "Beyoncé": "beyonce", 445 + " Guns N' Roses ": "guns n roses", 446 + "Song - Radio Edit": "song", 447 + "Café del Mar (Explicit)": "cafe del mar", 448 + } 449 + for in, want := range cases { 450 + if got := normalize(in); got != want { 451 + t.Errorf("normalize(%q) = %q, want %q", in, got, want) 452 + } 453 + } 454 + } 455 + 456 + func TestScoreCandidateRanking(t *testing.T) { 457 + params := SearchParams{Title: "Get Lucky", Artist: "Daft Punk"} 458 + exact := DeezerTrack{Title: "Get Lucky", Artist: DeezerArtist{Name: "Daft Punk"}} 459 + wrong := DeezerTrack{Title: "Something Else", Artist: DeezerArtist{Name: "Other Band"}} 460 + 461 + if scoreCandidate(params, exact) <= scoreCandidate(params, wrong) { 462 + t.Fatal("exact match should score higher than unrelated track") 463 + } 464 + if s := scoreCandidate(params, exact); s < 0.95 { 465 + t.Fatalf("expected near-perfect score for exact match, got %v", s) 466 + } 467 + } 468 + 469 + // Sanity: ensure the mock URLs are well formed (guards against accidental 470 + // hard-coded api.deezer.com in code paths under test). 471 + func TestServiceUsesConfiguredBaseURL(t *testing.T) { 472 + m := newMockDeezer() 473 + defer m.close() 474 + svc := newTestService(m) 475 + if !strings.HasPrefix(svc.baseURL, "http://127.0.0.1") && 476 + !strings.HasPrefix(svc.baseURL, fmt.Sprintf("http://%s", "127.0.0.1")) { 477 + t.Fatalf("service base URL not pointed at mock: %s", svc.baseURL) 478 + } 479 + }
+204
deezer/service/deezer/match.go
··· 1 + package deezer 2 + 3 + import ( 4 + "strings" 5 + "unicode" 6 + 7 + "golang.org/x/text/runes" 8 + "golang.org/x/text/transform" 9 + "golang.org/x/text/unicode/norm" 10 + ) 11 + 12 + // diacriticsRemover strips combining marks so that "Beyoncé" and "Beyonce" 13 + // compare equal. 14 + var diacriticsRemover = transform.Chain( 15 + norm.NFD, 16 + runes.Remove(runes.In(unicode.Mn)), 17 + norm.NFC, 18 + ) 19 + 20 + // titleNoiseSuffixes are common decorations we drop before comparing titles so 21 + // that "Song - Radio Edit" and "Song" score as a strong match. 22 + var titleNoiseSuffixes = []string{ 23 + " - album version (edited)", 24 + " - album version (explicit)", 25 + " - album version", 26 + " - radio edit", 27 + " - single version", 28 + " - remastered", 29 + " - explicit", 30 + " - edited", 31 + " (album version)", 32 + " (radio edit)", 33 + " (explicit)", 34 + " (edited)", 35 + } 36 + 37 + // normalize lower-cases, strips diacritics, drops punctuation and collapses 38 + // whitespace. It is the canonical form used for all string comparisons. 39 + func normalize(s string) string { 40 + s = strings.ToLower(strings.TrimSpace(s)) 41 + if out, _, err := transform.String(diacriticsRemover, s); err == nil { 42 + s = out 43 + } 44 + 45 + for _, suffix := range titleNoiseSuffixes { 46 + s = strings.TrimSuffix(s, suffix) 47 + } 48 + 49 + var b strings.Builder 50 + prevSpace := false 51 + for _, r := range s { 52 + switch { 53 + case unicode.IsLetter(r) || unicode.IsNumber(r): 54 + b.WriteRune(r) 55 + prevSpace = false 56 + case unicode.IsSpace(r): 57 + if !prevSpace { 58 + b.WriteRune(' ') 59 + prevSpace = true 60 + } 61 + default: 62 + // Treat punctuation as a word boundary. 63 + if !prevSpace { 64 + b.WriteRune(' ') 65 + prevSpace = true 66 + } 67 + } 68 + } 69 + return strings.TrimSpace(b.String()) 70 + } 71 + 72 + // levenshtein returns the edit distance between two rune slices. 73 + func levenshtein(a, b []rune) int { 74 + la, lb := len(a), len(b) 75 + if la == 0 { 76 + return lb 77 + } 78 + if lb == 0 { 79 + return la 80 + } 81 + 82 + prev := make([]int, lb+1) 83 + curr := make([]int, lb+1) 84 + for j := 0; j <= lb; j++ { 85 + prev[j] = j 86 + } 87 + 88 + for i := 1; i <= la; i++ { 89 + curr[0] = i 90 + for j := 1; j <= lb; j++ { 91 + cost := 1 92 + if a[i-1] == b[j-1] { 93 + cost = 0 94 + } 95 + curr[j] = min3(curr[j-1]+1, prev[j]+1, prev[j-1]+cost) 96 + } 97 + prev, curr = curr, prev 98 + } 99 + return prev[lb] 100 + } 101 + 102 + func min3(a, b, c int) int { 103 + m := a 104 + if b < m { 105 + m = b 106 + } 107 + if c < m { 108 + m = c 109 + } 110 + return m 111 + } 112 + 113 + // similarity returns a normalized-edit-distance ratio in [0,1] between two 114 + // already-normalized strings, with a bonus for one being a substring of the 115 + // other (which happens a lot with "feat." credits and title decorations). 116 + func similarity(a, b string) float64 { 117 + if a == "" && b == "" { 118 + return 1 119 + } 120 + if a == "" || b == "" { 121 + return 0 122 + } 123 + if a == b { 124 + return 1 125 + } 126 + 127 + ra, rb := []rune(a), []rune(b) 128 + dist := levenshtein(ra, rb) 129 + longest := len(ra) 130 + if len(rb) > longest { 131 + longest = len(rb) 132 + } 133 + ratio := 1 - float64(dist)/float64(longest) 134 + 135 + // Substring containment is a strong signal that edit distance underrates. 136 + if strings.Contains(a, b) || strings.Contains(b, a) { 137 + if ratio < 0.9 { 138 + ratio = 0.9 139 + } 140 + } 141 + return ratio 142 + } 143 + 144 + // artistSimilarity compares a scrobble artist string (which may contain several 145 + // artists joined by ", " / " x " / " & " / " feat. ") against a candidate 146 + // artist, returning the best per-token similarity. 147 + func artistSimilarity(query, candidate string) float64 { 148 + qNorm := normalize(query) 149 + cNorm := normalize(candidate) 150 + if qNorm == cNorm { 151 + return 1 152 + } 153 + 154 + best := similarity(qNorm, cNorm) 155 + for _, part := range splitArtists(query) { 156 + if s := similarity(normalize(part), cNorm); s > best { 157 + best = s 158 + } 159 + } 160 + return best 161 + } 162 + 163 + // splitArtists breaks a combined artist credit into individual names. 164 + func splitArtists(artist string) []string { 165 + seps := []string{",", ";", " x ", " X ", " & ", " feat. ", " feat ", " ft. ", " ft ", " with "} 166 + parts := []string{artist} 167 + for _, sep := range seps { 168 + var next []string 169 + for _, p := range parts { 170 + next = append(next, strings.Split(p, sep)...) 171 + } 172 + parts = next 173 + } 174 + 175 + out := make([]string, 0, len(parts)) 176 + for _, p := range parts { 177 + if t := strings.TrimSpace(p); t != "" { 178 + out = append(out, t) 179 + } 180 + } 181 + return out 182 + } 183 + 184 + // scoreCandidate produces a weighted confidence score in [0,1] for a Deezer 185 + // track against the requested title/artist/album. 186 + func scoreCandidate(params SearchParams, track DeezerTrack) float64 { 187 + titleSim := similarity(normalize(params.Title), normalize(track.Title)) 188 + // Deezer's title often carries a version suffix in title_version; fall back 189 + // to the short title when it scores better. 190 + if track.TitleShort != "" { 191 + if s := similarity(normalize(params.Title), normalize(track.TitleShort)); s > titleSim { 192 + titleSim = s 193 + } 194 + } 195 + artistSim := artistSimilarity(params.Artist, track.Artist.Name) 196 + 197 + if params.Album == "" { 198 + // 65% title, 35% artist when no album to lean on. 199 + return 0.65*titleSim + 0.35*artistSim 200 + } 201 + 202 + albumSim := similarity(normalize(params.Album), normalize(track.Album.Title)) 203 + return 0.55*titleSim + 0.30*artistSim + 0.15*albumSim 204 + }
+69
deezer/service/deezer/smoke_test.go
··· 1 + package deezer 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + // TestSmokeRealDeezerAPI hits the live Deezer API to verify the enrichment 11 + // end-to-end against the real service. It is skipped by default; run it with: 12 + // 13 + // DEEZER_SMOKE=1 go test ./service/deezer -run TestSmokeRealDeezerAPI -v 14 + // 15 + // It makes a single Enrich call (a handful of requests total), which is far 16 + // under Deezer's 50 req / 5 s quota, so it will not trip rate limits. 17 + func TestSmokeRealDeezerAPI(t *testing.T) { 18 + if os.Getenv("DEEZER_SMOKE") == "" { 19 + t.Skip("set DEEZER_SMOKE=1 to run the live Deezer API smoke test") 20 + } 21 + 22 + // Real base URL, real rate limiter (50 req / 5 s). 23 + svc := NewDeezerService() 24 + 25 + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 26 + defer cancel() 27 + 28 + resp, err := svc.Enrich(ctx, SearchParams{ 29 + Title: "Get Lucky", 30 + Artist: "Daft Punk", 31 + Album: "Random Access Memories", 32 + }) 33 + if err != nil { 34 + t.Fatalf("live Enrich error: %v", err) 35 + } 36 + if resp.Track == nil { 37 + t.Fatal("expected an enriched track from live Deezer") 38 + } 39 + 40 + tr := resp.Track 41 + t.Logf("enriched: title=%q artist=%q album=%q isrc=%q label=%q year=%d durationMs=%d genres=%v art=%q", 42 + tr.Title, tr.Artist, tr.Album, tr.ISRC, tr.Label, tr.Year, tr.DurationMs, tr.Genres, tr.AlbumArt) 43 + 44 + if tr.Title == "" || tr.Artist == "" { 45 + t.Error("live track missing title/artist") 46 + } 47 + // Duration unit alignment with Spotify's `duration_ms`: Deezer's API returns 48 + // seconds, which the service multiplies to milliseconds. A real song is 49 + // minutes long, so the value must be in the hundreds-of-thousands (ms), not 50 + // the low hundreds (seconds). 51 + if tr.DurationMs < 60_000 { 52 + t.Errorf("durationMs=%d looks like seconds, not milliseconds — unit misaligned with Spotify", tr.DurationMs) 53 + } 54 + if tr.ISRC == "" { 55 + t.Error("expected ISRC from live full-track fetch") 56 + } 57 + if tr.AlbumArt == "" { 58 + t.Error("expected album art from live Deezer") 59 + } 60 + if tr.DeezerTrackID == 0 { 61 + t.Error("expected a Deezer track id") 62 + } 63 + if len(resp.Matches) == 0 { 64 + t.Error("expected at least one match from live Deezer") 65 + } 66 + for i, m := range resp.Matches { 67 + t.Logf("match[%d]: id=%d title=%q artist=%q score=%.3f", i, m.ID, m.Title, m.Artist, m.Score) 68 + } 69 + }
+149
deezer/service/deezer/types.go
··· 1 + package deezer 2 + 3 + // This file mirrors the shape of the public Deezer API 4 + // (https://developers.deezer.com/api). Only the fields we consume are 5 + // modeled; unknown fields are ignored by the JSON decoder. 6 + 7 + // DeezerArtist is the artist object returned by the Deezer API. The picture 8 + // fields are only populated on the full artist endpoint (/artist/{id}) and on 9 + // the artist embedded in a full track object. 10 + type DeezerArtist struct { 11 + ID int64 `json:"id"` 12 + Name string `json:"name"` 13 + Link string `json:"link,omitempty"` 14 + Picture string `json:"picture,omitempty"` 15 + PictureSmall string `json:"picture_small,omitempty"` 16 + PictureMedium string `json:"picture_medium,omitempty"` 17 + PictureBig string `json:"picture_big,omitempty"` 18 + PictureXL string `json:"picture_xl,omitempty"` 19 + } 20 + 21 + // DeezerGenre is a single genre entry. 22 + type DeezerGenre struct { 23 + ID int64 `json:"id"` 24 + Name string `json:"name"` 25 + } 26 + 27 + // DeezerGenres wraps the genre list on the full album endpoint. 28 + type DeezerGenres struct { 29 + Data []DeezerGenre `json:"data"` 30 + } 31 + 32 + // DeezerAlbum is the album object returned by the Deezer API. Label, genres and 33 + // UPC are only populated on the full album endpoint (/album/{id}). 34 + type DeezerAlbum struct { 35 + ID int64 `json:"id"` 36 + Title string `json:"title"` 37 + Link string `json:"link,omitempty"` 38 + Cover string `json:"cover,omitempty"` 39 + CoverSmall string `json:"cover_small,omitempty"` 40 + CoverMedium string `json:"cover_medium,omitempty"` 41 + CoverBig string `json:"cover_big,omitempty"` 42 + CoverXL string `json:"cover_xl,omitempty"` 43 + ReleaseDate string `json:"release_date,omitempty"` 44 + Tracklist string `json:"tracklist,omitempty"` 45 + Label string `json:"label,omitempty"` 46 + UPC string `json:"upc,omitempty"` 47 + NbTracks int `json:"nb_tracks,omitempty"` 48 + Genres DeezerGenres `json:"genres,omitempty"` 49 + } 50 + 51 + // DeezerTrack is the track object returned by the Deezer API. ISRC, 52 + // track_position, disk_number, release_date, bpm and contributors are only 53 + // populated on the full track endpoint (/track/{id}). 54 + type DeezerTrack struct { 55 + ID int64 `json:"id"` 56 + Title string `json:"title"` 57 + TitleShort string `json:"title_short,omitempty"` 58 + TitleVersion string `json:"title_version,omitempty"` 59 + ISRC string `json:"isrc,omitempty"` 60 + Link string `json:"link,omitempty"` 61 + Duration int `json:"duration,omitempty"` // seconds 62 + Rank int `json:"rank,omitempty"` 63 + TrackPosition int `json:"track_position,omitempty"` 64 + DiskNumber int `json:"disk_number,omitempty"` 65 + ReleaseDate string `json:"release_date,omitempty"` 66 + ExplicitLyrics bool `json:"explicit_lyrics,omitempty"` 67 + Preview string `json:"preview,omitempty"` 68 + BPM float64 `json:"bpm,omitempty"` 69 + Gain float64 `json:"gain,omitempty"` 70 + Contributors []DeezerArtist `json:"contributors,omitempty"` 71 + Artist DeezerArtist `json:"artist"` 72 + Album DeezerAlbum `json:"album"` 73 + } 74 + 75 + // DeezerSearchResponse is the envelope returned by /search. 76 + type DeezerSearchResponse struct { 77 + Data []DeezerTrack `json:"data"` 78 + Total int `json:"total"` 79 + Next string `json:"next,omitempty"` 80 + } 81 + 82 + // DeezerAPIError is the error envelope Deezer returns (with HTTP 200) when a 83 + // request is invalid or the quota is exceeded. 84 + type DeezerAPIError struct { 85 + Error *struct { 86 + Type string `json:"type"` 87 + Message string `json:"message"` 88 + Code int `json:"code"` 89 + } `json:"error,omitempty"` 90 + } 91 + 92 + // SearchParams are the inputs accepted by the enrichment endpoints. 93 + type SearchParams struct { 94 + Title string `json:"title"` 95 + Artist string `json:"artist"` 96 + Album string `json:"album,omitempty"` 97 + } 98 + 99 + // Match is a lightweight, ranked candidate returned to callers alongside the 100 + // enriched track. Duration is expressed in milliseconds to match the rest of 101 + // the Rocksky pipeline. 102 + 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"` 115 + } 116 + 117 + // EnrichedTrack is the fully hydrated, normalized track metadata returned to 118 + // callers. All durations are in milliseconds. 119 + type EnrichedTrack struct { 120 + Title string `json:"title"` 121 + Artist string `json:"artist"` 122 + AlbumArtist string `json:"albumArtist,omitempty"` 123 + Album string `json:"album"` 124 + AlbumArt string `json:"albumArt,omitempty"` 125 + ISRC string `json:"isrc,omitempty"` 126 + UPC string `json:"upc,omitempty"` 127 + DurationMs int64 `json:"durationMs"` 128 + TrackNumber int `json:"trackNumber,omitempty"` 129 + DiscNumber int `json:"discNumber,omitempty"` 130 + ReleaseDate string `json:"releaseDate,omitempty"` 131 + Year int `json:"year,omitempty"` 132 + Label string `json:"label,omitempty"` 133 + Genres []string `json:"genres,omitempty"` 134 + ArtistPicture string `json:"artistPicture,omitempty"` 135 + DeezerLink string `json:"deezerLink,omitempty"` 136 + Preview string `json:"preview,omitempty"` 137 + Explicit bool `json:"explicit,omitempty"` 138 + DeezerTrackID int64 `json:"deezerTrackId,omitempty"` 139 + DeezerAlbumID int64 `json:"deezerAlbumId,omitempty"` 140 + DeezerArtistID int64 `json:"deezerArtistId,omitempty"` 141 + } 142 + 143 + // EnrichResponse is the payload returned by the /enrich and /search endpoints: 144 + // the best enriched track (nil when there is no match) plus the ranked list of 145 + // candidate matches. 146 + type EnrichResponse struct { 147 + Track *EnrichedTrack `json:"track"` 148 + Matches []Match `json:"matches"` 149 + }
+1
package.json
··· 30 30 "db:pgpull": "cargo run -p rockskyd --release -- pull", 31 31 "dev:feeds": "cd apps/feeds && deno task dev", 32 32 "mb": "cd musicbrainz && go run main.go", 33 + "deezer": "cd deezer && go run main.go", 33 34 "spotify": "cd apps/api && bun run spotify", 34 35 "build:raichu": "cd crates/raichu && wasm-pack build --release --target web && cp -r pkg ../../apps/web/src", 35 36 "cron": "tools/cron.ts"
+22
systemd/rocksky-deezer.service
··· 1 + [Unit] 2 + Description=Rocksky Deezer Service 3 + After=network.target 4 + Wants=network-online.target 5 + 6 + [Service] 7 + Type=simple 8 + User=root 9 + WorkingDirectory=/root/github/rocksky 10 + ExecStart=/bin/bash -ic 'exec bun run deezer' 11 + Restart=on-failure 12 + RestartSec=5 13 + StandardOutput=journal 14 + StandardError=journal 15 + SyslogIdentifier=rocksky-deezer 16 + 17 + # Environment 18 + Environment=HOME=/root 19 + Environment=USER=root 20 + 21 + [Install] 22 + WantedBy=multi-user.target