@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
package/lib/sync.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { LastFmClient } from "./lastfm-client.js";
|
|
2
|
+
import { getCoverUrl, getArtistName, getAlbumName, parseDate, getMbid, getTrackUrl } from "./utils.js";
|
|
3
|
+
import { getAllStats } from "./stats.js";
|
|
4
|
+
|
|
5
|
+
let syncInterval = null;
|
|
6
|
+
|
|
7
|
+
// In-memory cache for stats (accessible to public routes)
|
|
8
|
+
let cachedStats = null;
|
|
9
|
+
let cachedStatsTime = null;
|
|
10
|
+
const STATS_CACHE_TTL = 300_000; // 5 minutes
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get cached stats (for public API routes that can't access DB)
|
|
14
|
+
* @returns {object|null} - Cached stats or null
|
|
15
|
+
*/
|
|
16
|
+
export function getCachedStats() {
|
|
17
|
+
if (!cachedStats) return null;
|
|
18
|
+
if (cachedStatsTime && Date.now() - cachedStatsTime > STATS_CACHE_TTL) {
|
|
19
|
+
return cachedStats; // Return stale cache, sync will refresh
|
|
20
|
+
}
|
|
21
|
+
return cachedStats;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Update stats cache
|
|
26
|
+
* @param {object} stats - Stats to cache
|
|
27
|
+
*/
|
|
28
|
+
export function setCachedStats(stats) {
|
|
29
|
+
cachedStats = stats;
|
|
30
|
+
cachedStatsTime = Date.now();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Refresh stats cache from database (for when cache is empty)
|
|
35
|
+
* @param {object} db - MongoDB database instance
|
|
36
|
+
* @param {object} limits - Limits for top lists
|
|
37
|
+
* @param {object} client - LastFmClient instance for API-based stats
|
|
38
|
+
* @returns {Promise<object|null>} - Stats or null if failed
|
|
39
|
+
*/
|
|
40
|
+
export async function refreshStatsCache(db, limits = {}, client = null) {
|
|
41
|
+
if (!db) return null;
|
|
42
|
+
try {
|
|
43
|
+
const stats = await getAllStats(db, limits, client);
|
|
44
|
+
setCachedStats(stats);
|
|
45
|
+
console.log("[Last.fm] Stats cache refreshed on-demand");
|
|
46
|
+
return stats;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error("[Last.fm] Failed to refresh stats cache:", err.message);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Start background sync process
|
|
55
|
+
* @param {object} Indiekit - Indiekit instance
|
|
56
|
+
* @param {object} options - Plugin options
|
|
57
|
+
*/
|
|
58
|
+
export function startSync(Indiekit, options) {
|
|
59
|
+
const intervalMs = options.syncInterval || 300_000; // 5 minutes default
|
|
60
|
+
|
|
61
|
+
// Initial sync after a short delay
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
runSync(Indiekit, options).catch((err) => {
|
|
64
|
+
console.error("[Last.fm] Initial sync error:", err.message);
|
|
65
|
+
});
|
|
66
|
+
}, 5000);
|
|
67
|
+
|
|
68
|
+
// Schedule recurring sync
|
|
69
|
+
syncInterval = setInterval(() => {
|
|
70
|
+
runSync(Indiekit, options).catch((err) => {
|
|
71
|
+
console.error("[Last.fm] Sync error:", err.message);
|
|
72
|
+
});
|
|
73
|
+
}, intervalMs);
|
|
74
|
+
|
|
75
|
+
console.log(
|
|
76
|
+
`[Last.fm] Background sync started (interval: ${intervalMs / 1000}s)`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stop background sync
|
|
82
|
+
*/
|
|
83
|
+
export function stopSync() {
|
|
84
|
+
if (syncInterval) {
|
|
85
|
+
clearInterval(syncInterval);
|
|
86
|
+
syncInterval = null;
|
|
87
|
+
console.log("[Last.fm] Background sync stopped");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run a single sync operation
|
|
93
|
+
* @param {object} Indiekit - Indiekit instance
|
|
94
|
+
* @param {object} options - Plugin options
|
|
95
|
+
* @returns {Promise<object>} - Sync result
|
|
96
|
+
*/
|
|
97
|
+
export async function runSync(Indiekit, options) {
|
|
98
|
+
const db = Indiekit.database;
|
|
99
|
+
if (!db) {
|
|
100
|
+
console.log("[Last.fm] No database available, skipping sync");
|
|
101
|
+
return { synced: 0, error: "No database" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const client = new LastFmClient({
|
|
105
|
+
apiKey: options.apiKey,
|
|
106
|
+
username: options.username,
|
|
107
|
+
cacheTtl: 60_000, // Short cache for sync
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await syncScrobbles(db, client);
|
|
111
|
+
|
|
112
|
+
// Update stats cache after sync
|
|
113
|
+
try {
|
|
114
|
+
const stats = await getAllStats(db, options.limits || {}, client);
|
|
115
|
+
setCachedStats(stats);
|
|
116
|
+
console.log("[Last.fm] Stats cache updated");
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error("[Last.fm] Failed to cache stats:", err.message);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Sync scrobbles to MongoDB
|
|
126
|
+
* @param {object} db - MongoDB database instance
|
|
127
|
+
* @param {LastFmClient} client - Last.fm API client
|
|
128
|
+
* @returns {Promise<object>} - Sync result
|
|
129
|
+
*/
|
|
130
|
+
export async function syncScrobbles(db, client) {
|
|
131
|
+
const collection = db.collection("scrobbles");
|
|
132
|
+
|
|
133
|
+
// Create indexes for efficient queries
|
|
134
|
+
await collection.createIndex({ lastfmId: 1 }, { unique: true, sparse: true });
|
|
135
|
+
// Create compound index for deduplication (same track at same time)
|
|
136
|
+
await collection.createIndex(
|
|
137
|
+
{ trackTitle: 1, artistName: 1, scrobbledAt: 1 },
|
|
138
|
+
{ unique: true }
|
|
139
|
+
);
|
|
140
|
+
// Create index on scrobbledAt for time-based queries
|
|
141
|
+
await collection.createIndex({ scrobbledAt: -1 });
|
|
142
|
+
// Create indexes for aggregation
|
|
143
|
+
await collection.createIndex({ artistName: 1 });
|
|
144
|
+
await collection.createIndex({ albumTitle: 1 });
|
|
145
|
+
|
|
146
|
+
// Get the latest synced scrobble
|
|
147
|
+
const latest = await collection.findOne({}, { sort: { scrobbledAt: -1 } });
|
|
148
|
+
const latestDate = latest?.scrobbledAt || new Date(0);
|
|
149
|
+
|
|
150
|
+
console.log(
|
|
151
|
+
`[Last.fm] Syncing scrobbles since: ${latestDate.toISOString()}`
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Fetch new scrobbles
|
|
155
|
+
let newScrobbles;
|
|
156
|
+
if (latestDate.getTime() === 0) {
|
|
157
|
+
// First sync: get recent history (not all-time to avoid rate limits)
|
|
158
|
+
console.log("[Last.fm] First sync, fetching recent scrobbles...");
|
|
159
|
+
newScrobbles = await client.getAllRecentTracks(10); // ~2000 tracks max
|
|
160
|
+
} else {
|
|
161
|
+
// Incremental sync
|
|
162
|
+
newScrobbles = await client.getNewScrobbles(latestDate);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (newScrobbles.length === 0) {
|
|
166
|
+
console.log("[Last.fm] No new scrobbles to sync");
|
|
167
|
+
return { synced: 0 };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
console.log(`[Last.fm] Found ${newScrobbles.length} new scrobbles`);
|
|
171
|
+
|
|
172
|
+
// Transform to our schema
|
|
173
|
+
const docs = newScrobbles.map((s) => transformScrobble(s));
|
|
174
|
+
|
|
175
|
+
// Upsert each document (in case of duplicates)
|
|
176
|
+
let synced = 0;
|
|
177
|
+
for (const doc of docs) {
|
|
178
|
+
try {
|
|
179
|
+
await collection.updateOne(
|
|
180
|
+
{
|
|
181
|
+
trackTitle: doc.trackTitle,
|
|
182
|
+
artistName: doc.artistName,
|
|
183
|
+
scrobbledAt: doc.scrobbledAt,
|
|
184
|
+
},
|
|
185
|
+
{ $set: doc },
|
|
186
|
+
{ upsert: true }
|
|
187
|
+
);
|
|
188
|
+
synced++;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// Ignore duplicate key errors
|
|
191
|
+
if (err.code !== 11000) {
|
|
192
|
+
console.error(`[Last.fm] Error inserting scrobble:`, err.message);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log(`[Last.fm] Synced ${synced} scrobbles`);
|
|
198
|
+
return { synced };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Transform Last.fm scrobble to our schema
|
|
203
|
+
* @param {object} scrobble - Last.fm track object
|
|
204
|
+
* @returns {object} - Transformed document
|
|
205
|
+
*/
|
|
206
|
+
function transformScrobble(scrobble) {
|
|
207
|
+
const scrobbledAt = parseDate(scrobble.date);
|
|
208
|
+
const artistName = getArtistName(scrobble);
|
|
209
|
+
const albumTitle = getAlbumName(scrobble);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
// Create a unique ID from track info and timestamp
|
|
213
|
+
lastfmId: `${artistName}:${scrobble.name}:${scrobbledAt.getTime()}`,
|
|
214
|
+
trackTitle: scrobble.name,
|
|
215
|
+
trackUrl: getTrackUrl(scrobble),
|
|
216
|
+
artistName,
|
|
217
|
+
artistMbid: scrobble.artist?.mbid || null,
|
|
218
|
+
albumTitle,
|
|
219
|
+
albumMbid: scrobble.album?.mbid || null,
|
|
220
|
+
mbid: getMbid(scrobble),
|
|
221
|
+
coverUrl: getCoverUrl(scrobble),
|
|
222
|
+
loved: scrobble.loved === "1",
|
|
223
|
+
scrobbledAt,
|
|
224
|
+
syncedAt: new Date(),
|
|
225
|
+
};
|
|
226
|
+
}
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truncate text with ellipsis
|
|
3
|
+
* @param {string} text - Text to truncate
|
|
4
|
+
* @param {number} [maxLength] - Maximum length
|
|
5
|
+
* @returns {string} - Truncated text
|
|
6
|
+
*/
|
|
7
|
+
export function truncate(text, maxLength = 80) {
|
|
8
|
+
if (!text || text.length <= maxLength) return text || "";
|
|
9
|
+
return text.slice(0, maxLength - 1) + "...";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get the artist name from a Last.fm track
|
|
14
|
+
* @param {object} track - Last.fm track object
|
|
15
|
+
* @returns {string} - Artist name
|
|
16
|
+
*/
|
|
17
|
+
export function getArtistName(track) {
|
|
18
|
+
if (!track) return "Unknown Artist";
|
|
19
|
+
|
|
20
|
+
// Extended format has artist.name
|
|
21
|
+
if (track.artist?.name) {
|
|
22
|
+
return track.artist.name;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Standard format has artist["#text"]
|
|
26
|
+
if (track.artist?.["#text"]) {
|
|
27
|
+
return track.artist["#text"];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Sometimes it's just a string
|
|
31
|
+
if (typeof track.artist === "string") {
|
|
32
|
+
return track.artist;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return "Unknown Artist";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the album name from a Last.fm track
|
|
40
|
+
* @param {object} track - Last.fm track object
|
|
41
|
+
* @returns {string|null} - Album name or null
|
|
42
|
+
*/
|
|
43
|
+
export function getAlbumName(track) {
|
|
44
|
+
if (!track?.album) return null;
|
|
45
|
+
|
|
46
|
+
if (track.album["#text"]) {
|
|
47
|
+
return track.album["#text"];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (typeof track.album === "string") {
|
|
51
|
+
return track.album;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the best available cover URL from a Last.fm track
|
|
59
|
+
* @param {object} track - Last.fm track object
|
|
60
|
+
* @param {string} [preferredSize] - Size preference: 'small', 'medium', 'large', 'extralarge'
|
|
61
|
+
* @returns {string|null} - Cover URL or null
|
|
62
|
+
*/
|
|
63
|
+
export function getCoverUrl(track, preferredSize = "extralarge") {
|
|
64
|
+
if (!track?.image || !Array.isArray(track.image)) return null;
|
|
65
|
+
|
|
66
|
+
// Size priority order
|
|
67
|
+
const sizePriority = ["extralarge", "large", "medium", "small"];
|
|
68
|
+
const startIndex = sizePriority.indexOf(preferredSize);
|
|
69
|
+
const orderedSizes = [
|
|
70
|
+
...sizePriority.slice(startIndex),
|
|
71
|
+
...sizePriority.slice(0, startIndex),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const size of orderedSizes) {
|
|
75
|
+
const img = track.image.find((i) => i.size === size);
|
|
76
|
+
if (img?.["#text"]) {
|
|
77
|
+
return img["#text"];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fall back to any available image
|
|
82
|
+
const anyImg = track.image.find((i) => i["#text"]);
|
|
83
|
+
return anyImg?.["#text"] || null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get the track URL on Last.fm
|
|
88
|
+
* @param {object} track - Last.fm track object
|
|
89
|
+
* @returns {string|null} - Track URL or null
|
|
90
|
+
*/
|
|
91
|
+
export function getTrackUrl(track) {
|
|
92
|
+
return track?.url || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get the MusicBrainz ID if available
|
|
97
|
+
* @param {object} track - Last.fm track object
|
|
98
|
+
* @returns {string|null} - MBID or null
|
|
99
|
+
*/
|
|
100
|
+
export function getMbid(track) {
|
|
101
|
+
return track?.mbid || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse Unix timestamp from Last.fm date object
|
|
106
|
+
* @param {object|string} dateInput - Last.fm date object or ISO string
|
|
107
|
+
* @returns {Date} - JavaScript Date object
|
|
108
|
+
*/
|
|
109
|
+
export function parseDate(dateInput) {
|
|
110
|
+
if (!dateInput) return new Date();
|
|
111
|
+
|
|
112
|
+
// If it's a Last.fm date object with uts (Unix timestamp)
|
|
113
|
+
if (dateInput.uts) {
|
|
114
|
+
return new Date(parseInt(dateInput.uts) * 1000);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// If it's already a Date or ISO string
|
|
118
|
+
return new Date(dateInput);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Format duration in seconds to human-readable string
|
|
123
|
+
* @param {number} seconds - Duration in seconds
|
|
124
|
+
* @returns {string} - Formatted duration (e.g., "3:45" or "1h 23m")
|
|
125
|
+
*/
|
|
126
|
+
export function formatDuration(seconds) {
|
|
127
|
+
if (!seconds || seconds < 0) return "0:00";
|
|
128
|
+
|
|
129
|
+
const hours = Math.floor(seconds / 3600);
|
|
130
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
131
|
+
const secs = Math.floor(seconds % 60);
|
|
132
|
+
|
|
133
|
+
if (hours > 0) {
|
|
134
|
+
return `${hours}h ${minutes}m`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Format total listening time for stats display
|
|
142
|
+
* @param {number} seconds - Total seconds
|
|
143
|
+
* @returns {string} - Human-readable duration
|
|
144
|
+
*/
|
|
145
|
+
export function formatTotalTime(seconds) {
|
|
146
|
+
if (!seconds || seconds < 0) return "0 minutes";
|
|
147
|
+
|
|
148
|
+
const hours = Math.floor(seconds / 3600);
|
|
149
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
150
|
+
|
|
151
|
+
if (hours > 24) {
|
|
152
|
+
const days = Math.floor(hours / 24);
|
|
153
|
+
const remainingHours = hours % 24;
|
|
154
|
+
if (remainingHours > 0) {
|
|
155
|
+
return `${days}d ${remainingHours}h`;
|
|
156
|
+
}
|
|
157
|
+
return `${days} days`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (hours > 0) {
|
|
161
|
+
if (minutes > 0) {
|
|
162
|
+
return `${hours}h ${minutes}m`;
|
|
163
|
+
}
|
|
164
|
+
return `${hours} hours`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return `${minutes} minutes`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Format date for display
|
|
172
|
+
* @param {string|Date|object} dateInput - ISO date string, Date object, or Last.fm date
|
|
173
|
+
* @param {string} [locale] - Locale for formatting
|
|
174
|
+
* @returns {string} - Formatted date
|
|
175
|
+
*/
|
|
176
|
+
export function formatDate(dateInput, locale = "en") {
|
|
177
|
+
const date = parseDate(dateInput);
|
|
178
|
+
return date.toLocaleDateString(locale, {
|
|
179
|
+
year: "numeric",
|
|
180
|
+
month: "short",
|
|
181
|
+
day: "numeric",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Format relative time
|
|
187
|
+
* @param {string|Date|object} dateInput - ISO date string, Date object, or Last.fm date
|
|
188
|
+
* @returns {string} - Relative time string
|
|
189
|
+
*/
|
|
190
|
+
export function formatRelativeTime(dateInput) {
|
|
191
|
+
const date = parseDate(dateInput);
|
|
192
|
+
const now = new Date();
|
|
193
|
+
const diffMs = now - date;
|
|
194
|
+
const diffMins = Math.floor(diffMs / 60_000);
|
|
195
|
+
const diffHours = Math.floor(diffMs / 3_600_000);
|
|
196
|
+
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
197
|
+
|
|
198
|
+
if (diffMins < 1) return "just now";
|
|
199
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
200
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
201
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
202
|
+
|
|
203
|
+
return formatDate(dateInput);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Determine the playing status based on track attributes or timestamp
|
|
208
|
+
* @param {object} track - Last.fm track object
|
|
209
|
+
* @returns {string|null} - 'now-playing', 'recently-played', or null
|
|
210
|
+
*/
|
|
211
|
+
export function getPlayingStatus(track) {
|
|
212
|
+
// Check if currently playing via Last.fm attribute
|
|
213
|
+
if (track["@attr"]?.nowplaying === "true") {
|
|
214
|
+
return "now-playing";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Otherwise check based on timestamp
|
|
218
|
+
const date = parseDate(track.date);
|
|
219
|
+
const now = new Date();
|
|
220
|
+
const diffMs = now - date;
|
|
221
|
+
const diffMins = diffMs / 60_000;
|
|
222
|
+
|
|
223
|
+
if (diffMins < 60) {
|
|
224
|
+
return "now-playing";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (diffMins < 24 * 60) {
|
|
228
|
+
return "recently-played";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Format a scrobble entry for API response
|
|
236
|
+
* @param {object} scrobble - Last.fm track object or MongoDB document
|
|
237
|
+
* @param {boolean} [fromDb] - Whether the scrobble is from MongoDB
|
|
238
|
+
* @returns {object} - Formatted scrobble
|
|
239
|
+
*/
|
|
240
|
+
export function formatScrobble(scrobble, fromDb = false) {
|
|
241
|
+
if (fromDb) {
|
|
242
|
+
// From MongoDB
|
|
243
|
+
const scrobbledAt = scrobble.scrobbledAt;
|
|
244
|
+
return {
|
|
245
|
+
id: scrobble.lastfmId || scrobble._id?.toString(),
|
|
246
|
+
track: scrobble.trackTitle,
|
|
247
|
+
artist: scrobble.artistName,
|
|
248
|
+
album: scrobble.albumTitle,
|
|
249
|
+
coverUrl: scrobble.coverUrl,
|
|
250
|
+
trackUrl: scrobble.trackUrl,
|
|
251
|
+
mbid: scrobble.mbid,
|
|
252
|
+
loved: scrobble.loved || false,
|
|
253
|
+
scrobbledAt: scrobbledAt.toISOString(),
|
|
254
|
+
relativeTime: formatRelativeTime(scrobbledAt),
|
|
255
|
+
status: getPlayingStatus({ date: scrobbledAt }),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// From API
|
|
260
|
+
const scrobbledAt = parseDate(scrobble.date);
|
|
261
|
+
const isNowPlaying = scrobble["@attr"]?.nowplaying === "true";
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
id: `${scrobble.artist?.mbid || ""}:${scrobble.mbid || scrobble.name}:${scrobbledAt.getTime()}`,
|
|
265
|
+
track: scrobble.name,
|
|
266
|
+
artist: getArtistName(scrobble),
|
|
267
|
+
album: getAlbumName(scrobble),
|
|
268
|
+
coverUrl: getCoverUrl(scrobble),
|
|
269
|
+
trackUrl: getTrackUrl(scrobble),
|
|
270
|
+
mbid: getMbid(scrobble),
|
|
271
|
+
loved: scrobble.loved === "1",
|
|
272
|
+
scrobbledAt: isNowPlaying ? new Date().toISOString() : scrobbledAt.toISOString(),
|
|
273
|
+
relativeTime: isNowPlaying ? "now" : formatRelativeTime(scrobbledAt),
|
|
274
|
+
status: getPlayingStatus(scrobble),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Format a loved track entry for API response
|
|
280
|
+
* @param {object} track - Last.fm loved track object
|
|
281
|
+
* @returns {object} - Formatted loved track
|
|
282
|
+
*/
|
|
283
|
+
export function formatLovedTrack(track) {
|
|
284
|
+
const lovedAt = parseDate(track.date);
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
id: track.mbid || `${getArtistName(track)}:${track.name}`,
|
|
288
|
+
track: track.name,
|
|
289
|
+
artist: getArtistName(track),
|
|
290
|
+
coverUrl: getCoverUrl(track),
|
|
291
|
+
trackUrl: getTrackUrl(track),
|
|
292
|
+
mbid: getMbid(track),
|
|
293
|
+
lovedAt: lovedAt.toISOString(),
|
|
294
|
+
relativeTime: formatRelativeTime(lovedAt),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Format a top artist entry for API response
|
|
300
|
+
* @param {object} artist - Last.fm top artist object
|
|
301
|
+
* @param {number} rank - Artist rank
|
|
302
|
+
* @returns {object} - Formatted artist
|
|
303
|
+
*/
|
|
304
|
+
export function formatTopArtist(artist, rank) {
|
|
305
|
+
return {
|
|
306
|
+
rank,
|
|
307
|
+
name: artist.name,
|
|
308
|
+
playCount: parseInt(artist.playcount) || 0,
|
|
309
|
+
url: artist.url,
|
|
310
|
+
mbid: artist.mbid || null,
|
|
311
|
+
imageUrl: getCoverUrl(artist),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Format a top album entry for API response
|
|
317
|
+
* @param {object} album - Last.fm top album object
|
|
318
|
+
* @param {number} rank - Album rank
|
|
319
|
+
* @returns {object} - Formatted album
|
|
320
|
+
*/
|
|
321
|
+
export function formatTopAlbum(album, rank) {
|
|
322
|
+
return {
|
|
323
|
+
rank,
|
|
324
|
+
title: album.name,
|
|
325
|
+
artist: album.artist?.name || album.artist?.["#text"] || album.artist,
|
|
326
|
+
playCount: parseInt(album.playcount) || 0,
|
|
327
|
+
url: album.url,
|
|
328
|
+
mbid: album.mbid || null,
|
|
329
|
+
coverUrl: getCoverUrl(album),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Map Last.fm period to internal period name
|
|
335
|
+
* @param {string} period - Last.fm period (7day, 1month, 3month, 6month, 12month, overall)
|
|
336
|
+
* @returns {string} - Internal period name (week, month, all)
|
|
337
|
+
*/
|
|
338
|
+
export function mapPeriodToInternal(period) {
|
|
339
|
+
switch (period) {
|
|
340
|
+
case "7day":
|
|
341
|
+
return "week";
|
|
342
|
+
case "1month":
|
|
343
|
+
case "3month":
|
|
344
|
+
return "month";
|
|
345
|
+
default:
|
|
346
|
+
return "all";
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Map internal period to Last.fm period
|
|
352
|
+
* @param {string} period - Internal period (week, month, all)
|
|
353
|
+
* @returns {string} - Last.fm period
|
|
354
|
+
*/
|
|
355
|
+
export function mapPeriodToLastfm(period) {
|
|
356
|
+
switch (period) {
|
|
357
|
+
case "week":
|
|
358
|
+
return "7day";
|
|
359
|
+
case "month":
|
|
360
|
+
return "1month";
|
|
361
|
+
default:
|
|
362
|
+
return "overall";
|
|
363
|
+
}
|
|
364
|
+
}
|
package/locales/en.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lastfm": {
|
|
3
|
+
"title": "Last.fm",
|
|
4
|
+
"scrobbles": "Scrobble History",
|
|
5
|
+
"loved": "Loved Tracks",
|
|
6
|
+
"stats": "Statistics",
|
|
7
|
+
"nowPlaying": "Now Playing",
|
|
8
|
+
"recentlyPlayed": "Recently Played",
|
|
9
|
+
"lastPlayed": "Last Played",
|
|
10
|
+
"allTime": "All Time",
|
|
11
|
+
"thisWeek": "This Week",
|
|
12
|
+
"thisMonth": "This Month",
|
|
13
|
+
"trends": "Trends",
|
|
14
|
+
"scrobbleTrend": "Scrobble Trend (30 days)",
|
|
15
|
+
"topArtists": "Top Artists",
|
|
16
|
+
"topAlbums": "Top Albums",
|
|
17
|
+
"plays": "plays",
|
|
18
|
+
"tracks": "tracks",
|
|
19
|
+
"artists": "artists",
|
|
20
|
+
"albums": "albums",
|
|
21
|
+
"scrobbleTime": "Scrobble Time",
|
|
22
|
+
"noRecentPlays": "No music scrobbled recently",
|
|
23
|
+
"noLoved": "No loved tracks yet",
|
|
24
|
+
"viewAll": "View All",
|
|
25
|
+
"viewStats": "View Statistics",
|
|
26
|
+
"sync": "Sync Now",
|
|
27
|
+
"lastSync": "Last sync",
|
|
28
|
+
"error": {
|
|
29
|
+
"connection": "Could not connect to Last.fm",
|
|
30
|
+
"noConfig": "Last.fm endpoint not configured correctly"
|
|
31
|
+
},
|
|
32
|
+
"widget": {
|
|
33
|
+
"description": "View full scrobble activity on the public page",
|
|
34
|
+
"view": "View Public Page"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rmdes/indiekit-endpoint-lastfm",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Last.fm scrobble and listening activity endpoint for Indiekit. Display listening history, loved tracks, and statistics.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"indiekit",
|
|
7
|
+
"indiekit-plugin",
|
|
8
|
+
"indieweb",
|
|
9
|
+
"lastfm",
|
|
10
|
+
"last.fm",
|
|
11
|
+
"music",
|
|
12
|
+
"scrobble",
|
|
13
|
+
"listening",
|
|
14
|
+
"activity"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/rmdes/indiekit-endpoint-lastfm",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/rmdes/indiekit-endpoint-lastfm/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/rmdes/indiekit-endpoint-lastfm.git"
|
|
23
|
+
},
|
|
24
|
+
"author": {
|
|
25
|
+
"name": "Ricardo Mendes",
|
|
26
|
+
"url": "https://rmendes.net"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"type": "module",
|
|
33
|
+
"main": "index.js",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": "./index.js"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"includes",
|
|
39
|
+
"lib",
|
|
40
|
+
"locales",
|
|
41
|
+
"views",
|
|
42
|
+
"index.js"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@indiekit/error": "^1.0.0-beta.25",
|
|
46
|
+
"express": "^5.0.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@indiekit/indiekit": ">=1.0.0-beta.25"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|