commit 3d8a93b1024e657723eff0ef1e9aeeb1dbbbe5e6
parent 2cf8e3cacc2291005fee7cc440a93a9568596d81
Author: Pablo Murad <pblmrd@gmail.com>
Date: Tue, 27 Jan 2026 19:44:51 -0300
small fix
Diffstat:
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}")