diff --git a/.env.example b/.env.example index 408950d..fe22c36 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,9 @@ -# Environment Variables Example # Database URL (SQLite default for local development) DATABASE_URL="file:./dev.db" -# Optional: OpenAI API Key (reserved for Milestone 2) -# OPENAI_API_KEY="sk-..." +# Google Gemini API Key (Milestone 2 Screenwriter engine) +GEMINI_API_KEY="your-gemini-api-key" +GEMINI_MODEL="gemini-2.5-flash" -# Optional: ElevenLabs API Key (reserved for Milestone 2) -# ELEVENLABS_API_KEY="..." +# Control Key: restricts access to server default API credentials (bypassable if caller supplies BYO key) +APP_CONTROL_KEY="your-strong-password-here" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..ab93e7d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Pre-commit hook: scan staged changes for leaked secrets, tokens, and API keys with gitleaks + +set -e + +# Resolve gitleaks executable +if command -v gitleaks &> /dev/null; then + GITLEAKS_BIN="gitleaks" +elif [ -x "/opt/homebrew/bin/gitleaks" ]; then + GITLEAKS_BIN="/opt/homebrew/bin/gitleaks" +elif [ -x "/usr/local/bin/gitleaks" ]; then + GITLEAKS_BIN="/usr/local/bin/gitleaks" +else + echo "⚠️ [gitleaks-hook] Warning: 'gitleaks' executable not found on PATH." + echo " Please install it to prevent committing credentials: brew install gitleaks" + echo " Skipping secret check for this commit." + exit 0 +fi + +echo "🔍 [gitleaks-hook] Scanning staged changes for secrets..." + +# Run gitleaks protect on staged changes +if ! "$GITLEAKS_BIN" protect --staged -v; then + echo "" + echo "❌ [gitleaks-hook] Commit rejected: Potential secret or credential leak detected!" + echo " Please remove any sensitive keys, tokens, or passwords before committing." + echo " If this is a false positive and you are certain it is safe, bypass with: git commit --no-verify" + echo "" + exit 1 +fi + +echo "✅ [gitleaks-hook] No leaked secrets detected in staged files." +exit 0 diff --git a/.gitignore b/.gitignore index 830e3ca..de90f97 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ yarn-error.log* .DS_Store *.pem .vscode/ -.idea/ \ No newline at end of file +.idea/ +scratch/ \ No newline at end of file diff --git a/docs/prd.md b/docs/prd.md index a321435..d752c0b 100644 --- a/docs/prd.md +++ b/docs/prd.md @@ -1,21 +1,21 @@ # Product Requirements Document (PRD): Be My Anchor -**Status**: Approved (Post Grill-Me) -**Target Version**: 2.0 (Full Refactor) -**Architecture**: TypeScript / Node.js Fullstack +**Status**: Approved +**Target Version**: 2.0 (Listening-First Audio Platform) +**Architecture**: TypeScript / Next.js Fullstack **Primary Output**: `docs/prd.md` --- ## 1. Executive Summary & Vision -**Be My Anchor** transforms written materials—technical blogs, research papers, news articles, novels, and documentation—into an engaging, listening-first multimedia experience. +**Be My Anchor** transforms written materials—technical blogs, research papers, news articles, essays, and documentation—into an engaging, listening-first audio experience. -Rather than relying on brittle, heavy web scrapers or monotone text-to-speech readers, Be My Anchor adopts an **API-first ingestion** model combined with: +Rather than relying on brittle web scrapers or monotone text-to-speech readers, Be My Anchor adopts an **API-driven ingestion** model combined with: -1. **High-Fidelity Audio Generation**: Seamless multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) with smart text chunking and audio stitching. +1. **High-Fidelity Audio Generation**: Multi-provider neural TTS (Edge TTS, OpenAI, ElevenLabs) with smart text chunking and audio stitching. 2. **Hearing-Front Content Adaptation**: An LLM-powered scriptwriting engine that adapts dense text into conversational spoken formats (e.g., Feynman-style technical breakdown or 2-host conversational dialogue). -3. **Synchronized Visual Companion**: A web player that dynamically surfaces referenced figures, diagrams, and code snippets in sync with the audio narration. +3. **Mobile-First Zen Listening Experience**: A distraction-free PWA listening environment with uninterrupted playback, synchronized auto-scrolling transcript, and tap-to-seek navigation. --- @@ -26,7 +26,7 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re │ Phase 1: Robust Audio Generation Core │ │ - Multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) │ │ - Long-form text chunking & audio stitching │ -│ - Basic web player & episode library │ +│ - Persistent audio player & episode library │ └──────────────────────────────┬──────────────────────────────┘ │ ▼ @@ -34,15 +34,15 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re │ Phase 2: Hearing-Front Content Transformation │ │ - "Feynman" Deep-Dive Presenter Persona │ │ - Dual-Host Dialogue Persona (NotebookLM style) │ -│ - Script stage cue tagging │ +│ - Synchronized sentence & speaker timing manifests │ └──────────────────────────────┬──────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ -│ Phase 3: Interactive Synchronized Multimedia Player │ -│ - Timestamped visual cues (figures, code, diagrams) │ -│ - Karaoke-style synced transcript │ -│ - Stage-aware responsive visual pane │ +│ Phase 3: Mobile PWA & Zen Listening Experience │ +│ - Fullscreen Zen player with synchronized transcript │ +│ - Mobile PWA & MediaSession background lockscreen controls │ +│ - Dedicated article studio and queue status tracking │ └─────────────────────────────────────────────────────────────┘ ``` @@ -53,14 +53,12 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re ### Persona 1: The Engineer / Researcher ("The Feynman Mode") - **Content**: Technical architecture posts, RFCs, research papers, algorithm deep-dives. -- **Narrator Style**: A world-class communicator (like Richard Feynman or a top tech lead) explaining concepts using intuitive analogies, breaking down equations, and explaining diagrams. -- **Visual Need**: When the narrator says *"Looking at Figure 2, the ingress traffic routes through..."*, Figure 2 automatically appears on screen. +- **Narrator Style**: A world-class communicator (like Richard Feynman or a top tech lead) explaining concepts using intuitive analogies and breaking down dense material for the ear. ### Persona 2: The Informed Professional ("The Dual-Host Dialogue") - **Content**: Industry news, opinion pieces, market shifts, newsletters. -- **Narrator Style**: Two AI co-hosts bantering, asking each other rhetorical questions, and adding contextual background not directly present in the source article. -- **Visual Need**: Highlight cards, quotes, and source citations appearing on the dashboard. +- **Narrator Style**: Two AI co-hosts bantering, asking each other questions, and adding contextual background not directly present in the source article. ### Persona 3: The Audiobook / Direct Listener ("Verbatim Mode") @@ -74,7 +72,7 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re ``` ┌────────────────────────────┐ │ API Ingestion Endpoint │ - │ (Markdown / Text / Assets) │ + │ (Markdown / Text Input) │ └─────────────┬──────────────┘ │ ▼ @@ -82,11 +80,9 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re │ Hearing-Front Script Engine│ │ (LLM: OpenAI / Claude) │ │ - Rewrites text for ear │ - │ - Generates Stage Cues │ + │ - Speaker turn separation │ └─────────────┬──────────────┘ │ - Script with Stage Marks - │ ▼ ┌────────────────────────────┐ │ TTS Generation Core │ @@ -97,25 +93,28 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re │ │ ElevenLabs (Multi-Voice│ │ │ └────────────────────────┘ │ │ - Chunker & Audio Stitcher │ - │ - Timing Aligner (VTT/JSON)│ + │ - Timing Aligner (JSON) │ └─────────────┬──────────────┘ │ ▼ ┌───────────────────────────────────┴───────────────────────────────────┐ │ Data & Storage │ -│ - Database: Prisma ORM (MongoDB / PostgreSQL) │ -│ - File Storage: Local static / S3-compatible (Audio MP3s & Figures) │ +│ - Database: Prisma ORM (SQLite / PostgreSQL) │ +│ - File Storage: Local static / S3-compatible (Audio MP3s) │ └───────────────────────────────────┬───────────────────────────────────┘ │ ▼ ┌───────────────────────────────────────────────────────────────────────┐ -│ Interactive Web Player (Next.js / TS) │ +│ Mobile-First Web App (Next.js / TS / PWA) │ │ ┌───────────────────────┐ ┌───────────────────────────────────────┐ │ -│ │ Audio Player UI │ │ Synchronized Visual Stage │ │ -│ │ - Waveform / Scrubber│ │ - Figures / Diagrams from post │ │ -│ │ - Speed / ±15s Skips │ │ - Syntax-highlighted code snippets │ │ -│ │ - Live Transcript │ │ - Key takeaway summary cards │ │ +│ │ Main Index View │ │ Zen Mode Player │ │ +│ │ - Episode list │ │ - Fullscreen distraction-free player │ │ +│ │ - Live status pills │ │ - Synchronized auto-scrolled script │ │ +│ │ - Persistent bar │ │ - Tap-to-seek, large touch controls │ │ │ └───────────────────────┘ └───────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Dedicated Add Article View (/new) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────────────┘ ``` @@ -123,56 +122,45 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re ## 5. Functional Requirements -### 5.1 Content Ingestion (API-Driven) +### 5.1 Content Ingestion (API & UI Driven) -- Provide a clean REST API accepting: +- Clean REST API & dedicated UI accepting: - `title`: String - - `content`: Plain text or Markdown (including image references `![alt](url)`) - - `format_mode`: `'feynman'` | `'dual_host'` | `'verbatim'` + - `content`: Plain text or Markdown + - `mode`: `'VERBATIM'` | `'FEYNMAN'` | `'DUAL_HOST'` - `voice_config`: Provider preference (`edge` | `openai` | `elevenlabs`), voice IDs, speed - - `supplemental_materials`: Optional array of images/figures, captions, and links -- No requirement for headless Selenium or browser dependencies on the server. ### 5.2 Hearing-Front Content Transformation -- **Verbatim Cleaner**: Strips markdown artifacts, normalizes punctuation, phonetic hints for acronyms. -- **Feynman Script Generator**: - - Rewrites technical content into an oral lecture/walkthrough. - - Generates `[STAGE: figure_id]` or `[STAGE: code_block_id]` inline tags corresponding to visual assets. -- **Dual-Host Dialogue Generator**: - - Turns article content into an interactive conversation between Host A (e.g., inquisitive, relatable) and Host B (e.g., domain expert). - - Emits speaker-tagged lines: `SpeakerA: ...` and `SpeakerB: ...`. +- **Verbatim Cleaner**: Strips markdown artifacts, normalizes punctuation, phonetic hints. +- **Feynman Script Generator**: Rewrites technical content into an oral lecture/walkthrough. +- **Dual-Host Dialogue Generator**: Turns article content into an interactive conversation between Host A and Host B with speaker-tagged turns (`SpeakerA: ...`, `SpeakerB: ...`). ### 5.3 TTS Engine & Audio Processing - **Provider Abstraction Interface (`ITextToSpeechProvider`)**: - - `EdgeTTSProvider`: Free, instant, high-quality neural voices (`en-US-ChristopherNeural`, `en-US-JennyNeural`, etc.), zero API key required. + - `EdgeTTSProvider`: Free, instant neural voices (`zh-CN-XiaoxiaoNeural`, `en-US-ChristopherNeural`, etc.). - `OpenAITTSProvider`: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`. - - `ElevenLabsProvider`: High-fidelity multi-voice assignment with voice cloning or distinct character voices for multi-host dialogue. -- **Long-Text Chunker**: - - Breaks script into sentence/clause chunks respecting provider character limits (e.g. 4096 chars). - - Handles speaker switching in dual-host mode without awkward pauses. -- **Audio Stitcher & Aligner**: + - `ElevenLabsProvider`: High-fidelity multi-voice assignment for dual-host dialogue. +- **Long-Text Chunker & Audio Stitcher**: + - Breaks script into sentence/clause chunks respecting character limits. - Stitches chunk audio files into a unified master `.mp3`. - - Outputs a timing manifest (`timings.json` or WebVTT) recording the start and end timestamp of each paragraph, speaker turn, and stage marker. + - Generates synchronized sentence/turn timing manifests (`timingsJson`). -### 5.4 Synchronized Web View & Media Player +### 5.4 3-View Mobile-First Listening Experience -- **Episode Library**: - - List of generated episodes with duration, creation date, mode, and status (`processing`, `ready`, `failed`). -- **Audio Player Controls**: - - Play / Pause, Progress scrubber, Current time / Total duration. - - Jump Back 15s (`-15s`), Jump Forward 15s (`+15s`). - - Playback speed selection (0.8x, 1.0x, 1.25x, 1.5x, 2.0x). -- **Synchronized Transcript Pane**: - - Interactive transcript that auto-scrolls and highlights the active paragraph/dialogue turn as audio plays. - - Clicking any paragraph seeks playback directly to that timestamp. -- **Synchronized Stage (Supplemental Visual Information)**: - - Responsive visual viewport alongside or above the player. - - When the audio reaches a tagged stage (e.g. timestamp 02:15): - - Automatically displays the relevant figure, architecture diagram, equation, or code snippet. - - Provides zoom/expand view for detailed inspection. - - Gracefully transitions to summary notes when no specific figure is active. +1. **Main Index View (`/`)**: + - Episode list with live statuses (`QUEUED`, `PROCESSING`, `READY`, `FAILED`, `CANCELED`). + - Card play/pause, duration, auto-polling while jobs are active. + - Persistent bottom mini-player bar that never interrupts playing audio. +2. **Zen Fullscreen Player**: + - Toggled via full-screen button on persistent bar. + - Synchronized transcript with active sentence highlighting and smooth auto-scroll. + - Tap-to-seek anywhere in transcript. + - Swipe-down gesture or minimize button to return to index without audio interruption. +3. **Dedicated Add Article View (`/new`)**: + - Standalone view for new posts with character/word count and duration estimation. + - Extensible for future M2 settings (prompts, dual voices). --- @@ -180,7 +168,7 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re ```prisma datasource db { - provider = "postgresql" // or "mongodb" + provider = "sqlite" // or "postgresql" url = env("DATABASE_URL") } @@ -188,46 +176,20 @@ generator client { provider = "prisma-client-js" } -enum ProcessingStatus { - PENDING - SCRIPTING - SYNTHESIZING - READY - FAILED -} - -enum ScriptMode { - VERBATIM - FEYNMAN - DUAL_HOST -} - model Episode { - id String @id @default(cuid()) @map("_id") + id String @id @default(cuid()) title String originalContent String scriptContent String? - mode ScriptMode @default(FEYNMAN) - status ProcessingStatus @default(PENDING) + mode String @default("VERBATIM") // VERBATIM | FEYNMAN | DUAL_HOST + status String @default("PENDING") // QUEUED | PROCESSING | READY | FAILED | CANCELED + errorMessage String? durationSeconds Float? audioUrl String? - timingsJson String? // Serialized timestamp cues + timingsJson String? // Serialized array of cues [{text, startTime, endTime, speaker}] - stages StageItem[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model StageItem { - id String @id @default(cuid()) @map("_id") - episodeId String - timestampSeconds Float - type String // "IMAGE" | "CODE" | "CALLOUT" - title String? - content String // Image URL or Code snippet / Callout text - caption String? - - episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } ``` @@ -241,72 +203,48 @@ Creates a new conversion job. ```json { - "title": "Understanding Kubernetes Ingress Controllers", + "title": "Understanding Kubernetes Ingress", "content": "# Markdown content...", - "mode": "FEYNMAN", - "ttsProvider": "edge", - "voiceOptions": { - "primaryVoice": "en-US-ChristopherNeural", - "speed": 1.0 - }, - "visualAssets": [ - { - "id": "fig-1", - "type": "IMAGE", - "url": "https://example.com/arch.png", - "caption": "Figure 1: Ingress routing architecture" - } - ] + "mode": "VERBATIM", + "provider": "edge", + "voice": "en-US-ChristopherNeural" } ``` -**Response**: `202 Accepted` with `{ "episodeId": "clx...", "status": "PENDING" }`. - ### `GET /api/episodes/:id` -Retrieves episode details, audio URL, processing status, transcript timings, and stage markers. +Retrieves episode details, audio URL, processing status, and transcript timings. ### `GET /api/episodes` -Lists all generated episodes for the library view. +Lists all episodes for the index view. + +### `DELETE /api/episodes/:id` + +Deletes or cancels an episode. --- -## 8. Technical Stack +## 8. Implementation Roadmap -| Component | Technology | Rationale | -| --- | --- | --- | -| **Runtime & Language** | Node.js (v20+) / TypeScript | Rapid prototyping, strong typing, zero Python venv/setup friction. | -| **Framework** | Next.js (App Router) | Unified backend API routes + reactive frontend player in one repo. | -| **Database & ORM** | Prisma + MongoDB or PostgreSQL | Lightweight document/relational storage matching user preference. | -| **Audio Generation** | `msedge-tts`, OpenAI API, ElevenLabs SDK | Free zero-key tier (Edge) + premium commercial options. | -| **Audio Processing** | `fluent-ffmpeg` or Web Audio APIs | Fast server-side audio stitching and silence injection. | -| **LLM Orchestration** | Vercel AI SDK / OpenAI SDK | Streaming script generation and structured stage extraction. | -| **Styling** | Tailwind CSS / Lucide Icons | Clean modern aesthetic, responsive audio controls. | +### Phase 1: Core Audio Generation & Minimal Player (Completed) ---- +- [x] Next.js + TypeScript project structure and Prisma configuration. +- [x] `ITextToSpeechProvider` interface with `EdgeTTSProvider` and `OpenAITTSProvider`. +- [x] Long-text chunking, concatenation, and static file delivery. +- [x] Basic synchronized transcript viewer and audio player. -## 9. Implementation Roadmap - -### Phase 1: Core Audio Generation & Minimal Player (Week 1) - -- [ ] Initialize Next.js + TypeScript project structure and Prisma configuration. -- [ ] Implement `ITextToSpeechProvider` interface with `EdgeTTSProvider` and `OpenAITTSProvider`. -- [ ] Build audio chunking, concatenation, and static file delivery. -- [ ] Implement basic Web Player UI (Play/Pause, Scrubber, Episode list). - -### Phase 2: Hearing-Front Script Engine (Week 2) +### Phase 2: Hearing-Front Script Engine (Next) - [ ] Implement prompt templates for: - - Feynman Master Presenter (single host, conversational breakdown). + - Feynman Master Presenter (conversational breakdown). - Dual-Host Dialogue (speaker-tagged turns). - [ ] Parse speaker tags and route chunks to distinct voices in the TTS pipeline. - [ ] Output synchronized paragraph/sentence timestamp manifests. -### Phase 3: Synchronized Visual Stages (Week 3) +### Phase 3: Mobile PWA & Zen Listening Experience (Current) -- [ ] Extract or link visual assets (figures/diagrams/code) to script stages. -- [ ] Build the interactive Stage Viewer in the web player: - - Figure transitions locked to audio progress. - - Interactive transcript with click-to-seek. -- [ ] End-to-end testing with technical papers, blog posts, and news feeds. +- [x] Global `AudioProvider` ensuring uninterrupted audio playback across all navigation. +- [x] Fullscreen Zen Player with auto-scrolling synchronized transcript & tap-to-seek. +- [x] Dedicated Add Article View (`/new`) for extensible settings. +- [x] PWA manifest & mobile-responsive design for phone listening. diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a..a419cbe 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs index a505021..76fa612 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,7 @@ /** @type {import('next').NextConfig} */ +// Reload Prisma Client with targetLanguage schema const nextConfig = { - serverExternalPackages: ["msedge-tts"], + serverExternalPackages: ["msedge-tts", "@prisma/client"], }; export default nextConfig; diff --git a/package.json b/package.json index acc72f3..814725c 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "be-my-anchor", "version": "1.0.0", - "description": "Transform articles, tech blogs, and papers into a personalized listening experience with synchronized visual companion.", + "description": "Transform articles, tech blogs, and papers into a personalized listening-first audio experience.", "scripts": { "dev": "next dev --webpack", "build": "next build --webpack", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "gitleaks": "gitleaks protect --staged -v", + "prepare": "git config core.hooksPath .githooks || true" }, "repository": { "type": "git", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 77ff03c..5012833 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,9 +11,14 @@ model Episode { id String @id @default(cuid()) title String originalContent String + contentHash String? // SHA-256 for deduplication scriptContent String? mode String @default("VERBATIM") // VERBATIM | FEYNMAN | DUAL_HOST - status String @default("PENDING") // PENDING | PROCESSING | READY | FAILED + targetLanguage String @default("auto") // auto | zh | en + status String @default("QUEUED") // QUEUED | PROCESSING | READY | FAILED + retryCount Int @default(0) + maxRetries Int @default(3) + nextAttemptAt DateTime? errorMessage String? durationSeconds Float? audioUrl String? @@ -22,6 +27,8 @@ model Episode { stages StageItem[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([contentHash]) } model StageItem { diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..956612f --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..41af873 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Be My Anchor", + "short_name": "Anchor", + "description": "Transform written articles and papers into a listening-first audio experience", + "start_url": "/", + "display": "standalone", + "background_color": "#090d16", + "theme_color": "#090d16", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts new file mode 100644 index 0000000..ce18fa5 --- /dev/null +++ b/src/app/api/auth/status/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { isControlKeyConfigured } from "@/lib/auth/control-key"; + +export async function GET() { + return NextResponse.json({ + success: true, + isControlKeyConfigured: isControlKeyConfigured(), + }); +} diff --git a/src/app/api/episodes/[id]/route.ts b/src/app/api/episodes/[id]/route.ts index b3e436a..59f98c0 100644 --- a/src/app/api/episodes/[id]/route.ts +++ b/src/app/api/episodes/[id]/route.ts @@ -26,6 +26,20 @@ export async function GET( ...episode, timings: episode.timingsJson ? JSON.parse(episode.timingsJson) : [], }, + task: { + id: episode.id, + status: episode.status, + retryCount: episode.retryCount, + maxRetries: episode.maxRetries, + nextAttemptAt: episode.nextAttemptAt, + errorMessage: episode.errorMessage, + durationSeconds: episode.durationSeconds, + audioUrl: episode.audioUrl, + isSuccess: episode.status === "READY", + isProcessing: episode.status === "PROCESSING", + isQueued: episode.status === "QUEUED", + isFailed: episode.status === "FAILED", + }, }); } catch (error: any) { return NextResponse.json( @@ -35,6 +49,53 @@ export async function GET( } } +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const episode = await prisma.episode.findUnique({ where: { id } }); + + if (!episode) { + return NextResponse.json( + { success: false, error: "Episode not found" }, + { status: 404 } + ); + } + + const { taskQueue } = await import("@/lib/queue/task-queue"); + + const updated = await prisma.episode.update({ + where: { id }, + data: { + status: "QUEUED", + retryCount: 0, + errorMessage: null, + nextAttemptAt: null, + }, + }); + + taskQueue.enqueue({ + id: episode.id, + title: episode.title, + content: episode.originalContent, + mode: episode.mode, + }); + + return NextResponse.json({ + success: true, + message: "Episode re-queued for processing.", + episode: updated, + }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message || "Failed to retry episode" }, + { status: 500 } + ); + } +} + export async function DELETE( req: NextRequest, { params }: { params: Promise<{ id: string }> } diff --git a/src/app/api/episodes/route.ts b/src/app/api/episodes/route.ts index f91450c..70ae61d 100644 --- a/src/app/api/episodes/route.ts +++ b/src/app/api/episodes/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; -import { audioService } from "@/lib/tts/audio-service"; import { cleanHtmlTags } from "@/lib/tts/chunker"; +import { validateDefaultAuthAccess } from "@/lib/auth/control-key"; +import { taskQueue, computeContentHash } from "@/lib/queue/task-queue"; export async function GET() { try { @@ -22,7 +23,40 @@ export async function GET() { export async function POST(req: NextRequest) { try { const body = await req.json(); - const { title, content, mode = "VERBATIM", provider = "edge", voice } = body; + const { + title, + content, + mode = "VERBATIM", + targetLanguage = "auto", + provider = "edge", + voice, + voiceA, + voiceB, + apiKey, + controlKey, + } = body; + + // If LLM mode is requested without a BYO key, verify Control Key to authorize using server's default credentials + const hasByoKey = typeof apiKey === "string" && apiKey.trim().length > 0; + const isLlmMode = mode === "DUAL_HOST" || mode === "FEYNMAN"; + + if (isLlmMode && !hasByoKey) { + const isAuthorized = validateDefaultAuthAccess(req, { + hasByoKey: false, + bodyControlKey: controlKey, + }); + + if (!isAuthorized) { + return NextResponse.json( + { + success: false, + error: + "Unauthorized: Using the server's default AI credentials requires a valid Control Key. Alternatively, you can provide your own Gemini API Key (BYO key) without needing a Control Key.", + }, + { status: 401 } + ); + } + } if (!title || typeof title !== "string" || !title.trim()) { return NextResponse.json( @@ -39,58 +73,110 @@ export async function POST(req: NextRequest) { } const sanitizedTitle = cleanHtmlTags(title.trim()) || "Untitled Episode"; + const sanitizedContent = content.trim(); - // 1. Create episode record in PENDING state + // Compute content hash based strictly on title + content + mode + targetLanguage + const contentHash = computeContentHash(sanitizedTitle, sanitizedContent, mode, targetLanguage); + + // 1. Deduplication check: look for identical content already submitted + const existing = await prisma.episode.findFirst({ + where: { contentHash }, + orderBy: { createdAt: "desc" }, + }); + + if (existing) { + if (existing.status === "READY" || existing.status === "PROCESSING" || existing.status === "QUEUED") { + console.log(`[POST /api/episodes] Deduplicated: identical content already exists as episode [${existing.id}] (${existing.status})`); + return NextResponse.json( + { + success: true, + deduplicated: true, + message: `Identical article already exists with status: ${existing.status}.`, + episode: existing, + }, + { status: 200 } + ); + } + + if (existing.status === "FAILED") { + // Re-poke: re-queue the existing failed record instead of creating duplicate clutter + console.log(`[POST /api/episodes] Re-queuing previously failed episode [${existing.id}]...`); + const updated = await prisma.episode.update({ + where: { id: existing.id }, + data: { + status: "QUEUED", + targetLanguage, + retryCount: 0, + errorMessage: null, + nextAttemptAt: null, + }, + }); + + taskQueue.enqueue({ + id: existing.id, + title: sanitizedTitle, + content: sanitizedContent, + mode, + targetLanguage, + provider, + voice, + voiceA, + voiceB, + apiKey, + }); + + return NextResponse.json( + { + success: true, + deduplicated: false, + requeued: true, + message: "Previously failed episode re-queued for processing.", + episode: updated, + }, + { status: 202 } + ); + } + } + + // 2. Create new episode record in QUEUED state const episode = await prisma.episode.create({ data: { title: sanitizedTitle, - originalContent: content.trim(), + originalContent: sanitizedContent, + contentHash, mode, - status: "PROCESSING", + targetLanguage, + status: "QUEUED", + retryCount: 0, + maxRetries: 3, }, }); - // 2. Synthesize audio asynchronously or inline - try { - const audioResult = await audioService.processAndSaveEpisode( - episode.id, - content.trim(), - { provider, voice } - ); + // 3. Enqueue task for asynchronous single-worker FIFO execution + taskQueue.enqueue({ + id: episode.id, + title: sanitizedTitle, + content: sanitizedContent, + mode, + targetLanguage, + provider, + voice, + voiceA, + voiceB, + apiKey, + }); - const updatedEpisode = await prisma.episode.update({ - where: { id: episode.id }, - data: { - status: "READY", - audioUrl: audioResult.audioUrl, - durationSeconds: audioResult.durationSeconds, - timingsJson: JSON.stringify(audioResult.timings), - }, - }); + console.log(`[POST /api/episodes] Enqueued new episode [${episode.id}] "${sanitizedTitle}" (Mode: ${mode}, Lang: ${targetLanguage})`); - return NextResponse.json({ + return NextResponse.json( + { success: true, - episode: updatedEpisode, - }); - } catch (synthError: any) { - console.error(`Audio synthesis failed for episode ${episode.id}:`, synthError); - await prisma.episode.update({ - where: { id: episode.id }, - data: { - status: "FAILED", - errorMessage: synthError.message || "Audio synthesis failed", - }, - }); - - return NextResponse.json( - { - success: false, - error: synthError.message || "Failed to synthesize audio", - episodeId: episode.id, - }, - { status: 500 } - ); - } + queued: true, + deduplicated: false, + episode, + }, + { status: 202 } + ); } catch (error: any) { console.error("Episode creation error:", error); return NextResponse.json( diff --git a/src/app/api/worker/status/route.ts b/src/app/api/worker/status/route.ts new file mode 100644 index 0000000..8bd8c8d --- /dev/null +++ b/src/app/api/worker/status/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { taskQueue } from "@/lib/queue/task-queue"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const status = taskQueue.getStatus(); + return NextResponse.json({ + success: true, + worker: status, + }); + } catch (error: any) { + console.error("Failed to retrieve worker status:", error); + return NextResponse.json( + { success: false, error: error.message || "Failed to fetch worker status" }, + { status: 500 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index cda5554..4a707da 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,6 +3,11 @@ @tailwind utilities; :root { + --background: #f8fafc; + --foreground: #0f172a; +} + +.dark { --background: #090d16; --foreground: #f1f5f9; } @@ -15,6 +20,12 @@ body { padding: 0; min-height: 100vh; -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; +} + +/* Safe area inset for mobile phones with notch / home indicators */ +.safe-bottom { + padding-bottom: max(1.5rem, env(safe-area-inset-bottom)); } /* Custom scrollbars */ @@ -24,14 +35,22 @@ body { } ::-webkit-scrollbar-track { - background: #0f172a; + background: transparent; } ::-webkit-scrollbar-thumb { - background: #334155; + background: #cbd5e1; border-radius: 3px; } +.dark ::-webkit-scrollbar-thumb { + background: #334155; +} + ::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +.dark ::-webkit-scrollbar-thumb:hover { background: #475569; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5c0a904..103dbff 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,31 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import "./globals.css"; +import { ThemeProvider } from "@/contexts/ThemeContext"; +import { AudioProvider } from "@/contexts/AudioContext"; +import { AudioPlayer } from "@/components/AudioPlayer"; +import { ZenPlayer } from "@/components/ZenPlayer"; export const metadata: Metadata = { title: "Be My Anchor - Listen to Any Article", description: - "Convert written articles, tech blogs, and papers into a personalized listening experience with synchronized visual companion.", + "Convert written articles, tech blogs, and papers into a personalized listening experience with synchronized transcript.", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "Be My Anchor", + }, +}; + +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: dark)", color: "#090d16" }, + { media: "(prefers-color-scheme: light)", color: "#f8fafc" }, + ], + width: "device-width", + initialScale: 1, + maximumScale: 1, + viewportFit: "cover", }; export default function RootLayout({ @@ -13,9 +34,36 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {children} + + +