Update category management and RSS endpoint handling
This commit is contained in:
@ -159,6 +159,7 @@ function initializeDatabase(): Database {
|
||||
audio_path TEXT NOT NULL,
|
||||
duration INTEGER,
|
||||
file_size INTEGER,
|
||||
category TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(article_id) REFERENCES articles(id)
|
||||
);
|
||||
@ -207,14 +208,22 @@ function initializeDatabase(): Database {
|
||||
|
||||
// ALTER
|
||||
// ALTER TABLE feeds ADD COLUMN category TEXT DEFAULT NULL;
|
||||
// Ensure the category column exists
|
||||
const infos = db.prepare("PRAGMA table_info(feeds);").all();
|
||||
const hasCategory = infos.some((col: any) => col.name === "category");
|
||||
// Ensure the category column exists in feeds
|
||||
const feedInfos = db.prepare("PRAGMA table_info(feeds);").all();
|
||||
const hasFeedCategory = feedInfos.some((col: any) => col.name === "category");
|
||||
|
||||
if (!hasCategory) {
|
||||
if (!hasFeedCategory) {
|
||||
db.exec("ALTER TABLE feeds ADD COLUMN category TEXT DEFAULT NULL;");
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@ -251,6 +260,7 @@ export interface Episode {
|
||||
audioPath: string;
|
||||
duration?: number;
|
||||
fileSize?: number;
|
||||
category?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@ -271,6 +281,7 @@ export interface EpisodeWithFeedInfo {
|
||||
audioPath: string;
|
||||
duration?: number;
|
||||
fileSize?: number;
|
||||
category?: string;
|
||||
createdAt: string;
|
||||
articleId: string;
|
||||
articleTitle: string;
|
||||
@ -415,6 +426,7 @@ export async function fetchEpisodesWithFeedInfo(): Promise<
|
||||
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,
|
||||
@ -440,6 +452,7 @@ export async function fetchEpisodesWithFeedInfo(): Promise<
|
||||
audioPath: row.audioPath,
|
||||
duration: row.duration,
|
||||
fileSize: row.fileSize,
|
||||
category: row.category,
|
||||
createdAt: row.createdAt,
|
||||
articleId: row.articleId,
|
||||
articleTitle: row.articleTitle,
|
||||
@ -469,6 +482,7 @@ export async function fetchEpisodesByFeedId(
|
||||
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,
|
||||
@ -494,6 +508,7 @@ export async function fetchEpisodesByFeedId(
|
||||
audioPath: row.audioPath,
|
||||
duration: row.duration,
|
||||
fileSize: row.fileSize,
|
||||
category: row.category,
|
||||
createdAt: row.createdAt,
|
||||
articleId: row.articleId,
|
||||
articleTitle: row.articleTitle,
|
||||
@ -523,6 +538,7 @@ export async function fetchEpisodeWithSourceInfo(
|
||||
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,
|
||||
@ -548,6 +564,7 @@ export async function fetchEpisodeWithSourceInfo(
|
||||
audioPath: row.audioPath,
|
||||
duration: row.duration,
|
||||
fileSize: row.fileSize,
|
||||
category: row.category,
|
||||
createdAt: row.createdAt,
|
||||
articleId: row.articleId,
|
||||
articleTitle: row.articleTitle,
|
||||
@ -823,7 +840,7 @@ export async function saveEpisode(
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO episodes (id, article_id, title, description, audio_path, duration, file_size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO episodes (id, article_id, title, description, audio_path, duration, file_size, category, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
);
|
||||
stmt.run(
|
||||
id,
|
||||
@ -833,6 +850,7 @@ export async function saveEpisode(
|
||||
episode.audioPath,
|
||||
episode.duration || null,
|
||||
episode.fileSize || null,
|
||||
episode.category || null,
|
||||
createdAt,
|
||||
);
|
||||
return id;
|
||||
@ -876,6 +894,7 @@ export async function fetchAllEpisodes(): Promise<Episode[]> {
|
||||
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
|
||||
@ -1289,6 +1308,278 @@ export async function deleteEpisode(episodeId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
db.close();
|
||||
}
|
||||
|
Reference in New Issue
Block a user