1861 lines
50 KiB
TypeScript
1861 lines
50 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 = ?, 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<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 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<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;
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|