314 lines
13 KiB
JavaScript
314 lines
13 KiB
JavaScript
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// Posimai Feed API - Media Management Endpoints
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// Purpose: Allow users to add/remove custom RSS feeds
|
|
// Location: Copy this to Synology NAS at /app/server.js (Feed API section)
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
// Assume: authMiddleware, pool (PostgreSQL) are already imported
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// POST /feed/api/media/add
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
/**
|
|
* Add a new RSS feed source
|
|
*
|
|
* Request body:
|
|
* {
|
|
* feedUrl: string // RSS feed URL (required)
|
|
* feedName: string // Display name (optional, auto-detected from RSS)
|
|
* category: string // User-defined category (optional)
|
|
* }
|
|
*
|
|
* Response:
|
|
* {
|
|
* success: boolean
|
|
* mediaSource: { id, feedUrl, feedName, ... }
|
|
* }
|
|
*/
|
|
router.post('/media/add', authMiddleware, async (req, res) => {
|
|
const { feedUrl, feedName, category } = req.body || {};
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// 1. Validation
|
|
// ──────────────────────────────────────────────────────────────────
|
|
if (!feedUrl) {
|
|
return res.status(400).json({
|
|
error: 'Missing required field: feedUrl'
|
|
});
|
|
}
|
|
|
|
// Validate URL format
|
|
try {
|
|
new URL(feedUrl);
|
|
} catch {
|
|
return res.status(400).json({
|
|
error: 'Invalid URL format'
|
|
});
|
|
}
|
|
|
|
try {
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 2. Verify RSS feed (fetch and parse)
|
|
// ──────────────────────────────────────────────────────────────
|
|
console.log(`[Feed API] Verifying RSS feed: ${feedUrl}`);
|
|
|
|
const rssData = await fetchAndParseRSS(feedUrl);
|
|
|
|
if (!rssData) {
|
|
return res.status(400).json({
|
|
error: 'Invalid RSS feed: Unable to parse feed'
|
|
});
|
|
}
|
|
|
|
const detectedName = feedName || rssData.title || new URL(feedUrl).hostname;
|
|
const detectedIcon = rssData.icon || null;
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 3. Save to database
|
|
// ──────────────────────────────────────────────────────────────
|
|
const result = await pool.query(`
|
|
INSERT INTO media_sources (
|
|
user_id,
|
|
feed_url,
|
|
feed_name,
|
|
feed_icon,
|
|
category,
|
|
is_active,
|
|
last_fetched_at,
|
|
created_at,
|
|
updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, true, NOW(), NOW(), NOW()
|
|
)
|
|
ON CONFLICT (user_id, feed_url) DO UPDATE SET
|
|
feed_name = EXCLUDED.feed_name,
|
|
feed_icon = EXCLUDED.feed_icon,
|
|
category = EXCLUDED.category,
|
|
is_active = true,
|
|
updated_at = NOW()
|
|
RETURNING *
|
|
`, [
|
|
req.userId, // $1: user_id from authMiddleware
|
|
feedUrl, // $2: feed_url
|
|
detectedName, // $3: feed_name
|
|
detectedIcon, // $4: feed_icon
|
|
category || null // $5: category
|
|
]);
|
|
|
|
const mediaSource = result.rows[0];
|
|
|
|
console.log(`[Feed API] Media source added: ${detectedName} (ID=${mediaSource.id})`);
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 4. Success response
|
|
// ──────────────────────────────────────────────────────────────
|
|
return res.json({
|
|
success: true,
|
|
mediaSource: {
|
|
id: mediaSource.id,
|
|
feedUrl: mediaSource.feed_url,
|
|
feedName: mediaSource.feed_name,
|
|
feedIcon: mediaSource.feed_icon,
|
|
category: mediaSource.category,
|
|
isActive: mediaSource.is_active,
|
|
createdAt: mediaSource.created_at
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[Feed API] Failed to add media source:', error);
|
|
|
|
return res.status(500).json({
|
|
error: 'Failed to add RSS feed',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// GET /feed/api/media
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
/**
|
|
* Get all media sources for authenticated user
|
|
*
|
|
* Response:
|
|
* {
|
|
* mediaSources: [
|
|
* { id, feedUrl, feedName, feedIcon, category, isActive, ... }
|
|
* ]
|
|
* }
|
|
*/
|
|
router.get('/media', authMiddleware, async (req, res) => {
|
|
try {
|
|
const { rows } = await pool.query(`
|
|
SELECT
|
|
id,
|
|
feed_url,
|
|
feed_name,
|
|
feed_icon,
|
|
category,
|
|
is_active,
|
|
last_fetched_at,
|
|
fetch_interval_minutes,
|
|
created_at,
|
|
updated_at
|
|
FROM media_sources
|
|
WHERE user_id = $1
|
|
ORDER BY created_at DESC
|
|
`, [req.userId]);
|
|
|
|
return res.json({
|
|
mediaSources: rows.map(row => ({
|
|
id: row.id,
|
|
feedUrl: row.feed_url,
|
|
feedName: row.feed_name,
|
|
feedIcon: row.feed_icon,
|
|
category: row.category,
|
|
isActive: row.is_active,
|
|
lastFetchedAt: row.last_fetched_at,
|
|
fetchIntervalMinutes: row.fetch_interval_minutes,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}))
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[Feed API] Failed to fetch media sources:', error);
|
|
|
|
return res.status(500).json({
|
|
error: 'Failed to fetch media sources',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// DELETE /feed/api/media/:id
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
/**
|
|
* Remove a media source (soft delete - set is_active=false)
|
|
*
|
|
* Response:
|
|
* {
|
|
* success: boolean
|
|
* }
|
|
*/
|
|
router.delete('/media/:id', authMiddleware, async (req, res) => {
|
|
const { id } = req.params;
|
|
|
|
try {
|
|
const result = await pool.query(`
|
|
UPDATE media_sources
|
|
SET is_active = false, updated_at = NOW()
|
|
WHERE id = $1 AND user_id = $2
|
|
RETURNING id
|
|
`, [id, req.userId]);
|
|
|
|
if (result.rowCount === 0) {
|
|
return res.status(404).json({
|
|
error: 'Media source not found'
|
|
});
|
|
}
|
|
|
|
console.log(`[Feed API] Media source deleted: ID=${id}`);
|
|
|
|
return res.json({
|
|
success: true
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[Feed API] Failed to delete media source:', error);
|
|
|
|
return res.status(500).json({
|
|
error: 'Failed to delete media source',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// Helper Functions
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
/**
|
|
* Fetch and parse RSS feed
|
|
*
|
|
* @param {string} feedUrl - RSS feed URL
|
|
* @returns {Promise<{title: string, icon: string}|null>}
|
|
*/
|
|
async function fetchAndParseRSS(feedUrl) {
|
|
try {
|
|
const response = await fetch(feedUrl, {
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 Posimai Feed Bot'
|
|
},
|
|
timeout: 10000 // 10 second timeout
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error(`[RSS] HTTP error: ${response.status}`);
|
|
return null;
|
|
}
|
|
|
|
const text = await response.text();
|
|
|
|
// Simple RSS/Atom parsing (you may want to use a library like 'rss-parser')
|
|
// For now, we'll do basic regex matching
|
|
|
|
// Extract <title> tag
|
|
const titleMatch = text.match(/<title>([^<]+)<\/title>/i);
|
|
const title = titleMatch ? titleMatch[1].trim() : null;
|
|
|
|
// Extract <link> tag for icon (not standard, but some feeds have it)
|
|
const iconMatch = text.match(/<image>[\s\S]*?<url>([^<]+)<\/url>/i) ||
|
|
text.match(/<logo>([^<]+)<\/logo>/i);
|
|
const icon = iconMatch ? iconMatch[1].trim() : null;
|
|
|
|
// Fallback: Try to get favicon from feed homepage
|
|
const linkMatch = text.match(/<link>([^<]+)<\/link>/i);
|
|
if (linkMatch && !icon) {
|
|
const siteUrl = linkMatch[1].trim();
|
|
try {
|
|
const domain = new URL(siteUrl).origin;
|
|
const faviconUrl = `${domain}/favicon.ico`;
|
|
// Note: You might want to verify this URL exists
|
|
return { title, icon: faviconUrl };
|
|
} catch { }
|
|
}
|
|
|
|
return { title, icon };
|
|
|
|
} catch (error) {
|
|
console.error('[RSS] Failed to fetch/parse RSS:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// Export
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
module.exports = router;
|
|
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
// DEPLOYMENT CHECKLIST
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
//
|
|
// 1. [ ] Run database migration: synology-feed-media-add-migration.sql
|
|
// 2. [ ] Update server.js to include this router:
|
|
// app.use('/feed/api', require('./routes/feed-media'));
|
|
// 3. [ ] Install RSS parser library (optional, for better parsing):
|
|
// npm install rss-parser
|
|
// 4. [ ] Restart Feed API server: docker restart posimai-feed-api
|
|
// 5. [ ] Test POST /media/add with sample RSS URL
|
|
// 6. [ ] Check logs: docker logs -f posimai-feed-api
|
|
// 7. [ ] Update Feed UI to call these endpoints
|
|
//
|
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|