2134 lines
		
	
	
		
			57 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			2134 lines
		
	
	
		
			57 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
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<Feed, "id" | "createdAt">,
 | 
						|
): Promise<string> {
 | 
						|
  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 = ?, last_updated = ?, active = ? WHERE url = ?",
 | 
						|
      );
 | 
						|
      updateStmt.run(
 | 
						|
        feed.title || null,
 | 
						|
        feed.description || 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<Feed | null> {
 | 
						|
  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<Feed | null> {
 | 
						|
  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<Feed[]> {
 | 
						|
  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<Feed[]> {
 | 
						|
  return getAllFeeds();
 | 
						|
}
 | 
						|
 | 
						|
// Get paginated active feeds with total count
 | 
						|
export async function fetchActiveFeedsPaginated(
 | 
						|
  page = 1,
 | 
						|
  limit = 10,
 | 
						|
  category?: string,
 | 
						|
): Promise<{
 | 
						|
  feeds: Feed[];
 | 
						|
  total: number;
 | 
						|
  page: number;
 | 
						|
  limit: number;
 | 
						|
  totalPages: number;
 | 
						|
}> {
 | 
						|
  try {
 | 
						|
    const offset = (page - 1) * limit;
 | 
						|
 | 
						|
    // Build query conditions
 | 
						|
    let whereCondition = "WHERE active = 1";
 | 
						|
    const params: any[] = [];
 | 
						|
 | 
						|
    if (category) {
 | 
						|
      whereCondition += " AND category = ?";
 | 
						|
      params.push(category);
 | 
						|
    }
 | 
						|
 | 
						|
    // Get total count
 | 
						|
    const countStmt = db.prepare(
 | 
						|
      `SELECT COUNT(*) as count FROM feeds ${whereCondition}`,
 | 
						|
    );
 | 
						|
    const countResult = countStmt.get(...params) as { count: number };
 | 
						|
    const total = countResult.count;
 | 
						|
 | 
						|
    // Get paginated feeds
 | 
						|
    const feedsStmt = db.prepare(`
 | 
						|
      SELECT * FROM feeds 
 | 
						|
      ${whereCondition}
 | 
						|
      ORDER BY created_at DESC 
 | 
						|
      LIMIT ? OFFSET ?
 | 
						|
    `);
 | 
						|
 | 
						|
    const rows = feedsStmt.all(...params, limit, offset) as any[];
 | 
						|
 | 
						|
    const feeds = 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),
 | 
						|
    }));
 | 
						|
 | 
						|
    const totalPages = Math.ceil(total / limit);
 | 
						|
 | 
						|
    return {
 | 
						|
      feeds,
 | 
						|
      total,
 | 
						|
      page,
 | 
						|
      limit,
 | 
						|
      totalPages,
 | 
						|
    };
 | 
						|
  } catch (error) {
 | 
						|
    console.error("Error getting paginated feeds:", error);
 | 
						|
    throw error;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
// 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 with feed information for enhanced display (paginated)
 | 
						|
export async function fetchEpisodesWithFeedInfoPaginated(
 | 
						|
  page = 1,
 | 
						|
  limit = 10,
 | 
						|
  category?: string,
 | 
						|
): Promise<{
 | 
						|
  episodes: EpisodeWithFeedInfo[];
 | 
						|
  total: number;
 | 
						|
  page: number;
 | 
						|
  limit: number;
 | 
						|
  totalPages: number;
 | 
						|
}> {
 | 
						|
  try {
 | 
						|
    const offset = (page - 1) * limit;
 | 
						|
 | 
						|
    // Build query conditions
 | 
						|
    let whereCondition = "WHERE f.active = 1";
 | 
						|
    const params: any[] = [];
 | 
						|
 | 
						|
    if (category) {
 | 
						|
      whereCondition += " AND e.category = ?";
 | 
						|
      params.push(category);
 | 
						|
    }
 | 
						|
 | 
						|
    // Get total count
 | 
						|
    const countStmt = db.prepare(`
 | 
						|
      SELECT COUNT(*) as count 
 | 
						|
      FROM episodes e
 | 
						|
      JOIN articles a ON e.article_id = a.id
 | 
						|
      JOIN feeds f ON a.feed_id = f.id
 | 
						|
      ${whereCondition}
 | 
						|
    `);
 | 
						|
    const countResult = countStmt.get(...params) as { count: number };
 | 
						|
    const total = countResult.count;
 | 
						|
 | 
						|
    // Get paginated episodes
 | 
						|
    const episodesStmt = 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
 | 
						|
      ${whereCondition}
 | 
						|
      ORDER BY e.created_at DESC 
 | 
						|
      LIMIT ? OFFSET ?
 | 
						|
    `);
 | 
						|
 | 
						|
    const rows = episodesStmt.all(...params, limit, offset) as any[];
 | 
						|
 | 
						|
    const episodes = 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,
 | 
						|
    }));
 | 
						|
 | 
						|
    const totalPages = Math.ceil(total / limit);
 | 
						|
 | 
						|
    return {
 | 
						|
      episodes,
 | 
						|
      total,
 | 
						|
      page,
 | 
						|
      limit,
 | 
						|
      totalPages,
 | 
						|
    };
 | 
						|
  } catch (error) {
 | 
						|
    console.error("Error fetching paginated episodes with feed info:", error);
 | 
						|
    throw error;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
// Get episodes by feed ID
 | 
						|
export async function fetchEpisodesByFeedId(
 | 
						|
  feedId: string,
 | 
						|
): 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.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<EpisodeWithFeedInfo | null> {
 | 
						|
  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<EpisodeWithFeedInfo[]> {
 | 
						|
  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<Feed[]> {
 | 
						|
  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<Feed[]> {
 | 
						|
  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<string[]> {
 | 
						|
  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<boolean> {
 | 
						|
  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<boolean> {
 | 
						|
  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<Article, "id" | "discoveredAt">,
 | 
						|
): Promise<string> {
 | 
						|
  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<Article[]> {
 | 
						|
  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<void> {
 | 
						|
  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<boolean> {
 | 
						|
  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<Episode, "id" | "createdAt">,
 | 
						|
): Promise<string> {
 | 
						|
  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<void> {
 | 
						|
  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<Episode[]> {
 | 
						|
  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<string> {
 | 
						|
  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<TTSQueueItem[]> {
 | 
						|
  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<void> {
 | 
						|
  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<void> {
 | 
						|
  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<FeedRequest, "id" | "createdAt" | "status">,
 | 
						|
): Promise<string> {
 | 
						|
  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<FeedRequest[]> {
 | 
						|
  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<boolean> {
 | 
						|
  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<void> {
 | 
						|
  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<boolean> {
 | 
						|
  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<EpisodeWithFeedInfo[]> {
 | 
						|
  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<string[]> {
 | 
						|
  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<boolean> {
 | 
						|
  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;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
// Category cleanup functions
 | 
						|
export async function deleteFeedCategory(category: string): Promise<number> {
 | 
						|
  try {
 | 
						|
    const stmt = db.prepare(
 | 
						|
      "UPDATE feeds SET category = NULL WHERE category = ?",
 | 
						|
    );
 | 
						|
    const result = stmt.run(category);
 | 
						|
    return result.changes;
 | 
						|
  } catch (error) {
 | 
						|
    console.error("Error deleting feed category:", error);
 | 
						|
    throw error;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
export async function deleteEpisodeCategory(category: string): Promise<number> {
 | 
						|
  try {
 | 
						|
    const stmt = db.prepare(
 | 
						|
      "UPDATE episodes SET category = NULL WHERE category = ?",
 | 
						|
    );
 | 
						|
    const result = stmt.run(category);
 | 
						|
    return result.changes;
 | 
						|
  } catch (error) {
 | 
						|
    console.error("Error deleting episode category:", error);
 | 
						|
    throw error;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
export async function deleteCategoryFromBoth(
 | 
						|
  category: string,
 | 
						|
): Promise<{ feedChanges: number; episodeChanges: number }> {
 | 
						|
  try {
 | 
						|
    db.exec("BEGIN TRANSACTION");
 | 
						|
 | 
						|
    const feedChanges = await deleteFeedCategory(category);
 | 
						|
    const episodeChanges = await deleteEpisodeCategory(category);
 | 
						|
 | 
						|
    db.exec("COMMIT");
 | 
						|
 | 
						|
    return { feedChanges, episodeChanges };
 | 
						|
  } catch (error) {
 | 
						|
    db.exec("ROLLBACK");
 | 
						|
    console.error("Error deleting category from both tables:", error);
 | 
						|
    throw error;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
export async function getAllUsedCategories(): Promise<{
 | 
						|
  feedCategories: string[];
 | 
						|
  episodeCategories: string[];
 | 
						|
  allCategories: string[];
 | 
						|
}> {
 | 
						|
  try {
 | 
						|
    // Get feed categories
 | 
						|
    const feedCatStmt = db.prepare(
 | 
						|
      "SELECT DISTINCT category FROM feeds WHERE category IS NOT NULL AND category != '' ORDER BY category",
 | 
						|
    );
 | 
						|
    const feedCatRows = feedCatStmt.all() as any[];
 | 
						|
    const feedCategories = feedCatRows.map((row) => row.category);
 | 
						|
 | 
						|
    // Get episode categories
 | 
						|
    const episodeCatStmt = db.prepare(
 | 
						|
      "SELECT DISTINCT category FROM episodes WHERE category IS NOT NULL AND category != '' ORDER BY category",
 | 
						|
    );
 | 
						|
    const episodeCatRows = episodeCatStmt.all() as any[];
 | 
						|
    const episodeCategories = episodeCatRows.map((row) => row.category);
 | 
						|
 | 
						|
    // Get all unique categories
 | 
						|
    const allCategoriesSet = new Set([...feedCategories, ...episodeCategories]);
 | 
						|
    const allCategories = Array.from(allCategoriesSet).sort();
 | 
						|
 | 
						|
    return {
 | 
						|
      feedCategories,
 | 
						|
      episodeCategories,
 | 
						|
      allCategories,
 | 
						|
    };
 | 
						|
  } catch (error) {
 | 
						|
    console.error("Error getting all used categories:", error);
 | 
						|
    throw error;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
export async function getCategoryCounts(
 | 
						|
  category: string,
 | 
						|
): Promise<{ feedCount: number; episodeCount: number }> {
 | 
						|
  try {
 | 
						|
    // Count feeds with this category
 | 
						|
    const feedCountStmt = db.prepare(
 | 
						|
      "SELECT COUNT(*) as count FROM feeds WHERE category = ?",
 | 
						|
    );
 | 
						|
    const feedCountResult = feedCountStmt.get(category) as { count: number };
 | 
						|
 | 
						|
    // Count episodes with this category
 | 
						|
    const episodeCountStmt = db.prepare(
 | 
						|
      "SELECT COUNT(*) as count FROM episodes WHERE category = ?",
 | 
						|
    );
 | 
						|
    const episodeCountResult = episodeCountStmt.get(category) as {
 | 
						|
      count: number;
 | 
						|
    };
 | 
						|
 | 
						|
    return {
 | 
						|
      feedCount: feedCountResult.count,
 | 
						|
      episodeCount: episodeCountResult.count,
 | 
						|
    };
 | 
						|
  } catch (error) {
 | 
						|
    console.error("Error getting category counts:", error);
 | 
						|
    throw error;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
// Migration function to classify existing episodes without categories
 | 
						|
export async function migrateEpisodesWithCategories(): Promise<void> {
 | 
						|
  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<void> {
 | 
						|
  const defaultSettings: Omit<Setting, "updatedAt">[] = [
 | 
						|
    {
 | 
						|
      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<Setting[]> {
 | 
						|
  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<Setting | null> {
 | 
						|
  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<boolean> {
 | 
						|
  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<boolean> {
 | 
						|
  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<Setting[]> {
 | 
						|
  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();
 | 
						|
}
 |