diff --git a/admin-panel/src/App.tsx b/admin-panel/src/App.tsx index 1d1ab45..7089116 100644 --- a/admin-panel/src/App.tsx +++ b/admin-panel/src/App.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect } from "react"; +import type React from "react"; +import { useEffect, useState } from "react"; interface Feed { id: string; diff --git a/admin-panel/vite.config.ts b/admin-panel/vite.config.ts index 87fcb4b..6542018 100644 --- a/admin-panel/vite.config.ts +++ b/admin-panel/vite.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], diff --git a/admin-server.ts b/admin-server.ts index 0230a65..1e6fbe4 100644 --- a/admin-server.ts +++ b/admin-server.ts @@ -1,22 +1,22 @@ -import { Hono } from "hono"; -import { serve } from "@hono/node-server"; -import { basicAuth } from "hono/basic-auth"; +import { Database } from "bun:sqlite"; import path from "path"; +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { basicAuth } from "hono/basic-auth"; +import { addNewFeedUrl, batchProcess } from "./scripts/fetch_and_generate.js"; +import { batchScheduler } from "./services/batch-scheduler.js"; import { config, validateConfig } from "./services/config.js"; import { - getAllFeedsIncludingInactive, deleteFeed, - toggleFeedActive, - getFeedByUrl, - getFeedById, fetchAllEpisodes, fetchEpisodesWithArticles, + getAllFeedsIncludingInactive, + getFeedById, + getFeedByUrl, getFeedRequests, + toggleFeedActive, updateFeedRequestStatus, } from "./services/database.js"; -import { Database } from "bun:sqlite"; -import { batchProcess, addNewFeedUrl } from "./scripts/fetch_and_generate.js"; -import { batchScheduler } from "./services/batch-scheduler.js"; // Validate configuration on startup try { diff --git a/bun.lock b/bun.lock index 7185d96..02fc308 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "openai": "^4.104.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-helmet-async": "^2.0.5", "react-router-dom": "^7.6.2", "rss-parser": "^3.13.0", "xml2js": "^0.6.2", @@ -478,12 +479,16 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -526,6 +531,10 @@ "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], + + "react-helmet-async": ["react-helmet-async@2.0.5", "", { "dependencies": { "invariant": "^2.2.4", "react-fast-compare": "^3.2.2", "shallowequal": "^1.1.0" }, "peerDependencies": { "react": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-router": ["react-router@7.6.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w=="], @@ -550,6 +559,8 @@ "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], + "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 79a552e..f6acaea 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,7 +1,7 @@ import js from "@eslint/js"; -import globals from "globals"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; import tseslint from "typescript-eslint"; export default tseslint.config( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f91326..fd319e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,9 @@ -import { Routes, Route, Link, useLocation } from "react-router-dom"; -import EpisodeList from "./components/EpisodeList"; -import FeedManager from "./components/FeedManager"; -import FeedList from "./components/FeedList"; -import FeedDetail from "./components/FeedDetail"; +import { Link, Route, Routes, useLocation } from "react-router-dom"; import EpisodeDetail from "./components/EpisodeDetail"; +import EpisodeList from "./components/EpisodeList"; +import FeedDetail from "./components/FeedDetail"; +import FeedList from "./components/FeedList"; +import FeedManager from "./components/FeedManager"; function App() { const location = useLocation(); diff --git a/frontend/src/components/EpisodeDetail.tsx b/frontend/src/components/EpisodeDetail.tsx index d9400da..168006d 100644 --- a/frontend/src/components/EpisodeDetail.tsx +++ b/frontend/src/components/EpisodeDetail.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect } from "react"; -import { useParams, Link } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { Link, useParams } from "react-router-dom"; interface EpisodeWithFeedInfo { id: string; @@ -162,6 +163,66 @@ function EpisodeDetail() { return (
+ + {episode.title} - Voice RSS Summary + + + {/* OpenGraph metadata */} + + + + + + + + {/* Image metadata */} + + + + + + + {/* Twitter Card metadata */} + + + + + + + {/* Audio-specific metadata */} + + + + {/* Article metadata */} + + {episode.articlePubDate && + episode.articlePubDate !== episode.createdAt && ( + + )} + {episode.feedTitle && ( + + )} + +
お使いのブラウザは音声の再生に対応していません。 @@ -277,7 +342,11 @@ function EpisodeDetail() {
音声URL: @@ -314,13 +320,20 @@ function EpisodeList() { 共有
- {currentAudio === (episode.audioPath.startsWith('/') ? episode.audioPath : `/podcast_audio/${episode.audioPath}`) && ( + {currentAudio === + (episode.audioPath.startsWith("/") + ? episode.audioPath + : `/podcast_audio/${episode.audioPath}`) && (
diff --git a/frontend/src/components/FeedDetail.tsx b/frontend/src/components/FeedDetail.tsx index fa49b24..bfda817 100644 --- a/frontend/src/components/FeedDetail.tsx +++ b/frontend/src/components/FeedDetail.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from "react"; -import { useParams, Link } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; interface Feed { id: string; @@ -284,7 +284,13 @@ function FeedDetail() {
@@ -295,13 +301,20 @@ function FeedDetail() { 共有
- {currentAudio === (episode.audioPath.startsWith('/') ? episode.audioPath : `/podcast_audio/${episode.audioPath}`) && ( + {currentAudio === + (episode.audioPath.startsWith("/") + ? episode.audioPath + : `/podcast_audio/${episode.audioPath}`) && (
diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx index 75d694d..21f8235 100644 --- a/frontend/src/components/FeedList.tsx +++ b/frontend/src/components/FeedList.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; interface Feed { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 78b0a8d..4e53d63 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,16 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { HelmetProvider } from "react-helmet-async"; import { BrowserRouter } from "react-router-dom"; import App from "./App.tsx"; import "./styles.css"; createRoot(document.getElementById("root")!).render( - - - + + + + + , ); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f95be2a..f84d2e1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; export default defineConfig({ root: ".", // プロジェクトルートを明示 diff --git a/package.json b/package.json index 493c309..7c9edaa 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "openai": "^4.104.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-helmet-async": "^2.0.5", "react-router-dom": "^7.6.2", "rss-parser": "^3.13.0", "xml2js": "^0.6.2" diff --git a/scripts/fetch_and_generate.ts b/scripts/fetch_and_generate.ts index 2077c27..6d2af1d 100644 --- a/scripts/fetch_and_generate.ts +++ b/scripts/fetch_and_generate.ts @@ -1,23 +1,23 @@ +import crypto from "crypto"; +import fs from "fs/promises"; import Parser from "rss-parser"; +import { config } from "../services/config.js"; +import { enhanceArticleContent } from "../services/content-extractor.js"; +import { + getFeedById, + getFeedByUrl, + getUnprocessedArticles, + markArticleAsProcessed, + saveArticle, + saveEpisode, + saveFeed, +} from "../services/database.js"; import { openAI_ClassifyFeed, openAI_GeneratePodcastContent, } from "../services/llm.js"; -import { generateTTS, generateTTSWithoutQueue } from "../services/tts.js"; -import { enhanceArticleContent } from "../services/content-extractor.js"; -import { - saveFeed, - getFeedByUrl, - getFeedById, - saveArticle, - getUnprocessedArticles, - markArticleAsProcessed, - saveEpisode, -} from "../services/database.js"; import { updatePodcastRSS } from "../services/podcast.js"; -import { config } from "../services/config.js"; -import crypto from "crypto"; -import fs from "fs/promises"; +import { generateTTS, generateTTSWithoutQueue } from "../services/tts.js"; interface FeedItem { id?: string; diff --git a/server.ts b/server.ts index 53c0c70..6d912cd 100644 --- a/server.ts +++ b/server.ts @@ -1,8 +1,8 @@ -import { Hono } from "hono"; -import { serve } from "@hono/node-server"; import path from "path"; -import { config, validateConfig } from "./services/config.js"; +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; import { batchScheduler } from "./services/batch-scheduler.js"; +import { config, validateConfig } from "./services/config.js"; // Validate configuration on startup try { @@ -61,16 +61,16 @@ app.get("/podcast_audio/*", async (c) => { if (range) { // Handle range requests for streaming const parts = range.replace(/bytes=/, "").split("-"); - const start = parseInt(parts[0] || "0", 10); - const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; - + const start = Number.parseInt(parts[0] || "0", 10); + const end = parts[1] ? Number.parseInt(parts[1], 10) : fileSize - 1; + if (start >= fileSize) { return c.text("Requested range not satisfiable", 416, { "Content-Range": `bytes */${fileSize}`, }); } - const chunkSize = (end - start) + 1; + const chunkSize = end - start + 1; const stream = file.stream(); return c.body(stream, 206, { diff --git a/services/config.ts b/services/config.ts index d67a263..88cb519 100644 --- a/services/config.ts +++ b/services/config.ts @@ -82,7 +82,7 @@ function createConfig(): Config { voicevox: { host: getOptionalEnv("VOICEVOX_HOST", "http://localhost:50021"), - styleId: parseInt(getOptionalEnv("VOICEVOX_STYLE_ID", "0")), + styleId: Number.parseInt(getOptionalEnv("VOICEVOX_STYLE_ID", "0")), }, podcast: { @@ -100,7 +100,7 @@ function createConfig(): Config { }, admin: { - port: parseInt(getOptionalEnv("ADMIN_PORT", "3001")), + port: Number.parseInt(getOptionalEnv("ADMIN_PORT", "3001")), username: import.meta.env["ADMIN_USERNAME"], password: import.meta.env["ADMIN_PASSWORD"], }, diff --git a/services/database.ts b/services/database.ts index 51cba6d..ec90977 100644 --- a/services/database.ts +++ b/services/database.ts @@ -1,6 +1,6 @@ import { Database } from "bun:sqlite"; -import fs from "fs"; import crypto from "crypto"; +import fs from "fs"; import { config } from "./config.js"; // Database integrity fixes function @@ -275,7 +275,7 @@ export async function saveFeed( try { // Check if feed already exists const existingFeed = await getFeedByUrl(feed.url); - + if (existingFeed) { // Update existing feed const updateStmt = db.prepare( @@ -293,7 +293,7 @@ export async function saveFeed( // Create new feed const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); - + const insertStmt = db.prepare( "INSERT INTO feeds (id, url, title, description, last_updated, created_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)", ); @@ -879,7 +879,7 @@ export interface TTSQueueItem { export async function addToQueue( itemId: string, scriptText: string, - retryCount: number = 0, + retryCount = 0, ): Promise { const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); @@ -897,9 +897,7 @@ export async function addToQueue( } } -export async function getQueueItems( - limit: number = 10, -): Promise { +export async function getQueueItems(limit = 10): Promise { try { const stmt = db.prepare(` SELECT * FROM tts_queue diff --git a/services/llm.ts b/services/llm.ts index beebd2b..29abda8 100644 --- a/services/llm.ts +++ b/services/llm.ts @@ -1,4 +1,4 @@ -import { OpenAI, ClientOptions } from "openai"; +import { type ClientOptions, OpenAI } from "openai"; import { config, validateConfig } from "./config.js"; // Validate config on module load diff --git a/services/podcast.ts b/services/podcast.ts index 61628cb..96f9af4 100644 --- a/services/podcast.ts +++ b/services/podcast.ts @@ -1,9 +1,9 @@ import { promises as fs } from "fs"; -import { dirname } from "path"; -import { fetchEpisodesWithFeedInfo } from "./database.js"; -import path from "node:path"; import fsSync from "node:fs"; +import path from "node:path"; +import { dirname } from "path"; import { config } from "./config.js"; +import { fetchEpisodesWithFeedInfo } from "./database.js"; function escapeXml(text: string): string { return text diff --git a/services/tts.ts b/services/tts.ts index b64da74..8fd618b 100644 --- a/services/tts.ts +++ b/services/tts.ts @@ -7,7 +7,7 @@ import { config } from "./config.js"; * Split text into natural chunks for TTS processing * Aims for approximately 50 characters per chunk, breaking at natural points */ -function splitTextIntoChunks(text: string, maxLength: number = 50): string[] { +function splitTextIntoChunks(text: string, maxLength = 50): string[] { if (text.length <= maxLength) { return [text]; } @@ -242,7 +242,7 @@ async function concatenateAudioFiles( export async function generateTTSWithoutQueue( itemId: string, scriptText: string, - retryCount: number = 0, + retryCount = 0, ): Promise { if (!itemId || itemId.trim() === "") { throw new Error("Item ID is required for TTS generation"); @@ -337,7 +337,7 @@ export async function generateTTSWithoutQueue( export async function generateTTS( itemId: string, scriptText: string, - retryCount: number = 0, + retryCount = 0, ): Promise { const maxRetries = 2;