@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/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
|
+
};
|