@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 ADDED
@@ -0,0 +1,74 @@
1
+ # @rmdes/indiekit-endpoint-lastfm
2
+
3
+ Last.fm scrobble and listening activity endpoint for [Indiekit](https://getindiekit.com).
4
+
5
+ ## Features
6
+
7
+ - Display scrobble history from Last.fm
8
+ - Now playing / recently played status
9
+ - Loved tracks
10
+ - Listening statistics (top artists, albums, trends)
11
+ - Background sync to MongoDB for offline access
12
+ - Public JSON API for frontend integration
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @rmdes/indiekit-endpoint-lastfm
18
+ ```
19
+
20
+ ## Configuration
21
+
22
+ Add to your Indiekit config:
23
+
24
+ ```javascript
25
+ import LastFmEndpoint from "@rmdes/indiekit-endpoint-lastfm";
26
+
27
+ export default {
28
+ plugins: [
29
+ "@rmdes/indiekit-endpoint-lastfm",
30
+ // ... other plugins
31
+ ],
32
+
33
+ "@rmdes/indiekit-endpoint-lastfm": {
34
+ mountPath: "/lastfmapi",
35
+ apiKey: process.env.LASTFM_API_KEY,
36
+ username: process.env.LASTFM_USERNAME,
37
+ cacheTtl: 900_000, // 15 minutes
38
+ syncInterval: 300_000, // 5 minutes
39
+ limits: {
40
+ scrobbles: 20,
41
+ loved: 20,
42
+ topArtists: 10,
43
+ topAlbums: 10,
44
+ },
45
+ },
46
+ };
47
+ ```
48
+
49
+ ## Environment Variables
50
+
51
+ | Variable | Description |
52
+ |----------|-------------|
53
+ | `LASTFM_API_KEY` | Your Last.fm API key ([get one here](https://www.last.fm/api/account/create)) |
54
+ | `LASTFM_USERNAME` | Last.fm username to track |
55
+
56
+ ## API Endpoints
57
+
58
+ | Endpoint | Description |
59
+ |----------|-------------|
60
+ | `GET /api/now-playing` | Current or recently played track |
61
+ | `GET /api/scrobbles` | Paginated scrobble history |
62
+ | `GET /api/loved` | Paginated loved tracks |
63
+ | `GET /api/stats` | Listening statistics |
64
+ | `GET /api/stats/trends` | Daily scrobble trends |
65
+
66
+ ## Requirements
67
+
68
+ - Node.js >= 20
69
+ - Indiekit >= 1.0.0-beta.25
70
+ - MongoDB (for background sync and statistics)
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,90 @@
1
+ {#
2
+ Now Playing Widget
3
+ Fetches data from /lastfm/api/now-playing
4
+ Include this in your Eleventy templates
5
+ #}
6
+ <div class="lastfm-now-playing-widget" id="lastfm-now-playing">
7
+ <div class="lastfm-now-playing-widget__loading">
8
+ Loading...
9
+ </div>
10
+ </div>
11
+
12
+ <script>
13
+ (function() {
14
+ const container = document.getElementById('lastfm-now-playing');
15
+ const endpoint = '{{ application.lastfmEndpoint or "/lastfm" }}/api/now-playing';
16
+
17
+ fetch(endpoint)
18
+ .then(res => res.json())
19
+ .then(data => {
20
+ container.textContent = '';
21
+
22
+ if (!data.track) {
23
+ const empty = document.createElement('p');
24
+ empty.className = 'lastfm-widget__empty';
25
+ empty.textContent = 'No recent plays';
26
+ container.appendChild(empty);
27
+ return;
28
+ }
29
+
30
+ const widget = document.createElement('div');
31
+ widget.className = 'lastfm-widget' + (data.status === 'now-playing' ? ' lastfm-widget--playing' : '');
32
+
33
+ if (data.status === 'now-playing') {
34
+ const bars = document.createElement('div');
35
+ bars.className = 'lastfm-bars';
36
+ for (let i = 0; i < 3; i++) {
37
+ bars.appendChild(document.createElement('span'));
38
+ }
39
+ widget.appendChild(bars);
40
+ }
41
+
42
+ const status = document.createElement('span');
43
+ status.className = 'lastfm-widget__status';
44
+ status.textContent = data.status === 'now-playing' ? 'Now Playing' :
45
+ data.status === 'recently-played' ? 'Recently Played' : 'Last Played';
46
+ widget.appendChild(status);
47
+
48
+ if (data.coverUrl) {
49
+ const img = document.createElement('img');
50
+ img.src = data.coverUrl;
51
+ img.alt = '';
52
+ img.className = 'lastfm-widget__cover';
53
+ widget.appendChild(img);
54
+ }
55
+
56
+ const info = document.createElement('div');
57
+ info.className = 'lastfm-widget__info';
58
+
59
+ const link = document.createElement('a');
60
+ link.href = data.trackUrl;
61
+ link.className = 'lastfm-widget__title';
62
+ link.target = '_blank';
63
+ link.rel = 'noopener';
64
+ link.textContent = data.artist + ' - ' + data.track;
65
+ info.appendChild(link);
66
+
67
+ if (data.loved) {
68
+ const heart = document.createElement('span');
69
+ heart.className = 'lastfm-widget__loved';
70
+ heart.textContent = ' \u2665';
71
+ link.appendChild(heart);
72
+ }
73
+
74
+ const time = document.createElement('span');
75
+ time.className = 'lastfm-widget__time';
76
+ time.textContent = data.relativeTime;
77
+ info.appendChild(time);
78
+
79
+ widget.appendChild(info);
80
+ container.appendChild(widget);
81
+ })
82
+ .catch(err => {
83
+ container.textContent = '';
84
+ const error = document.createElement('p');
85
+ error.className = 'lastfm-widget__error';
86
+ error.textContent = 'Could not load';
87
+ container.appendChild(error);
88
+ });
89
+ })();
90
+ </script>
@@ -0,0 +1,75 @@
1
+ {#
2
+ Stats Widget
3
+ Fetches data from /lastfm/api/stats
4
+ Include this in your Eleventy templates for a sidebar widget
5
+ #}
6
+ <div class="lastfm-stats-widget" id="lastfm-stats">
7
+ <div class="lastfm-stats-widget__loading">
8
+ Loading stats...
9
+ </div>
10
+ </div>
11
+
12
+ <script>
13
+ (function() {
14
+ const container = document.getElementById('lastfm-stats');
15
+ const endpoint = '{{ application.lastfmEndpoint or "/lastfm" }}/api/stats';
16
+
17
+ fetch(endpoint)
18
+ .then(res => res.json())
19
+ .then(data => {
20
+ container.textContent = '';
21
+ const all = data.summary?.all || {};
22
+
23
+ const grid = document.createElement('div');
24
+ grid.className = 'lastfm-stats-widget__grid';
25
+
26
+ // Plays stat
27
+ const playsStat = document.createElement('div');
28
+ playsStat.className = 'lastfm-stats-widget__stat';
29
+ const playsValue = document.createElement('span');
30
+ playsValue.className = 'lastfm-stats-widget__value';
31
+ playsValue.textContent = all.totalPlays || 0;
32
+ const playsLabel = document.createElement('span');
33
+ playsLabel.className = 'lastfm-stats-widget__label';
34
+ playsLabel.textContent = 'scrobbles';
35
+ playsStat.appendChild(playsValue);
36
+ playsStat.appendChild(playsLabel);
37
+ grid.appendChild(playsStat);
38
+
39
+ // Artists stat
40
+ const artistsStat = document.createElement('div');
41
+ artistsStat.className = 'lastfm-stats-widget__stat';
42
+ const artistsValue = document.createElement('span');
43
+ artistsValue.className = 'lastfm-stats-widget__value';
44
+ artistsValue.textContent = all.uniqueArtists || 0;
45
+ const artistsLabel = document.createElement('span');
46
+ artistsLabel.className = 'lastfm-stats-widget__label';
47
+ artistsLabel.textContent = 'artists';
48
+ artistsStat.appendChild(artistsValue);
49
+ artistsStat.appendChild(artistsLabel);
50
+ grid.appendChild(artistsStat);
51
+
52
+ // Loved stat
53
+ const lovedStat = document.createElement('div');
54
+ lovedStat.className = 'lastfm-stats-widget__stat';
55
+ const lovedValue = document.createElement('span');
56
+ lovedValue.className = 'lastfm-stats-widget__value';
57
+ lovedValue.textContent = all.lovedCount || 0;
58
+ const lovedLabel = document.createElement('span');
59
+ lovedLabel.className = 'lastfm-stats-widget__label';
60
+ lovedLabel.textContent = 'loved';
61
+ lovedStat.appendChild(lovedValue);
62
+ lovedStat.appendChild(lovedLabel);
63
+ grid.appendChild(lovedStat);
64
+
65
+ container.appendChild(grid);
66
+ })
67
+ .catch(err => {
68
+ container.textContent = '';
69
+ const error = document.createElement('p');
70
+ error.className = 'lastfm-widget__error';
71
+ error.textContent = 'Could not load stats';
72
+ container.appendChild(error);
73
+ });
74
+ })();
75
+ </script>
@@ -0,0 +1,12 @@
1
+ {% call widget({
2
+ title: __("lastfm.title")
3
+ }) %}
4
+ <p class="prose">{{ __("lastfm.widget.description") }}</p>
5
+ <div class="button-grid">
6
+ {{ button({
7
+ classes: "button--secondary-on-offset",
8
+ href: application.lastfmEndpoint or "/lastfm",
9
+ text: __("lastfm.widget.view")
10
+ }) }}
11
+ </div>
12
+ {% endcall %}
package/index.js ADDED
@@ -0,0 +1,110 @@
1
+ import express from "express";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+
5
+ import { dashboardController } from "./lib/controllers/dashboard.js";
6
+ import { scrobblesController } from "./lib/controllers/scrobbles.js";
7
+ import { lovedController } from "./lib/controllers/loved.js";
8
+ import { statsController } from "./lib/controllers/stats.js";
9
+ import { nowPlayingController } from "./lib/controllers/now-playing.js";
10
+ import { startSync } from "./lib/sync.js";
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ const protectedRouter = express.Router();
15
+ const publicRouter = express.Router();
16
+
17
+ const defaults = {
18
+ mountPath: "/lastfm",
19
+ apiKey: process.env.LASTFM_API_KEY,
20
+ username: process.env.LASTFM_USERNAME,
21
+ cacheTtl: 900_000, // 15 minutes in ms
22
+ syncInterval: 300_000, // 5 minutes in ms
23
+ limits: {
24
+ scrobbles: 20,
25
+ loved: 20,
26
+ topArtists: 10,
27
+ topAlbums: 10,
28
+ },
29
+ };
30
+
31
+ export default class LastFmEndpoint {
32
+ name = "Last.fm listening activity endpoint";
33
+
34
+ constructor(options = {}) {
35
+ this.options = { ...defaults, ...options };
36
+ this.mountPath = this.options.mountPath;
37
+ }
38
+
39
+ get environment() {
40
+ return ["LASTFM_API_KEY", "LASTFM_USERNAME"];
41
+ }
42
+
43
+ get localesDirectory() {
44
+ return path.join(__dirname, "locales");
45
+ }
46
+
47
+ get navigationItems() {
48
+ return {
49
+ href: this.options.mountPath,
50
+ text: "lastfm.title",
51
+ requiresDatabase: true,
52
+ };
53
+ }
54
+
55
+ get shortcutItems() {
56
+ return {
57
+ url: this.options.mountPath,
58
+ name: "lastfm.scrobbles",
59
+ iconName: "syndicate",
60
+ requiresDatabase: true,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Protected routes (require authentication)
66
+ * Admin dashboard only - detailed views are on the public frontend
67
+ */
68
+ get routes() {
69
+ // Dashboard overview
70
+ protectedRouter.get("/", dashboardController.get);
71
+
72
+ // Manual sync trigger
73
+ protectedRouter.post("/sync", dashboardController.sync);
74
+
75
+ return protectedRouter;
76
+ }
77
+
78
+ /**
79
+ * Public routes (no authentication required)
80
+ * JSON API endpoints for Eleventy frontend
81
+ */
82
+ get routesPublic() {
83
+ publicRouter.get("/api/now-playing", nowPlayingController.api);
84
+ publicRouter.get("/api/scrobbles", scrobblesController.api);
85
+ publicRouter.get("/api/loved", lovedController.api);
86
+ publicRouter.get("/api/stats", statsController.api);
87
+ publicRouter.get("/api/stats/trends", statsController.apiTrends);
88
+
89
+ return publicRouter;
90
+ }
91
+
92
+ init(Indiekit) {
93
+ Indiekit.addEndpoint(this);
94
+
95
+ // Add MongoDB collection for scrobbles sync
96
+ Indiekit.addCollection("scrobbles");
97
+
98
+ // Store Last.fm config in application for controller access
99
+ Indiekit.config.application.lastfmConfig = this.options;
100
+ Indiekit.config.application.lastfmEndpoint = this.mountPath;
101
+
102
+ // Store database getter for controller access
103
+ Indiekit.config.application.getLastfmDb = () => Indiekit.database;
104
+
105
+ // Start background sync if database is available
106
+ if (Indiekit.config.application.mongodbUrl) {
107
+ startSync(Indiekit, this.options);
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,140 @@
1
+ import { LastFmClient } from "../lastfm-client.js";
2
+ import { runSync, getCachedStats, refreshStatsCache } from "../sync.js";
3
+ import * as utils from "../utils.js";
4
+
5
+ /**
6
+ * Dashboard controller
7
+ */
8
+ export const dashboardController = {
9
+ /**
10
+ * Render dashboard page
11
+ * @type {import("express").RequestHandler}
12
+ */
13
+ async get(request, response, next) {
14
+ try {
15
+ const { lastfmConfig, lastfmEndpoint } = request.app.locals.application;
16
+
17
+ if (!lastfmConfig) {
18
+ return response.status(500).render("lastfm", {
19
+ title: "Last.fm",
20
+ error: { message: "Last.fm endpoint not configured" },
21
+ });
22
+ }
23
+
24
+ const { apiKey, username, cacheTtl, limits } = lastfmConfig;
25
+
26
+ if (!apiKey || !username) {
27
+ return response.render("lastfm", {
28
+ title: response.locals.__("lastfm.title"),
29
+ error: { message: response.locals.__("lastfm.error.noConfig") },
30
+ });
31
+ }
32
+
33
+ const client = new LastFmClient({
34
+ apiKey,
35
+ username,
36
+ cacheTtl,
37
+ });
38
+
39
+ // Fetch recent data from API
40
+ let scrobbles = [];
41
+ let lovedTracks = [];
42
+ let nowPlaying = null;
43
+ let userInfo = null;
44
+
45
+ try {
46
+ const [scrobblesRes, lovedRes, userRes] = await Promise.all([
47
+ client.getRecentTracks(1, limits.scrobbles || 10),
48
+ client.getLovedTracks(1, limits.loved || 5),
49
+ client.getUserInfo(),
50
+ ]);
51
+
52
+ const tracks = scrobblesRes.recenttracks?.track || [];
53
+ scrobbles = tracks.map((s) => utils.formatScrobble(s));
54
+ lovedTracks = (lovedRes.lovedtracks?.track || []).map((t) =>
55
+ utils.formatLovedTrack(t)
56
+ );
57
+ userInfo = userRes.user || null;
58
+
59
+ // Check for now playing
60
+ if (scrobbles.length > 0 && scrobbles[0].status) {
61
+ nowPlaying = scrobbles[0];
62
+ }
63
+ } catch (apiError) {
64
+ console.error("[Last.fm] API error:", apiError.message);
65
+ return response.render("lastfm", {
66
+ title: response.locals.__("lastfm.title"),
67
+ error: { message: response.locals.__("lastfm.error.connection") },
68
+ });
69
+ }
70
+
71
+ // Get stats from cache (same source as public API)
72
+ // If cache is empty, try to refresh it from database
73
+ let cachedStats = getCachedStats();
74
+ if (!cachedStats) {
75
+ const getDb = request.app.locals.application.getLastfmDb;
76
+ if (getDb) {
77
+ const db = getDb();
78
+ if (db) {
79
+ cachedStats = await refreshStatsCache(db, limits, client);
80
+ }
81
+ }
82
+ }
83
+ const summary = cachedStats?.summary?.all || null;
84
+
85
+ // Determine public frontend URL (strip 'api' from mount path)
86
+ // e.g., /lastfmapi -> /lastfm
87
+ const publicUrl = lastfmEndpoint
88
+ ? lastfmEndpoint.replace(/api$/, "")
89
+ : "/lastfm";
90
+
91
+ response.render("lastfm", {
92
+ title: response.locals.__("lastfm.title"),
93
+ nowPlaying,
94
+ scrobbles: scrobbles.slice(0, 5),
95
+ lovedTracks: lovedTracks.slice(0, 5),
96
+ totalPlays: summary?.totalPlays || userInfo?.playcount || 0,
97
+ uniqueTracks: summary?.uniqueTracks || 0,
98
+ uniqueArtists: summary?.uniqueArtists || 0,
99
+ hasStats: !!summary,
100
+ userInfo,
101
+ publicUrl,
102
+ mountPath: request.baseUrl,
103
+ });
104
+ } catch (error) {
105
+ console.error("[Last.fm] Dashboard error:", error);
106
+ next(error);
107
+ }
108
+ },
109
+
110
+ /**
111
+ * Trigger manual sync
112
+ * @type {import("express").RequestHandler}
113
+ */
114
+ async sync(request, response, next) {
115
+ try {
116
+ const { lastfmConfig } = request.app.locals.application;
117
+
118
+ if (!lastfmConfig) {
119
+ return response.status(500).json({ error: "Not configured" });
120
+ }
121
+
122
+ // Get Indiekit instance from app
123
+ const Indiekit = request.app.locals.indiekit;
124
+ if (!Indiekit || !Indiekit.database) {
125
+ return response.status(500).json({ error: "Database not available" });
126
+ }
127
+
128
+ const result = await runSync(Indiekit, lastfmConfig);
129
+
130
+ response.json({
131
+ success: true,
132
+ synced: result.synced,
133
+ message: `Synced ${result.synced} new scrobbles`,
134
+ });
135
+ } catch (error) {
136
+ console.error("[Last.fm] Manual sync error:", error);
137
+ response.status(500).json({ error: error.message });
138
+ }
139
+ },
140
+ };
@@ -0,0 +1,52 @@
1
+ import { LastFmClient } from "../lastfm-client.js";
2
+ import * as utils from "../utils.js";
3
+
4
+ /**
5
+ * Loved tracks controller
6
+ */
7
+ export const lovedController = {
8
+ /**
9
+ * JSON API for loved tracks
10
+ * @type {import("express").RequestHandler}
11
+ */
12
+ async api(request, response, next) {
13
+ try {
14
+ const { lastfmConfig } = request.app.locals.application;
15
+
16
+ if (!lastfmConfig) {
17
+ return response.status(500).json({ error: "Not configured" });
18
+ }
19
+
20
+ const { apiKey, username, cacheTtl, limits } = lastfmConfig;
21
+ const page = parseInt(request.query.page) || 1;
22
+ const limit = Math.min(
23
+ parseInt(request.query.limit) || limits.loved || 20,
24
+ 200
25
+ );
26
+
27
+ const client = new LastFmClient({
28
+ apiKey,
29
+ username,
30
+ cacheTtl,
31
+ });
32
+
33
+ const lovedRes = await client.getLovedTracks(page, limit);
34
+ const tracks = lovedRes.lovedtracks?.track || [];
35
+ const loved = tracks.map((t) => utils.formatLovedTrack(t));
36
+
37
+ const attrs = lovedRes.lovedtracks?.["@attr"] || {};
38
+ const totalPages = parseInt(attrs.totalPages) || 1;
39
+
40
+ response.json({
41
+ loved,
42
+ total: parseInt(attrs.total) || loved.length,
43
+ page: parseInt(attrs.page) || page,
44
+ hasNext: page < totalPages,
45
+ hasPrev: page > 1,
46
+ });
47
+ } catch (error) {
48
+ console.error("[Last.fm] Loved API error:", error);
49
+ response.status(500).json({ error: error.message });
50
+ }
51
+ },
52
+ };
@@ -0,0 +1,57 @@
1
+ import { LastFmClient } from "../lastfm-client.js";
2
+ import * as utils from "../utils.js";
3
+
4
+ /**
5
+ * Now Playing controller
6
+ */
7
+ export const nowPlayingController = {
8
+ /**
9
+ * JSON API for now playing / recently played
10
+ * @type {import("express").RequestHandler}
11
+ */
12
+ async api(request, response, next) {
13
+ try {
14
+ const { lastfmConfig } = request.app.locals.application;
15
+
16
+ if (!lastfmConfig) {
17
+ return response.status(500).json({ error: "Not configured" });
18
+ }
19
+
20
+ const { apiKey, username, cacheTtl } = lastfmConfig;
21
+
22
+ const client = new LastFmClient({
23
+ apiKey,
24
+ username,
25
+ cacheTtl: Math.min(cacheTtl, 60_000), // Max 1 minute cache for now playing
26
+ });
27
+
28
+ const track = await client.getLatestScrobble();
29
+
30
+ if (!track) {
31
+ return response.json({
32
+ playing: false,
33
+ status: null,
34
+ message: "No recent plays",
35
+ });
36
+ }
37
+
38
+ const formatted = utils.formatScrobble(track);
39
+
40
+ response.json({
41
+ playing: formatted.status === "now-playing",
42
+ status: formatted.status,
43
+ track: formatted.track,
44
+ artist: formatted.artist,
45
+ album: formatted.album,
46
+ coverUrl: formatted.coverUrl,
47
+ trackUrl: formatted.trackUrl,
48
+ loved: formatted.loved,
49
+ scrobbledAt: formatted.scrobbledAt,
50
+ relativeTime: formatted.relativeTime,
51
+ });
52
+ } catch (error) {
53
+ console.error("[Last.fm] Now Playing API error:", error);
54
+ response.status(500).json({ error: error.message });
55
+ }
56
+ },
57
+ };
@@ -0,0 +1,52 @@
1
+ import { LastFmClient } from "../lastfm-client.js";
2
+ import * as utils from "../utils.js";
3
+
4
+ /**
5
+ * Scrobbles controller
6
+ */
7
+ export const scrobblesController = {
8
+ /**
9
+ * JSON API for scrobbles
10
+ * @type {import("express").RequestHandler}
11
+ */
12
+ async api(request, response, next) {
13
+ try {
14
+ const { lastfmConfig } = request.app.locals.application;
15
+
16
+ if (!lastfmConfig) {
17
+ return response.status(500).json({ error: "Not configured" });
18
+ }
19
+
20
+ const { apiKey, username, cacheTtl, limits } = lastfmConfig;
21
+ const page = parseInt(request.query.page) || 1;
22
+ const limit = Math.min(
23
+ parseInt(request.query.limit) || limits.scrobbles || 20,
24
+ 200
25
+ );
26
+
27
+ const client = new LastFmClient({
28
+ apiKey,
29
+ username,
30
+ cacheTtl,
31
+ });
32
+
33
+ const scrobblesRes = await client.getRecentTracks(page, limit);
34
+ const tracks = scrobblesRes.recenttracks?.track || [];
35
+ const scrobbles = tracks.map((s) => utils.formatScrobble(s));
36
+
37
+ const attrs = scrobblesRes.recenttracks?.["@attr"] || {};
38
+ const totalPages = parseInt(attrs.totalPages) || 1;
39
+
40
+ response.json({
41
+ scrobbles,
42
+ total: parseInt(attrs.total) || scrobbles.length,
43
+ page: parseInt(attrs.page) || page,
44
+ hasNext: page < totalPages,
45
+ hasPrev: page > 1,
46
+ });
47
+ } catch (error) {
48
+ console.error("[Last.fm] Scrobbles API error:", error);
49
+ response.status(500).json({ error: error.message });
50
+ }
51
+ },
52
+ };