import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PodcastEpisode, PodcastStatus } from './entities/podcast-episode.entity'; import { ConfigService } from '@nestjs/config'; // import { EdgeTTS } from 'node-edge-tts'; // Deprecated due to 403 errors import * as fs from 'fs-extra'; import * as path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity'; import { ChatService } from '../chat/chat.service'; @Injectable() export class PodcastService { private readonly logger = new Logger(PodcastService.name); private readonly outputDir: string; private readonly pythonPath = 'python'; // Or from config private readonly scriptPath = path.join(process.cwd(), 'text_to_speech.py'); constructor( @InjectRepository(PodcastEpisode) private podcastRepository: Repository, @InjectRepository(KnowledgeGroup) private groupRepository: Repository, private configService: ConfigService, private chatService: ChatService, // Reusing ChatService to generate script ) { // this.tts = new EdgeTTS(); this.outputDir = path.join(process.cwd(), 'uploads', 'podcasts'); fs.ensureDirSync(this.outputDir); } async create(userId: string, createDto: any): Promise { this.logger.log(`Creating podcast with DTO: ${JSON.stringify(createDto)}`); if (!userId) { throw new Error('User ID is required to create a podcast'); } const episode = this.podcastRepository.create({ ...createDto, briefing: createDto.content || createDto.briefing, // Map content to briefing if needed user: { id: userId }, group: createDto.groupId ? { id: createDto.groupId } : undefined, status: PodcastStatus.PENDING, }) as unknown as PodcastEpisode; // Restore cast to fix TS inference issue const saved = await this.podcastRepository.save(episode); // Start background processing this.processPodcast(saved.id, userId, createDto); return saved; } async findAll(userId: string, groupId?: string): Promise { const query = this.podcastRepository.createQueryBuilder('podcast') .where('podcast.userId = :userId', { userId }) .orderBy('podcast.createdAt', 'DESC'); if (groupId) { query.andWhere('podcast.groupId = :groupId', { groupId }); } return query.getMany(); } async findOne(userId: string, id: string): Promise { const episode = await this.podcastRepository.findOne({ where: { id, userId } }); if (!episode) throw new NotFoundException(`Podcast ${id} not found`); return episode; } async delete(userId: string, id: string): Promise { const episode = await this.findOne(userId, id); // Delete audio file if exists if (episode.audioUrl) { const filename = path.basename(episode.audioUrl); const filePath = path.join(this.outputDir, filename); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } await this.podcastRepository.remove(episode); } async processPodcast(episodeId: string, userId: string, dto: any) { try { this.logger.log(`Starting processing for podcast ${episodeId}`); await this.updateStatus(episodeId, PodcastStatus.PROCESSING); // 1. Gather Context let context = dto.content || ''; if (dto.groupId) { // TODO: Fetch context from group (files) if content is empty // For now assuming content is passed or we just use what we have } // 2. Generate Script using ChatService (LLM) const fileIds = dto.fileId ? [dto.fileId] : undefined; const language = dto.language || 'zh'; this.logger.log(`Generating script for language: ${language}`); const script = await this.generateScript(context, dto.topic || 'General Discussion', userId, language, dto.groupId, fileIds); await this.podcastRepository.update(episodeId, { transcript: script }); // 3. Generate Audio using Edge TTS const audioFileName = `${episodeId}.mp3`; const audioFilePath = path.join(this.outputDir, audioFileName); await this.generateAudioInternal(script, audioFilePath, language); // 4. Update Episode await this.podcastRepository.update(episodeId, { status: PodcastStatus.COMPLETED, audioUrl: `/api/podcasts/audio/${audioFileName}`, }); this.logger.log(`Podcast ${episodeId} completed (Language: ${language})`); } catch (error) { this.logger.error(`Failed to process podcast ${episodeId}`, error); await this.updateStatus(episodeId, PodcastStatus.FAILED); } } private async updateStatus(id: string, status: PodcastStatus) { await this.podcastRepository.update(id, { status }); } async generateScript(context: string, topic: string, userId: string, language: string = 'zh', groupId?: string, fileIds?: string[]): Promise { // ... (RAG context logic omitted for brevity, logic remains same) // If groupId or fileIds are provided, try to enhance context with RAG if ((groupId || (fileIds && fileIds.length > 0)) && (!context || context.length < 100)) { try { // tenantId is optional, we pass undefined here, groupId is string, fileIds is string[] const ragContext = await this.chatService.getContextForTopic(topic, userId, undefined, groupId, fileIds); if (ragContext) { context = `Manual Context: ${context}\n\nSearch Results:\n${ragContext}`; } } catch (err) { this.logger.warn(`Failed to fetch RAG context for podcast: ${err.message}`); } } let targetLang = 'Chinese (Simplified)'; if (language === 'en') targetLang = 'English'; if (language === 'ja') targetLang = 'Japanese'; const prompt = ` You are an expert podcast producer. Create a podcast script about the following topic: "${topic}". Context information (use this to inform the discussion): ${context ? context.substring(0, 5000) : 'No specific context provided, use general knowledge.'} The podcast should be a dialogue between a Host and a Guest. - Host: Ask insightful questions and guide the conversation. - Guest: Provide expert answers and insights based on the context. - Tone: Professional yet conversational. - Length: Approximately 8-12 exchanges. - Language: ${targetLang}. IMPORTANT: The dialogue MUST be spoken in separate ${targetLang} sentences. Even if the context is valid in another language, translate the concepts and discuss them in ${targetLang}. Output the script as a valid JSON array of objects, where each object has "speaker" (Host/Guest) and "text" (the spoken content). Example: [ // Example structure, ensure actual content is in ${targetLang} {"speaker": "Host", "text": "..."} ] Do not include markdown formatting like \`\`\`json. Just the raw JSON. `; try { const rawContent = await this.chatService.generateSimpleChat( [{ role: 'user', content: prompt }], userId ); // Clean up code blocks if present const jsonString = rawContent.replace(/```json/g, '').replace(/```/g, '').trim(); try { return JSON.parse(jsonString); } catch (e) { this.logger.error('Failed to parse podcast script JSON:', rawContent); // Fallback parsing if JSON fails? Or just throw. // Simple fallback: Split by newlines and try to guess speaker? // For now, throw to see errors. throw new Error('Script generation failed to produce valid JSON'); } } catch (error) { this.logger.error('Failed to generate script:', error); throw error; } } private async generateAudioInternal(script: any[], outputPath: string, language: string = 'zh') { const { spawn } = await import('child_process'); const writeStream = fs.createWriteStream(outputPath); // Voice map const voices = { zh: { host: 'zh-CN-YunxiNeural', guest: 'zh-CN-XiaoxiaoNeural' }, en: { host: 'en-US-AndrewNeural', guest: 'en-US-AvaNeural' }, ja: { host: 'ja-JP-KeitaNeural', guest: 'ja-JP-NanamiNeural' } }; const voiceConfig = voices[language] || voices['zh']; for (const line of script) { if (!line.text) continue; // Voice selection const voice = line.speaker === 'Host' ? voiceConfig.host : voiceConfig.guest; const tempPath = path.join(this.outputDir, `temp_${uuidv4()}.mp3`); try { await new Promise((resolve, reject) => { const process = spawn(this.pythonPath, [ this.scriptPath, '--text', line.text, '--voice', voice, '--output', tempPath ]); let errorOutput = ''; process.stderr.on('data', (data) => { errorOutput += data.toString(); }); process.on('close', (code) => { if (code !== 0) { reject(new Error(`Python script exited with code ${code}: ${errorOutput}`)); } else { resolve(); } }); }); if (fs.existsSync(tempPath)) { const buffer = fs.readFileSync(tempPath); writeStream.write(buffer); fs.unlinkSync(tempPath); } } catch (e) { this.logger.error(`TTS Error for line: ${line.text}`, e); } } writeStream.end(); await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); }); } }