@mwyeow/moonify.js 1.0.1 → 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 +109 -53
- package/dist/index.cjs +97 -5
- package/dist/index.d.cts +49 -1
- package/dist/index.d.ts +49 -1
- package/dist/index.js +97 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,51 +1,50 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
<a href="https://
|
|
8
|
-
|
|
9
|
-
</a>
|
|
10
|
-
<a href="https://www.npmjs.com/package/@mwyeow/moonify.js">
|
|
11
|
-
<img src="https://img.shields.io/npm/dt/@mwyeow/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
|
|
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
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
##
|
|
87
|
-
|
|
88
|
-
`getCurrentlyPlaying(username)`
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
|
93
|
-
|
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
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 {
|
|
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)
|
|
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)
|
|
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 {
|
|
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 {
|
|
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)
|
|
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)
|
|
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 {
|
|
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,
|