@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,187 @@
1
+ import { IndiekitError } from "@indiekit/error";
2
+
3
+ export class FunkwhaleClient {
4
+ /**
5
+ * @param {object} options - Client options
6
+ * @param {string} options.instanceUrl - Funkwhale instance URL
7
+ * @param {string} options.token - API access token
8
+ * @param {string} options.username - Username to filter by
9
+ * @param {number} [options.cacheTtl] - Cache TTL in milliseconds
10
+ */
11
+ constructor(options = {}) {
12
+ this.instanceUrl = options.instanceUrl?.replace(/\/$/, ""); // Remove trailing slash
13
+ this.token = options.token;
14
+ this.username = options.username;
15
+ this.cacheTtl = options.cacheTtl || 900_000;
16
+ this.cache = new Map();
17
+ }
18
+
19
+ /**
20
+ * Fetch from Funkwhale API with caching
21
+ * @param {string} endpoint - API endpoint path
22
+ * @param {object} [params] - Query parameters
23
+ * @returns {Promise<object>} - Response data
24
+ */
25
+ async fetch(endpoint, params = {}) {
26
+ const url = new URL(endpoint, this.instanceUrl);
27
+ Object.entries(params).forEach(([key, value]) => {
28
+ if (value !== undefined && value !== null) {
29
+ url.searchParams.set(key, String(value));
30
+ }
31
+ });
32
+
33
+ const cacheKey = url.toString();
34
+
35
+ // Check cache first
36
+ const cached = this.cache.get(cacheKey);
37
+ if (cached && Date.now() - cached.timestamp < this.cacheTtl) {
38
+ return cached.data;
39
+ }
40
+
41
+ const headers = {
42
+ Accept: "application/json",
43
+ };
44
+
45
+ if (this.token) {
46
+ headers.Authorization = `Bearer ${this.token}`;
47
+ }
48
+
49
+ const response = await fetch(url.toString(), { headers });
50
+
51
+ if (!response.ok) {
52
+ throw await IndiekitError.fromFetch(response);
53
+ }
54
+
55
+ const data = await response.json();
56
+
57
+ // Cache result
58
+ this.cache.set(cacheKey, { data, timestamp: Date.now() });
59
+
60
+ return data;
61
+ }
62
+
63
+ /**
64
+ * Get listening history
65
+ * @param {number} [page] - Page number
66
+ * @param {number} [pageSize] - Items per page
67
+ * @returns {Promise<object>} - Paginated listenings
68
+ */
69
+ async getListenings(page = 1, pageSize = 50) {
70
+ return this.fetch("/api/v2/history/listenings", {
71
+ page,
72
+ page_size: pageSize,
73
+ scope: "all",
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Get all listenings by paginating through all pages
79
+ * @returns {Promise<Array>} - All listenings
80
+ */
81
+ async getAllListenings() {
82
+ const allListenings = [];
83
+ let page = 1;
84
+ let hasMore = true;
85
+
86
+ while (hasMore) {
87
+ const response = await this.getListenings(page, 100);
88
+ allListenings.push(...response.results);
89
+ hasMore = response.next !== null;
90
+ page++;
91
+ }
92
+
93
+ return allListenings;
94
+ }
95
+
96
+ /**
97
+ * Get new listenings since a given date
98
+ * Used for incremental sync
99
+ * @param {Date} since - Only fetch listenings after this date
100
+ * @returns {Promise<Array>} - New listenings
101
+ */
102
+ async getNewListenings(since) {
103
+ const newListenings = [];
104
+ let page = 1;
105
+ let hasMore = true;
106
+
107
+ while (hasMore) {
108
+ const response = await this.getListenings(page, 100);
109
+
110
+ for (const listening of response.results) {
111
+ const listenedAt = new Date(listening.creation_date);
112
+ if (listenedAt > since) {
113
+ newListenings.push(listening);
114
+ } else {
115
+ // We've reached older listenings, stop
116
+ hasMore = false;
117
+ break;
118
+ }
119
+ }
120
+
121
+ if (hasMore && response.next !== null) {
122
+ page++;
123
+ } else {
124
+ hasMore = false;
125
+ }
126
+ }
127
+
128
+ return newListenings;
129
+ }
130
+
131
+ /**
132
+ * Get favorite tracks
133
+ * @param {number} [page] - Page number
134
+ * @param {number} [pageSize] - Items per page
135
+ * @returns {Promise<object>} - Paginated favorites filtered to username
136
+ */
137
+ async getFavorites(page = 1, pageSize = 50) {
138
+ const response = await this.fetch("/api/v2/favorites/tracks", {
139
+ page,
140
+ page_size: pageSize,
141
+ });
142
+
143
+ // Filter to configured username only
144
+ if (this.username) {
145
+ response.results = response.results.filter(
146
+ (fav) => fav.actor?.preferred_username === this.username
147
+ );
148
+ }
149
+
150
+ return response;
151
+ }
152
+
153
+ /**
154
+ * Get all favorites for the configured user
155
+ * @returns {Promise<Array>} - All favorites
156
+ */
157
+ async getAllFavorites() {
158
+ const allFavorites = [];
159
+ let page = 1;
160
+ let hasMore = true;
161
+
162
+ while (hasMore) {
163
+ const response = await this.getFavorites(page, 100);
164
+ allFavorites.push(...response.results);
165
+ hasMore = response.next !== null && response.results.length > 0;
166
+ page++;
167
+ }
168
+
169
+ return allFavorites;
170
+ }
171
+
172
+ /**
173
+ * Get the most recent listening
174
+ * @returns {Promise<object|null>} - Most recent listening or null
175
+ */
176
+ async getLatestListening() {
177
+ const response = await this.getListenings(1, 1);
178
+ return response.results?.[0] || null;
179
+ }
180
+
181
+ /**
182
+ * Clear the cache
183
+ */
184
+ clearCache() {
185
+ this.cache.clear();
186
+ }
187
+ }
package/lib/stats.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Get date match filter for a time period
3
+ * @param {string} period - 'all', 'week', or 'month'
4
+ * @returns {object} - MongoDB match filter
5
+ */
6
+ function getDateMatch(period) {
7
+ const now = new Date();
8
+ switch (period) {
9
+ case "week":
10
+ return { listenedAt: { $gte: new Date(now - 7 * 24 * 60 * 60 * 1000) } };
11
+ case "month":
12
+ return { listenedAt: { $gte: new Date(now - 30 * 24 * 60 * 60 * 1000) } };
13
+ default:
14
+ return {};
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Get top artists for a time period
20
+ * @param {object} db - MongoDB database
21
+ * @param {string} period - 'all', 'week', or 'month'
22
+ * @param {number} limit - Number of artists to return
23
+ * @returns {Promise<Array>} - Top artists
24
+ */
25
+ export async function getTopArtists(db, period = "all", limit = 10) {
26
+ const match = getDateMatch(period);
27
+ const collection = db.collection("listenings");
28
+
29
+ return collection
30
+ .aggregate([
31
+ { $match: match },
32
+ {
33
+ $group: {
34
+ _id: "$artistId",
35
+ name: { $first: "$artistName" },
36
+ playCount: { $sum: 1 },
37
+ totalDuration: { $sum: "$duration" },
38
+ },
39
+ },
40
+ { $match: { _id: { $ne: null } } },
41
+ { $sort: { playCount: -1 } },
42
+ { $limit: limit },
43
+ ])
44
+ .toArray();
45
+ }
46
+
47
+ /**
48
+ * Get top albums for a time period
49
+ * @param {object} db - MongoDB database
50
+ * @param {string} period - 'all', 'week', or 'month'
51
+ * @param {number} limit - Number of albums to return
52
+ * @returns {Promise<Array>} - Top albums
53
+ */
54
+ export async function getTopAlbums(db, period = "all", limit = 10) {
55
+ const match = getDateMatch(period);
56
+ const collection = db.collection("listenings");
57
+
58
+ return collection
59
+ .aggregate([
60
+ { $match: { ...match, albumId: { $ne: null } } },
61
+ {
62
+ $group: {
63
+ _id: "$albumId",
64
+ title: { $first: "$albumTitle" },
65
+ artist: { $first: "$artistName" },
66
+ coverUrl: { $first: "$coverUrl" },
67
+ playCount: { $sum: 1 },
68
+ totalDuration: { $sum: "$duration" },
69
+ },
70
+ },
71
+ { $sort: { playCount: -1 } },
72
+ { $limit: limit },
73
+ ])
74
+ .toArray();
75
+ }
76
+
77
+ /**
78
+ * Get listening trends (daily counts)
79
+ * @param {object} db - MongoDB database
80
+ * @param {number} days - Number of days to look back
81
+ * @returns {Promise<Array>} - Daily listening counts
82
+ */
83
+ export async function getListeningTrends(db, days = 30) {
84
+ const startDate = new Date();
85
+ startDate.setDate(startDate.getDate() - days);
86
+ startDate.setHours(0, 0, 0, 0);
87
+
88
+ const collection = db.collection("listenings");
89
+
90
+ return collection
91
+ .aggregate([
92
+ { $match: { listenedAt: { $gte: startDate } } },
93
+ {
94
+ $group: {
95
+ _id: {
96
+ $dateToString: { format: "%Y-%m-%d", date: "$listenedAt" },
97
+ },
98
+ count: { $sum: 1 },
99
+ duration: { $sum: "$duration" },
100
+ },
101
+ },
102
+ { $sort: { _id: 1 } },
103
+ {
104
+ $project: {
105
+ _id: 0,
106
+ date: "$_id",
107
+ count: 1,
108
+ duration: 1,
109
+ },
110
+ },
111
+ ])
112
+ .toArray();
113
+ }
114
+
115
+ /**
116
+ * Get summary statistics for a time period
117
+ * @param {object} db - MongoDB database
118
+ * @param {string} period - 'all', 'week', or 'month'
119
+ * @returns {Promise<object>} - Summary stats
120
+ */
121
+ export async function getSummary(db, period = "all") {
122
+ const match = getDateMatch(period);
123
+ const collection = db.collection("listenings");
124
+
125
+ const result = await collection
126
+ .aggregate([
127
+ { $match: match },
128
+ {
129
+ $group: {
130
+ _id: null,
131
+ totalPlays: { $sum: 1 },
132
+ totalDuration: { $sum: "$duration" },
133
+ uniqueTracks: { $addToSet: "$trackId" },
134
+ uniqueArtists: { $addToSet: "$artistId" },
135
+ uniqueAlbums: { $addToSet: "$albumId" },
136
+ },
137
+ },
138
+ {
139
+ $project: {
140
+ _id: 0,
141
+ totalPlays: 1,
142
+ totalDuration: 1,
143
+ uniqueTracks: { $size: "$uniqueTracks" },
144
+ uniqueArtists: {
145
+ $size: {
146
+ $filter: {
147
+ input: "$uniqueArtists",
148
+ cond: { $ne: ["$$this", null] },
149
+ },
150
+ },
151
+ },
152
+ uniqueAlbums: {
153
+ $size: {
154
+ $filter: {
155
+ input: "$uniqueAlbums",
156
+ cond: { $ne: ["$$this", null] },
157
+ },
158
+ },
159
+ },
160
+ },
161
+ },
162
+ ])
163
+ .toArray();
164
+
165
+ return (
166
+ result[0] || {
167
+ totalPlays: 0,
168
+ totalDuration: 0,
169
+ uniqueTracks: 0,
170
+ uniqueArtists: 0,
171
+ uniqueAlbums: 0,
172
+ }
173
+ );
174
+ }
175
+
176
+ /**
177
+ * Get all stats for all time periods
178
+ * @param {object} db - MongoDB database
179
+ * @param {object} limits - Limits for top lists
180
+ * @returns {Promise<object>} - All stats
181
+ */
182
+ export async function getAllStats(db, limits = {}) {
183
+ const topArtistsLimit = limits.topArtists || 10;
184
+ const topAlbumsLimit = limits.topAlbums || 10;
185
+
186
+ const [
187
+ summaryAll,
188
+ summaryMonth,
189
+ summaryWeek,
190
+ topArtistsAll,
191
+ topArtistsMonth,
192
+ topArtistsWeek,
193
+ topAlbumsAll,
194
+ topAlbumsMonth,
195
+ topAlbumsWeek,
196
+ trends,
197
+ ] = await Promise.all([
198
+ getSummary(db, "all"),
199
+ getSummary(db, "month"),
200
+ getSummary(db, "week"),
201
+ getTopArtists(db, "all", topArtistsLimit),
202
+ getTopArtists(db, "month", topArtistsLimit),
203
+ getTopArtists(db, "week", topArtistsLimit),
204
+ getTopAlbums(db, "all", topAlbumsLimit),
205
+ getTopAlbums(db, "month", topAlbumsLimit),
206
+ getTopAlbums(db, "week", topAlbumsLimit),
207
+ getListeningTrends(db, 30),
208
+ ]);
209
+
210
+ return {
211
+ summary: {
212
+ all: summaryAll,
213
+ month: summaryMonth,
214
+ week: summaryWeek,
215
+ },
216
+ topArtists: {
217
+ all: topArtistsAll,
218
+ month: topArtistsMonth,
219
+ week: topArtistsWeek,
220
+ },
221
+ topAlbums: {
222
+ all: topAlbumsAll,
223
+ month: topAlbumsMonth,
224
+ week: topAlbumsWeek,
225
+ },
226
+ trends,
227
+ };
228
+ }
package/lib/sync.js ADDED
@@ -0,0 +1,160 @@
1
+ import { FunkwhaleClient } from "./funkwhale-client.js";
2
+ import { getCoverUrl, getArtistName } from "./utils.js";
3
+
4
+ let syncInterval = null;
5
+
6
+ /**
7
+ * Start background sync process
8
+ * @param {object} Indiekit - Indiekit instance
9
+ * @param {object} options - Plugin options
10
+ */
11
+ export function startSync(Indiekit, options) {
12
+ const intervalMs = options.syncInterval || 300_000; // 5 minutes default
13
+
14
+ // Initial sync after a short delay
15
+ setTimeout(() => {
16
+ runSync(Indiekit, options).catch((err) => {
17
+ console.error("[Funkwhale] Initial sync error:", err.message);
18
+ });
19
+ }, 5000);
20
+
21
+ // Schedule recurring sync
22
+ syncInterval = setInterval(() => {
23
+ runSync(Indiekit, options).catch((err) => {
24
+ console.error("[Funkwhale] Sync error:", err.message);
25
+ });
26
+ }, intervalMs);
27
+
28
+ console.log(
29
+ `[Funkwhale] Background sync started (interval: ${intervalMs / 1000}s)`
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Stop background sync
35
+ */
36
+ export function stopSync() {
37
+ if (syncInterval) {
38
+ clearInterval(syncInterval);
39
+ syncInterval = null;
40
+ console.log("[Funkwhale] Background sync stopped");
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Run a single sync operation
46
+ * @param {object} Indiekit - Indiekit instance
47
+ * @param {object} options - Plugin options
48
+ * @returns {Promise<object>} - Sync result
49
+ */
50
+ export async function runSync(Indiekit, options) {
51
+ const db = Indiekit.database;
52
+ if (!db) {
53
+ console.log("[Funkwhale] No database available, skipping sync");
54
+ return { synced: 0, error: "No database" };
55
+ }
56
+
57
+ const client = new FunkwhaleClient({
58
+ instanceUrl: options.instanceUrl,
59
+ token: options.token,
60
+ username: options.username,
61
+ cacheTtl: 60_000, // Short cache for sync
62
+ });
63
+
64
+ return syncListenings(db, client);
65
+ }
66
+
67
+ /**
68
+ * Sync listenings to MongoDB
69
+ * @param {object} db - MongoDB database instance
70
+ * @param {FunkwhaleClient} client - Funkwhale API client
71
+ * @returns {Promise<object>} - Sync result
72
+ */
73
+ export async function syncListenings(db, client) {
74
+ const collection = db.collection("listenings");
75
+
76
+ // Create index on funkwhaleId for upsert operations
77
+ await collection.createIndex({ funkwhaleId: 1 }, { unique: true });
78
+ // Create index on listenedAt for time-based queries
79
+ await collection.createIndex({ listenedAt: -1 });
80
+ // Create indexes for aggregation
81
+ await collection.createIndex({ artistId: 1 });
82
+ await collection.createIndex({ albumId: 1 });
83
+
84
+ // Get the latest synced listening
85
+ const latest = await collection.findOne({}, { sort: { listenedAt: -1 } });
86
+ const latestDate = latest?.listenedAt || new Date(0);
87
+
88
+ console.log(
89
+ `[Funkwhale] Syncing listenings since: ${latestDate.toISOString()}`
90
+ );
91
+
92
+ // Fetch new listenings
93
+ let newListenings;
94
+ if (latestDate.getTime() === 0) {
95
+ // First sync: get all
96
+ console.log("[Funkwhale] First sync, fetching all listenings...");
97
+ newListenings = await client.getAllListenings();
98
+ } else {
99
+ // Incremental sync
100
+ newListenings = await client.getNewListenings(latestDate);
101
+ }
102
+
103
+ if (newListenings.length === 0) {
104
+ console.log("[Funkwhale] No new listenings to sync");
105
+ return { synced: 0 };
106
+ }
107
+
108
+ console.log(`[Funkwhale] Found ${newListenings.length} new listenings`);
109
+
110
+ // Transform to our schema
111
+ const docs = newListenings.map((l) => transformListening(l));
112
+
113
+ // Upsert each document (in case of duplicates)
114
+ let synced = 0;
115
+ for (const doc of docs) {
116
+ try {
117
+ await collection.updateOne(
118
+ { funkwhaleId: doc.funkwhaleId },
119
+ { $set: doc },
120
+ { upsert: true }
121
+ );
122
+ synced++;
123
+ } catch (err) {
124
+ // Ignore duplicate key errors
125
+ if (err.code !== 11000) {
126
+ console.error(`[Funkwhale] Error inserting listening:`, err.message);
127
+ }
128
+ }
129
+ }
130
+
131
+ console.log(`[Funkwhale] Synced ${synced} listenings`);
132
+ return { synced };
133
+ }
134
+
135
+ /**
136
+ * Transform Funkwhale listening to our schema
137
+ * @param {object} listening - Funkwhale listening object
138
+ * @returns {object} - Transformed document
139
+ */
140
+ function transformListening(listening) {
141
+ const track = listening.track;
142
+ const artist = track.artist_credit?.[0]?.artist;
143
+ const album = track.album;
144
+ const upload = track.uploads?.[0];
145
+
146
+ return {
147
+ funkwhaleId: listening.id,
148
+ trackId: track.id,
149
+ trackTitle: track.title,
150
+ trackFid: track.fid,
151
+ artistName: artist?.name || getArtistName(track),
152
+ artistId: artist?.id || null,
153
+ albumTitle: album?.title || null,
154
+ albumId: album?.id || null,
155
+ coverUrl: getCoverUrl(track),
156
+ duration: upload?.duration || 0,
157
+ listenedAt: new Date(listening.creation_date),
158
+ syncedAt: new Date(),
159
+ };
160
+ }