@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.
- package/README.md +74 -0
- package/includes/@indiekit-endpoint-lastfm-now-playing.njk +90 -0
- package/includes/@indiekit-endpoint-lastfm-stats.njk +75 -0
- package/includes/@indiekit-endpoint-lastfm-widget.njk +12 -0
- package/index.js +110 -0
- package/lib/controllers/dashboard.js +140 -0
- package/lib/controllers/loved.js +52 -0
- package/lib/controllers/now-playing.js +57 -0
- package/lib/controllers/scrobbles.js +52 -0
- package/lib/controllers/stats.js +89 -0
- package/lib/lastfm-client.js +258 -0
- package/lib/stats.js +308 -0
- package/lib/sync.js +226 -0
- package/lib/utils.js +364 -0
- package/locales/en.json +37 -0
- package/package.json +54 -0
- package/views/lastfm.njk +295 -0
|
@@ -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
|
+
}
|