@mwyeow/moonify.js 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,51 +1,50 @@
1
- # Moonify.JS
1
+ <p align="center">
2
+ <img src="https://github.com/user-attachments/assets/25066cc7-6966-489c-aa77-daf52a4a9810" alt="moonify.js logo" width="550" />
3
+ </p>
2
4
 
3
5
  <p align="center">
4
- <a href="https://discord.gg/Bjgx9gaaHG">
5
- <img src="https://img.shields.io/discord/1338630753512325242?color=5865F2&logo=discord&logoColor=white" alt="Discord" />
6
- </a>
7
- <a href="https://www.npmjs.com/package/moonify.js">
8
- <img src="https://img.shields.io/npm/v/moonify.js?color=CB3837&logo=npm" alt="npm version" />
9
- </a>
10
- <a href="https://www.npmjs.com/package/moonify.js">
11
- <img src="https://img.shields.io/npm/dt/moonify.js?color=blue" alt="npm downloads" />
12
- </a>
13
- <a href="https://github.com/mwyeow/moonify.js/graphs/contributors">
14
- <img src="https://img.shields.io/github/contributors/mwyeow/moonify.js?color=teal" alt="Contributors" />
15
- </a>
16
- <a href="https://github.com/mwyeow/moonify.js/commits/main">
17
- <img src="https://img.shields.io/github/last-commit/mwyeow/moonify.js" alt="Last Commit" />
18
- </a>
6
+ <a href="https://discord.gg/Bjgx9gaaHG"><img src="https://img.shields.io/discord/1338630753512325242?color=5865F2&logo=discord&logoColor=white" alt="Discord" /></a>
7
+ <a href="https://www.npmjs.com/package/@mwyeow/moonify.js"><img src="https://img.shields.io/npm/v/@mwyeow/moonify.js?color=CB3837&logo=npm" alt="npm version" /></a>
8
+ <a href="https://www.npmjs.com/package/@mwyeow/moonify.js"><img src="https://img.shields.io/npm/dt/@mwyeow/moonify.js?color=blue" alt="npm downloads" /></a>
9
+ <a href="https://github.com/mwyeow/moonify.js/graphs/contributors"><img src="https://img.shields.io/github/contributors/mwyeow/moonify.js?color=teal" alt="Contributors" /></a>
10
+ <a href="https://github.com/mwyeow/moonify.js/commits/main"><img src="https://img.shields.io/github/last-commit/mwyeow/moonify.js" alt="Last Commit" /></a>
19
11
  <img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License" />
20
12
  </p>
21
13
 
22
- A lightweight TypeScript client to fetch currently playing scrobbles from Last.fm and automatically enrich them with Spotify track links, artist URLs, and repeat tracking.
14
+ A lightweight TypeScript client for Last.fm and Spotify, providing scrobble history, user profiles, track information, artwork, and links in one simple package.
23
15
 
24
16
  ---
25
17
 
26
18
  ## Features
27
19
 
28
- - Fetches live playback status via Last.fm API.
29
- - Automatically searches Spotify for track and artist links.
30
- - Gracefully falls back to Last.fm URLs if Spotify credentials are unset or the track is not found.
31
- - Detects whether the current track is on repeat across the user's latest scrobbles.
32
- - Zero external runtime dependencies (uses native web APIs: `fetch`, `AbortController`, `btoa`).
33
- - Works across Node.js (18+), Bun, and Deno.
20
+ - Fetches live playback status via the Last.fm API.
21
+ - Searches Spotify for track links, artist URLs, album names, and cover art.
22
+ - Falls back to Last.fm artwork and URLs when needed.
23
+ - Resolves recent scrobbles with customisable limits and playback flags.
24
+ - Retrieves Last.fm user profiles.
25
+ - Detects whether the current track is on repeat.
26
+ - Zero external runtime dependencies.
27
+ - Works with Node.js 18+, Bun, and Deno.
28
+ -
34
29
 
35
30
  ---
36
31
 
37
32
  ## Installation
38
33
 
34
+ ### API Keys
35
+
36
+ - **Last.fm:** Get your API key and secret from the [Last.fm API account page](https://www.last.fm/api/account/create).
37
+ - **Spotify:** Create an app in the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) to get your Client ID and Client Secret.
38
+
39
39
  ```bash
40
40
  # using bun
41
- bun add moonify.js
41
+ bun add @mwyeow/moonify.js
42
42
 
43
43
  # using npm
44
- npm install moonify.js
44
+ npm install @mwyeow/moonify.js
45
45
 
46
46
  # using pnpm
47
- pnpm add moonify.js
48
-
47
+ pnpm add @mwyeow/moonify.js
49
48
  ```
50
49
 
51
50
  ---
@@ -53,7 +52,7 @@ pnpm add moonify.js
53
52
  ## Quick Start
54
53
 
55
54
  ```typescript
56
- import { Moonify } from "moonify.js";
55
+ import { Moonify } from "@mwyeow/moonify.js";
57
56
 
58
57
  const moonify = new Moonify({
59
58
  lastfmApiKey: process.env.LASTFM_API_KEY!,
@@ -63,18 +62,34 @@ const moonify = new Moonify({
63
62
  });
64
63
 
65
64
  async function run() {
66
- const result = await moonify.getCurrentlyPlaying("some_username");
67
-
68
- if (!result) {
69
- console.log("No track is playing right now.");
70
- return;
65
+ // fetch currently playing track
66
+ const current = await moonify.getCurrentlyPlaying("some_username");
67
+
68
+ if (current) {
69
+ console.log(`${current.trackName} by ${current.artistName}`);
70
+ console.log(`Album: ${current.albumName}`);
71
+ console.log(`Cover Art: ${current.coverArtUrl}`);
72
+ console.log(`Listen: ${current.trackUrl}`);
73
+
74
+ if (current.isOnRepeat) {
75
+ console.log(`Repeated ${current.repeatCount} times recently.`);
76
+ }
71
77
  }
72
78
 
73
- console.log(`${result.trackName} by ${result.artistName}`);
74
- console.log(`Listen here: ${result.trackUrl}`);
79
+ // fetch recent scrobbles
80
+ const recents = await moonify.getRecentTracks("some_username", 5);
81
+ for (const track of recents) {
82
+ console.log(
83
+ `${track.trackName} - ${track.artistName} (Playing: ${track.isPlaying})`,
84
+ );
85
+ }
75
86
 
76
- if (result.isOnRepeat) {
77
- console.log(`Repeated ${result.repeatCount} times recently.`);
87
+ // fetch user profile
88
+ const profile = await moonify.getUserProfile("some_username");
89
+ if (profile) {
90
+ console.log(
91
+ `${profile.username} has ${profile.playCount.toLocaleString()} total scrobbles`,
92
+ );
78
93
  }
79
94
  }
80
95
 
@@ -83,22 +98,63 @@ run();
83
98
 
84
99
  ---
85
100
 
86
- ## Response Object
87
-
88
- `getCurrentlyPlaying(username)` resolves to `null` if nothing is playing or user scrobbles are empty. When active, it returns:
89
-
90
- | Field | Type | Description |
91
- | ------------------ | --------- | -------------------------------------------------------- |
92
- | `trackName` | `string` | Track name |
93
- | `artistName` | `string` | Artist name |
94
- | `trackUrl` | `string` | Spotify track URL (falls back to Last.fm URL) |
95
- | `artistUrl` | `string` | Spotify artist URL (falls back to Last.fm URL) |
96
- | `lastFmTrackUrl` | `string` | Guaranteed Last.fm track URL |
97
- | `lastFmArtistUrl` | `string` | Guaranteed Last.fm artist URL |
98
- | `spotifyTrackUrl` | `string` | Direct Spotify track URL (null if unfound/unconfigured) |
99
- | `spotifyArtistUrl` | `string` | Direct Spotify artist URL (null if unfound/unconfigured) |
100
- | `isOnRepeat` | `boolean` | `true` if repeated 2+ times in recent 10 tracks |
101
- | `repeatCount` | `number` | Total repeat count in recent tracks |
101
+ ## Reference
102
+
103
+ ### `moonify.getCurrentlyPlaying(username)`
104
+
105
+ Resolves to `null` if nothing is playing or if the user's scrobble history is empty. When active, it returns:
106
+
107
+ | Field | Type | Description |
108
+ | ------------------ | --------- | --------------------------------------------------------------- |
109
+ | `trackName` | `string` | Track name |
110
+ | `artistName` | `string` | Artist name |
111
+ | `albumName` | `string` | Spotify album title (falls back to Last.fm album) |
112
+ | `coverArtUrl` | `string` | Spotify high-resolution cover art (falls back to Last.fm image) |
113
+ | `trackUrl` | `string` | Spotify track URL (falls back to Last.fm URL) |
114
+ | `artistUrl` | `string` | Spotify artist URL (falls back to Last.fm URL) |
115
+ | `lastFmTrackUrl` | `string` | Last.fm track URL |
116
+ | `lastFmArtistUrl` | `string` | Last.fm artist URL |
117
+ | `spotifyTrackUrl` | `string` | Direct Spotify track URL |
118
+ | `spotifyArtistUrl` | `string` | Direct Spotify artist URL |
119
+ | `isOnRepeat` | `boolean` | `true` if repeated 2+ times in recent tracks |
120
+ | `repeatCount` | `number` | Total repeat occurrences in recent scrobbles |
121
+
122
+ ---
123
+
124
+ ### `moonify.getRecentTracks(username, limit?)`
125
+
126
+ Retrieves an array of recent scrobbles up to the specified `limit` (default: `5`), fully enriched with artwork, album metadata, and playback states:
127
+
128
+ | Field | Type | Description |
129
+ | ------------------ | --------- | --------------------------------------------------------------- |
130
+ | `trackName` | `string` | Track name |
131
+ | `artistName` | `string` | Artist name |
132
+ | `albumName` | `string` | Spotify album title (falls back to Last.fm album) |
133
+ | `coverArtUrl` | `string` | Spotify high-resolution cover art (falls back to Last.fm image) |
134
+ | `trackUrl` | `string` | Spotify track URL (falls back to Last.fm URL) |
135
+ | `artistUrl` | `string` | Spotify artist URL (falls back to Last.fm URL) |
136
+ | `lastFmTrackUrl` | `string` | Last.fm track URL |
137
+ | `lastFmArtistUrl` | `string` | Last.fm artist URL |
138
+ | `spotifyTrackUrl` | `string` | Direct Spotify track URL |
139
+ | `spotifyArtistUrl` | `string` | Direct Spotify artist URL |
140
+ | `isPlaying` | `boolean` | `true` if this track is actively scrobbling right now |
141
+
142
+ ---
143
+
144
+ ### `moonify.getUserProfile(username)`
145
+
146
+ Resolves user profile details and scrobble statistics via Last.fm's `user.getinfo` endpoint. Returns `null` if the user is not found:
147
+
148
+ | Field | Type | Description |
149
+ | -------------- | -------- | ----------------------------------- |
150
+ | `username` | `string` | Last.fm username |
151
+ | `url` | `string` | Profile URL on Last.fm |
152
+ | `avatarUrl` | `string` | High-resolution avatar URL |
153
+ | `country` | `string` | Country listed on profile |
154
+ | `playCount` | `number` | Total lifetime scrobble count |
155
+ | `registeredAt` | `number` | Account registration Unix timestamp |
156
+
157
+ ---
102
158
 
103
159
  ## Contributing
104
160
 
package/dist/index.cjs CHANGED
@@ -95,6 +95,15 @@ var LastFmService = class {
95
95
  if (!raw) return [];
96
96
  return Array.isArray(raw) ? raw : [raw];
97
97
  }
98
+ async getUserInfo(username) {
99
+ const url = `https://ws.audioscrobbler.com/2.0/?method=user.getinfo&user=${encodeURIComponent(
100
+ username
101
+ )}&api_key=${this.apiKey}&format=json`;
102
+ const data = await requestJson("lastfm", url, {
103
+ timeoutMs: this.timeoutMs
104
+ });
105
+ return data.user || null;
106
+ }
98
107
  };
99
108
 
100
109
  // src/services/spotify.service.ts
@@ -141,11 +150,22 @@ var SpotifyService = class {
141
150
  }
142
151
  async resolveTrack(track, artist) {
143
152
  if (!this.isConfigured) {
144
- return { trackUrl: null, artistUrl: null };
153
+ return {
154
+ trackUrl: null,
155
+ artistUrl: null,
156
+ albumName: null,
157
+ coverArtUrl: null
158
+ };
145
159
  }
146
160
  try {
147
161
  const token = await this.getAccessToken();
148
- if (!token) return { trackUrl: null, artistUrl: null };
162
+ if (!token)
163
+ return {
164
+ trackUrl: null,
165
+ artistUrl: null,
166
+ albumName: null,
167
+ coverArtUrl: null
168
+ };
149
169
  const query = encodeURIComponent(`track:${track} artist:${artist}`);
150
170
  const data = await requestJson(
151
171
  "spotify",
@@ -156,13 +176,28 @@ var SpotifyService = class {
156
176
  }
157
177
  );
158
178
  const item = data.tracks?.items?.[0];
159
- if (!item) return { trackUrl: null, artistUrl: null };
179
+ if (!item)
180
+ return {
181
+ trackUrl: null,
182
+ artistUrl: null,
183
+ albumName: null,
184
+ coverArtUrl: null
185
+ };
186
+ const coverArtUrl = item.album?.images?.[0]?.url || null;
187
+ const albumName = item.album?.name || null;
160
188
  return {
161
189
  trackUrl: item.external_urls?.spotify || null,
162
- artistUrl: item.artists?.[0]?.external_urls?.spotify || null
190
+ artistUrl: item.artists?.[0]?.external_urls?.spotify || null,
191
+ albumName,
192
+ coverArtUrl
163
193
  };
164
194
  } catch {
165
- return { trackUrl: null, artistUrl: null };
195
+ return {
196
+ trackUrl: null,
197
+ artistUrl: null,
198
+ albumName: null,
199
+ coverArtUrl: null
200
+ };
166
201
  }
167
202
  }
168
203
  };
@@ -200,6 +235,9 @@ var Moonify = class {
200
235
  const artistName = currentTrack.artist["#text"] || currentTrack.artist.name || "Unknown Artist";
201
236
  const lastFmTrackUrl = currentTrack.url;
202
237
  const lastFmArtistUrl = `https://www.last.fm/music/${encodeURIComponent(artistName)}`;
238
+ const lastFmAlbum = currentTrack.album?.["#text"] || null;
239
+ const lastFmImages = currentTrack.image;
240
+ const lastFmCoverArt = lastFmImages?.find((img) => img.size === "extralarge")?.["#text"] || lastFmImages?.find((img) => img.size === "large")?.["#text"] || lastFmImages?.find((img) => img["#text"])?.["#text"] || null;
203
241
  const spotifyMatch = await this.spotify.resolveTrack(trackName, artistName);
204
242
  const normTrack = normalizeString(trackName);
205
243
  const normArtist = normalizeString(artistName);
@@ -217,6 +255,8 @@ var Moonify = class {
217
255
  return {
218
256
  trackName,
219
257
  artistName,
258
+ albumName: spotifyMatch.albumName || lastFmAlbum,
259
+ coverArtUrl: spotifyMatch.coverArtUrl || lastFmCoverArt,
220
260
  trackUrl: spotifyMatch.trackUrl || lastFmTrackUrl,
221
261
  artistUrl: spotifyMatch.artistUrl || lastFmArtistUrl,
222
262
  lastFmTrackUrl,
@@ -227,6 +267,58 @@ var Moonify = class {
227
267
  repeatCount
228
268
  };
229
269
  }
270
+ async getRecentTracks(username, limit = 5) {
271
+ const rawTracks = await this.lastfm.getRecentTracks(username, limit);
272
+ if (!rawTracks.length) return [];
273
+ const slicedTracks = rawTracks.slice(0, limit);
274
+ return Promise.all(
275
+ slicedTracks.map(async (raw) => {
276
+ const trackName = raw.name;
277
+ const artistName = raw.artist["#text"] || raw.artist.name || "Unknown Artist";
278
+ const isPlaying = raw["@attr"]?.nowplaying === "true";
279
+ const lastFmTrackUrl = raw.url;
280
+ const lastFmArtistUrl = `https://www.last.fm/music/${encodeURIComponent(artistName)}`;
281
+ const lastFmAlbum = raw.album?.["#text"] || null;
282
+ const lastFmImages = raw.image;
283
+ const lastFmCoverArt = lastFmImages?.find((img) => img.size === "extralarge")?.["#text"] || lastFmImages?.find((img) => img.size === "large")?.["#text"] || lastFmImages?.find((img) => img["#text"])?.["#text"] || null;
284
+ const spotifyMatch = await this.spotify.resolveTrack(
285
+ trackName,
286
+ artistName
287
+ );
288
+ return {
289
+ trackName,
290
+ artistName,
291
+ albumName: spotifyMatch.albumName || lastFmAlbum,
292
+ coverArtUrl: spotifyMatch.coverArtUrl || lastFmCoverArt,
293
+ trackUrl: spotifyMatch.trackUrl || lastFmTrackUrl,
294
+ artistUrl: spotifyMatch.artistUrl || lastFmArtistUrl,
295
+ lastFmTrackUrl,
296
+ lastFmArtistUrl,
297
+ spotifyTrackUrl: spotifyMatch.trackUrl,
298
+ spotifyArtistUrl: spotifyMatch.artistUrl,
299
+ isPlaying
300
+ };
301
+ })
302
+ );
303
+ }
304
+ async getUserProfile(username) {
305
+ const user = await this.lastfm.getUserInfo(username);
306
+ if (!user) return null;
307
+ const validImages = user.image?.filter(
308
+ (img) => Boolean(img["#text"]?.trim())
309
+ );
310
+ const avatarUrl = validImages?.find((img) => img.size === "extralarge")?.["#text"] || validImages?.find((img) => img.size === "large")?.["#text"] || validImages?.at(-1)?.["#text"] || null;
311
+ const playCount = Number(user.playcount ?? 0);
312
+ const registeredAt = user.registered ? Number(user.registered.unixtime || user.registered["#text"]) : null;
313
+ return {
314
+ username: user.name,
315
+ url: user.url,
316
+ avatarUrl,
317
+ country: user.country && user.country !== "None" ? user.country : null,
318
+ playCount: Number.isNaN(playCount) ? 0 : playCount,
319
+ registeredAt: Number.isNaN(registeredAt) ? null : registeredAt
320
+ };
321
+ }
230
322
  };
231
323
  // Annotate the CommonJS export names for ESM import in node:
232
324
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -8,6 +8,8 @@ interface MoonifyConfig {
8
8
  interface ResolvedCurrentlyPlaying {
9
9
  trackName: string;
10
10
  artistName: string;
11
+ albumName: string | null;
12
+ coverArtUrl: string | null;
11
13
  trackUrl: string;
12
14
  artistUrl: string;
13
15
  lastFmTrackUrl: string;
@@ -17,12 +19,35 @@ interface ResolvedCurrentlyPlaying {
17
19
  isOnRepeat: boolean;
18
20
  repeatCount: number;
19
21
  }
22
+ interface ResolvedTrackItem {
23
+ trackName: string;
24
+ artistName: string;
25
+ albumName: string | null;
26
+ coverArtUrl: string | null;
27
+ trackUrl: string;
28
+ artistUrl: string;
29
+ lastFmTrackUrl: string;
30
+ lastFmArtistUrl: string;
31
+ spotifyTrackUrl: string | null;
32
+ spotifyArtistUrl: string | null;
33
+ isPlaying: boolean;
34
+ }
35
+ interface LastFmUserProfile {
36
+ username: string;
37
+ url: string;
38
+ avatarUrl: string | null;
39
+ country: string | null;
40
+ playCount: number;
41
+ registeredAt: number | null;
42
+ }
20
43
 
21
44
  declare class Moonify {
22
45
  private lastfm;
23
46
  private spotify;
24
47
  constructor(config: MoonifyConfig);
25
48
  getCurrentlyPlaying(username: string): Promise<ResolvedCurrentlyPlaying | null>;
49
+ getRecentTracks(username: string, limit?: number): Promise<ResolvedTrackItem[]>;
50
+ getUserProfile(username: string): Promise<LastFmUserProfile | null>;
26
51
  }
27
52
 
28
53
  interface LastFmTrackArtist {
@@ -30,6 +55,10 @@ interface LastFmTrackArtist {
30
55
  name?: string;
31
56
  mbid?: string;
32
57
  }
58
+ interface LastFmTrackImage {
59
+ "#text": string;
60
+ size: "small" | "medium" | "large" | "extralarge" | "";
61
+ }
33
62
  interface LastFmRawTrack {
34
63
  name: string;
35
64
  artist: LastFmTrackArtist;
@@ -39,21 +68,40 @@ interface LastFmRawTrack {
39
68
  album?: {
40
69
  "#text"?: string;
41
70
  };
71
+ image?: LastFmTrackImage[];
42
72
  "@attr"?: {
43
73
  nowplaying?: string;
44
74
  };
45
75
  }
76
+ interface LastFmUserInfoResponse {
77
+ user?: {
78
+ name: string;
79
+ realname?: string;
80
+ url: string;
81
+ image?: LastFmTrackImage[];
82
+ country?: string;
83
+ playcount?: string | number;
84
+ playlists?: string | number;
85
+ registered?: {
86
+ unixtime: string;
87
+ "#text": number;
88
+ };
89
+ };
90
+ }
46
91
 
47
92
  declare class LastFmService {
48
93
  private apiKey;
49
94
  private timeoutMs;
50
95
  constructor(apiKey: string, timeoutMs?: number);
51
96
  getRecentTracks(username: string, limit?: number): Promise<LastFmRawTrack[]>;
97
+ getUserInfo(username: string): Promise<LastFmUserInfoResponse["user"] | null>;
52
98
  }
53
99
 
54
100
  interface SpotifyTrackMatch {
55
101
  trackUrl: string | null;
56
102
  artistUrl: string | null;
103
+ albumName: string | null;
104
+ coverArtUrl: string | null;
57
105
  }
58
106
 
59
107
  declare class SpotifyService {
@@ -80,4 +128,4 @@ declare class MoonifyAuthError extends MoonifyError {
80
128
  constructor(service: "lastfm" | "spotify", message: string);
81
129
  }
82
130
 
83
- export { type LastFmRawTrack, LastFmService, Moonify, MoonifyApiError, MoonifyAuthError, type MoonifyConfig, MoonifyError, type ResolvedCurrentlyPlaying, SpotifyService, type SpotifyTrackMatch };
131
+ export { type LastFmRawTrack, LastFmService, type LastFmUserInfoResponse, type LastFmUserProfile, Moonify, MoonifyApiError, MoonifyAuthError, type MoonifyConfig, MoonifyError, type ResolvedCurrentlyPlaying, type ResolvedTrackItem, SpotifyService, type SpotifyTrackMatch };
package/dist/index.d.ts CHANGED
@@ -8,6 +8,8 @@ interface MoonifyConfig {
8
8
  interface ResolvedCurrentlyPlaying {
9
9
  trackName: string;
10
10
  artistName: string;
11
+ albumName: string | null;
12
+ coverArtUrl: string | null;
11
13
  trackUrl: string;
12
14
  artistUrl: string;
13
15
  lastFmTrackUrl: string;
@@ -17,12 +19,35 @@ interface ResolvedCurrentlyPlaying {
17
19
  isOnRepeat: boolean;
18
20
  repeatCount: number;
19
21
  }
22
+ interface ResolvedTrackItem {
23
+ trackName: string;
24
+ artistName: string;
25
+ albumName: string | null;
26
+ coverArtUrl: string | null;
27
+ trackUrl: string;
28
+ artistUrl: string;
29
+ lastFmTrackUrl: string;
30
+ lastFmArtistUrl: string;
31
+ spotifyTrackUrl: string | null;
32
+ spotifyArtistUrl: string | null;
33
+ isPlaying: boolean;
34
+ }
35
+ interface LastFmUserProfile {
36
+ username: string;
37
+ url: string;
38
+ avatarUrl: string | null;
39
+ country: string | null;
40
+ playCount: number;
41
+ registeredAt: number | null;
42
+ }
20
43
 
21
44
  declare class Moonify {
22
45
  private lastfm;
23
46
  private spotify;
24
47
  constructor(config: MoonifyConfig);
25
48
  getCurrentlyPlaying(username: string): Promise<ResolvedCurrentlyPlaying | null>;
49
+ getRecentTracks(username: string, limit?: number): Promise<ResolvedTrackItem[]>;
50
+ getUserProfile(username: string): Promise<LastFmUserProfile | null>;
26
51
  }
27
52
 
28
53
  interface LastFmTrackArtist {
@@ -30,6 +55,10 @@ interface LastFmTrackArtist {
30
55
  name?: string;
31
56
  mbid?: string;
32
57
  }
58
+ interface LastFmTrackImage {
59
+ "#text": string;
60
+ size: "small" | "medium" | "large" | "extralarge" | "";
61
+ }
33
62
  interface LastFmRawTrack {
34
63
  name: string;
35
64
  artist: LastFmTrackArtist;
@@ -39,21 +68,40 @@ interface LastFmRawTrack {
39
68
  album?: {
40
69
  "#text"?: string;
41
70
  };
71
+ image?: LastFmTrackImage[];
42
72
  "@attr"?: {
43
73
  nowplaying?: string;
44
74
  };
45
75
  }
76
+ interface LastFmUserInfoResponse {
77
+ user?: {
78
+ name: string;
79
+ realname?: string;
80
+ url: string;
81
+ image?: LastFmTrackImage[];
82
+ country?: string;
83
+ playcount?: string | number;
84
+ playlists?: string | number;
85
+ registered?: {
86
+ unixtime: string;
87
+ "#text": number;
88
+ };
89
+ };
90
+ }
46
91
 
47
92
  declare class LastFmService {
48
93
  private apiKey;
49
94
  private timeoutMs;
50
95
  constructor(apiKey: string, timeoutMs?: number);
51
96
  getRecentTracks(username: string, limit?: number): Promise<LastFmRawTrack[]>;
97
+ getUserInfo(username: string): Promise<LastFmUserInfoResponse["user"] | null>;
52
98
  }
53
99
 
54
100
  interface SpotifyTrackMatch {
55
101
  trackUrl: string | null;
56
102
  artistUrl: string | null;
103
+ albumName: string | null;
104
+ coverArtUrl: string | null;
57
105
  }
58
106
 
59
107
  declare class SpotifyService {
@@ -80,4 +128,4 @@ declare class MoonifyAuthError extends MoonifyError {
80
128
  constructor(service: "lastfm" | "spotify", message: string);
81
129
  }
82
130
 
83
- export { type LastFmRawTrack, LastFmService, Moonify, MoonifyApiError, MoonifyAuthError, type MoonifyConfig, MoonifyError, type ResolvedCurrentlyPlaying, SpotifyService, type SpotifyTrackMatch };
131
+ export { type LastFmRawTrack, LastFmService, type LastFmUserInfoResponse, type LastFmUserProfile, Moonify, MoonifyApiError, MoonifyAuthError, type MoonifyConfig, MoonifyError, type ResolvedCurrentlyPlaying, type ResolvedTrackItem, SpotifyService, type SpotifyTrackMatch };
package/dist/index.js CHANGED
@@ -64,6 +64,15 @@ var LastFmService = class {
64
64
  if (!raw) return [];
65
65
  return Array.isArray(raw) ? raw : [raw];
66
66
  }
67
+ async getUserInfo(username) {
68
+ const url = `https://ws.audioscrobbler.com/2.0/?method=user.getinfo&user=${encodeURIComponent(
69
+ username
70
+ )}&api_key=${this.apiKey}&format=json`;
71
+ const data = await requestJson("lastfm", url, {
72
+ timeoutMs: this.timeoutMs
73
+ });
74
+ return data.user || null;
75
+ }
67
76
  };
68
77
 
69
78
  // src/services/spotify.service.ts
@@ -110,11 +119,22 @@ var SpotifyService = class {
110
119
  }
111
120
  async resolveTrack(track, artist) {
112
121
  if (!this.isConfigured) {
113
- return { trackUrl: null, artistUrl: null };
122
+ return {
123
+ trackUrl: null,
124
+ artistUrl: null,
125
+ albumName: null,
126
+ coverArtUrl: null
127
+ };
114
128
  }
115
129
  try {
116
130
  const token = await this.getAccessToken();
117
- if (!token) return { trackUrl: null, artistUrl: null };
131
+ if (!token)
132
+ return {
133
+ trackUrl: null,
134
+ artistUrl: null,
135
+ albumName: null,
136
+ coverArtUrl: null
137
+ };
118
138
  const query = encodeURIComponent(`track:${track} artist:${artist}`);
119
139
  const data = await requestJson(
120
140
  "spotify",
@@ -125,13 +145,28 @@ var SpotifyService = class {
125
145
  }
126
146
  );
127
147
  const item = data.tracks?.items?.[0];
128
- if (!item) return { trackUrl: null, artistUrl: null };
148
+ if (!item)
149
+ return {
150
+ trackUrl: null,
151
+ artistUrl: null,
152
+ albumName: null,
153
+ coverArtUrl: null
154
+ };
155
+ const coverArtUrl = item.album?.images?.[0]?.url || null;
156
+ const albumName = item.album?.name || null;
129
157
  return {
130
158
  trackUrl: item.external_urls?.spotify || null,
131
- artistUrl: item.artists?.[0]?.external_urls?.spotify || null
159
+ artistUrl: item.artists?.[0]?.external_urls?.spotify || null,
160
+ albumName,
161
+ coverArtUrl
132
162
  };
133
163
  } catch {
134
- return { trackUrl: null, artistUrl: null };
164
+ return {
165
+ trackUrl: null,
166
+ artistUrl: null,
167
+ albumName: null,
168
+ coverArtUrl: null
169
+ };
135
170
  }
136
171
  }
137
172
  };
@@ -169,6 +204,9 @@ var Moonify = class {
169
204
  const artistName = currentTrack.artist["#text"] || currentTrack.artist.name || "Unknown Artist";
170
205
  const lastFmTrackUrl = currentTrack.url;
171
206
  const lastFmArtistUrl = `https://www.last.fm/music/${encodeURIComponent(artistName)}`;
207
+ const lastFmAlbum = currentTrack.album?.["#text"] || null;
208
+ const lastFmImages = currentTrack.image;
209
+ const lastFmCoverArt = lastFmImages?.find((img) => img.size === "extralarge")?.["#text"] || lastFmImages?.find((img) => img.size === "large")?.["#text"] || lastFmImages?.find((img) => img["#text"])?.["#text"] || null;
172
210
  const spotifyMatch = await this.spotify.resolveTrack(trackName, artistName);
173
211
  const normTrack = normalizeString(trackName);
174
212
  const normArtist = normalizeString(artistName);
@@ -186,6 +224,8 @@ var Moonify = class {
186
224
  return {
187
225
  trackName,
188
226
  artistName,
227
+ albumName: spotifyMatch.albumName || lastFmAlbum,
228
+ coverArtUrl: spotifyMatch.coverArtUrl || lastFmCoverArt,
189
229
  trackUrl: spotifyMatch.trackUrl || lastFmTrackUrl,
190
230
  artistUrl: spotifyMatch.artistUrl || lastFmArtistUrl,
191
231
  lastFmTrackUrl,
@@ -196,6 +236,58 @@ var Moonify = class {
196
236
  repeatCount
197
237
  };
198
238
  }
239
+ async getRecentTracks(username, limit = 5) {
240
+ const rawTracks = await this.lastfm.getRecentTracks(username, limit);
241
+ if (!rawTracks.length) return [];
242
+ const slicedTracks = rawTracks.slice(0, limit);
243
+ return Promise.all(
244
+ slicedTracks.map(async (raw) => {
245
+ const trackName = raw.name;
246
+ const artistName = raw.artist["#text"] || raw.artist.name || "Unknown Artist";
247
+ const isPlaying = raw["@attr"]?.nowplaying === "true";
248
+ const lastFmTrackUrl = raw.url;
249
+ const lastFmArtistUrl = `https://www.last.fm/music/${encodeURIComponent(artistName)}`;
250
+ const lastFmAlbum = raw.album?.["#text"] || null;
251
+ const lastFmImages = raw.image;
252
+ const lastFmCoverArt = lastFmImages?.find((img) => img.size === "extralarge")?.["#text"] || lastFmImages?.find((img) => img.size === "large")?.["#text"] || lastFmImages?.find((img) => img["#text"])?.["#text"] || null;
253
+ const spotifyMatch = await this.spotify.resolveTrack(
254
+ trackName,
255
+ artistName
256
+ );
257
+ return {
258
+ trackName,
259
+ artistName,
260
+ albumName: spotifyMatch.albumName || lastFmAlbum,
261
+ coverArtUrl: spotifyMatch.coverArtUrl || lastFmCoverArt,
262
+ trackUrl: spotifyMatch.trackUrl || lastFmTrackUrl,
263
+ artistUrl: spotifyMatch.artistUrl || lastFmArtistUrl,
264
+ lastFmTrackUrl,
265
+ lastFmArtistUrl,
266
+ spotifyTrackUrl: spotifyMatch.trackUrl,
267
+ spotifyArtistUrl: spotifyMatch.artistUrl,
268
+ isPlaying
269
+ };
270
+ })
271
+ );
272
+ }
273
+ async getUserProfile(username) {
274
+ const user = await this.lastfm.getUserInfo(username);
275
+ if (!user) return null;
276
+ const validImages = user.image?.filter(
277
+ (img) => Boolean(img["#text"]?.trim())
278
+ );
279
+ const avatarUrl = validImages?.find((img) => img.size === "extralarge")?.["#text"] || validImages?.find((img) => img.size === "large")?.["#text"] || validImages?.at(-1)?.["#text"] || null;
280
+ const playCount = Number(user.playcount ?? 0);
281
+ const registeredAt = user.registered ? Number(user.registered.unixtime || user.registered["#text"]) : null;
282
+ return {
283
+ username: user.name,
284
+ url: user.url,
285
+ avatarUrl,
286
+ country: user.country && user.country !== "None" ? user.country : null,
287
+ playCount: Number.isNaN(playCount) ? 0 : playCount,
288
+ registeredAt: Number.isNaN(registeredAt) ? null : registeredAt
289
+ };
290
+ }
199
291
  };
200
292
  export {
201
293
  LastFmService,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mwyeow/moonify.js",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Resolve Last.fm currently playing track and enrich with Spotify metadata",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",