feat: add /feed/media CRUD endpoints to server.js

Adds GET/POST/PATCH/DELETE for feed_media table, bringing git in sync
with the deployed Synology version. Safe to deploy-server.sh after this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
posimai 2026-03-22 17:06:14 +09:00
parent 1e0d7f602e
commit c73f4f3180
1 changed files with 60 additions and 1 deletions

View File

@ -1057,6 +1057,65 @@ ${excerpt}
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
}); });
// ── Feed MediaカスタムRSSソース管理────────────────────────────
// テーブル: feed_media (id SERIAL PK, user_id TEXT, name TEXT, feed_url TEXT,
// site_url TEXT, category TEXT, is_active BOOLEAN DEFAULT true,
// created_at TIMESTAMPTZ DEFAULT NOW())
r.get('/feed/media', authMiddleware, async (req, res) => {
try {
const result = await pool.query(
'SELECT id, name, feed_url, site_url, category, is_active, created_at FROM feed_media WHERE user_id=$1 ORDER BY created_at ASC',
[req.userId]
);
res.json(result.rows);
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
r.post('/feed/media', authMiddleware, async (req, res) => {
const { name, feed_url, site_url = '', category = 'tech', is_active = true } = req.body || {};
if (!name || !feed_url) return res.status(400).json({ error: 'name and feed_url required' });
try {
const result = await pool.query(
'INSERT INTO feed_media (user_id, name, feed_url, site_url, category, is_active) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, name, feed_url, site_url, category, is_active, created_at',
[req.userId, name, feed_url, site_url, category, is_active]
);
res.status(201).json(result.rows[0]);
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
r.patch('/feed/media/:id', authMiddleware, async (req, res) => {
const { name, feed_url, site_url, category, is_active } = req.body || {};
try {
const fields = [];
const vals = [];
let idx = 1;
if (name !== undefined) { fields.push(`name=$${idx++}`); vals.push(name); }
if (feed_url !== undefined) { fields.push(`feed_url=$${idx++}`); vals.push(feed_url); }
if (site_url !== undefined) { fields.push(`site_url=$${idx++}`); vals.push(site_url); }
if (category !== undefined) { fields.push(`category=$${idx++}`); vals.push(category); }
if (is_active !== undefined) { fields.push(`is_active=$${idx++}`); vals.push(is_active); }
if (fields.length === 0) return res.status(400).json({ error: 'no fields to update' });
vals.push(req.params.id, req.userId);
const result = await pool.query(
`UPDATE feed_media SET ${fields.join(',')} WHERE id=$${idx} AND user_id=$${idx + 1} RETURNING id, name, feed_url, site_url, category, is_active`,
vals
);
if (result.rowCount === 0) return res.status(404).json({ error: 'not found' });
res.json(result.rows[0]);
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
r.delete('/feed/media/:id', authMiddleware, async (req, res) => {
try {
await pool.query(
'DELETE FROM feed_media WHERE id=$1 AND user_id=$2',
[req.params.id, req.userId]
);
res.json({ ok: true });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// ── TTS (VOICEVOX) ───────────────────────────────────────────── // ── TTS (VOICEVOX) ─────────────────────────────────────────────
// VOICEVOX_URL: docker-compose.yml で設定(同ネットワーク内なら http://voicevox:50021 // VOICEVOX_URL: docker-compose.yml で設定(同ネットワーク内なら http://voicevox:50021
// 未設定時は localhost フォールバックNAS上で同じコンテナホストの場合 // 未設定時は localhost フォールバックNAS上で同じコンテナホストの場合
@ -1295,7 +1354,7 @@ ${excerpt}
const bodyStart = fullContent.search(/^#{1,2}\s/m); const bodyStart = fullContent.search(/^#{1,2}\s/m);
const excerpt = (bodyStart >= 0 ? fullContent.slice(bodyStart) : fullContent).slice(0, 4000); const excerpt = (bodyStart >= 0 ? fullContent.slice(bodyStart) : fullContent).slice(0, 4000);
const model = genAITogether.getGenerativeModel({ model: 'gemini-2.5-flash' }); const model = genAITogether.getGenerativeModel({ model: 'gemini-2.5-flash' });
const prompt = `以下の記事を分析して、JSONのみを返してくださいコードブロック不要\n\n{"summary":"1〜2文の日本語要約","tags":["タグ1","タグ2","タグ3"]}\n\n- summary: 読者が読む価値があるかを判断できる1〜2文\n- tags: 内容を表す日本語タグを2〜4個例: AI, テクノロジー, ビジネス, 健康, 旅行, 料理, スポーツ, 政治, 経済, エンタメ, ゲーム, 科学, デザイン)\n\n記事:\n${excerpt}`; const prompt = `以下の記事を分析して、JSONのみを返してくださいコードブロック不要\n\n{"summary":"1〜2文の日本語要約","tags":["タグ1","タグ2","タグ3"]}\n\n- summary: 読者が読む価値があるかを判断できる1〜2文\n- tags: 内容を表す具体的な日本語タグを2〜4個。「その他」は絶対に使わないこと。内容が不明な場合でも最も近いカテゴリを選ぶ(例: AI, テクノロジー, ビジネス, 健康, 旅行, 料理, スポーツ, 政治, 経済, エンタメ, ゲーム, 科学, デザイン, ライフスタイル, 教育, 環境, 医療, 法律, 文化, 歴史\n\n記事:\n${excerpt}`;
const timeoutP = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000)); const timeoutP = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000));
const result = await Promise.race([model.generateContent(prompt), timeoutP]); const result = await Promise.race([model.generateContent(prompt), timeoutP]);
const raw = result.response.text().trim(); const raw = result.response.text().trim();