@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 +122 -0
- package/index.js +111 -0
- package/lib/controllers/dashboard.js +153 -0
- package/lib/controllers/episodes.js +121 -0
- package/lib/controllers/sources.js +57 -0
- package/lib/sync.js +334 -0
- package/locales/en.json +28 -0
- package/package.json +53 -0
- package/views/dashboard.njk +93 -0
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
|
+
}
|
package/locales/en.json
ADDED
|
@@ -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 %}
|