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