@rmdes/indiekit-endpoint-funkwhale 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 +116 -0
- package/assets/styles.css +453 -0
- package/includes/@indiekit-endpoint-funkwhale-now-playing.njk +83 -0
- package/includes/@indiekit-endpoint-funkwhale-stats.njk +75 -0
- package/includes/@indiekit-endpoint-funkwhale-widget.njk +12 -0
- package/index.js +108 -0
- package/lib/controllers/dashboard.js +122 -0
- package/lib/controllers/favorites.js +110 -0
- package/lib/controllers/listenings.js +109 -0
- package/lib/controllers/now-playing.js +58 -0
- package/lib/controllers/stats.js +138 -0
- package/lib/funkwhale-client.js +187 -0
- package/lib/stats.js +228 -0
- package/lib/sync.js +160 -0
- package/lib/utils.js +242 -0
- package/locales/en.json +29 -0
- package/package.json +54 -0
- package/views/favorites.njk +60 -0
- package/views/funkwhale.njk +145 -0
- package/views/listenings.njk +65 -0
- package/views/partials/stats-summary.njk +18 -0
- package/views/partials/top-albums.njk +20 -0
- package/views/partials/top-artists.njk +13 -0
- package/views/stats.njk +130 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{% call widget({
|
|
2
|
+
title: __("funkwhale.title")
|
|
3
|
+
}) %}
|
|
4
|
+
<p class="prose">{{ __("funkwhale.widget.description") }}</p>
|
|
5
|
+
<div class="button-grid">
|
|
6
|
+
{{ button({
|
|
7
|
+
classes: "button--secondary-on-offset",
|
|
8
|
+
href: application.funkwhaleEndpoint or "/funkwhale",
|
|
9
|
+
text: __("funkwhale.widget.view")
|
|
10
|
+
}) }}
|
|
11
|
+
</div>
|
|
12
|
+
{% endcall %}
|
package/index.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
|
|
3
|
+
import { dashboardController } from "./lib/controllers/dashboard.js";
|
|
4
|
+
import { listeningsController } from "./lib/controllers/listenings.js";
|
|
5
|
+
import { favoritesController } from "./lib/controllers/favorites.js";
|
|
6
|
+
import { statsController } from "./lib/controllers/stats.js";
|
|
7
|
+
import { nowPlayingController } from "./lib/controllers/now-playing.js";
|
|
8
|
+
import { startSync } from "./lib/sync.js";
|
|
9
|
+
|
|
10
|
+
const protectedRouter = express.Router();
|
|
11
|
+
const publicRouter = express.Router();
|
|
12
|
+
|
|
13
|
+
const defaults = {
|
|
14
|
+
mountPath: "/funkwhale",
|
|
15
|
+
instanceUrl: "",
|
|
16
|
+
username: "",
|
|
17
|
+
token: process.env.FUNKWHALE_TOKEN,
|
|
18
|
+
cacheTtl: 900_000, // 15 minutes in ms
|
|
19
|
+
syncInterval: 300_000, // 5 minutes in ms
|
|
20
|
+
limits: {
|
|
21
|
+
listenings: 20,
|
|
22
|
+
favorites: 20,
|
|
23
|
+
topArtists: 10,
|
|
24
|
+
topAlbums: 10,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default class FunkwhaleEndpoint {
|
|
29
|
+
name = "Funkwhale listening activity endpoint";
|
|
30
|
+
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
this.options = { ...defaults, ...options };
|
|
33
|
+
this.mountPath = this.options.mountPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get environment() {
|
|
37
|
+
return ["FUNKWHALE_TOKEN", "FUNKWHALE_INSTANCE", "FUNKWHALE_USERNAME"];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get navigationItems() {
|
|
41
|
+
return {
|
|
42
|
+
href: this.options.mountPath,
|
|
43
|
+
text: "funkwhale.title",
|
|
44
|
+
requiresDatabase: true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get shortcutItems() {
|
|
49
|
+
return {
|
|
50
|
+
url: this.options.mountPath,
|
|
51
|
+
name: "funkwhale.listenings",
|
|
52
|
+
iconName: "syndicate",
|
|
53
|
+
requiresDatabase: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Protected routes (require authentication)
|
|
59
|
+
* HTML pages for admin dashboard
|
|
60
|
+
*/
|
|
61
|
+
get routes() {
|
|
62
|
+
// Dashboard
|
|
63
|
+
protectedRouter.get("/", dashboardController.get);
|
|
64
|
+
|
|
65
|
+
// Individual sections (HTML pages)
|
|
66
|
+
protectedRouter.get("/listenings", listeningsController.get);
|
|
67
|
+
protectedRouter.get("/favorites", favoritesController.get);
|
|
68
|
+
protectedRouter.get("/stats", statsController.get);
|
|
69
|
+
|
|
70
|
+
// Manual sync trigger
|
|
71
|
+
protectedRouter.post("/sync", dashboardController.sync);
|
|
72
|
+
|
|
73
|
+
return protectedRouter;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Public routes (no authentication required)
|
|
78
|
+
* JSON API endpoints for Eleventy widgets
|
|
79
|
+
*/
|
|
80
|
+
get routesPublic() {
|
|
81
|
+
// Now playing widget
|
|
82
|
+
publicRouter.get("/api/now-playing", nowPlayingController.api);
|
|
83
|
+
|
|
84
|
+
// JSON API for widgets
|
|
85
|
+
publicRouter.get("/api/listenings", listeningsController.api);
|
|
86
|
+
publicRouter.get("/api/favorites", favoritesController.api);
|
|
87
|
+
publicRouter.get("/api/stats", statsController.api);
|
|
88
|
+
publicRouter.get("/api/stats/trends", statsController.apiTrends);
|
|
89
|
+
|
|
90
|
+
return publicRouter;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
init(Indiekit) {
|
|
94
|
+
Indiekit.addEndpoint(this);
|
|
95
|
+
|
|
96
|
+
// Add MongoDB collection for listenings sync
|
|
97
|
+
Indiekit.addCollection("listenings");
|
|
98
|
+
|
|
99
|
+
// Store Funkwhale config in application for controller access
|
|
100
|
+
Indiekit.config.application.funkwhaleConfig = this.options;
|
|
101
|
+
Indiekit.config.application.funkwhaleEndpoint = this.mountPath;
|
|
102
|
+
|
|
103
|
+
// Start background sync if database is available
|
|
104
|
+
if (Indiekit.config.application.mongodbUrl) {
|
|
105
|
+
startSync(Indiekit, this.options);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { FunkwhaleClient } from "../funkwhale-client.js";
|
|
2
|
+
import { runSync } from "../sync.js";
|
|
3
|
+
import { getSummary } from "../stats.js";
|
|
4
|
+
import * as utils from "../utils.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dashboard controller
|
|
8
|
+
*/
|
|
9
|
+
export const dashboardController = {
|
|
10
|
+
/**
|
|
11
|
+
* Render dashboard page
|
|
12
|
+
* @type {import("express").RequestHandler}
|
|
13
|
+
*/
|
|
14
|
+
async get(request, response, next) {
|
|
15
|
+
try {
|
|
16
|
+
const { funkwhaleConfig } = request.app.locals.application;
|
|
17
|
+
|
|
18
|
+
if (!funkwhaleConfig) {
|
|
19
|
+
return response.status(500).render("funkwhale", {
|
|
20
|
+
title: "Funkwhale",
|
|
21
|
+
error: { message: "Funkwhale endpoint not configured" },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { instanceUrl, token, username, cacheTtl, limits } = funkwhaleConfig;
|
|
26
|
+
|
|
27
|
+
if (!instanceUrl || !token) {
|
|
28
|
+
return response.render("funkwhale", {
|
|
29
|
+
title: response.locals.__("funkwhale.title"),
|
|
30
|
+
error: { message: response.locals.__("funkwhale.error.noConfig") },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const client = new FunkwhaleClient({
|
|
35
|
+
instanceUrl,
|
|
36
|
+
token,
|
|
37
|
+
username,
|
|
38
|
+
cacheTtl,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Fetch recent data from API
|
|
42
|
+
let listenings = [];
|
|
43
|
+
let favorites = [];
|
|
44
|
+
let nowPlaying = null;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const [listeningsRes, favoritesRes] = await Promise.all([
|
|
48
|
+
client.getListenings(1, limits.listenings || 10),
|
|
49
|
+
client.getFavorites(1, limits.favorites || 5),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
listenings = listeningsRes.results.map((l) => utils.formatListening(l));
|
|
53
|
+
favorites = favoritesRes.results.map((f) => utils.formatFavorite(f));
|
|
54
|
+
|
|
55
|
+
// Check for now playing
|
|
56
|
+
if (listenings.length > 0 && listenings[0].status) {
|
|
57
|
+
nowPlaying = listenings[0];
|
|
58
|
+
}
|
|
59
|
+
} catch (apiError) {
|
|
60
|
+
console.error("[Funkwhale] API error:", apiError.message);
|
|
61
|
+
return response.render("funkwhale", {
|
|
62
|
+
title: response.locals.__("funkwhale.title"),
|
|
63
|
+
error: { message: response.locals.__("funkwhale.error.connection") },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Get stats summary from database if available
|
|
68
|
+
let summary = null;
|
|
69
|
+
const db = request.app.locals.database;
|
|
70
|
+
if (db) {
|
|
71
|
+
try {
|
|
72
|
+
summary = await getSummary(db, "all");
|
|
73
|
+
} catch (dbError) {
|
|
74
|
+
console.error("[Funkwhale] DB error:", dbError.message);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
response.render("funkwhale", {
|
|
79
|
+
title: response.locals.__("funkwhale.title"),
|
|
80
|
+
nowPlaying,
|
|
81
|
+
listenings: listenings.slice(0, 5),
|
|
82
|
+
favorites: favorites.slice(0, 5),
|
|
83
|
+
summary,
|
|
84
|
+
mountPath: request.baseUrl,
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error("[Funkwhale] Dashboard error:", error);
|
|
88
|
+
next(error);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Trigger manual sync
|
|
94
|
+
* @type {import("express").RequestHandler}
|
|
95
|
+
*/
|
|
96
|
+
async sync(request, response, next) {
|
|
97
|
+
try {
|
|
98
|
+
const { funkwhaleConfig } = request.app.locals.application;
|
|
99
|
+
|
|
100
|
+
if (!funkwhaleConfig) {
|
|
101
|
+
return response.status(500).json({ error: "Not configured" });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Get Indiekit instance from app
|
|
105
|
+
const Indiekit = request.app.locals.indiekit;
|
|
106
|
+
if (!Indiekit || !Indiekit.database) {
|
|
107
|
+
return response.status(500).json({ error: "Database not available" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const result = await runSync(Indiekit, funkwhaleConfig);
|
|
111
|
+
|
|
112
|
+
response.json({
|
|
113
|
+
success: true,
|
|
114
|
+
synced: result.synced,
|
|
115
|
+
message: `Synced ${result.synced} new listenings`,
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error("[Funkwhale] Manual sync error:", error);
|
|
119
|
+
response.status(500).json({ error: error.message });
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { FunkwhaleClient } from "../funkwhale-client.js";
|
|
2
|
+
import * as utils from "../utils.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Favorites controller
|
|
6
|
+
*/
|
|
7
|
+
export const favoritesController = {
|
|
8
|
+
/**
|
|
9
|
+
* Render favorites page
|
|
10
|
+
* @type {import("express").RequestHandler}
|
|
11
|
+
*/
|
|
12
|
+
async get(request, response, next) {
|
|
13
|
+
try {
|
|
14
|
+
const { funkwhaleConfig } = request.app.locals.application;
|
|
15
|
+
|
|
16
|
+
if (!funkwhaleConfig) {
|
|
17
|
+
return response.status(500).render("favorites", {
|
|
18
|
+
title: "Favorites",
|
|
19
|
+
error: { message: "Funkwhale endpoint not configured" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { instanceUrl, token, username, cacheTtl, limits } = funkwhaleConfig;
|
|
24
|
+
const page = parseInt(request.query.page) || 1;
|
|
25
|
+
const pageSize = limits.favorites || 20;
|
|
26
|
+
|
|
27
|
+
const client = new FunkwhaleClient({
|
|
28
|
+
instanceUrl,
|
|
29
|
+
token,
|
|
30
|
+
username,
|
|
31
|
+
cacheTtl,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const favoritesRes = await client.getFavorites(page, pageSize);
|
|
36
|
+
const favorites = favoritesRes.results.map((f) =>
|
|
37
|
+
utils.formatFavorite(f)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Note: count may not reflect filtered count
|
|
41
|
+
const totalPages = Math.ceil(favoritesRes.count / pageSize);
|
|
42
|
+
|
|
43
|
+
response.render("favorites", {
|
|
44
|
+
title: response.locals.__("funkwhale.favorites"),
|
|
45
|
+
favorites,
|
|
46
|
+
pagination: {
|
|
47
|
+
current: page,
|
|
48
|
+
total: totalPages,
|
|
49
|
+
hasNext: favoritesRes.next !== null,
|
|
50
|
+
hasPrev: favoritesRes.previous !== null,
|
|
51
|
+
},
|
|
52
|
+
mountPath: request.baseUrl,
|
|
53
|
+
});
|
|
54
|
+
} catch (apiError) {
|
|
55
|
+
console.error("[Funkwhale] API error:", apiError.message);
|
|
56
|
+
return response.render("favorites", {
|
|
57
|
+
title: response.locals.__("funkwhale.favorites"),
|
|
58
|
+
error: { message: response.locals.__("funkwhale.error.connection") },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error("[Funkwhale] Favorites error:", error);
|
|
63
|
+
next(error);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* JSON API for favorites
|
|
69
|
+
* @type {import("express").RequestHandler}
|
|
70
|
+
*/
|
|
71
|
+
async api(request, response, next) {
|
|
72
|
+
try {
|
|
73
|
+
const { funkwhaleConfig } = request.app.locals.application;
|
|
74
|
+
|
|
75
|
+
if (!funkwhaleConfig) {
|
|
76
|
+
return response.status(500).json({ error: "Not configured" });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { instanceUrl, token, username, cacheTtl, limits } = funkwhaleConfig;
|
|
80
|
+
const page = parseInt(request.query.page) || 1;
|
|
81
|
+
const limit = Math.min(
|
|
82
|
+
parseInt(request.query.limit) || limits.favorites || 20,
|
|
83
|
+
100
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const client = new FunkwhaleClient({
|
|
87
|
+
instanceUrl,
|
|
88
|
+
token,
|
|
89
|
+
username,
|
|
90
|
+
cacheTtl,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const favoritesRes = await client.getFavorites(page, limit);
|
|
94
|
+
const favorites = favoritesRes.results.map((f) =>
|
|
95
|
+
utils.formatFavorite(f)
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
response.json({
|
|
99
|
+
favorites,
|
|
100
|
+
total: favoritesRes.count,
|
|
101
|
+
page,
|
|
102
|
+
hasNext: favoritesRes.next !== null,
|
|
103
|
+
hasPrev: favoritesRes.previous !== null,
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error("[Funkwhale] Favorites API error:", error);
|
|
107
|
+
response.status(500).json({ error: error.message });
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { FunkwhaleClient } from "../funkwhale-client.js";
|
|
2
|
+
import * as utils from "../utils.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Listenings controller
|
|
6
|
+
*/
|
|
7
|
+
export const listeningsController = {
|
|
8
|
+
/**
|
|
9
|
+
* Render listenings page
|
|
10
|
+
* @type {import("express").RequestHandler}
|
|
11
|
+
*/
|
|
12
|
+
async get(request, response, next) {
|
|
13
|
+
try {
|
|
14
|
+
const { funkwhaleConfig } = request.app.locals.application;
|
|
15
|
+
|
|
16
|
+
if (!funkwhaleConfig) {
|
|
17
|
+
return response.status(500).render("listenings", {
|
|
18
|
+
title: "Listening History",
|
|
19
|
+
error: { message: "Funkwhale endpoint not configured" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { instanceUrl, token, username, cacheTtl, limits } = funkwhaleConfig;
|
|
24
|
+
const page = parseInt(request.query.page) || 1;
|
|
25
|
+
const pageSize = limits.listenings || 20;
|
|
26
|
+
|
|
27
|
+
const client = new FunkwhaleClient({
|
|
28
|
+
instanceUrl,
|
|
29
|
+
token,
|
|
30
|
+
username,
|
|
31
|
+
cacheTtl,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const listeningsRes = await client.getListenings(page, pageSize);
|
|
36
|
+
const listenings = listeningsRes.results.map((l) =>
|
|
37
|
+
utils.formatListening(l)
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const totalPages = Math.ceil(listeningsRes.count / pageSize);
|
|
41
|
+
|
|
42
|
+
response.render("listenings", {
|
|
43
|
+
title: response.locals.__("funkwhale.listenings"),
|
|
44
|
+
listenings,
|
|
45
|
+
pagination: {
|
|
46
|
+
current: page,
|
|
47
|
+
total: totalPages,
|
|
48
|
+
hasNext: listeningsRes.next !== null,
|
|
49
|
+
hasPrev: listeningsRes.previous !== null,
|
|
50
|
+
},
|
|
51
|
+
mountPath: request.baseUrl,
|
|
52
|
+
});
|
|
53
|
+
} catch (apiError) {
|
|
54
|
+
console.error("[Funkwhale] API error:", apiError.message);
|
|
55
|
+
return response.render("listenings", {
|
|
56
|
+
title: response.locals.__("funkwhale.listenings"),
|
|
57
|
+
error: { message: response.locals.__("funkwhale.error.connection") },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error("[Funkwhale] Listenings error:", error);
|
|
62
|
+
next(error);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* JSON API for listenings
|
|
68
|
+
* @type {import("express").RequestHandler}
|
|
69
|
+
*/
|
|
70
|
+
async api(request, response, next) {
|
|
71
|
+
try {
|
|
72
|
+
const { funkwhaleConfig } = request.app.locals.application;
|
|
73
|
+
|
|
74
|
+
if (!funkwhaleConfig) {
|
|
75
|
+
return response.status(500).json({ error: "Not configured" });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { instanceUrl, token, username, cacheTtl, limits } = funkwhaleConfig;
|
|
79
|
+
const page = parseInt(request.query.page) || 1;
|
|
80
|
+
const limit = Math.min(
|
|
81
|
+
parseInt(request.query.limit) || limits.listenings || 20,
|
|
82
|
+
100
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const client = new FunkwhaleClient({
|
|
86
|
+
instanceUrl,
|
|
87
|
+
token,
|
|
88
|
+
username,
|
|
89
|
+
cacheTtl,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const listeningsRes = await client.getListenings(page, limit);
|
|
93
|
+
const listenings = listeningsRes.results.map((l) =>
|
|
94
|
+
utils.formatListening(l)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
response.json({
|
|
98
|
+
listenings,
|
|
99
|
+
total: listeningsRes.count,
|
|
100
|
+
page,
|
|
101
|
+
hasNext: listeningsRes.next !== null,
|
|
102
|
+
hasPrev: listeningsRes.previous !== null,
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error("[Funkwhale] Listenings API error:", error);
|
|
106
|
+
response.status(500).json({ error: error.message });
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { FunkwhaleClient } from "../funkwhale-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 { funkwhaleConfig } = request.app.locals.application;
|
|
15
|
+
|
|
16
|
+
if (!funkwhaleConfig) {
|
|
17
|
+
return response.status(500).json({ error: "Not configured" });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { instanceUrl, token, username, cacheTtl } = funkwhaleConfig;
|
|
21
|
+
|
|
22
|
+
const client = new FunkwhaleClient({
|
|
23
|
+
instanceUrl,
|
|
24
|
+
token,
|
|
25
|
+
username,
|
|
26
|
+
cacheTtl: Math.min(cacheTtl, 60_000), // Max 1 minute cache for now playing
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const listening = await client.getLatestListening();
|
|
30
|
+
|
|
31
|
+
if (!listening) {
|
|
32
|
+
return response.json({
|
|
33
|
+
playing: false,
|
|
34
|
+
status: null,
|
|
35
|
+
message: "No recent plays",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const formatted = utils.formatListening(listening);
|
|
40
|
+
|
|
41
|
+
response.json({
|
|
42
|
+
playing: formatted.status === "now-playing",
|
|
43
|
+
status: formatted.status,
|
|
44
|
+
track: formatted.track,
|
|
45
|
+
artist: formatted.artist,
|
|
46
|
+
album: formatted.album,
|
|
47
|
+
coverUrl: formatted.coverUrl,
|
|
48
|
+
trackUrl: formatted.trackUrl,
|
|
49
|
+
duration: formatted.duration,
|
|
50
|
+
listenedAt: formatted.listenedAt,
|
|
51
|
+
relativeTime: formatted.relativeTime,
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error("[Funkwhale] Now Playing API error:", error);
|
|
55
|
+
response.status(500).json({ error: error.message });
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { getAllStats, getListeningTrends } from "../stats.js";
|
|
2
|
+
import { formatTotalTime } from "../utils.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stats controller
|
|
6
|
+
*/
|
|
7
|
+
export const statsController = {
|
|
8
|
+
/**
|
|
9
|
+
* Render stats page with tabs
|
|
10
|
+
* @type {import("express").RequestHandler}
|
|
11
|
+
*/
|
|
12
|
+
async get(request, response, next) {
|
|
13
|
+
try {
|
|
14
|
+
const { funkwhaleConfig } = request.app.locals.application;
|
|
15
|
+
|
|
16
|
+
if (!funkwhaleConfig) {
|
|
17
|
+
return response.status(500).render("stats", {
|
|
18
|
+
title: "Statistics",
|
|
19
|
+
error: { message: "Funkwhale endpoint not configured" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const db = request.app.locals.database;
|
|
24
|
+
if (!db) {
|
|
25
|
+
return response.render("stats", {
|
|
26
|
+
title: response.locals.__("funkwhale.stats"),
|
|
27
|
+
error: { message: "Database not available for statistics" },
|
|
28
|
+
mountPath: request.baseUrl,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const stats = await getAllStats(db, funkwhaleConfig.limits);
|
|
34
|
+
|
|
35
|
+
// Format durations for display
|
|
36
|
+
const formattedStats = {
|
|
37
|
+
summary: {
|
|
38
|
+
all: {
|
|
39
|
+
...stats.summary.all,
|
|
40
|
+
totalDurationFormatted: formatTotalTime(stats.summary.all.totalDuration),
|
|
41
|
+
},
|
|
42
|
+
month: {
|
|
43
|
+
...stats.summary.month,
|
|
44
|
+
totalDurationFormatted: formatTotalTime(stats.summary.month.totalDuration),
|
|
45
|
+
},
|
|
46
|
+
week: {
|
|
47
|
+
...stats.summary.week,
|
|
48
|
+
totalDurationFormatted: formatTotalTime(stats.summary.week.totalDuration),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
topArtists: stats.topArtists,
|
|
52
|
+
topAlbums: stats.topAlbums,
|
|
53
|
+
trends: stats.trends,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
response.render("stats", {
|
|
57
|
+
title: response.locals.__("funkwhale.stats"),
|
|
58
|
+
stats: formattedStats,
|
|
59
|
+
mountPath: request.baseUrl,
|
|
60
|
+
});
|
|
61
|
+
} catch (dbError) {
|
|
62
|
+
console.error("[Funkwhale] Stats DB error:", dbError.message);
|
|
63
|
+
return response.render("stats", {
|
|
64
|
+
title: response.locals.__("funkwhale.stats"),
|
|
65
|
+
error: { message: "Could not load statistics" },
|
|
66
|
+
mountPath: request.baseUrl,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error("[Funkwhale] Stats error:", error);
|
|
71
|
+
next(error);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* JSON API for all stats
|
|
77
|
+
* @type {import("express").RequestHandler}
|
|
78
|
+
*/
|
|
79
|
+
async api(request, response, next) {
|
|
80
|
+
try {
|
|
81
|
+
const { funkwhaleConfig } = request.app.locals.application;
|
|
82
|
+
|
|
83
|
+
if (!funkwhaleConfig) {
|
|
84
|
+
return response.status(500).json({ error: "Not configured" });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const db = request.app.locals.database;
|
|
88
|
+
if (!db) {
|
|
89
|
+
return response.status(500).json({ error: "Database not available" });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const stats = await getAllStats(db, funkwhaleConfig.limits);
|
|
93
|
+
|
|
94
|
+
// Add formatted durations
|
|
95
|
+
stats.summary.all.totalDurationFormatted = formatTotalTime(
|
|
96
|
+
stats.summary.all.totalDuration
|
|
97
|
+
);
|
|
98
|
+
stats.summary.month.totalDurationFormatted = formatTotalTime(
|
|
99
|
+
stats.summary.month.totalDuration
|
|
100
|
+
);
|
|
101
|
+
stats.summary.week.totalDurationFormatted = formatTotalTime(
|
|
102
|
+
stats.summary.week.totalDuration
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
response.json(stats);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error("[Funkwhale] Stats API error:", error);
|
|
108
|
+
response.status(500).json({ error: error.message });
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* JSON API for trends only (for charts)
|
|
114
|
+
* @type {import("express").RequestHandler}
|
|
115
|
+
*/
|
|
116
|
+
async apiTrends(request, response, next) {
|
|
117
|
+
try {
|
|
118
|
+
const { funkwhaleConfig } = request.app.locals.application;
|
|
119
|
+
|
|
120
|
+
if (!funkwhaleConfig) {
|
|
121
|
+
return response.status(500).json({ error: "Not configured" });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const db = request.app.locals.database;
|
|
125
|
+
if (!db) {
|
|
126
|
+
return response.status(500).json({ error: "Database not available" });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const days = Math.min(parseInt(request.query.days) || 30, 90);
|
|
130
|
+
const trends = await getListeningTrends(db, days);
|
|
131
|
+
|
|
132
|
+
response.json({ trends, days });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error("[Funkwhale] Trends API error:", error);
|
|
135
|
+
response.status(500).json({ error: error.message });
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|