@rmdes/indiekit-endpoint-lastfm 1.0.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.
@@ -0,0 +1,89 @@
1
+ import { LastFmClient } from "../lastfm-client.js";
2
+ import { getAllStats, getScrobbleTrends } from "../stats.js";
3
+ import { getCachedStats } from "../sync.js";
4
+
5
+ /**
6
+ * Stats controller
7
+ */
8
+ export const statsController = {
9
+ /**
10
+ * JSON API for all stats
11
+ * @type {import("express").RequestHandler}
12
+ */
13
+ async api(request, response, next) {
14
+ try {
15
+ const { lastfmConfig } = request.app.locals.application;
16
+
17
+ if (!lastfmConfig) {
18
+ return response.status(500).json({ error: "Not configured" });
19
+ }
20
+
21
+ // Try database first, fall back to cache for public routes
22
+ const db = request.app.locals.database;
23
+ let stats;
24
+
25
+ if (db) {
26
+ // Create client for API-based top artists/albums
27
+ const client = new LastFmClient({
28
+ apiKey: lastfmConfig.apiKey,
29
+ username: lastfmConfig.username,
30
+ cacheTtl: lastfmConfig.cacheTtl,
31
+ });
32
+
33
+ stats = await getAllStats(db, lastfmConfig.limits, client);
34
+ } else {
35
+ // Public routes don't have DB access, use cached stats
36
+ stats = getCachedStats();
37
+ if (!stats) {
38
+ return response.status(503).json({
39
+ error: "Stats not available yet",
40
+ message:
41
+ "Stats are computed during background sync. Please try again shortly.",
42
+ });
43
+ }
44
+ }
45
+
46
+ response.json(stats);
47
+ } catch (error) {
48
+ console.error("[Last.fm] Stats API error:", error);
49
+ response.status(500).json({ error: error.message });
50
+ }
51
+ },
52
+
53
+ /**
54
+ * JSON API for trends only (for charts)
55
+ * @type {import("express").RequestHandler}
56
+ */
57
+ async apiTrends(request, response, next) {
58
+ try {
59
+ const { lastfmConfig } = request.app.locals.application;
60
+
61
+ if (!lastfmConfig) {
62
+ return response.status(500).json({ error: "Not configured" });
63
+ }
64
+
65
+ const db = request.app.locals.database;
66
+ const days = Math.min(parseInt(request.query.days) || 30, 90);
67
+
68
+ if (db) {
69
+ const trends = await getScrobbleTrends(db, days);
70
+ return response.json({ trends, days });
71
+ }
72
+
73
+ // Fall back to cached stats for public routes
74
+ const cachedStats = getCachedStats();
75
+ if (cachedStats?.trends) {
76
+ return response.json({ trends: cachedStats.trends, days: 30 });
77
+ }
78
+
79
+ return response.status(503).json({
80
+ error: "Trends not available yet",
81
+ message:
82
+ "Trends are computed during background sync. Please try again shortly.",
83
+ });
84
+ } catch (error) {
85
+ console.error("[Last.fm] Trends API error:", error);
86
+ response.status(500).json({ error: error.message });
87
+ }
88
+ },
89
+ };
@@ -0,0 +1,258 @@
1
+ import { IndiekitError } from "@indiekit/error";
2
+
3
+ const API_BASE = "https://ws.audioscrobbler.com/2.0/";
4
+
5
+ export class LastFmClient {
6
+ /**
7
+ * @param {object} options - Client options
8
+ * @param {string} options.apiKey - Last.fm API key
9
+ * @param {string} options.username - Last.fm username to fetch data for
10
+ * @param {number} [options.cacheTtl] - Cache TTL in milliseconds
11
+ */
12
+ constructor(options = {}) {
13
+ this.apiKey = options.apiKey;
14
+ this.username = options.username;
15
+ this.cacheTtl = options.cacheTtl || 900_000;
16
+ this.cache = new Map();
17
+ }
18
+
19
+ /**
20
+ * Fetch from Last.fm API with caching
21
+ * @param {string} method - API method name
22
+ * @param {object} [params] - Additional query parameters
23
+ * @returns {Promise<object>} - Response data
24
+ */
25
+ async fetch(method, params = {}) {
26
+ const url = new URL(API_BASE);
27
+ url.searchParams.set("method", method);
28
+ url.searchParams.set("api_key", this.apiKey);
29
+ url.searchParams.set("format", "json");
30
+ url.searchParams.set("user", this.username);
31
+
32
+ Object.entries(params).forEach(([key, value]) => {
33
+ if (value !== undefined && value !== null) {
34
+ url.searchParams.set(key, String(value));
35
+ }
36
+ });
37
+
38
+ const cacheKey = url.toString();
39
+
40
+ // Check cache first
41
+ const cached = this.cache.get(cacheKey);
42
+ if (cached && Date.now() - cached.timestamp < this.cacheTtl) {
43
+ return cached.data;
44
+ }
45
+
46
+ const response = await fetch(url.toString(), {
47
+ headers: {
48
+ Accept: "application/json",
49
+ "User-Agent": "indiekit-endpoint-lastfm/1.0.0",
50
+ },
51
+ });
52
+
53
+ if (!response.ok) {
54
+ throw await IndiekitError.fromFetch(response);
55
+ }
56
+
57
+ const data = await response.json();
58
+
59
+ // Check for Last.fm API errors
60
+ if (data.error) {
61
+ throw new Error(`Last.fm API error ${data.error}: ${data.message}`);
62
+ }
63
+
64
+ // Cache result
65
+ this.cache.set(cacheKey, { data, timestamp: Date.now() });
66
+
67
+ return data;
68
+ }
69
+
70
+ /**
71
+ * Get recent tracks (scrobbles)
72
+ * @param {number} [page] - Page number
73
+ * @param {number} [limit] - Items per page (max 200)
74
+ * @param {number} [from] - Start timestamp (UNIX)
75
+ * @param {number} [to] - End timestamp (UNIX)
76
+ * @returns {Promise<object>} - Recent tracks response
77
+ */
78
+ async getRecentTracks(page = 1, limit = 50, from = null, to = null) {
79
+ const params = {
80
+ page,
81
+ limit: Math.min(limit, 200),
82
+ extended: 1, // Include loved status and artist info
83
+ };
84
+
85
+ if (from) params.from = from;
86
+ if (to) params.to = to;
87
+
88
+ return this.fetch("user.getRecentTracks", params);
89
+ }
90
+
91
+ /**
92
+ * Get all recent tracks by paginating through all pages
93
+ * @param {number} [maxPages] - Maximum pages to fetch (safety limit)
94
+ * @returns {Promise<Array>} - All recent tracks
95
+ */
96
+ async getAllRecentTracks(maxPages = 50) {
97
+ const allTracks = [];
98
+ let page = 1;
99
+ let hasMore = true;
100
+
101
+ while (hasMore && page <= maxPages) {
102
+ const response = await this.getRecentTracks(page, 200);
103
+ const tracks = response.recenttracks?.track || [];
104
+
105
+ // Filter out "now playing" track (has @attr.nowplaying)
106
+ const scrobbledTracks = tracks.filter((t) => !t["@attr"]?.nowplaying);
107
+ allTracks.push(...scrobbledTracks);
108
+
109
+ const totalPages = parseInt(response.recenttracks?.["@attr"]?.totalPages) || 1;
110
+ hasMore = page < totalPages;
111
+ page++;
112
+ }
113
+
114
+ return allTracks;
115
+ }
116
+
117
+ /**
118
+ * Get new scrobbles since a given timestamp
119
+ * Used for incremental sync
120
+ * @param {Date} since - Only fetch scrobbles after this date
121
+ * @returns {Promise<Array>} - New scrobbles
122
+ */
123
+ async getNewScrobbles(since) {
124
+ const fromTimestamp = Math.floor(since.getTime() / 1000);
125
+ const newScrobbles = [];
126
+ let page = 1;
127
+ let hasMore = true;
128
+
129
+ while (hasMore) {
130
+ const response = await this.getRecentTracks(page, 200, fromTimestamp);
131
+ const tracks = response.recenttracks?.track || [];
132
+
133
+ // Filter out "now playing" track
134
+ const scrobbledTracks = tracks.filter((t) => !t["@attr"]?.nowplaying);
135
+ newScrobbles.push(...scrobbledTracks);
136
+
137
+ const totalPages = parseInt(response.recenttracks?.["@attr"]?.totalPages) || 1;
138
+ hasMore = page < totalPages;
139
+ page++;
140
+ }
141
+
142
+ return newScrobbles;
143
+ }
144
+
145
+ /**
146
+ * Get loved tracks
147
+ * @param {number} [page] - Page number
148
+ * @param {number} [limit] - Items per page
149
+ * @returns {Promise<object>} - Loved tracks response
150
+ */
151
+ async getLovedTracks(page = 1, limit = 50) {
152
+ return this.fetch("user.getLovedTracks", {
153
+ page,
154
+ limit: Math.min(limit, 200),
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Get all loved tracks
160
+ * @param {number} [maxPages] - Maximum pages to fetch
161
+ * @returns {Promise<Array>} - All loved tracks
162
+ */
163
+ async getAllLovedTracks(maxPages = 20) {
164
+ const allTracks = [];
165
+ let page = 1;
166
+ let hasMore = true;
167
+
168
+ while (hasMore && page <= maxPages) {
169
+ const response = await this.getLovedTracks(page, 200);
170
+ const tracks = response.lovedtracks?.track || [];
171
+ allTracks.push(...tracks);
172
+
173
+ const totalPages = parseInt(response.lovedtracks?.["@attr"]?.totalPages) || 1;
174
+ hasMore = page < totalPages;
175
+ page++;
176
+ }
177
+
178
+ return allTracks;
179
+ }
180
+
181
+ /**
182
+ * Get top artists for a time period
183
+ * @param {string} [period] - Time period: overall, 7day, 1month, 3month, 6month, 12month
184
+ * @param {number} [limit] - Number of artists to return
185
+ * @returns {Promise<object>} - Top artists response
186
+ */
187
+ async getTopArtists(period = "overall", limit = 10) {
188
+ return this.fetch("user.getTopArtists", {
189
+ period,
190
+ limit,
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Get top albums for a time period
196
+ * @param {string} [period] - Time period: overall, 7day, 1month, 3month, 6month, 12month
197
+ * @param {number} [limit] - Number of albums to return
198
+ * @returns {Promise<object>} - Top albums response
199
+ */
200
+ async getTopAlbums(period = "overall", limit = 10) {
201
+ return this.fetch("user.getTopAlbums", {
202
+ period,
203
+ limit,
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Get top tracks for a time period
209
+ * @param {string} [period] - Time period: overall, 7day, 1month, 3month, 6month, 12month
210
+ * @param {number} [limit] - Number of tracks to return
211
+ * @returns {Promise<object>} - Top tracks response
212
+ */
213
+ async getTopTracks(period = "overall", limit = 10) {
214
+ return this.fetch("user.getTopTracks", {
215
+ period,
216
+ limit,
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Get user info
222
+ * @returns {Promise<object>} - User info response
223
+ */
224
+ async getUserInfo() {
225
+ return this.fetch("user.getInfo");
226
+ }
227
+
228
+ /**
229
+ * Get the most recent scrobble (or now playing)
230
+ * @returns {Promise<object|null>} - Most recent track or null
231
+ */
232
+ async getLatestScrobble() {
233
+ const response = await this.getRecentTracks(1, 1);
234
+ return response.recenttracks?.track?.[0] || null;
235
+ }
236
+
237
+ /**
238
+ * Check if currently playing
239
+ * @returns {Promise<object|null>} - Now playing track or null
240
+ */
241
+ async getNowPlaying() {
242
+ const response = await this.getRecentTracks(1, 1);
243
+ const track = response.recenttracks?.track?.[0];
244
+
245
+ if (track && track["@attr"]?.nowplaying === "true") {
246
+ return track;
247
+ }
248
+
249
+ return null;
250
+ }
251
+
252
+ /**
253
+ * Clear the cache
254
+ */
255
+ clearCache() {
256
+ this.cache.clear();
257
+ }
258
+ }
package/lib/stats.js ADDED
@@ -0,0 +1,308 @@
1
+ import { formatTopArtist, formatTopAlbum, mapPeriodToLastfm } from "./utils.js";
2
+
3
+ /**
4
+ * Get date match filter for a time period
5
+ * @param {string} period - 'all', 'week', or 'month'
6
+ * @returns {object} - MongoDB match filter
7
+ */
8
+ function getDateMatch(period) {
9
+ const now = new Date();
10
+ switch (period) {
11
+ case "week":
12
+ return { scrobbledAt: { $gte: new Date(now - 7 * 24 * 60 * 60 * 1000) } };
13
+ case "month":
14
+ return { scrobbledAt: { $gte: new Date(now - 30 * 24 * 60 * 60 * 1000) } };
15
+ default:
16
+ return {};
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Get top artists from MongoDB (fallback if API unavailable)
22
+ * @param {object} db - MongoDB database
23
+ * @param {string} period - 'all', 'week', or 'month'
24
+ * @param {number} limit - Number of artists to return
25
+ * @returns {Promise<Array>} - Top artists
26
+ */
27
+ export async function getTopArtistsFromDb(db, period = "all", limit = 10) {
28
+ const match = getDateMatch(period);
29
+ const collection = db.collection("scrobbles");
30
+
31
+ return collection
32
+ .aggregate([
33
+ { $match: match },
34
+ {
35
+ $group: {
36
+ _id: "$artistName",
37
+ name: { $first: "$artistName" },
38
+ playCount: { $sum: 1 },
39
+ mbid: { $first: "$artistMbid" },
40
+ },
41
+ },
42
+ { $match: { _id: { $ne: null } } },
43
+ { $sort: { playCount: -1 } },
44
+ { $limit: limit },
45
+ {
46
+ $project: {
47
+ _id: 0,
48
+ name: 1,
49
+ playCount: 1,
50
+ mbid: 1,
51
+ },
52
+ },
53
+ ])
54
+ .toArray();
55
+ }
56
+
57
+ /**
58
+ * Get top albums from MongoDB (fallback if API unavailable)
59
+ * @param {object} db - MongoDB database
60
+ * @param {string} period - 'all', 'week', or 'month'
61
+ * @param {number} limit - Number of albums to return
62
+ * @returns {Promise<Array>} - Top albums
63
+ */
64
+ export async function getTopAlbumsFromDb(db, period = "all", limit = 10) {
65
+ const match = getDateMatch(period);
66
+ const collection = db.collection("scrobbles");
67
+
68
+ return collection
69
+ .aggregate([
70
+ { $match: { ...match, albumTitle: { $ne: null } } },
71
+ {
72
+ $group: {
73
+ _id: { album: "$albumTitle", artist: "$artistName" },
74
+ title: { $first: "$albumTitle" },
75
+ artist: { $first: "$artistName" },
76
+ coverUrl: { $first: "$coverUrl" },
77
+ playCount: { $sum: 1 },
78
+ mbid: { $first: "$albumMbid" },
79
+ },
80
+ },
81
+ { $sort: { playCount: -1 } },
82
+ { $limit: limit },
83
+ {
84
+ $project: {
85
+ _id: 0,
86
+ title: 1,
87
+ artist: 1,
88
+ coverUrl: 1,
89
+ playCount: 1,
90
+ mbid: 1,
91
+ },
92
+ },
93
+ ])
94
+ .toArray();
95
+ }
96
+
97
+ /**
98
+ * Get top artists from Last.fm API
99
+ * @param {object} client - LastFmClient instance
100
+ * @param {string} period - 'all', 'week', or 'month'
101
+ * @param {number} limit - Number of artists to return
102
+ * @returns {Promise<Array>} - Top artists
103
+ */
104
+ export async function getTopArtistsFromApi(client, period = "all", limit = 10) {
105
+ const lastfmPeriod = mapPeriodToLastfm(period);
106
+ const response = await client.getTopArtists(lastfmPeriod, limit);
107
+ const artists = response.topartists?.artist || [];
108
+
109
+ return artists.map((artist, index) => formatTopArtist(artist, index + 1));
110
+ }
111
+
112
+ /**
113
+ * Get top albums from Last.fm API
114
+ * @param {object} client - LastFmClient instance
115
+ * @param {string} period - 'all', 'week', or 'month'
116
+ * @param {number} limit - Number of albums to return
117
+ * @returns {Promise<Array>} - Top albums
118
+ */
119
+ export async function getTopAlbumsFromApi(client, period = "all", limit = 10) {
120
+ const lastfmPeriod = mapPeriodToLastfm(period);
121
+ const response = await client.getTopAlbums(lastfmPeriod, limit);
122
+ const albums = response.topalbums?.album || [];
123
+
124
+ return albums.map((album, index) => formatTopAlbum(album, index + 1));
125
+ }
126
+
127
+ /**
128
+ * Get scrobble trends (daily counts)
129
+ * @param {object} db - MongoDB database
130
+ * @param {number} days - Number of days to look back
131
+ * @returns {Promise<Array>} - Daily scrobble counts
132
+ */
133
+ export async function getScrobbleTrends(db, days = 30) {
134
+ const startDate = new Date();
135
+ startDate.setDate(startDate.getDate() - days);
136
+ startDate.setHours(0, 0, 0, 0);
137
+
138
+ const collection = db.collection("scrobbles");
139
+
140
+ return collection
141
+ .aggregate([
142
+ { $match: { scrobbledAt: { $gte: startDate } } },
143
+ {
144
+ $group: {
145
+ _id: {
146
+ $dateToString: { format: "%Y-%m-%d", date: "$scrobbledAt" },
147
+ },
148
+ count: { $sum: 1 },
149
+ },
150
+ },
151
+ { $sort: { _id: 1 } },
152
+ {
153
+ $project: {
154
+ _id: 0,
155
+ date: "$_id",
156
+ count: 1,
157
+ },
158
+ },
159
+ ])
160
+ .toArray();
161
+ }
162
+
163
+ /**
164
+ * Get summary statistics for a time period
165
+ * @param {object} db - MongoDB database
166
+ * @param {string} period - 'all', 'week', or 'month'
167
+ * @returns {Promise<object>} - Summary stats
168
+ */
169
+ export async function getSummary(db, period = "all") {
170
+ const match = getDateMatch(period);
171
+ const collection = db.collection("scrobbles");
172
+
173
+ const result = await collection
174
+ .aggregate([
175
+ { $match: match },
176
+ {
177
+ $group: {
178
+ _id: null,
179
+ totalPlays: { $sum: 1 },
180
+ uniqueTracks: { $addToSet: { $concat: ["$artistName", ":", "$trackTitle"] } },
181
+ uniqueArtists: { $addToSet: "$artistName" },
182
+ uniqueAlbums: { $addToSet: "$albumTitle" },
183
+ lovedCount: {
184
+ $sum: { $cond: [{ $eq: ["$loved", true] }, 1, 0] },
185
+ },
186
+ },
187
+ },
188
+ {
189
+ $project: {
190
+ _id: 0,
191
+ totalPlays: 1,
192
+ lovedCount: 1,
193
+ uniqueTracks: { $size: "$uniqueTracks" },
194
+ uniqueArtists: {
195
+ $size: {
196
+ $filter: {
197
+ input: "$uniqueArtists",
198
+ cond: { $ne: ["$$this", null] },
199
+ },
200
+ },
201
+ },
202
+ uniqueAlbums: {
203
+ $size: {
204
+ $filter: {
205
+ input: "$uniqueAlbums",
206
+ cond: { $ne: ["$$this", null] },
207
+ },
208
+ },
209
+ },
210
+ },
211
+ },
212
+ ])
213
+ .toArray();
214
+
215
+ return (
216
+ result[0] || {
217
+ totalPlays: 0,
218
+ lovedCount: 0,
219
+ uniqueTracks: 0,
220
+ uniqueArtists: 0,
221
+ uniqueAlbums: 0,
222
+ }
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Get all stats for all time periods
228
+ * Uses Last.fm API for top artists/albums (more accurate) with DB fallback
229
+ * @param {object} db - MongoDB database
230
+ * @param {object} limits - Limits for top lists
231
+ * @param {object} [client] - LastFmClient instance (optional, for API-based stats)
232
+ * @returns {Promise<object>} - All stats
233
+ */
234
+ export async function getAllStats(db, limits = {}, client = null) {
235
+ const topArtistsLimit = limits.topArtists || 10;
236
+ const topAlbumsLimit = limits.topAlbums || 10;
237
+
238
+ // Get summaries from database
239
+ const [summaryAll, summaryMonth, summaryWeek, trends] = await Promise.all([
240
+ getSummary(db, "all"),
241
+ getSummary(db, "month"),
242
+ getSummary(db, "week"),
243
+ getScrobbleTrends(db, 30),
244
+ ]);
245
+
246
+ // Get top artists/albums - prefer API (more accurate), fall back to DB
247
+ let topArtists = { all: [], month: [], week: [] };
248
+ let topAlbums = { all: [], month: [], week: [] };
249
+
250
+ if (client) {
251
+ try {
252
+ const [
253
+ topArtistsAll,
254
+ topArtistsMonth,
255
+ topArtistsWeek,
256
+ topAlbumsAll,
257
+ topAlbumsMonth,
258
+ topAlbumsWeek,
259
+ ] = await Promise.all([
260
+ getTopArtistsFromApi(client, "all", topArtistsLimit),
261
+ getTopArtistsFromApi(client, "month", topArtistsLimit),
262
+ getTopArtistsFromApi(client, "week", topArtistsLimit),
263
+ getTopAlbumsFromApi(client, "all", topAlbumsLimit),
264
+ getTopAlbumsFromApi(client, "month", topAlbumsLimit),
265
+ getTopAlbumsFromApi(client, "week", topAlbumsLimit),
266
+ ]);
267
+
268
+ topArtists = { all: topArtistsAll, month: topArtistsMonth, week: topArtistsWeek };
269
+ topAlbums = { all: topAlbumsAll, month: topAlbumsMonth, week: topAlbumsWeek };
270
+ } catch (err) {
271
+ console.warn("[Last.fm] API stats failed, using DB fallback:", err.message);
272
+ // Fall through to DB-based stats
273
+ }
274
+ }
275
+
276
+ // Fall back to DB if API failed or client not provided
277
+ if (topArtists.all.length === 0) {
278
+ const [
279
+ topArtistsAll,
280
+ topArtistsMonth,
281
+ topArtistsWeek,
282
+ topAlbumsAll,
283
+ topAlbumsMonth,
284
+ topAlbumsWeek,
285
+ ] = await Promise.all([
286
+ getTopArtistsFromDb(db, "all", topArtistsLimit),
287
+ getTopArtistsFromDb(db, "month", topArtistsLimit),
288
+ getTopArtistsFromDb(db, "week", topArtistsLimit),
289
+ getTopAlbumsFromDb(db, "all", topAlbumsLimit),
290
+ getTopAlbumsFromDb(db, "month", topAlbumsLimit),
291
+ getTopAlbumsFromDb(db, "week", topAlbumsLimit),
292
+ ]);
293
+
294
+ topArtists = { all: topArtistsAll, month: topArtistsMonth, week: topArtistsWeek };
295
+ topAlbums = { all: topAlbumsAll, month: topAlbumsMonth, week: topAlbumsWeek };
296
+ }
297
+
298
+ return {
299
+ summary: {
300
+ all: summaryAll,
301
+ month: summaryMonth,
302
+ week: summaryWeek,
303
+ },
304
+ topArtists,
305
+ topAlbums,
306
+ trends,
307
+ };
308
+ }