feat: enhance plugin system with async support and lifecycle hooks & reverted PR #79 due to Node.js v24 stream compatibility issues
This commit is contained in:
+59
-17
@@ -106,30 +106,67 @@ try {
|
||||
console.warn('No services directory found or failed to read services:', e.message);
|
||||
}
|
||||
|
||||
// Freeze services object to prevent plugins from mutating shared state
|
||||
Object.freeze(services);
|
||||
|
||||
// Plugin loader: loads any .js file inside server/plugins and calls the
|
||||
// exported function with (app, services).
|
||||
try {
|
||||
const pluginsDir = path.join(__dirname, 'plugins');
|
||||
if (fs.existsSync(pluginsDir)) {
|
||||
const pluginFiles = fs.readdirSync(pluginsDir).filter(f => f.endsWith('.js'));
|
||||
for (const file of pluginFiles) {
|
||||
const pluginPath = path.join(pluginsDir, file);
|
||||
try {
|
||||
const plugin = require(pluginPath);
|
||||
if (typeof plugin === 'function') {
|
||||
plugin(app, services);
|
||||
console.log(`Loaded plugin: ${file}`);
|
||||
} else {
|
||||
console.warn(`Plugin ${file} does not export a function, skipping.`);
|
||||
// Supports both function exports and object exports with lifecycle hooks.
|
||||
const loadedPlugins = [];
|
||||
|
||||
async function loadPlugins() {
|
||||
try {
|
||||
const pluginsDir = path.join(__dirname, 'plugins');
|
||||
if (fs.existsSync(pluginsDir)) {
|
||||
// Sort plugin files alphabetically for deterministic load order
|
||||
const pluginFiles = fs.readdirSync(pluginsDir)
|
||||
.filter(f => f.endsWith('.js'))
|
||||
.sort();
|
||||
|
||||
for (const file of pluginFiles) {
|
||||
const pluginPath = path.join(pluginsDir, file);
|
||||
try {
|
||||
const plugin = require(pluginPath);
|
||||
|
||||
// Support both function exports and object exports with lifecycle hooks
|
||||
if (typeof plugin === 'function') {
|
||||
// Direct function export (sync or async)
|
||||
await plugin(app, services);
|
||||
loadedPlugins.push({ name: file, plugin: null });
|
||||
console.log(`✓ Loaded plugin: ${file}`);
|
||||
} else if (plugin && typeof plugin.init === 'function') {
|
||||
// Object export with init/shutdown lifecycle
|
||||
await plugin.init(app, services);
|
||||
loadedPlugins.push({ name: file, plugin });
|
||||
console.log(`✓ Loaded plugin: ${file} (with lifecycle hooks)`);
|
||||
} else {
|
||||
console.warn(`⚠ Plugin ${file} does not export a function or object with init(), skipping.`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`✗ Failed to load plugin ${file}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Plugin loader failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown handler for plugins with shutdown hooks
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, shutting down plugins...');
|
||||
for (const { name, plugin } of loadedPlugins) {
|
||||
if (plugin && typeof plugin.shutdown === 'function') {
|
||||
try {
|
||||
await plugin.shutdown();
|
||||
console.log(`✓ Shutdown plugin: ${name}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load plugin ${file}:`, err);
|
||||
console.error(`✗ Error shutting down plugin ${name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Plugin loader failed:', err.message);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
@@ -164,6 +201,11 @@ app.use((err, req, res, next) => {
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`NodeCast TV server running on http://localhost:${PORT}`);
|
||||
|
||||
// Load plugins
|
||||
await loadPlugins().catch(err => {
|
||||
console.error('Plugin initialization failed:', err);
|
||||
});
|
||||
|
||||
// Trigger background sync with delay to allow server to settle
|
||||
setTimeout(async () => {
|
||||
await syncService.syncAll().catch(console.error);
|
||||
|
||||
+147
-7
@@ -6,18 +6,158 @@ This directory allows you to extend the functionality of **NodeCast TV** without
|
||||
|
||||
## 🛠️ How It Works
|
||||
|
||||
The plugin loader in `server/index.js` scans this directory and expects each file to export a **initialization function**.
|
||||
The plugin loader in `server/index.js` scans this directory at startup and loads plugins in **alphabetical order** (sorted by filename). Each plugin file should export either:
|
||||
- A function (sync or async) that will be called during initialization
|
||||
- An object with `init()` and optionally `shutdown()` methods for lifecycle management
|
||||
|
||||
When the server starts, it calls this function and passes the internal Express instance and the loaded services.
|
||||
When the server starts, it calls your plugin's initialization code and passes:
|
||||
- `app` - The Express application instance
|
||||
- `services` - A **frozen** (read-only) object containing all internal services
|
||||
|
||||
### Plugin Signature
|
||||
Each plugin must follow this structure:
|
||||
---
|
||||
|
||||
## 📝 Plugin Patterns
|
||||
|
||||
### Pattern 1: Simple Function Export
|
||||
|
||||
The simplest plugin pattern - just export a function:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* @param {Object} app - The Express application instance
|
||||
* @param {Object} services - Object containing all internal services (db, syncService, etc.)
|
||||
* @param {Object} services - Frozen object containing all internal services
|
||||
*/
|
||||
module.exports = function(app, services) {
|
||||
// Your logic here
|
||||
};
|
||||
// Register routes, middleware, etc.
|
||||
app.get('/api/my-route', (req, res) => {
|
||||
res.json({ message: 'Hello from plugin!' });
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 2: Async Function Export
|
||||
|
||||
For plugins that need to perform async initialization (database connections, API calls, etc.):
|
||||
|
||||
```javascript
|
||||
module.exports = async function(app, services) {
|
||||
// Async initialization
|
||||
await someAsyncSetup();
|
||||
|
||||
// Access services
|
||||
if (services.syncService) {
|
||||
console.log('Sync service available');
|
||||
}
|
||||
|
||||
// Register routes
|
||||
app.get('/api/my-route', (req, res) => {
|
||||
res.json({ status: 'ready' });
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 3: Lifecycle Hooks (Advanced)
|
||||
|
||||
For plugins that need cleanup on shutdown:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
/**
|
||||
* Called during server startup
|
||||
*/
|
||||
init: async (app, services) => {
|
||||
// Setup code
|
||||
this.interval = setInterval(() => {
|
||||
console.log('Background task running...');
|
||||
}, 60000);
|
||||
|
||||
app.get('/api/status', (req, res) => {
|
||||
res.json({ uptime: process.uptime() });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Called on SIGTERM (graceful shutdown)
|
||||
*/
|
||||
shutdown: async () => {
|
||||
// Cleanup code
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
console.log('Plugin cleaned up');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Permissions
|
||||
|
||||
> [!WARNING]
|
||||
> Plugins run with **full Node.js permissions** and have access to the Express app and all services. Only install plugins from trusted sources.
|
||||
|
||||
**Important notes:**
|
||||
- The `services` object is **frozen** - you cannot modify or delete services
|
||||
- Plugins can still mutate properties of service objects (e.g., `services.cache.set()`)
|
||||
- Plugins can register routes, middleware, and access the file system
|
||||
- This is appropriate for self-hosted applications where you control what plugins are installed
|
||||
|
||||
---
|
||||
|
||||
## 📦 Available Services
|
||||
|
||||
The `services` object contains all modules from `server/services/`:
|
||||
|
||||
| Service | Description |
|
||||
|---------|-------------|
|
||||
| `cache` | Caching utilities |
|
||||
| `epgParser` | EPG/XMLTV parsing |
|
||||
| `hwDetect` | Hardware acceleration detection |
|
||||
| `m3uParser` | M3U playlist parsing |
|
||||
| `m3uXtreamAdapter` | Xtream API adapter |
|
||||
| `syncService` | Channel/EPG synchronization |
|
||||
| `transcodeSession` | Transcoding session management |
|
||||
| `xtreamApi` | Xtream API client |
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
module.exports = async function(app, services) {
|
||||
// Use the EPG parser service
|
||||
const epgData = await services.epgParser.fetchAndParse('http://example.com/epg.xml');
|
||||
console.log(`Loaded ${epgData.programmes.length} programmes`);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Load Order
|
||||
|
||||
Plugins are loaded in **alphabetical order** by filename. If you need specific ordering:
|
||||
|
||||
```
|
||||
plugins/
|
||||
01-database.js # Loads first
|
||||
02-api-client.js # Loads second
|
||||
99-cleanup.js # Loads last
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Your Plugin
|
||||
|
||||
1. Place your `.js` file in `server/plugins/`
|
||||
2. Restart the NodeCast TV server
|
||||
3. Check the console for plugin loading messages:
|
||||
- `✓ Loaded plugin: your-plugin.js` - Success
|
||||
- `⚠ Plugin your-plugin.js does not export...` - Wrong export format
|
||||
- `✗ Failed to load plugin your-plugin.js` - Error during initialization
|
||||
|
||||
---
|
||||
|
||||
## 💡 Example Use Cases
|
||||
|
||||
- **Custom scrapers** - Add support for new streaming sources
|
||||
- **Notification systems** - Send alerts when new content is available
|
||||
- **Analytics** - Track viewing patterns
|
||||
- **Custom APIs** - Expose additional endpoints for integrations
|
||||
- **Middleware** - Add authentication, logging, or rate limiting
|
||||
+21
-4
@@ -1,8 +1,25 @@
|
||||
module.exports = function(app, services) {
|
||||
console.log("🚀 Plugin 'Hello' activé !");
|
||||
/**
|
||||
* Example plugin demonstrating async initialization and service access
|
||||
* This plugin registers a test route at /api/hello
|
||||
*/
|
||||
module.exports = async function (app, services) {
|
||||
console.log("Plugin 'Hello' activated!");
|
||||
|
||||
//route de test accessible sur http://localhost:3000/api/hello
|
||||
// Example: Access loaded services
|
||||
if (services.syncService) {
|
||||
console.log(" - syncService is available");
|
||||
}
|
||||
|
||||
// Simulate async initialization (e.g., database connection, API setup)
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Register a test route accessible at http://localhost:3000/api/hello
|
||||
app.get('/api/hello', (req, res) => {
|
||||
res.json({ message: "Le système de plugin fonctionne !" });
|
||||
res.json({
|
||||
message: "The plugin system is working!",
|
||||
availableServices: Object.keys(services)
|
||||
});
|
||||
});
|
||||
|
||||
console.log(" - Registered route: GET /api/hello");
|
||||
};
|
||||
@@ -266,11 +266,9 @@ async function* fetchAndParseStreaming(url, batchSize = 1000) {
|
||||
* @yields {{ channels: Array|null, programmes: Array, isLast: boolean }}
|
||||
*/
|
||||
async function* parseStreaming(input, batchSize = 1000) {
|
||||
let channels = [];
|
||||
const channels = [];
|
||||
let programmeBatch = [];
|
||||
let channelsYielded = false;
|
||||
const maxPenmdingBatches = 4;
|
||||
let paused = false;
|
||||
|
||||
// We need to convert SAX events to an async iterator
|
||||
// This requires collecting events and yielding when batch is full
|
||||
@@ -281,7 +279,7 @@ async function* parseStreaming(input, batchSize = 1000) {
|
||||
let currentObject = null;
|
||||
let textBuffer = '';
|
||||
let resolveNext = null;
|
||||
const pendingBatches = [];
|
||||
let pendingBatch = null;
|
||||
let ended = false;
|
||||
let error = null;
|
||||
|
||||
@@ -347,27 +345,13 @@ async function* parseStreaming(input, batchSize = 1000) {
|
||||
isLast: false
|
||||
};
|
||||
channelsYielded = true;
|
||||
|
||||
// allow channels array to be GC'd after included in first batch
|
||||
try { if (channels && channels.length) channels = null; } catch (e) {}
|
||||
|
||||
programmeBatch = [];
|
||||
|
||||
if (resolveNext) {
|
||||
resolveNext(batch);
|
||||
resolveNext = null;
|
||||
} else {
|
||||
pendingBatches.push(batch);
|
||||
|
||||
// Apply backpressure when queue grows too large
|
||||
try {
|
||||
if (!paused && pendingBatches.length >= maxPenmdingBatches) {
|
||||
if (input && typeof input.pause === 'function') {
|
||||
input.pause();
|
||||
paused = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
pendingBatch = batch;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,7 +398,7 @@ async function* parseStreaming(input, batchSize = 1000) {
|
||||
resolveNext(batch);
|
||||
resolveNext = null;
|
||||
} else {
|
||||
pendingBatches.push(batch);
|
||||
pendingBatch = batch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -429,21 +413,10 @@ async function* parseStreaming(input, batchSize = 1000) {
|
||||
input.pipe(saxStream);
|
||||
|
||||
// Yield batches as they become available
|
||||
|
||||
while (!ended || pendingBatches.length > 0) {
|
||||
if (pendingBatches.length > 0) {
|
||||
const batch = pendingBatches.shift();
|
||||
|
||||
// If paused and queue drained below threshold, resume
|
||||
try {
|
||||
if (paused && pendingBatches.length < maxPenmdingBatches) {
|
||||
if (input && typeof input.resume === 'function') {
|
||||
input.resume();
|
||||
paused = false;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
while (!ended || pendingBatch) {
|
||||
if (pendingBatch) {
|
||||
const batch = pendingBatch;
|
||||
pendingBatch = null;
|
||||
yield batch;
|
||||
if (batch.isLast) break;
|
||||
} else if (!ended) {
|
||||
@@ -472,3 +445,4 @@ module.exports = {
|
||||
getProgrammesForChannel,
|
||||
getCurrentAndUpcoming
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user