lazier

personal summarizer
Log | Files | Refs | README

commit 3d8a93b1024e657723eff0ef1e9aeeb1dbbbe5e6
parent 2cf8e3cacc2291005fee7cc440a93a9568596d81
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Tue, 27 Jan 2026 19:44:51 -0300

small fix

Diffstat:
A.agent/skills | 1+
Mlazier/api/routes.py | 19++++++++++++-------
Mlazier/audio_processor.py | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlazier/downloader.py | 40++++++++++++++++++++++++++--------------
Mlazier/summarizer.py | 25+++++++++++++++++++------
Mlazier/transcriber.py | 72+++++++++++++++++++++++++++++++++++++++++++++++-------------------------
6 files changed, 199 insertions(+), 52 deletions(-)

diff --git a/.agent/skills b/.agent/skills @@ -0,0 +1 @@ +Subproject commit 44d6277b698618538f3ae68fa8c803d7e5f07482 diff --git a/lazier/api/routes.py b/lazier/api/routes.py @@ -270,6 +270,7 @@ def process_file_async( @router.post("/upload") async def upload_files( + background_tasks: BackgroundTasks, files: List[UploadFile] = File(...), format: str = Form("docx"), transcribe: bool = Form(True), @@ -289,11 +290,15 @@ async def upload_files( # Salva arquivo file_path = UPLOAD_DIR / f"{uuid.uuid4()}_{file.filename}" - with open(file_path, 'wb') as f: - content = await file.read() - f.write(content) - - # Se for vídeo, extrair áudio imediatamente + try: + with open(file_path, 'wb') as f: + content = await file.read() + f.write(content) + except Exception as e: + logger.error(f"Erro ao salvar arquivo {file.filename}: {e}") + raise HTTPException(status_code=500, detail=f"Erro ao salvar arquivo: {str(e)}") + + # Se for vídeo, extrair áudio imediatamente (sincrono para o upload mas rápido) if ext in VIDEO_EXTENSIONS: try: # Extrai áudio do vídeo @@ -328,8 +333,8 @@ async def upload_files( 'created_at': datetime.now().isoformat(), } - # Processa em background - process_file_async(str(file_path), job_id, format, transcribe, summarize) + # Processa em background REAL (usando BackgroundTasks) + background_tasks.add_task(process_file_async, str(file_path), job_id, format, transcribe, summarize) job_ids.append(job_id) diff --git a/lazier/audio_processor.py b/lazier/audio_processor.py @@ -108,6 +108,100 @@ def convert_audio_format(audio_path: str, output_format: str = 'mp3') -> str: raise Exception(f"Erro ao converter áudio: {error_msg}") +def get_audio_duration(audio_path: str) -> float: + """ + Retorna a duração do áudio em segundos usando ffprobe + """ + if not check_ffmpeg(): + raise Exception("ffmpeg/ffprobe não está disponível.") + + cmd = [ + 'ffprobe', + '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + audio_path + ] + + try: + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + return float(result.stdout) + except Exception: + return 0.0 + + +def split_audio(audio_path: str, chunk_size_mb: int = 24) -> list[str]: + """ + Divide um arquivo de áudio em chunks menores usando ffmpeg + O objetivo é respeitar o limite de 25MB da OpenAI Whisper API + + Args: + audio_path: Caminho do arquivo original + chunk_size_mb: Tamanho aproximado de cada chunk em MB + + Returns: + Lista de caminhos para os arquivos criados + """ + if not check_ffmpeg(): + raise Exception("ffmpeg não está disponível.") + + file_size_mb = os.path.getsize(audio_path) / (1024 * 1024) + if file_size_mb <= chunk_size_mb: + return [audio_path] + + duration = get_audio_duration(audio_path) + if duration <= 0: + raise Exception("Não foi possível determinar a duração do áudio para divisão.") + + # Calcula duração de cada chunk proporcionalmente ao tamanho do arquivo + num_chunks = int(file_size_mb / chunk_size_mb) + 1 + chunk_duration = duration / num_chunks + + audio_path_obj = Path(audio_path) + output_dir = audio_path_obj.parent / f"{audio_path_obj.stem}_chunks" + output_dir.mkdir(parents=True, exist_ok=True) + + chunks = [] + + # Comando ffmpeg para dividir + cmd = [ + 'ffmpeg', + '-i', audio_path, + '-f', 'segment', + '-segment_time', f"{chunk_duration}", + '-c', 'copy', # Copia stream sem re-encodar (rápido!) + '-reset_timestamps', '1', + str(output_dir / f"chunk_%03d{audio_path_obj.suffix}") + ] + + try: + subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + + # Coleta arquivos gerados + chunks = sorted([str(p) for p in output_dir.glob(f"chunk_*{audio_path_obj.suffix}")]) + + # Se algum chunk ainda for muito grande, tenta re-encodar com bitrate menor + final_chunks = [] + for chunk in chunks: + if os.path.getsize(chunk) > chunk_size_mb * 1024 * 1024: + # Se ainda é grande (pode acontecer com VBR ou cópia), re-encodamos com bitrate baixo + compressed_chunk = f"{Path(chunk).parent}/{Path(chunk).stem}_compressed.mp3" + compress_cmd = [ + 'ffmpeg', '-i', chunk, + '-acodec', 'libmp3lame', '-ab', '64k', + '-y', compressed_chunk + ] + subprocess.run(compress_cmd, check=True, capture_output=True) + final_chunks.append(compressed_chunk) + else: + final_chunks.append(chunk) + + return final_chunks + + except Exception as e: + raise Exception(f"Erro ao dividir áudio: {str(e)}") + + def prepare_audio_file(input_path: str, is_video: bool = False) -> str: """ Prepara arquivo de áudio para transcrição diff --git a/lazier/downloader.py b/lazier/downloader.py @@ -85,14 +85,19 @@ def _create_ydl_opts(output_path: Path, format_str: str = 'bestaudio/best', use_ Returns: Dicionário com opções do yt-dlp """ - # Priorizar tv_embedded como primeiro cliente (não requer PO Token) + # Lista de clientes para tentar em ordem de confiabilidade + # tv_embedded é o mais robusto ultimamente + # web_creator e android são alternativas + player_clients = ['tv_embedded', 'ios', 'android', 'mweb', 'web'] + extractor_args = { 'youtube': { - 'player_client': ['tv_embedded', 'ios', 'mweb'], + 'player_client': player_clients, + 'skip': ['hls', 'dash'], # Evita formatos que costumam dar erro } } - # Adiciona PO Token se fornecido via variável de ambiente + # Adifica PO Token se fornecido via variável de ambiente po_token = os.getenv('YOUTUBE_PO_TOKEN') if po_token: extractor_args['youtube']['po_token'] = po_token @@ -103,13 +108,20 @@ def _create_ydl_opts(output_path: Path, format_str: str = 'bestaudio/best', use_ 'outtmpl': str(output_path / '%(title)s.%(ext)s'), 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'extractor_args': extractor_args, - 'remote_components': ['ejs:github'], # Baixa componentes EJS do GitHub - 'retries': 3, - 'fragment_retries': 3, + 'nocheckcertificate': True, + 'ignoreerrors': False, + 'logtostderr': False, 'quiet': False, 'no_warnings': False, - 'extractaudio': False, - 'postprocessors': [], + 'retries': 5, + 'fragment_retries': 5, + 'extractaudio': True, + 'audioformat': 'mp3', + 'postprocessors': [{ + 'key': 'FFmpegExtractAudio', + 'preferredcodec': 'mp3', + 'preferredquality': '192', + }], } # Adiciona hook de progresso se fornecido @@ -152,22 +164,22 @@ def download_youtube_audio(url: str, output_dir: Optional[str] = None) -> tuple[ strategies = [ { 'format': 'bestaudio/best', - 'description': 'melhor áudio disponível', + 'description': 'melhor áudio disponível (padrão)', 'use_deno': True, }, { - 'format': '251/250/249/bestaudio/best', # Opus, Opus, Opus, fallback - 'description': 'formatos Opus alternativos', + 'format': 'ba*/b', + 'description': 'qualquer formato de áudio (fallback 1)', 'use_deno': True, }, { 'format': 'bestaudio/best', - 'description': 'sem JavaScript runtime', + 'description': 'melhor áudio sem JavaScript runtime (fallback 2)', 'use_deno': False, }, { - 'format': 'worstaudio/worst', # Último recurso - 'description': 'qualidade mínima', + 'format': 'worstaudio/worst', + 'description': 'qualidade mínima de áudio (último recurso)', 'use_deno': False, }, ] diff --git a/lazier/summarizer.py b/lazier/summarizer.py @@ -47,17 +47,30 @@ def summarize_text(text: str, model: str = 'gpt-4o-mini', language: str = 'pt-BR chunks = _split_text_into_chunks(text, max_chars) chunk_summaries = [] + print(f"Texto longo detectado ({len(text)} caracteres). Dividido em {len(chunks)} partes.") + for i, chunk in enumerate(chunks): - print(f"Sumarizando chunk {i+1}/{len(chunks)}...") - summary = _summarize_chunk(chunk, model, language) - chunk_summaries.append(summary) + try: + print(f"Sumarizando parte {i+1}/{len(chunks)}...") + summary = _summarize_chunk(chunk, model, language) + chunk_summaries.append(summary) + except Exception as e: + print(f"Erro ao sumarizar parte {i+1}: {e}") + chunk_summaries.append(f"[Erro nesta parte: {str(e)}]") + + # Filtra sumários válidos + valid_summaries = [s for s in chunk_summaries if not s.startswith("[Erro")] + if not valid_summaries: + return "Falha ao gerar sumário: todas as partes falharam." + # Se temos múltiplos chunks, sumariza os sumários - if len(chunk_summaries) > 1: - combined_summaries = "\n\n".join(chunk_summaries) + if len(valid_summaries) > 1: + print("Consolidando sumários parciais...") + combined_summaries = "\n\n".join(valid_summaries) return _summarize_chunk(combined_summaries, model, language, is_final=True) else: - return chunk_summaries[0] + return valid_summaries[0] def _summarize_chunk(text: str, model: str, language: str, is_final: bool = False) -> str: diff --git a/lazier/transcriber.py b/lazier/transcriber.py @@ -11,9 +11,13 @@ from dotenv import load_dotenv load_dotenv() +from .audio_processor import split_audio + + def transcribe_audio(audio_path: str, language: str = 'pt', model: str = 'whisper-1') -> str: """ - Transcreve um arquivo de áudio usando OpenAI Whisper API + Transcreve um arquivo de áudio usando OpenAI Whisper API, + com suporte a divisão automática de arquivos grandes. Args: audio_path: Caminho do arquivo de áudio @@ -33,41 +37,59 @@ def transcribe_audio(audio_path: str, language: str = 'pt', model: str = 'whispe if not os.path.exists(audio_path): raise FileNotFoundError(f"Arquivo de áudio não encontrado: {audio_path}") - # Verifica tamanho do arquivo (limite da API é 25MB) + # Verifica se precisa dividir o arquivo (limite da API é 25MB) file_size = os.path.getsize(audio_path) - max_size = 25 * 1024 * 1024 # 25MB - - if file_size > max_size: - raise Exception( - f"Arquivo muito grande ({file_size / 1024 / 1024:.2f}MB). " - f"Limite da API é 25MB. Considere usar um arquivo menor ou dividir o áudio." - ) + max_size_per_chunk = 24 * 1024 * 1024 # 24MB para margem de segurança try: client = OpenAI(api_key=api_key) - with open(audio_path, 'rb') as audio_file: - transcript = client.audio.transcriptions.create( - model=model, - file=audio_file, - language=language, - response_format='text' - ) - - # Se retornou como objeto, pega o texto - if hasattr(transcript, 'text'): - return transcript.text - elif isinstance(transcript, str): - return transcript + if file_size > max_size_per_chunk: + print(f"Arquivo grande detectado ({file_size / 1024 / 1024:.2f}MB). Dividindo em chunks...") + chunks = split_audio(audio_path, chunk_size_mb=24) + transcriptions = [] + + for i, chunk_path in enumerate(chunks): + print(f"Processando chunk {i+1}/{len(chunks)}...") + with open(chunk_path, 'rb') as audio_file: + transcript = client.audio.transcriptions.create( + model=model, + file=audio_file, + language=language, + response_format='text' + ) + + if hasattr(transcript, 'text'): + text = transcript.text + else: + text = str(transcript) + + transcriptions.append(text.strip()) + + return " ".join(transcriptions) else: - # Tenta converter para string - return str(transcript) + # Caso contrário, transcreve direto + with open(audio_path, 'rb') as audio_file: + transcript = client.audio.transcriptions.create( + model=model, + file=audio_file, + language=language, + response_format='text' + ) + + # Se retornou como objeto, pega o texto + if hasattr(transcript, 'text'): + return transcript.text + elif isinstance(transcript, str): + return transcript + else: + return str(transcript) except Exception as e: error_msg = str(e) if 'api_key' in error_msg.lower() or 'authentication' in error_msg.lower(): raise Exception("Erro de autenticação com OpenAI API. Verifique sua OPENAI_API_KEY.") elif 'file_size' in error_msg.lower() or 'too large' in error_msg.lower(): - raise Exception(f"Arquivo muito grande para a API. Limite é 25MB.") + raise Exception(f"Arquivo muito grande para a API. Limite é 25MB por chunk.") else: raise Exception(f"Erro ao transcrever áudio: {error_msg}")