import { Database } from "bun:sqlite"; import crypto from "crypto"; import fs from "fs"; import path from "path"; import { config } from "./config.js"; // Database integrity fixes function export function performDatabaseIntegrityFixes(db: Database): void { console.log("๐Ÿ”ง Performing database integrity checks..."); try { // Fix 1: Set active flag to 1 for feeds where it's NULL const nullActiveFeeds = db .prepare("UPDATE feeds SET active = 1 WHERE active IS NULL") .run(); if (nullActiveFeeds.changes > 0) { console.log( `โœ… Fixed ${nullActiveFeeds.changes} feeds with NULL active flag`, ); } // Fix 2: Fix orphaned articles (articles referencing non-existent feeds) const orphanedArticles = db .prepare( ` SELECT a.id, a.link, a.title FROM articles a LEFT JOIN feeds f ON a.feed_id = f.id WHERE f.id IS NULL `, ) .all() as any[]; if (orphanedArticles.length > 0) { console.log( `๐Ÿ” Found ${orphanedArticles.length} orphaned articles, attempting to fix...`, ); for (const article of orphanedArticles) { // Try to match article to feed based on URL domain const articleDomain = extractDomain(article.link); if (articleDomain) { const matchingFeed = db .prepare( ` SELECT id FROM feeds WHERE url LIKE ? OR url LIKE ? ORDER BY created_at DESC LIMIT 1 `, ) .get( `%${articleDomain}%`, `%${articleDomain.replace("www.", "")}%`, ) as any; if (matchingFeed) { db.prepare("UPDATE articles SET feed_id = ? WHERE id = ?").run( matchingFeed.id, article.id, ); console.log( `โœ… Fixed article "${article.title}" -> feed ${matchingFeed.id}`, ); } else { console.log( `! Could not find matching feed for article: ${article.title} (${articleDomain})`, ); } } } } // Fix 3: Ensure all episodes have valid article references const orphanedEpisodes = db .prepare( ` SELECT e.id, e.title, e.article_id FROM episodes e LEFT JOIN articles a ON e.article_id = a.id WHERE a.id IS NULL `, ) .all() as any[]; if (orphanedEpisodes.length > 0) { console.log( `! Found ${orphanedEpisodes.length} episodes with invalid article references`, ); // We could delete these or try to fix them, but for now just log } console.log("โœ… Database integrity checks completed"); } catch (error) { console.error("โŒ Error during database integrity fixes:", error); } } // Helper function to extract domain from URL function extractDomain(url: string): string | null { try { const urlObj = new URL(url); return urlObj.hostname; } catch { return null; } } // Initialize database with proper error handling function initializeDatabase(): Database { // Ensure data directory exists if (!fs.existsSync(config.paths.dataDir)) { fs.mkdirSync(config.paths.dataDir, { recursive: true }); } // Create database file if it doesn't exist if (!fs.existsSync(config.paths.dbPath)) { fs.closeSync(fs.openSync(config.paths.dbPath, "w")); } const db = new Database(config.paths.dbPath); // Enable WAL mode for better concurrent access db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA synchronous = NORMAL;"); db.exec("PRAGMA cache_size = 1000;"); db.exec("PRAGMA temp_store = memory;"); // Load and execute schema from file const schemaPath = path.join(config.paths.projectRoot, "schema.sql"); if (fs.existsSync(schemaPath)) { const schema = fs.readFileSync(schemaPath, "utf-8"); db.exec(schema); } else { throw new Error(`Schema file not found: ${schemaPath}`); } // Perform database integrity checks and fixes performDatabaseIntegrityFixes(db); // Initialize settings table with default values initializeSettings(db); // Ensure the category column exists in episodes const episodeInfos = db.prepare("PRAGMA table_info(episodes);").all(); const hasEpisodeCategory = episodeInfos.some( (col: any) => col.name === "category", ); if (!hasEpisodeCategory) { db.exec("ALTER TABLE episodes ADD COLUMN category TEXT DEFAULT NULL;"); } return db; } const db = initializeDatabase(); export interface Feed { id: string; url: string; title?: string; description?: string; category?: string; lastUpdated?: string; createdAt: string; active: boolean; } export interface Article { id: string; feedId: string; title: string; link: string; description?: string; content?: string; pubDate: string; discoveredAt: string; processed: boolean; } export interface Episode { id: string; articleId: string; title: string; description?: string; audioPath: string; duration?: number; fileSize?: number; category?: string; createdAt: string; } // Legacy interface for backward compatibility export interface LegacyEpisode { id: string; title: string; pubDate: string; audioPath: string; sourceLink: string; } // Extended interfaces for frontend display export interface EpisodeWithFeedInfo { id: string; title: string; description?: string; audioPath: string; duration?: number; fileSize?: number; category?: string; createdAt: string; articleId: string; articleTitle: string; articleLink: string; articlePubDate: string; feedId: string; feedTitle?: string; feedUrl: string; feedCategory?: string; } // Feed management functions export async function saveFeed( feed: Omit, ): Promise { try { // Check if feed already exists const existingFeed = await getFeedByUrl(feed.url); if (existingFeed) { // Update existing feed const updateStmt = db.prepare( "UPDATE feeds SET title = ?, description = ?, category = ?, last_updated = ?, active = ? WHERE url = ?", ); updateStmt.run( feed.title || null, feed.description || null, feed.category || null, feed.lastUpdated || null, feed.active !== undefined ? (feed.active ? 1 : 0) : 1, feed.url, ); return existingFeed.id; } else { // Create new feed const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); const insertStmt = db.prepare( "INSERT INTO feeds (id, url, title, description, category, last_updated, created_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ); insertStmt.run( id, feed.url, feed.title || null, feed.description || null, feed.category || null, feed.lastUpdated || null, createdAt, feed.active !== undefined ? (feed.active ? 1 : 0) : 1, ); return id; } } catch (error) { console.error("Error saving feed:", error); throw error; } } export async function getFeedByUrl(url: string): Promise { try { const stmt = db.prepare("SELECT * FROM feeds WHERE url = ?"); const row = stmt.get(url) as any; if (!row) return null; return { id: row.id, url: row.url, title: row.title, description: row.description, category: row.category, lastUpdated: row.last_updated, createdAt: row.created_at, active: Boolean(row.active), }; } catch (error) { console.error("Error getting feed by URL:", error); throw error; } } export async function getFeedById(id: string): Promise { try { const stmt = db.prepare("SELECT * FROM feeds WHERE id = ?"); const row = stmt.get(id) as any; if (!row) return null; return { id: row.id, url: row.url, title: row.title, description: row.description, category: row.category, lastUpdated: row.last_updated, createdAt: row.created_at, active: Boolean(row.active), }; } catch (error) { console.error("Error getting feed by ID:", error); throw error; } } export async function getAllFeeds(): Promise { try { const stmt = db.prepare( "SELECT * FROM feeds WHERE active = 1 ORDER BY created_at DESC", ); const rows = stmt.all() as any[]; return rows.map((row) => ({ id: row.id, url: row.url, title: row.title, description: row.description, category: row.category, lastUpdated: row.last_updated, createdAt: row.created_at, active: Boolean(row.active), })); } catch (error) { console.error("Error getting all feeds:", error); throw error; } } // Get active feeds for user display export async function fetchActiveFeeds(): Promise { return getAllFeeds(); } // Get episodes with feed information for enhanced display export async function fetchEpisodesWithFeedInfo(): Promise< EpisodeWithFeedInfo[] > { try { const stmt = db.prepare(` SELECT e.id, e.title, e.description, e.audio_path as audioPath, e.duration, e.file_size as fileSize, e.category, e.created_at as createdAt, e.article_id as articleId, a.title as articleTitle, a.link as articleLink, a.pub_date as articlePubDate, f.id as feedId, f.title as feedTitle, f.url as feedUrl, f.category as feedCategory FROM episodes e JOIN articles a ON e.article_id = a.id JOIN feeds f ON a.feed_id = f.id WHERE f.active = 1 ORDER BY e.created_at DESC `); const rows = stmt.all() as any[]; return rows.map((row) => ({ id: row.id, title: row.title, description: row.description, audioPath: row.audioPath, duration: row.duration, fileSize: row.fileSize, category: row.category, createdAt: row.createdAt, articleId: row.articleId, articleTitle: row.articleTitle, articleLink: row.articleLink, articlePubDate: row.articlePubDate, feedId: row.feedId, feedTitle: row.feedTitle, feedUrl: row.feedUrl, feedCategory: row.feedCategory, })); } catch (error) { console.error("Error fetching episodes with feed info:", error); throw error; } } // Get episodes by feed ID export async function fetchEpisodesByFeedId( feedId: string, ): Promise { try { const stmt = db.prepare(` SELECT e.id, e.title, e.description, e.audio_path as audioPath, e.duration, e.file_size as fileSize, e.category, e.created_at as createdAt, e.article_id as articleId, a.title as articleTitle, a.link as articleLink, a.pub_date as articlePubDate, f.id as feedId, f.title as feedTitle, f.url as feedUrl, f.category as feedCategory FROM episodes e JOIN articles a ON e.article_id = a.id JOIN feeds f ON a.feed_id = f.id WHERE f.id = ? AND f.active = 1 ORDER BY e.created_at DESC `); const rows = stmt.all(feedId) as any[]; return rows.map((row) => ({ id: row.id, title: row.title, description: row.description, audioPath: row.audioPath, duration: row.duration, fileSize: row.fileSize, category: row.category, createdAt: row.createdAt, articleId: row.articleId, articleTitle: row.articleTitle, articleLink: row.articleLink, articlePubDate: row.articlePubDate, feedId: row.feedId, feedTitle: row.feedTitle, feedUrl: row.feedUrl, feedCategory: row.feedCategory, })); } catch (error) { console.error("Error fetching episodes by feed ID:", error); throw error; } } // Get single episode with source information export async function fetchEpisodeWithSourceInfo( episodeId: string, ): Promise { try { const stmt = db.prepare(` SELECT e.id, e.title, e.description, e.audio_path as audioPath, e.duration, e.file_size as fileSize, e.category, e.created_at as createdAt, e.article_id as articleId, a.title as articleTitle, a.link as articleLink, a.pub_date as articlePubDate, f.id as feedId, f.title as feedTitle, f.url as feedUrl, f.category as feedCategory FROM episodes e JOIN articles a ON e.article_id = a.id JOIN feeds f ON a.feed_id = f.id WHERE e.id = ? `); const row = stmt.get(episodeId) as any; if (!row) return null; return { id: row.id, title: row.title, description: row.description, audioPath: row.audioPath, duration: row.duration, fileSize: row.fileSize, category: row.category, createdAt: row.createdAt, articleId: row.articleId, articleTitle: row.articleTitle, articleLink: row.articleLink, articlePubDate: row.articlePubDate, feedId: row.feedId, feedTitle: row.feedTitle, feedUrl: row.feedUrl, feedCategory: row.feedCategory, }; } catch (error) { console.error("Error fetching episode with source info:", error); throw error; } } // Search episodes with feed information export async function searchEpisodesWithFeedInfo( query: string, category?: string, ): Promise { try { let whereClause = ` WHERE f.active = 1 AND ( e.title LIKE ? OR e.description LIKE ? OR a.title LIKE ? OR a.description LIKE ? OR a.content LIKE ? ) `; const searchPattern = `%${query}%`; const params = [ searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, ]; if (category) { whereClause += " AND f.category = ?"; params.push(category); } const stmt = db.prepare(` SELECT e.id, e.title, e.description, e.audio_path as audioPath, e.duration, e.file_size as fileSize, e.category, e.created_at as createdAt, e.article_id as articleId, a.title as articleTitle, a.link as articleLink, a.pub_date as articlePubDate, f.id as feedId, f.title as feedTitle, f.url as feedUrl, f.category as feedCategory FROM episodes e JOIN articles a ON e.article_id = a.id JOIN feeds f ON a.feed_id = f.id ${whereClause} ORDER BY e.created_at DESC `); const rows = stmt.all(...params) as any[]; return rows.map((row) => ({ id: row.id, title: row.title, description: row.description, audioPath: row.audioPath, duration: row.duration, fileSize: row.fileSize, category: row.category, createdAt: row.createdAt, articleId: row.articleId, articleTitle: row.articleTitle, articleLink: row.articleLink, articlePubDate: row.articlePubDate, feedId: row.feedId, feedTitle: row.feedTitle, feedUrl: row.feedUrl, feedCategory: row.feedCategory, })); } catch (error) { console.error("Error searching episodes with feed info:", error); throw error; } } export async function getAllFeedsIncludingInactive(): Promise { try { const stmt = db.prepare("SELECT * FROM feeds ORDER BY created_at DESC"); const rows = stmt.all() as any[]; return rows.map((row) => ({ id: row.id, url: row.url, title: row.title, description: row.description, category: row.category, lastUpdated: row.last_updated, createdAt: row.created_at, active: Boolean(row.active), })); } catch (error) { console.error("Error getting all feeds including inactive:", error); throw error; } } export async function getFeedsByCategory(category?: string): Promise { try { let stmt; let rows; if (category) { stmt = db.prepare( "SELECT * FROM feeds WHERE category = ? AND active = 1 ORDER BY created_at DESC", ); rows = stmt.all(category) as any[]; } else { // If no category specified, return all active feeds stmt = db.prepare( "SELECT * FROM feeds WHERE active = 1 ORDER BY created_at DESC", ); rows = stmt.all() as any[]; } return rows.map((row) => ({ id: row.id, url: row.url, title: row.title, description: row.description, category: row.category, lastUpdated: row.last_updated, createdAt: row.created_at, active: Boolean(row.active), })); } catch (error) { console.error("Error getting feeds by category:", error); throw error; } } export async function getAllCategories(): Promise { try { const stmt = db.prepare( "SELECT DISTINCT category FROM feeds WHERE category IS NOT NULL AND active = 1 ORDER BY category", ); const rows = stmt.all() as any[]; return rows.map((row) => row.category).filter(Boolean); } catch (error) { console.error("Error getting all categories:", error); throw error; } } export async function getFeedsGroupedByCategory(): Promise<{ [category: string]: Feed[]; }> { try { const feeds = await getAllFeeds(); const grouped: { [category: string]: Feed[] } = {}; for (const feed of feeds) { const category = feed.category || "Uncategorized"; if (!grouped[category]) { grouped[category] = []; } grouped[category].push(feed); } return grouped; } catch (error) { console.error("Error getting feeds grouped by category:", error); throw error; } } export async function deleteFeed(feedId: string): Promise { try { // Start transaction db.exec("BEGIN TRANSACTION"); // Delete all episodes for articles belonging to this feed const deleteEpisodesStmt = db.prepare(` DELETE FROM episodes WHERE article_id IN ( SELECT id FROM articles WHERE feed_id = ? ) `); deleteEpisodesStmt.run(feedId); // Delete all articles for this feed const deleteArticlesStmt = db.prepare( "DELETE FROM articles WHERE feed_id = ?", ); deleteArticlesStmt.run(feedId); // Delete the feed itself const deleteFeedStmt = db.prepare("DELETE FROM feeds WHERE id = ?"); const result = deleteFeedStmt.run(feedId); db.exec("COMMIT"); return result.changes > 0; } catch (error) { db.exec("ROLLBACK"); console.error("Error deleting feed:", error); throw error; } } export async function toggleFeedActive( feedId: string, active: boolean, ): Promise { try { const stmt = db.prepare("UPDATE feeds SET active = ? WHERE id = ?"); const result = stmt.run(active ? 1 : 0, feedId); return result.changes > 0; } catch (error) { console.error("Error toggling feed active status:", error); throw error; } } // Article management functions export async function saveArticle( article: Omit, ): Promise { const id = crypto.randomUUID(); const discoveredAt = new Date().toISOString(); try { const stmt = db.prepare( "INSERT OR IGNORE INTO articles (id, feed_id, title, link, description, content, pub_date, discovered_at, processed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ); const result = stmt.run( id, article.feedId, article.title, article.link, article.description || null, article.content || null, article.pubDate, discoveredAt, article.processed ? 1 : 0, ); // Return existing ID if article already exists if (result.changes === 0) { const existing = db .prepare("SELECT id FROM articles WHERE link = ?") .get(article.link) as any; return existing?.id || id; } return id; } catch (error) { console.error("Error saving article:", error); throw error; } } export async function getUnprocessedArticles( limit?: number, ): Promise { try { const sql = ` SELECT * FROM articles WHERE processed = 0 AND pub_date >= datetime('now','-6 hours') ORDER BY pub_date DESC ${limit ? `LIMIT ${limit}` : ""} `; const stmt = db.prepare(sql); const rows = stmt.all() as any[]; return rows.map((row) => ({ id: row.id, feedId: row.feed_id, title: row.title, link: row.link, description: row.description, content: row.content, pubDate: row.pub_date, discoveredAt: row.discovered_at, processed: Boolean(row.processed), })); } catch (error) { console.error("Error getting unprocessed articles:", error); throw error; } } export async function markArticleAsProcessed(articleId: string): Promise { try { const stmt = db.prepare("UPDATE articles SET processed = 1 WHERE id = ?"); stmt.run(articleId); } catch (error) { console.error("Error marking article as processed:", error); throw error; } } // Legacy function for backward compatibility export async function markAsProcessed( feedUrl: string, itemId: string, ): Promise { if (!feedUrl || !itemId) { throw new Error("feedUrl and itemId are required"); } try { const stmt = db.prepare( "SELECT 1 FROM processed_feed_items WHERE feed_url = ? AND item_id = ?", ); const row = stmt.get(feedUrl, itemId); if (row) return true; const insert = db.prepare( "INSERT INTO processed_feed_items (feed_url, item_id, processed_at) VALUES (?, ?, ?)", ); insert.run(feedUrl, itemId, new Date().toISOString()); return false; } catch (error) { console.error("Error marking item as processed:", error); throw error; } } // Episode management functions export async function saveEpisode( episode: Omit, ): Promise { const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); if (!episode.articleId || !episode.title || !episode.audioPath) { throw new Error("articleId, title, and audioPath are required"); } try { const stmt = db.prepare( "INSERT INTO episodes (id, article_id, title, description, audio_path, duration, file_size, category, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ); stmt.run( id, episode.articleId, episode.title, episode.description || null, episode.audioPath, episode.duration || null, episode.fileSize || null, episode.category || null, createdAt, ); return id; } catch (error) { console.error("Error saving episode:", error); throw error; } } // Legacy function for backward compatibility export async function saveLegacyEpisode(ep: LegacyEpisode): Promise { if (!ep.id || !ep.title || !ep.pubDate || !ep.audioPath || !ep.sourceLink) { throw new Error("All episode fields are required"); } try { // For now, save to a temporary table for migration const stmt = db.prepare( "CREATE TABLE IF NOT EXISTS legacy_episodes (id TEXT PRIMARY KEY, title TEXT, pubDate TEXT, audioPath TEXT, sourceLink TEXT)", ); stmt.run(); const insert = db.prepare( "INSERT OR IGNORE INTO legacy_episodes (id, title, pubDate, audioPath, sourceLink) VALUES (?, ?, ?, ?, ?)", ); insert.run(ep.id, ep.title, ep.pubDate, ep.audioPath, ep.sourceLink); } catch (error) { console.error("Error saving legacy episode:", error); throw error; } } export async function fetchAllEpisodes(): Promise { try { const stmt = db.prepare(` SELECT e.id, e.article_id as articleId, e.title, e.description, e.audio_path as audioPath, e.duration, e.file_size as fileSize, e.category, e.created_at as createdAt FROM episodes e ORDER BY e.created_at DESC `); return stmt.all() as Episode[]; } catch (error) { console.error("Error fetching episodes:", error); throw error; } } export async function fetchEpisodesWithArticles(): Promise< (Episode & { article: Article; feed: Feed })[] > { try { const stmt = db.prepare(` SELECT e.id, e.article_id as articleId, e.title, e.description, e.audio_path as audioPath, e.duration, e.file_size as fileSize, e.created_at as createdAt, a.id as article_id, a.feed_id as article_feedId, a.title as article_title, a.link as article_link, a.description as article_description, a.content as article_content, a.pub_date as article_pubDate, a.discovered_at as article_discoveredAt, a.processed as article_processed, f.id as feed_id, f.url as feed_url, f.title as feed_title, f.description as feed_description, f.last_updated as feed_lastUpdated, f.created_at as feed_createdAt, f.active as feed_active FROM episodes e JOIN articles a ON e.article_id = a.id JOIN feeds f ON a.feed_id = f.id ORDER BY e.created_at DESC `); const rows = stmt.all() as any[]; return rows.map((row) => ({ id: row.id, articleId: row.articleId, title: row.title, description: row.description, audioPath: row.audioPath, duration: row.duration, fileSize: row.fileSize, createdAt: row.createdAt, article: { id: row.article_id, feedId: row.article_feedId, title: row.article_title, link: row.article_link, description: row.article_description, content: row.article_content, pubDate: row.article_pubDate, discoveredAt: row.article_discoveredAt, processed: Boolean(row.article_processed), }, feed: { id: row.feed_id, url: row.feed_url, title: row.feed_title, description: row.feed_description, lastUpdated: row.feed_lastUpdated, createdAt: row.feed_createdAt, active: Boolean(row.feed_active), }, })); } catch (error) { console.error("Error fetching episodes with articles:", error); throw error; } } // TTS Queue management functions export interface TTSQueueItem { id: string; itemId: string; scriptText: string; retryCount: number; createdAt: string; lastAttemptedAt?: string; status: "pending" | "processing" | "failed"; } export async function addToQueue( itemId: string, scriptText: string, retryCount = 0, ): Promise { const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); try { const stmt = db.prepare( "INSERT INTO tts_queue (id, item_id, script_text, retry_count, created_at, status) VALUES (?, ?, ?, ?, ?, 'pending')", ); stmt.run(id, itemId, scriptText, retryCount, createdAt); console.log(`TTS queue ใซ่ฟฝๅŠ : ${itemId} (่ฉฆ่กŒๅ›žๆ•ฐ: ${retryCount})`); return id; } catch (error) { console.error("Error adding to TTS queue:", error); throw error; } } export async function getQueueItems(limit = 10): Promise { try { const stmt = db.prepare(` SELECT * FROM tts_queue WHERE status = 'pending' ORDER BY created_at ASC LIMIT ? `); const rows = stmt.all(limit) as any[]; return rows.map((row) => ({ id: row.id, itemId: row.item_id, scriptText: row.script_text, retryCount: row.retry_count, createdAt: row.created_at, lastAttemptedAt: row.last_attempted_at, status: row.status, })); } catch (error) { console.error("Error getting queue items:", error); throw error; } } export async function updateQueueItemStatus( queueId: string, status: "pending" | "processing" | "failed", lastAttemptedAt?: string, ): Promise { try { const stmt = db.prepare( "UPDATE tts_queue SET status = ?, last_attempted_at = ? WHERE id = ?", ); stmt.run(status, lastAttemptedAt || new Date().toISOString(), queueId); } catch (error) { console.error("Error updating queue item status:", error); throw error; } } export async function removeFromQueue(queueId: string): Promise { try { const stmt = db.prepare("DELETE FROM tts_queue WHERE id = ?"); stmt.run(queueId); } catch (error) { console.error("Error removing from queue:", error); throw error; } } // Feed Request management functions export interface FeedRequest { id: string; url: string; requestedBy?: string; requestMessage?: string; status: "pending" | "approved" | "rejected"; createdAt: string; reviewedAt?: string; reviewedBy?: string; adminNotes?: string; } export async function submitFeedRequest( request: Omit, ): Promise { const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); try { const stmt = db.prepare( "INSERT INTO feed_requests (id, url, requested_by, request_message, status, created_at) VALUES (?, ?, ?, ?, 'pending', ?)", ); stmt.run( id, request.url, request.requestedBy || null, request.requestMessage || null, createdAt, ); console.log(`Feed request submitted: ${request.url}`); return id; } catch (error) { console.error("Error submitting feed request:", error); throw error; } } export async function getFeedRequests(status?: string): Promise { try { const sql = status ? "SELECT * FROM feed_requests WHERE status = ? ORDER BY created_at DESC" : "SELECT * FROM feed_requests ORDER BY created_at DESC"; const stmt = db.prepare(sql); const rows = status ? stmt.all(status) : stmt.all(); return (rows as any[]).map((row) => ({ id: row.id, url: row.url, requestedBy: row.requested_by, requestMessage: row.request_message, status: row.status, createdAt: row.created_at, reviewedAt: row.reviewed_at, reviewedBy: row.reviewed_by, adminNotes: row.admin_notes, })); } catch (error) { console.error("Error getting feed requests:", error); throw error; } } export async function updateFeedRequestStatus( requestId: string, status: "approved" | "rejected", reviewedBy?: string, adminNotes?: string, ): Promise { try { const reviewedAt = new Date().toISOString(); const stmt = db.prepare( "UPDATE feed_requests SET status = ?, reviewed_at = ?, reviewed_by = ?, admin_notes = ? WHERE id = ?", ); const result = stmt.run( status, reviewedAt, reviewedBy || null, adminNotes || null, requestId, ); return result.changes > 0; } catch (error) { console.error("Error updating feed request status:", error); throw error; } } // Migration function to classify existing feeds without categories export async function migrateFeedsWithCategories(): Promise { try { console.log("๐Ÿ”„ Starting feed category migration..."); // Get all feeds without categories const stmt = db.prepare( "SELECT * FROM feeds WHERE category IS NULL OR category = ''", ); const feedsWithoutCategories = stmt.all() as any[]; if (feedsWithoutCategories.length === 0) { console.log("โœ… All feeds already have categories assigned"); return; } console.log( `๐Ÿ“‹ Found ${feedsWithoutCategories.length} feeds without categories`, ); // Import LLM service const { openAI_ClassifyFeed } = await import("./llm.js"); let processedCount = 0; let errorCount = 0; for (const feed of feedsWithoutCategories) { try { // Use title for classification, fallback to URL if no title const titleForClassification = feed.title || feed.url; console.log(`๐Ÿ” Classifying feed: ${titleForClassification}`); // Classify the feed const category = await openAI_ClassifyFeed(titleForClassification); // Update the feed with the category const updateStmt = db.prepare( "UPDATE feeds SET category = ? WHERE id = ?", ); updateStmt.run(category, feed.id); console.log( `โœ… Assigned category "${category}" to feed: ${titleForClassification}`, ); processedCount++; // Add a small delay to avoid rate limiting await new Promise((resolve) => setTimeout(resolve, 1000)); } catch (error) { console.error( `โŒ Failed to classify feed ${feed.title || feed.url}:`, error, ); errorCount++; // Set a default category for failed classifications const defaultCategory = "ใใฎไป–"; const updateStmt = db.prepare( "UPDATE feeds SET category = ? WHERE id = ?", ); updateStmt.run(defaultCategory, feed.id); console.log( `! Assigned default category "${defaultCategory}" to feed: ${feed.title || feed.url}`, ); } } console.log(`โœ… Feed category migration completed`); console.log( `๐Ÿ“Š Processed: ${processedCount}, Errors: ${errorCount}, Total: ${feedsWithoutCategories.length}`, ); } catch (error) { console.error("โŒ Error during feed category migration:", error); throw error; } } // Function to get migration status export async function getFeedCategoryMigrationStatus(): Promise<{ totalFeeds: number; feedsWithCategories: number; feedsWithoutCategories: number; migrationComplete: boolean; }> { try { const totalStmt = db.prepare( "SELECT COUNT(*) as count FROM feeds WHERE active = 1", ); const totalResult = totalStmt.get() as any; const totalFeeds = totalResult.count; const withCategoriesStmt = db.prepare( "SELECT COUNT(*) as count FROM feeds WHERE active = 1 AND category IS NOT NULL AND category != ''", ); const withCategoriesResult = withCategoriesStmt.get() as any; const feedsWithCategories = withCategoriesResult.count; const feedsWithoutCategories = totalFeeds - feedsWithCategories; const migrationComplete = feedsWithoutCategories === 0; return { totalFeeds, feedsWithCategories, feedsWithoutCategories, migrationComplete, }; } catch (error) { console.error("Error getting migration status:", error); throw error; } } export async function deleteEpisode(episodeId: string): Promise { try { // Get episode info first to find the audio file path const episodeStmt = db.prepare( "SELECT audio_path FROM episodes WHERE id = ?", ); const episode = episodeStmt.get(episodeId) as any; if (!episode) { return false; // Episode not found } // Delete from database const deleteStmt = db.prepare("DELETE FROM episodes WHERE id = ?"); const result = deleteStmt.run(episodeId); // If database deletion successful, try to delete the audio file if (result.changes > 0 && episode.audio_path) { try { const fullAudioPath = path.join( config.paths.projectRoot, episode.audio_path, ); if (fs.existsSync(fullAudioPath)) { fs.unlinkSync(fullAudioPath); console.log(`๐Ÿ—‘ Deleted audio file: ${fullAudioPath}`); } } catch (fileError) { console.warn( `! Failed to delete audio file ${episode.audio_path}:`, fileError, ); // Don't fail the operation if file deletion fails } } return result.changes > 0; } catch (error) { console.error("Error deleting episode:", error); throw error; } } // Episode category management functions export async function getEpisodesByCategory( category?: string, ): Promise { try { let stmt; let rows; if (category) { stmt = db.prepare(` SELECT e.id, e.title, e.description, e.audio_path as audioPath, e.duration, e.file_size as fileSize, e.category, e.created_at as createdAt, e.article_id as articleId, a.title as articleTitle, a.link as articleLink, a.pub_date as articlePubDate, f.id as feedId, f.title as feedTitle, f.url as feedUrl, f.category as feedCategory FROM episodes e JOIN articles a ON e.article_id = a.id JOIN feeds f ON a.feed_id = f.id WHERE e.category = ? AND f.active = 1 ORDER BY e.created_at DESC `); rows = stmt.all(category) as any[]; } else { // If no category specified, return all episodes stmt = db.prepare(` SELECT e.id, e.title, e.description, e.audio_path as audioPath, e.duration, e.file_size as fileSize, e.category, e.created_at as createdAt, e.article_id as articleId, a.title as articleTitle, a.link as articleLink, a.pub_date as articlePubDate, f.id as feedId, f.title as feedTitle, f.url as feedUrl, f.category as feedCategory FROM episodes e JOIN articles a ON e.article_id = a.id JOIN feeds f ON a.feed_id = f.id WHERE f.active = 1 ORDER BY e.created_at DESC `); rows = stmt.all() as any[]; } return rows.map((row) => ({ id: row.id, title: row.title, description: row.description, audioPath: row.audioPath, duration: row.duration, fileSize: row.fileSize, category: row.category, createdAt: row.createdAt, articleId: row.articleId, articleTitle: row.articleTitle, articleLink: row.articleLink, articlePubDate: row.articlePubDate, feedId: row.feedId, feedTitle: row.feedTitle, feedUrl: row.feedUrl, feedCategory: row.feedCategory, })); } catch (error) { console.error("Error getting episodes by category:", error); throw error; } } export async function getAllEpisodeCategories(): Promise { try { const stmt = db.prepare( "SELECT DISTINCT category FROM episodes WHERE category IS NOT NULL ORDER BY category", ); const rows = stmt.all() as any[]; return rows.map((row) => row.category).filter(Boolean); } catch (error) { console.error("Error getting all episode categories:", error); throw error; } } export async function getEpisodesGroupedByCategory(): Promise<{ [category: string]: EpisodeWithFeedInfo[]; }> { try { const episodes = await fetchEpisodesWithFeedInfo(); const grouped: { [category: string]: EpisodeWithFeedInfo[] } = {}; for (const episode of episodes) { const category = episode.category || "ๆœชๅˆ†้กž"; if (!grouped[category]) { grouped[category] = []; } grouped[category].push(episode); } return grouped; } catch (error) { console.error("Error getting episodes grouped by category:", error); throw error; } } export async function getEpisodeCategoryStats(): Promise<{ [category: string]: number; }> { try { const stmt = db.prepare(` SELECT COALESCE(e.category, 'ๆœชๅˆ†้กž') as category, COUNT(*) as count FROM episodes e JOIN articles a ON e.article_id = a.id JOIN feeds f ON a.feed_id = f.id WHERE f.active = 1 GROUP BY e.category ORDER BY count DESC `); const rows = stmt.all() as any[]; const stats: { [category: string]: number } = {}; for (const row of rows) { stats[row.category] = row.count; } return stats; } catch (error) { console.error("Error getting episode category stats:", error); throw error; } } export async function updateEpisodeCategory( episodeId: string, category: string, ): Promise { try { const stmt = db.prepare("UPDATE episodes SET category = ? WHERE id = ?"); const result = stmt.run(category, episodeId); return result.changes > 0; } catch (error) { console.error("Error updating episode category:", error); throw error; } } // Migration function to classify existing episodes without categories export async function migrateEpisodesWithCategories(): Promise { try { console.log("๐Ÿ”„ Starting episode category migration..."); // Get all episodes without categories const stmt = db.prepare( "SELECT * FROM episodes WHERE category IS NULL OR category = ''", ); const episodesWithoutCategories = stmt.all() as any[]; if (episodesWithoutCategories.length === 0) { console.log("โœ… All episodes already have categories assigned"); return; } console.log( `๐Ÿ“‹ Found ${episodesWithoutCategories.length} episodes without categories`, ); // Import LLM service const { openAI_ClassifyEpisode } = await import("./llm.js"); let processedCount = 0; let errorCount = 0; for (const episode of episodesWithoutCategories) { try { console.log(`๐Ÿ” Classifying episode: ${episode.title}`); // Classify the episode using title and description const category = await openAI_ClassifyEpisode( episode.title, episode.description, ); // Update the episode with the category const updateStmt = db.prepare( "UPDATE episodes SET category = ? WHERE id = ?", ); updateStmt.run(category, episode.id); console.log( `โœ… Assigned category "${category}" to episode: ${episode.title}`, ); processedCount++; // Add a small delay to avoid rate limiting await new Promise((resolve) => setTimeout(resolve, 1000)); } catch (error) { console.error(`โŒ Failed to classify episode ${episode.title}:`, error); errorCount++; // Set a default category for failed classifications const defaultCategory = "ใใฎไป–"; const updateStmt = db.prepare( "UPDATE episodes SET category = ? WHERE id = ?", ); updateStmt.run(defaultCategory, episode.id); console.log( `! Assigned default category "${defaultCategory}" to episode: ${episode.title}`, ); } } console.log(`โœ… Episode category migration completed`); console.log( `๐Ÿ“Š Processed: ${processedCount}, Errors: ${errorCount}, Total: ${episodesWithoutCategories.length}`, ); } catch (error) { console.error("โŒ Error during episode category migration:", error); throw error; } } // Function to get episode migration status export async function getEpisodeCategoryMigrationStatus(): Promise<{ totalEpisodes: number; episodesWithCategories: number; episodesWithoutCategories: number; migrationComplete: boolean; }> { try { const totalStmt = db.prepare("SELECT COUNT(*) as count FROM episodes"); const totalResult = totalStmt.get() as any; const totalEpisodes = totalResult.count; const withCategoriesStmt = db.prepare( "SELECT COUNT(*) as count FROM episodes WHERE category IS NOT NULL AND category != ''", ); const withCategoriesResult = withCategoriesStmt.get() as any; const episodesWithCategories = withCategoriesResult.count; const episodesWithoutCategories = totalEpisodes - episodesWithCategories; const migrationComplete = episodesWithoutCategories === 0; return { totalEpisodes, episodesWithCategories, episodesWithoutCategories, migrationComplete, }; } catch (error) { console.error("Error getting episode migration status:", error); throw error; } } // Settings management functions export interface Setting { key: string; value: string | null; isCredential: boolean; description: string; defaultValue: string; required: boolean; updatedAt: string; } export async function initializeSettings(database: Database): Promise { const defaultSettings: Omit[] = [ { key: "OPENAI_API_KEY", value: null, isCredential: true, description: "OpenAI API Key for content generation", defaultValue: "", required: true, }, { key: "OPENAI_API_ENDPOINT", value: null, isCredential: false, description: "OpenAI API Endpoint URL", defaultValue: "https://api.openai.com/v1", required: false, }, { key: "OPENAI_MODEL_NAME", value: null, isCredential: false, description: "OpenAI Model Name", defaultValue: "gpt-4o-mini", required: false, }, { key: "VOICEVOX_HOST", value: null, isCredential: false, description: "VOICEVOX Server Host URL", defaultValue: "http://localhost:50021", required: false, }, { key: "VOICEVOX_STYLE_ID", value: null, isCredential: false, description: "VOICEVOX Voice Style ID", defaultValue: "0", required: false, }, { key: "PODCAST_TITLE", value: null, isCredential: false, description: "Podcast Title", defaultValue: "่‡ชๅ‹•็”Ÿๆˆใƒใƒƒใƒ‰ใ‚ญใƒฃใ‚นใƒˆ", required: false, }, { key: "PODCAST_LINK", value: null, isCredential: false, description: "Podcast Link URL", defaultValue: "https://your-domain.com/podcast", required: false, }, { key: "PODCAST_DESCRIPTION", value: null, isCredential: false, description: "Podcast Description", defaultValue: "RSSใƒ•ใ‚ฃใƒผใƒ‰ใ‹ใ‚‰่‡ชๅ‹•็”Ÿๆˆใ•ใ‚ŒใŸ้Ÿณๅฃฐใƒใƒƒใƒ‰ใ‚ญใƒฃใ‚นใƒˆ", required: false, }, { key: "PODCAST_LANGUAGE", value: null, isCredential: false, description: "Podcast Language", defaultValue: "ja", required: false, }, { key: "PODCAST_AUTHOR", value: null, isCredential: false, description: "Podcast Author", defaultValue: "็ฎก็†่€…", required: false, }, { key: "PODCAST_CATEGORIES", value: null, isCredential: false, description: "Podcast Categories", defaultValue: "Technology", required: false, }, { key: "PODCAST_TTL", value: null, isCredential: false, description: "Podcast TTL", defaultValue: "60", required: false, }, { key: "PODCAST_BASE_URL", value: null, isCredential: false, description: "Podcast Base URL", defaultValue: "https://your-domain.com", required: false, }, { key: "ADMIN_PORT", value: null, isCredential: false, description: "Admin Panel Port", defaultValue: "3001", required: false, }, { key: "ADMIN_USERNAME", value: null, isCredential: true, description: "Admin Panel Username", defaultValue: "", required: false, }, { key: "ADMIN_PASSWORD", value: null, isCredential: true, description: "Admin Panel Password", defaultValue: "", required: false, }, { key: "DISABLE_INITIAL_BATCH", value: null, isCredential: false, description: "Disable Initial Batch Process", defaultValue: "false", required: false, }, { key: "FEED_URLS_FILE", value: null, isCredential: false, description: "Feed URLs File Path", defaultValue: "feed_urls.txt", required: false, }, ]; const now = new Date().toISOString(); for (const setting of defaultSettings) { try { const stmt = database.prepare( "INSERT OR IGNORE INTO settings (key, value, is_credential, description, default_value, required, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", ); stmt.run( setting.key, setting.value, setting.isCredential ? 1 : 0, setting.description, setting.defaultValue, setting.required ? 1 : 0, now, ); } catch (error) { console.error(`Error initializing setting ${setting.key}:`, error); } } } export async function getAllSettings(): Promise { try { const stmt = db.prepare("SELECT * FROM settings ORDER BY key"); const rows = stmt.all() as any[]; return rows.map((row) => ({ key: row.key, value: row.value, isCredential: Boolean(row.is_credential), description: row.description, defaultValue: row.default_value, required: Boolean(row.required), updatedAt: row.updated_at, })); } catch (error) { console.error("Error getting all settings:", error); throw error; } } export async function getSetting(key: string): Promise { try { const stmt = db.prepare("SELECT * FROM settings WHERE key = ?"); const row = stmt.get(key) as any; if (!row) return null; return { key: row.key, value: row.value, isCredential: Boolean(row.is_credential), description: row.description, defaultValue: row.default_value, required: Boolean(row.required), updatedAt: row.updated_at, }; } catch (error) { console.error(`Error getting setting ${key}:`, error); throw error; } } export async function updateSetting( key: string, value: string, ): Promise { try { const now = new Date().toISOString(); const stmt = db.prepare( "UPDATE settings SET value = ?, updated_at = ? WHERE key = ?", ); const result = stmt.run(value, now, key); return result.changes > 0; } catch (error) { console.error(`Error updating setting ${key}:`, error); throw error; } } export async function deleteSetting(key: string): Promise { try { const stmt = db.prepare("DELETE FROM settings WHERE key = ?"); const result = stmt.run(key); return result.changes > 0; } catch (error) { console.error(`Error deleting setting ${key}:`, error); throw error; } } export async function getSettingsForAdminUI(): Promise { try { const settings = await getAllSettings(); return settings.map((setting) => ({ ...setting, // Mask credential values for security value: setting.isCredential && setting.value ? "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" : setting.value, })); } catch (error) { console.error("Error getting settings for admin UI:", error); throw error; } } export function closeDatabase(): void { db.close(); }