@rmdes/indiekit-endpoint-podroll 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,122 @@
1
+ # @rmdes/indiekit-endpoint-podroll
2
+
3
+ Podcast roll endpoint for Indiekit. Aggregates podcast episodes from a FreshRSS instance and provides JSON APIs for displaying a podroll page with episode listings and OPML sidebar.
4
+
5
+ ## Features
6
+
7
+ - Syncs podcast episodes from FreshRSS greader API
8
+ - Syncs podcast sources from OPML export
9
+ - Caches data in MongoDB for fast API responses
10
+ - Background sync at configurable intervals
11
+ - Public JSON APIs for frontend consumption
12
+ - Admin dashboard for manual sync and status
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @rmdes/indiekit-endpoint-podroll
18
+ ```
19
+
20
+ ## Configuration
21
+
22
+ Add to your Indiekit config:
23
+
24
+ ```javascript
25
+ import PodrollEndpoint from "@rmdes/indiekit-endpoint-podroll";
26
+
27
+ export default {
28
+ plugins: [
29
+ new PodrollEndpoint({
30
+ episodesUrl: "https://your-freshrss.example/api/query.php?user=USER&t=TOKEN&f=greader",
31
+ opmlUrl: "https://your-freshrss.example/api/query.php?user=USER&t=TOKEN&f=opml",
32
+ syncInterval: 900000, // 15 minutes (default)
33
+ maxEpisodes: 100, // Maximum episodes to cache (default)
34
+ }),
35
+ ],
36
+ };
37
+ ```
38
+
39
+ ## API Endpoints
40
+
41
+ ### Public (no auth required)
42
+
43
+ | Endpoint | Description |
44
+ |----------|-------------|
45
+ | `GET /podrollapi/api/episodes` | List episodes. Params: `limit`, `offset`, `source` |
46
+ | `GET /podrollapi/api/episodes/:id` | Get single episode |
47
+ | `GET /podrollapi/api/sources` | List podcast sources from OPML. Params: `category` |
48
+ | `GET /podrollapi/api/status` | Sync status and counts |
49
+
50
+ ### Protected (requires auth)
51
+
52
+ | Endpoint | Description |
53
+ |----------|-------------|
54
+ | `GET /podrollapi/` | Admin dashboard |
55
+ | `POST /podrollapi/sync` | Trigger manual sync |
56
+ | `POST /podrollapi/clear-resync` | Clear cache and re-sync |
57
+
58
+ ## Episode Response Schema
59
+
60
+ ```json
61
+ {
62
+ "items": [
63
+ {
64
+ "id": "unique-episode-id",
65
+ "title": "Episode Title",
66
+ "url": "https://podcast.example/episode",
67
+ "published": "2026-01-31T12:00:00.000Z",
68
+ "content": "<p>Episode description HTML</p>",
69
+ "author": "Author Name",
70
+ "enclosure": {
71
+ "url": "https://cdn.example/episode.mp3",
72
+ "type": "audio/mpeg",
73
+ "length": 12345678
74
+ },
75
+ "podcast": {
76
+ "title": "Podcast Name",
77
+ "url": "https://podcast.example",
78
+ "feedUrl": "https://podcast.example/feed.xml"
79
+ }
80
+ }
81
+ ],
82
+ "total": 100,
83
+ "limit": 50,
84
+ "offset": 0,
85
+ "hasMore": true
86
+ }
87
+ ```
88
+
89
+ ## Sources Response Schema
90
+
91
+ ```json
92
+ {
93
+ "items": [
94
+ {
95
+ "title": "Podcast Name",
96
+ "xmlUrl": "https://podcast.example/feed.xml",
97
+ "htmlUrl": "https://podcast.example",
98
+ "category": "Technology"
99
+ }
100
+ ],
101
+ "total": 70,
102
+ "categories": ["Technology", "Culture", "Politics"]
103
+ }
104
+ ```
105
+
106
+ ## Frontend Integration
107
+
108
+ The APIs are designed for client-side fetching. Example with vanilla JavaScript:
109
+
110
+ ```javascript
111
+ // Fetch episodes
112
+ const response = await fetch('/podrollapi/api/episodes?limit=20');
113
+ const { items, hasMore } = await response.json();
114
+
115
+ // Fetch sources for sidebar
116
+ const sourcesResponse = await fetch('/podrollapi/api/sources');
117
+ const { items: sources } = await sourcesResponse.json();
118
+ ```
119
+
120
+ ## License
121
+
122
+ MIT
package/index.js ADDED
@@ -0,0 +1,111 @@
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 { episodesController } from "./lib/controllers/episodes.js";
7
+ import { sourcesController } from "./lib/controllers/sources.js";
8
+ import { startSync } from "./lib/sync.js";
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ const protectedRouter = express.Router();
13
+ const publicRouter = express.Router();
14
+
15
+ const defaults = {
16
+ mountPath: "/podrollapi",
17
+ syncInterval: 900_000, // 15 minutes
18
+ maxEpisodes: 100,
19
+ fetchTimeout: 15_000,
20
+ // These should be overridden in config
21
+ episodesUrl: "",
22
+ opmlUrl: "",
23
+ };
24
+
25
+ export default class PodrollEndpoint {
26
+ name = "Podcast roll endpoint";
27
+
28
+ constructor(options = {}) {
29
+ this.options = { ...defaults, ...options };
30
+ this.mountPath = this.options.mountPath;
31
+ }
32
+
33
+ get localesDirectory() {
34
+ return path.join(__dirname, "locales");
35
+ }
36
+
37
+ get navigationItems() {
38
+ return {
39
+ href: this.options.mountPath,
40
+ text: "podroll.title",
41
+ requiresDatabase: true,
42
+ };
43
+ }
44
+
45
+ get shortcutItems() {
46
+ return {
47
+ url: this.options.mountPath,
48
+ name: "podroll.title",
49
+ iconName: "syndicate",
50
+ requiresDatabase: true,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Protected routes (require authentication)
56
+ * Admin dashboard and management
57
+ */
58
+ get routes() {
59
+ // Dashboard
60
+ protectedRouter.get("/", dashboardController.get);
61
+
62
+ // Manual sync trigger
63
+ protectedRouter.post("/sync", dashboardController.sync);
64
+
65
+ // Clear and re-sync
66
+ protectedRouter.post("/clear-resync", dashboardController.clearResync);
67
+
68
+ return protectedRouter;
69
+ }
70
+
71
+ /**
72
+ * Public routes (no authentication required)
73
+ * Read-only JSON API endpoints for frontend
74
+ */
75
+ get routesPublic() {
76
+ // Episodes API (read-only)
77
+ publicRouter.get("/api/episodes", episodesController.list);
78
+ publicRouter.get("/api/episodes/:id", episodesController.get);
79
+
80
+ // Sources/OPML API (read-only)
81
+ publicRouter.get("/api/sources", sourcesController.list);
82
+
83
+ // Status API
84
+ publicRouter.get("/api/status", dashboardController.status);
85
+
86
+ return publicRouter;
87
+ }
88
+
89
+ init(Indiekit) {
90
+ Indiekit.addEndpoint(this);
91
+
92
+ // Add MongoDB collections
93
+ Indiekit.addCollection("podrollEpisodes");
94
+ Indiekit.addCollection("podrollSources");
95
+ Indiekit.addCollection("podrollMeta");
96
+
97
+ // Store config in application for controller access
98
+ Indiekit.config.application.podrollConfig = this.options;
99
+ Indiekit.config.application.podrollEndpoint = this.mountPath;
100
+
101
+ // Store database getter for controller access
102
+ Indiekit.config.application.getPodrollDb = () => Indiekit.database;
103
+
104
+ // Start background sync if database is available and URLs are configured
105
+ if (Indiekit.config.application.mongodbUrl && this.options.episodesUrl) {
106
+ startSync(Indiekit, this.options);
107
+ } else if (!this.options.episodesUrl) {
108
+ console.warn("[Podroll] No episodesUrl configured, sync disabled");
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,153 @@
1
+ import { runSync } from "../sync.js";
2
+
3
+ /**
4
+ * Dashboard controller for admin UI
5
+ */
6
+ export const dashboardController = {
7
+ /**
8
+ * Render dashboard
9
+ * GET /
10
+ */
11
+ async get(request, response) {
12
+ try {
13
+ const { application } = request.app.locals;
14
+ const db = application.getPodrollDb();
15
+
16
+ let stats = {
17
+ episodeCount: 0,
18
+ sourceCount: 0,
19
+ lastEpisodesSync: null,
20
+ lastSourcesSync: null,
21
+ };
22
+
23
+ if (db) {
24
+ const [episodeCount, sourceCount, episodesMeta, sourcesMeta] = await Promise.all([
25
+ db.collection("podrollEpisodes").countDocuments(),
26
+ db.collection("podrollSources").countDocuments(),
27
+ db.collection("podrollMeta").findOne({ key: "lastEpisodesSync" }),
28
+ db.collection("podrollMeta").findOne({ key: "lastSourcesSync" }),
29
+ ]);
30
+
31
+ stats = {
32
+ episodeCount,
33
+ sourceCount,
34
+ lastEpisodesSync: episodesMeta?.timestamp || null,
35
+ lastSourcesSync: sourcesMeta?.timestamp || null,
36
+ };
37
+ }
38
+
39
+ response.render("dashboard", {
40
+ title: response.__("podroll.title"),
41
+ stats,
42
+ config: {
43
+ episodesUrl: application.podrollConfig?.episodesUrl ? "Configured" : "Not set",
44
+ opmlUrl: application.podrollConfig?.opmlUrl ? "Configured" : "Not set",
45
+ syncInterval: application.podrollConfig?.syncInterval || 900000,
46
+ },
47
+ });
48
+ } catch (error) {
49
+ console.error("[Podroll] Dashboard error:", error);
50
+ response.status(500).render("error", {
51
+ title: "Error",
52
+ message: error.message,
53
+ });
54
+ }
55
+ },
56
+
57
+ /**
58
+ * Manual sync trigger
59
+ * POST /sync
60
+ */
61
+ async sync(request, response) {
62
+ try {
63
+ const { application } = request.app.locals;
64
+ const db = application.getPodrollDb();
65
+
66
+ if (!db) {
67
+ return response.status(503).json({ error: "Database not available" });
68
+ }
69
+
70
+ const result = await runSync(db, application.podrollConfig);
71
+
72
+ // Redirect back to dashboard with success message
73
+ response.redirect(application.podrollEndpoint + "?synced=true");
74
+ } catch (error) {
75
+ console.error("[Podroll] Sync error:", error);
76
+ response.redirect(application.podrollEndpoint + "?error=" + encodeURIComponent(error.message));
77
+ }
78
+ },
79
+
80
+ /**
81
+ * Clear all data and re-sync
82
+ * POST /clear-resync
83
+ */
84
+ async clearResync(request, response) {
85
+ try {
86
+ const { application } = request.app.locals;
87
+ const db = application.getPodrollDb();
88
+
89
+ if (!db) {
90
+ return response.status(503).json({ error: "Database not available" });
91
+ }
92
+
93
+ // Clear collections
94
+ await Promise.all([
95
+ db.collection("podrollEpisodes").deleteMany({}),
96
+ db.collection("podrollSources").deleteMany({}),
97
+ db.collection("podrollMeta").deleteMany({}),
98
+ ]);
99
+
100
+ console.log("[Podroll] Cleared all data, starting fresh sync...");
101
+
102
+ // Run fresh sync
103
+ const result = await runSync(db, application.podrollConfig);
104
+
105
+ response.redirect(application.podrollEndpoint + "?cleared=true");
106
+ } catch (error) {
107
+ console.error("[Podroll] Clear/resync error:", error);
108
+ response.redirect(application.podrollEndpoint + "?error=" + encodeURIComponent(error.message));
109
+ }
110
+ },
111
+
112
+ /**
113
+ * Status API (public)
114
+ * GET /api/status
115
+ */
116
+ async status(request, response) {
117
+ try {
118
+ const { application } = request.app.locals;
119
+ const db = application.getPodrollDb();
120
+
121
+ if (!db) {
122
+ return response.json({
123
+ status: "unavailable",
124
+ message: "Database not connected",
125
+ });
126
+ }
127
+
128
+ const [episodeCount, sourceCount, episodesMeta, sourcesMeta] = await Promise.all([
129
+ db.collection("podrollEpisodes").countDocuments(),
130
+ db.collection("podrollSources").countDocuments(),
131
+ db.collection("podrollMeta").findOne({ key: "lastEpisodesSync" }),
132
+ db.collection("podrollMeta").findOne({ key: "lastSourcesSync" }),
133
+ ]);
134
+
135
+ response.json({
136
+ status: "ok",
137
+ episodes: {
138
+ count: episodeCount,
139
+ lastSync: episodesMeta?.timestamp || null,
140
+ },
141
+ sources: {
142
+ count: sourceCount,
143
+ lastSync: sourcesMeta?.timestamp || null,
144
+ },
145
+ });
146
+ } catch (error) {
147
+ response.status(500).json({
148
+ status: "error",
149
+ message: error.message,
150
+ });
151
+ }
152
+ },
153
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Episodes API controller
3
+ */
4
+ export const episodesController = {
5
+ /**
6
+ * List episodes
7
+ * GET /api/episodes
8
+ * Query params: limit, offset, source (filter by origin title)
9
+ */
10
+ async list(request, response) {
11
+ try {
12
+ const { application } = request.app.locals;
13
+ const db = application.getPodrollDb();
14
+
15
+ if (!db) {
16
+ return response.status(503).json({
17
+ error: "Database not available",
18
+ });
19
+ }
20
+
21
+ const limit = Math.min(parseInt(request.query.limit) || 50, 200);
22
+ const offset = parseInt(request.query.offset) || 0;
23
+ const source = request.query.source || null;
24
+
25
+ const collection = db.collection("podrollEpisodes");
26
+
27
+ // Build query
28
+ const query = {};
29
+ if (source) {
30
+ query["origin.title"] = { $regex: source, $options: "i" };
31
+ }
32
+
33
+ // Get total count
34
+ const total = await collection.countDocuments(query);
35
+
36
+ // Get episodes
37
+ const episodes = await collection
38
+ .find(query)
39
+ .sort({ published: -1 })
40
+ .skip(offset)
41
+ .limit(limit)
42
+ .toArray();
43
+
44
+ // Transform for API response
45
+ const items = episodes.map((ep) => ({
46
+ id: ep.id,
47
+ title: ep.title,
48
+ url: ep.url,
49
+ published: ep.published,
50
+ content: ep.content,
51
+ author: ep.author,
52
+ enclosure: ep.enclosure,
53
+ podcast: ep.origin
54
+ ? {
55
+ title: ep.origin.title,
56
+ url: ep.origin.htmlUrl,
57
+ feedUrl: ep.origin.feedUrl,
58
+ }
59
+ : null,
60
+ }));
61
+
62
+ response.json({
63
+ items,
64
+ total,
65
+ limit,
66
+ offset,
67
+ hasMore: offset + items.length < total,
68
+ });
69
+ } catch (error) {
70
+ console.error("[Podroll] Episodes list error:", error);
71
+ response.status(500).json({ error: error.message });
72
+ }
73
+ },
74
+
75
+ /**
76
+ * Get single episode
77
+ * GET /api/episodes/:id
78
+ */
79
+ async get(request, response) {
80
+ try {
81
+ const { application } = request.app.locals;
82
+ const db = application.getPodrollDb();
83
+
84
+ if (!db) {
85
+ return response.status(503).json({
86
+ error: "Database not available",
87
+ });
88
+ }
89
+
90
+ const { id } = request.params;
91
+ const collection = db.collection("podrollEpisodes");
92
+
93
+ const episode = await collection.findOne({ id });
94
+
95
+ if (!episode) {
96
+ return response.status(404).json({ error: "Episode not found" });
97
+ }
98
+
99
+ response.json({
100
+ id: episode.id,
101
+ title: episode.title,
102
+ url: episode.url,
103
+ published: episode.published,
104
+ content: episode.content,
105
+ author: episode.author,
106
+ enclosure: episode.enclosure,
107
+ podcast: episode.origin
108
+ ? {
109
+ title: episode.origin.title,
110
+ url: episode.origin.htmlUrl,
111
+ feedUrl: episode.origin.feedUrl,
112
+ }
113
+ : null,
114
+ categories: episode.categories,
115
+ });
116
+ } catch (error) {
117
+ console.error("[Podroll] Episode get error:", error);
118
+ response.status(500).json({ error: error.message });
119
+ }
120
+ },
121
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Sources (OPML) API controller
3
+ */
4
+ export const sourcesController = {
5
+ /**
6
+ * List podcast sources from OPML
7
+ * GET /api/sources
8
+ * Query params: category (filter by category)
9
+ */
10
+ async list(request, response) {
11
+ try {
12
+ const { application } = request.app.locals;
13
+ const db = application.getPodrollDb();
14
+
15
+ if (!db) {
16
+ return response.status(503).json({
17
+ error: "Database not available",
18
+ });
19
+ }
20
+
21
+ const category = request.query.category || null;
22
+ const collection = db.collection("podrollSources");
23
+
24
+ // Build query
25
+ const query = {};
26
+ if (category) {
27
+ query.category = { $regex: category, $options: "i" };
28
+ }
29
+
30
+ // Get sources sorted by order (original OPML order)
31
+ const sources = await collection
32
+ .find(query)
33
+ .sort({ category: 1, order: 1 })
34
+ .toArray();
35
+
36
+ // Group by category if multiple categories exist
37
+ const categories = [...new Set(sources.map((s) => s.category).filter(Boolean))];
38
+
39
+ // Transform for API response
40
+ const items = sources.map((s) => ({
41
+ title: s.title,
42
+ xmlUrl: s.xmlUrl,
43
+ htmlUrl: s.htmlUrl,
44
+ category: s.category,
45
+ }));
46
+
47
+ response.json({
48
+ items,
49
+ total: items.length,
50
+ categories: categories.length > 0 ? categories : null,
51
+ });
52
+ } catch (error) {
53
+ console.error("[Podroll] Sources list error:", error);
54
+ response.status(500).json({ error: error.message });
55
+ }
56
+ },
57
+ };
package/lib/sync.js ADDED
@@ -0,0 +1,334 @@
1
+ import { parseString } from "xml2js";
2
+ import { promisify } from "node:util";
3
+
4
+ const parseXml = promisify(parseString);
5
+
6
+ /**
7
+ * Fetch episodes from FreshRSS greader API
8
+ * @param {string} url - FreshRSS API URL
9
+ * @param {number} timeout - Fetch timeout in ms
10
+ * @returns {Promise<Array>} Array of episode objects
11
+ */
12
+ async function fetchEpisodes(url, timeout) {
13
+ const controller = new AbortController();
14
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
15
+
16
+ try {
17
+ const response = await fetch(url, {
18
+ signal: controller.signal,
19
+ headers: {
20
+ "User-Agent": "Indiekit-Podroll/1.0",
21
+ Accept: "application/json",
22
+ },
23
+ });
24
+
25
+ clearTimeout(timeoutId);
26
+
27
+ if (!response.ok) {
28
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
29
+ }
30
+
31
+ const data = await response.json();
32
+ return data.items || [];
33
+ } catch (error) {
34
+ clearTimeout(timeoutId);
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Fetch OPML sources from FreshRSS
41
+ * @param {string} url - OPML URL
42
+ * @param {number} timeout - Fetch timeout in ms
43
+ * @returns {Promise<Array>} Array of source objects
44
+ */
45
+ async function fetchOpmlSources(url, timeout) {
46
+ const controller = new AbortController();
47
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
48
+
49
+ try {
50
+ const response = await fetch(url, {
51
+ signal: controller.signal,
52
+ headers: {
53
+ "User-Agent": "Indiekit-Podroll/1.0",
54
+ Accept: "application/xml, text/xml",
55
+ },
56
+ });
57
+
58
+ clearTimeout(timeoutId);
59
+
60
+ if (!response.ok) {
61
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
62
+ }
63
+
64
+ const xml = await response.text();
65
+ const result = await parseXml(xml, { explicitArray: false });
66
+
67
+ // Extract outlines from OPML
68
+ const sources = [];
69
+ const body = result?.opml?.body;
70
+
71
+ if (body?.outline) {
72
+ const outlines = Array.isArray(body.outline) ? body.outline : [body.outline];
73
+
74
+ for (const outline of outlines) {
75
+ // Handle nested outlines (categories)
76
+ if (outline.outline) {
77
+ const children = Array.isArray(outline.outline) ? outline.outline : [outline.outline];
78
+ for (const child of children) {
79
+ if (child.$ && child.$.xmlUrl) {
80
+ sources.push({
81
+ title: child.$.text || child.$.title || "Unknown",
82
+ xmlUrl: child.$.xmlUrl,
83
+ htmlUrl: child.$.htmlUrl || "",
84
+ type: child.$.type || "rss",
85
+ category: outline.$.text || outline.$.title || "",
86
+ });
87
+ }
88
+ }
89
+ } else if (outline.$ && outline.$.xmlUrl) {
90
+ // Direct feed outline
91
+ sources.push({
92
+ title: outline.$.text || outline.$.title || "Unknown",
93
+ xmlUrl: outline.$.xmlUrl,
94
+ htmlUrl: outline.$.htmlUrl || "",
95
+ type: outline.$.type || "rss",
96
+ category: "",
97
+ });
98
+ }
99
+ }
100
+ }
101
+
102
+ return sources;
103
+ } catch (error) {
104
+ clearTimeout(timeoutId);
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Transform FreshRSS episode to our schema
111
+ * @param {object} item - FreshRSS item
112
+ * @returns {object} Transformed episode
113
+ */
114
+ function transformEpisode(item) {
115
+ // Extract enclosure (audio file)
116
+ let enclosure = null;
117
+ if (item.enclosure && item.enclosure.length > 0) {
118
+ const enc = item.enclosure[0];
119
+ enclosure = {
120
+ url: enc.href || enc.url,
121
+ type: enc.type || "audio/mpeg",
122
+ length: enc.length ? parseInt(enc.length, 10) : 0,
123
+ };
124
+ }
125
+
126
+ // Extract origin (podcast source)
127
+ let origin = null;
128
+ if (item.origin) {
129
+ origin = {
130
+ streamId: item.origin.streamId || "",
131
+ title: item.origin.title || "",
132
+ htmlUrl: item.origin.htmlUrl || "",
133
+ feedUrl: item.origin.feedUrl || "",
134
+ };
135
+ }
136
+
137
+ // Get canonical URL
138
+ let url = "";
139
+ if (item.canonical && item.canonical.length > 0) {
140
+ url = item.canonical[0].href || "";
141
+ } else if (item.alternate && item.alternate.length > 0) {
142
+ url = item.alternate[0].href || "";
143
+ }
144
+
145
+ return {
146
+ id: item["frss:id"] || item.id || item.guid,
147
+ guid: item.guid || item.id,
148
+ title: item.title || "Untitled Episode",
149
+ url: url,
150
+ published: item.published ? new Date(item.published * 1000) : new Date(),
151
+ content: item.content?.content || item.summary?.content || "",
152
+ author: item.author || "",
153
+ enclosure: enclosure,
154
+ origin: origin,
155
+ categories: item.categories || [],
156
+ fetchedAt: new Date(),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Sync episodes from FreshRSS to MongoDB
162
+ * @param {object} db - MongoDB database instance
163
+ * @param {object} options - Sync options
164
+ * @returns {Promise<object>} Sync result stats
165
+ */
166
+ async function syncEpisodes(db, options) {
167
+ const { episodesUrl, fetchTimeout, maxEpisodes } = options;
168
+
169
+ if (!episodesUrl) {
170
+ return { success: false, error: "No episodesUrl configured" };
171
+ }
172
+
173
+ try {
174
+ console.log("[Podroll] Fetching episodes from FreshRSS...");
175
+ const rawEpisodes = await fetchEpisodes(episodesUrl, fetchTimeout);
176
+ console.log(`[Podroll] Fetched ${rawEpisodes.length} episodes`);
177
+
178
+ const episodes = rawEpisodes
179
+ .map(transformEpisode)
180
+ .slice(0, maxEpisodes);
181
+
182
+ const collection = db.collection("podrollEpisodes");
183
+
184
+ // Upsert episodes
185
+ let inserted = 0;
186
+ let updated = 0;
187
+
188
+ for (const episode of episodes) {
189
+ const result = await collection.updateOne(
190
+ { id: episode.id },
191
+ { $set: episode },
192
+ { upsert: true }
193
+ );
194
+
195
+ if (result.upsertedCount > 0) {
196
+ inserted++;
197
+ } else if (result.modifiedCount > 0) {
198
+ updated++;
199
+ }
200
+ }
201
+
202
+ // Update sync metadata
203
+ await db.collection("podrollMeta").updateOne(
204
+ { key: "lastEpisodesSync" },
205
+ {
206
+ $set: {
207
+ key: "lastEpisodesSync",
208
+ timestamp: new Date(),
209
+ episodeCount: episodes.length,
210
+ inserted,
211
+ updated,
212
+ },
213
+ },
214
+ { upsert: true }
215
+ );
216
+
217
+ console.log(`[Podroll] Synced episodes: ${inserted} new, ${updated} updated`);
218
+
219
+ return {
220
+ success: true,
221
+ total: episodes.length,
222
+ inserted,
223
+ updated,
224
+ };
225
+ } catch (error) {
226
+ console.error("[Podroll] Episode sync failed:", error.message);
227
+ return { success: false, error: error.message };
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Sync OPML sources to MongoDB
233
+ * @param {object} db - MongoDB database instance
234
+ * @param {object} options - Sync options
235
+ * @returns {Promise<object>} Sync result stats
236
+ */
237
+ async function syncSources(db, options) {
238
+ const { opmlUrl, fetchTimeout } = options;
239
+
240
+ if (!opmlUrl) {
241
+ return { success: false, error: "No opmlUrl configured" };
242
+ }
243
+
244
+ try {
245
+ console.log("[Podroll] Fetching OPML sources...");
246
+ const sources = await fetchOpmlSources(opmlUrl, fetchTimeout);
247
+ console.log(`[Podroll] Fetched ${sources.length} podcast sources`);
248
+
249
+ const collection = db.collection("podrollSources");
250
+
251
+ // Clear existing and insert fresh
252
+ await collection.deleteMany({});
253
+ if (sources.length > 0) {
254
+ await collection.insertMany(
255
+ sources.map((s, index) => ({
256
+ ...s,
257
+ order: index,
258
+ fetchedAt: new Date(),
259
+ }))
260
+ );
261
+ }
262
+
263
+ // Update sync metadata
264
+ await db.collection("podrollMeta").updateOne(
265
+ { key: "lastSourcesSync" },
266
+ {
267
+ $set: {
268
+ key: "lastSourcesSync",
269
+ timestamp: new Date(),
270
+ sourceCount: sources.length,
271
+ },
272
+ },
273
+ { upsert: true }
274
+ );
275
+
276
+ console.log(`[Podroll] Synced ${sources.length} podcast sources`);
277
+
278
+ return {
279
+ success: true,
280
+ total: sources.length,
281
+ };
282
+ } catch (error) {
283
+ console.error("[Podroll] Source sync failed:", error.message);
284
+ return { success: false, error: error.message };
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Run full sync (episodes + sources)
290
+ * @param {object} db - MongoDB database instance
291
+ * @param {object} options - Sync options
292
+ * @returns {Promise<object>} Combined sync results
293
+ */
294
+ export async function runSync(db, options) {
295
+ const [episodesResult, sourcesResult] = await Promise.all([
296
+ syncEpisodes(db, options),
297
+ options.opmlUrl ? syncSources(db, options) : { success: true, skipped: true },
298
+ ]);
299
+
300
+ return {
301
+ episodes: episodesResult,
302
+ sources: sourcesResult,
303
+ timestamp: new Date(),
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Start background sync interval
309
+ * @param {object} Indiekit - Indiekit instance
310
+ * @param {object} options - Sync options
311
+ */
312
+ export function startSync(Indiekit, options) {
313
+ const { syncInterval } = options;
314
+
315
+ // Initial sync after short delay
316
+ setTimeout(async () => {
317
+ const db = Indiekit.database;
318
+ if (db) {
319
+ console.log("[Podroll] Running initial sync...");
320
+ await runSync(db, options);
321
+ }
322
+ }, 5000);
323
+
324
+ // Periodic sync
325
+ setInterval(async () => {
326
+ const db = Indiekit.database;
327
+ if (db) {
328
+ console.log("[Podroll] Running scheduled sync...");
329
+ await runSync(db, options);
330
+ }
331
+ }, syncInterval);
332
+
333
+ console.log(`[Podroll] Background sync started (interval: ${syncInterval / 1000}s)`);
334
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "podroll": {
3
+ "title": "Podroll",
4
+ "description": "Podcast subscriptions aggregated from FreshRSS",
5
+ "stats": "Statistics",
6
+ "episodeCount": "Episodes",
7
+ "sourceCount": "Podcast Sources",
8
+ "lastEpisodesSync": "Last Episodes Sync",
9
+ "lastSourcesSync": "Last Sources Sync",
10
+ "never": "Never",
11
+ "configuration": "Configuration",
12
+ "episodesUrl": "Episodes Feed URL",
13
+ "opmlUrl": "OPML URL",
14
+ "syncInterval": "Sync Interval",
15
+ "minutes": "minutes",
16
+ "actions": "Actions",
17
+ "syncNow": "Sync Now",
18
+ "clearResync": "Clear & Re-sync",
19
+ "clearConfirm": "This will delete all cached episodes and sources, then re-fetch from FreshRSS. Continue?",
20
+ "syncSuccess": "Sync completed successfully",
21
+ "clearSuccess": "Data cleared and re-synced successfully",
22
+ "syncError": "Sync failed",
23
+ "apiEndpoints": "API Endpoints",
24
+ "apiEpisodes": "List podcast episodes (supports limit, offset, source params)",
25
+ "apiSources": "List podcast sources from OPML (supports category param)",
26
+ "apiStatus": "Sync status and counts"
27
+ }
28
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@rmdes/indiekit-endpoint-podroll",
3
+ "version": "1.0.0",
4
+ "description": "Podcast roll endpoint for Indiekit. Aggregates podcast episodes from FreshRSS, displays on frontend with OPML sidebar.",
5
+ "keywords": [
6
+ "indiekit",
7
+ "indiekit-plugin",
8
+ "indieweb",
9
+ "podcast",
10
+ "podroll",
11
+ "opml",
12
+ "rss"
13
+ ],
14
+ "homepage": "https://github.com/rmdes/indiekit-endpoint-podroll",
15
+ "bugs": {
16
+ "url": "https://github.com/rmdes/indiekit-endpoint-podroll/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/rmdes/indiekit-endpoint-podroll.git"
21
+ },
22
+ "author": {
23
+ "name": "Ricardo Mendes",
24
+ "url": "https://rmendes.net"
25
+ },
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "type": "module",
31
+ "main": "index.js",
32
+ "exports": {
33
+ ".": "./index.js"
34
+ },
35
+ "files": [
36
+ "lib",
37
+ "locales",
38
+ "views",
39
+ "index.js"
40
+ ],
41
+ "dependencies": {
42
+ "@indiekit/error": "^1.0.0-beta.25",
43
+ "express": "^5.0.0",
44
+ "sanitize-html": "^2.13.0",
45
+ "xml2js": "^0.6.2"
46
+ },
47
+ "peerDependencies": {
48
+ "@indiekit/indiekit": ">=1.0.0-beta.25"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
@@ -0,0 +1,93 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <header class="page-header">
5
+ <h1 class="page-header__title">{{ __("podroll.title") }}</h1>
6
+ <p class="page-header__description">{{ __("podroll.description") }}</p>
7
+ </header>
8
+
9
+ {% if request.query.synced %}
10
+ <div class="notification notification--success">
11
+ {{ __("podroll.syncSuccess") }}
12
+ </div>
13
+ {% endif %}
14
+
15
+ {% if request.query.cleared %}
16
+ <div class="notification notification--success">
17
+ {{ __("podroll.clearSuccess") }}
18
+ </div>
19
+ {% endif %}
20
+
21
+ {% if request.query.error %}
22
+ <div class="notification notification--error">
23
+ {{ __("podroll.syncError") }}: {{ request.query.error }}
24
+ </div>
25
+ {% endif %}
26
+
27
+ <div class="dashboard">
28
+ <section class="dashboard__section">
29
+ <h2>{{ __("podroll.stats") }}</h2>
30
+ <dl class="definition-list">
31
+ <div class="definition-list__item">
32
+ <dt>{{ __("podroll.episodeCount") }}</dt>
33
+ <dd>{{ stats.episodeCount }}</dd>
34
+ </div>
35
+ <div class="definition-list__item">
36
+ <dt>{{ __("podroll.sourceCount") }}</dt>
37
+ <dd>{{ stats.sourceCount }}</dd>
38
+ </div>
39
+ <div class="definition-list__item">
40
+ <dt>{{ __("podroll.lastEpisodesSync") }}</dt>
41
+ <dd>{{ stats.lastEpisodesSync | date("PPpp") if stats.lastEpisodesSync else __("podroll.never") }}</dd>
42
+ </div>
43
+ <div class="definition-list__item">
44
+ <dt>{{ __("podroll.lastSourcesSync") }}</dt>
45
+ <dd>{{ stats.lastSourcesSync | date("PPpp") if stats.lastSourcesSync else __("podroll.never") }}</dd>
46
+ </div>
47
+ </dl>
48
+ </section>
49
+
50
+ <section class="dashboard__section">
51
+ <h2>{{ __("podroll.configuration") }}</h2>
52
+ <dl class="definition-list">
53
+ <div class="definition-list__item">
54
+ <dt>{{ __("podroll.episodesUrl") }}</dt>
55
+ <dd>{{ config.episodesUrl }}</dd>
56
+ </div>
57
+ <div class="definition-list__item">
58
+ <dt>{{ __("podroll.opmlUrl") }}</dt>
59
+ <dd>{{ config.opmlUrl }}</dd>
60
+ </div>
61
+ <div class="definition-list__item">
62
+ <dt>{{ __("podroll.syncInterval") }}</dt>
63
+ <dd>{{ (config.syncInterval / 60000) | round }} {{ __("podroll.minutes") }}</dd>
64
+ </div>
65
+ </dl>
66
+ </section>
67
+
68
+ <section class="dashboard__section">
69
+ <h2>{{ __("podroll.actions") }}</h2>
70
+ <div class="button-group">
71
+ <form method="post" action="{{ application.podrollEndpoint }}/sync" style="display: inline;">
72
+ <button type="submit" class="button button--primary">
73
+ {{ __("podroll.syncNow") }}
74
+ </button>
75
+ </form>
76
+ <form method="post" action="{{ application.podrollEndpoint }}/clear-resync" style="display: inline;" onsubmit="return confirm('{{ __("podroll.clearConfirm") }}');">
77
+ <button type="submit" class="button button--secondary">
78
+ {{ __("podroll.clearResync") }}
79
+ </button>
80
+ </form>
81
+ </div>
82
+ </section>
83
+
84
+ <section class="dashboard__section">
85
+ <h2>{{ __("podroll.apiEndpoints") }}</h2>
86
+ <ul class="api-list">
87
+ <li><code>GET {{ application.podrollEndpoint }}/api/episodes</code> - {{ __("podroll.apiEpisodes") }}</li>
88
+ <li><code>GET {{ application.podrollEndpoint }}/api/sources</code> - {{ __("podroll.apiSources") }}</li>
89
+ <li><code>GET {{ application.podrollEndpoint }}/api/status</code> - {{ __("podroll.apiStatus") }}</li>
90
+ </ul>
91
+ </section>
92
+ </div>
93
+ {% endblock %}