posimai-root/docs/synology/synology-feed-media-add-end...

314 lines
13 KiB
JavaScript
Raw Normal View History

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 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
//
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━