lazier

personal summarizer
Log | Files | Refs | README

commit ded9e411ecc4c7b916c832b17bc31f68dfb4f593
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Sat, 24 Jan 2026 21:17:32 -0300

Initial commit: Lazier - Sistema CLI e WebGUI para transcrição e sumarização

Diffstat:
A.env.example | 3+++
A.gitignore | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 350+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocker/Dockerfile | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocker/Dockerfile.alternative | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocker/docker-compose.yml | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/__init__.py | 9+++++++++
Alazier/api/__init__.py | 3+++
Alazier/api/main.py | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/api/routes.py | 830+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/api/websocket.py | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/audio_processor.py | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/cli.py | 390+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/core/__init__.py | 3+++
Alazier/core/batch.py | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/core/cache.py | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/core/file_handler.py | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/core/formats.py | 288+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/core/playlist.py | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/docx_generator.py | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/downloader.py | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/summarizer.py | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/transcriber.py | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/utils.py | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/web/__init__.py | 3+++
Alazier/web/extractor.py | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/web/templates/index.html | 1036+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apyproject.toml | 45+++++++++++++++++++++++++++++++++++++++++++++
Arequirements.txt | 19+++++++++++++++++++
Asetup.py | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/__init__.py | 2++
31 files changed, 5299 insertions(+), 0 deletions(-)

diff --git a/.env.example b/.env.example @@ -0,0 +1,3 @@ +# Configuração da API OpenAI +# Obtenha sua chave em: https://platform.openai.com/api-keys +OPENAI_API_KEY= diff --git a/.gitignore b/.gitignore @@ -0,0 +1,55 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment variables +.env +docker/.env.local + +# Arquivos temporários +*.tmp +*.temp +*_audio.* +*.docx + +# Outputs +outputs/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/README.md b/README.md @@ -0,0 +1,350 @@ +# Lazier + +Sistema CLI e WebGUI para transcrição e sumarização de áudios/vídeos/textos/PDFs usando OpenAI API. + +**Versão 0.01** - Desenvolvido por Pablo Murad (pablomurad@pm.me) + +## Descrição + +Lazier é uma plataforma completa que permite: +- Transcrever arquivos de áudio locais (mp3, wav, m4a, etc.) +- Transcrever arquivos de vídeo locais (mp4, avi, mkv, etc.) +- Baixar e transcrever vídeos do YouTube (incluindo playlists) +- Sumarizar textos, PDFs e páginas web +- Upload de arquivos via interface web +- Cache inteligente com Redis +- Múltiplos formatos de saída (DOCX, TXT, Markdown, JSON) + +Todas as transcrições e sumários são gerados em **português do Brasil** usando a API da OpenAI. + +## Requisitos + +- Python 3.8 ou superior +- Docker e Docker Compose (para uso em container) +- Chave da API OpenAI (obtenha em https://platform.openai.com/api-keys) + +**Nota:** ffmpeg e yt-dlp são instalados automaticamente no container Docker. + +## Instalação + +### Opção 1: Docker (Recomendado) + +1. Clone o repositório: +```bash +git clone <repo-url> +cd listener +``` + +2. Configure a chave da API: +```bash +# Crie arquivo .env na raiz +echo "OPENAI_API_KEY=sua_chave_aqui" > .env +``` + +3. Inicie os serviços: +```bash +cd docker +docker compose up -d +``` + +4. Acesse a interface web em: http://localhost:19283 + +**Nota:** Os arquivos gerados são salvos diretamente na pasta `outputs/` na raiz do projeto. Você pode acessá-los diretamente sem precisar copiar do container. + +### Opção 2: Instalação Local + +1. Clone o repositório +2. Instale as dependências: + +```bash +pip install -r requirements.txt +``` + +Ou instale o pacote em modo desenvolvimento: + +```bash +pip install -e . +``` + +3. Configure sua chave da API OpenAI: + +Crie um arquivo `.env` na raiz do projeto: + +```bash +OPENAI_API_KEY=sua_chave_aqui +REDIS_HOST=localhost +REDIS_PORT=6379 +``` + +4. Inicie Redis (necessário para cache): +```bash +# Docker +docker run -d -p 6379:6379 redis:7-alpine + +# Ou instale Redis localmente +``` + +## Uso + +### CLI + +#### Comando Principal + +O comando principal transcreve e sumariza automaticamente: + +```bash +lazier <arquivo|url> [opções] +``` + +**Exemplos:** + +```bash +# Transcrever e sumarizar arquivo de áudio local +lazier audio.mp3 + +# Transcrever e sumarizar vídeo local +lazier video.mp4 + +# Processar PDF +lazier document.pdf + +# Transcrever e sumarizar vídeo do YouTube +lazier "https://www.youtube.com/watch?v=VIDEO_ID" + +# Especificar formato de saída +lazier audio.mp3 --format json + +# Apenas transcrição (sem sumário) +lazier transcribe video.mp4 + +# Processar playlist do YouTube +lazier "https://www.youtube.com/playlist?list=PLAYLIST_ID" +``` + +#### Subcomandos + +- `lazier transcribe <arquivo|url>` - Apenas transcrição +- `lazier web` - Inicia servidor web na porta 19283 +- `lazier cache clear` - Limpa cache Redis +- `lazier cache stats` - Mostra estatísticas do cache + +#### Opções + +- `--output, -o`: Nome do arquivo de saída (padrão: baseado no input) +- `--format, -f`: Formato de saída - docx, txt, md, json (padrão: docx) +- `--language, -l`: Idioma para transcrição (padrão: pt) +- `--model`: Modelo Whisper (padrão: whisper-1) +- `--gpt-model`: Modelo GPT para sumarização (padrão: gpt-4o-mini) +- `--keep-files`: Não deletar arquivos temporários após processamento +- `--only-audio`: Processar apenas áudio (para vídeos) + +### WebGUI + +Inicie o servidor web: + +```bash +lazier web +``` + +Ou com opções: + +```bash +lazier web --port 19283 --host 0.0.0.0 +``` + +Acesse http://localhost:19283 no navegador. + +**Funcionalidades da WebGUI:** +- Upload de múltiplos arquivos (drag & drop) +- Processamento de URLs (YouTube, páginas web) +- Visualização de progresso em tempo real +- Download de resultados em múltiplos formatos +- Histórico de processamentos + +## Formatos Suportados + +**Áudio:** mp3, wav, m4a, aac, flac, ogg, opus, wma + +**Vídeo:** mp4, avi, mkv, mov, wmv, flv, webm, m4v + +**Documentos:** pdf, txt, md, html + +**YouTube:** Qualquer URL do YouTube (vídeos e playlists) + +**Web:** URLs de páginas web (extração automática de conteúdo) + +## Fluxo de Processamento + +### Para Arquivos Locais (Áudio/Vídeo): + +1. Validação do arquivo +2. Verificação de cache Redis +3. Se vídeo → extração de áudio com ffmpeg +4. Transcrição com OpenAI Whisper API (pt-BR) +5. Sumarização com OpenAI GPT (pt-BR) +6. Geração do arquivo no formato solicitado +7. Limpeza de arquivos temporários + +### Para PDFs/Textos: + +1. Validação do arquivo +2. Verificação de cache Redis +3. Extração de conteúdo (PDF ou texto direto) +4. Sumarização com OpenAI GPT (pt-BR) +5. Geração do arquivo no formato solicitado + +### Para URLs do YouTube: + +1. Validação da URL +2. Verificação de cache Redis +3. Download do melhor áudio disponível com yt-dlp +4. Transcrição com OpenAI Whisper API (pt-BR) +5. Sumarização com OpenAI GPT (pt-BR) +6. Geração do arquivo no formato solicitado +7. **Sempre deleta** o arquivo baixado (limpeza automática) + +### Para Playlists do YouTube: + +1. Detecção de playlist +2. Extração da lista de vídeos +3. Processamento sequencial de cada vídeo +4. Consolidação de resultados + +### Para Páginas Web: + +1. Validação da URL +2. Verificação de cache Redis +3. Extração de conteúdo da página +4. Sumarização com OpenAI GPT (pt-BR) +5. Geração do arquivo no formato solicitado + +## Cache Redis + +O Lazier usa Redis para cache inteligente: +- **Transcrições**: Cacheadas por hash do arquivo +- **Sumários**: Cacheados por hash do texto +- **Metadados YouTube**: Cacheados por video_id +- **Conteúdo Web/PDF**: Cacheado por hash da URL/arquivo + +**TTL padrão:** 7 dias + +**Comandos de cache:** +```bash +lazier cache clear # Limpa todo o cache +lazier cache stats # Mostra estatísticas +``` + +## Docker + +### Estrutura + +O projeto inclui Docker Compose com: +- **lazier**: Aplicação principal (porta 19283) +- **redis**: Cache Redis (porta 6379) + +### Comandos Docker + +```bash +# Iniciar todos os serviços +cd docker +docker compose up -d + +# Ver logs +docker compose logs -f lazier +docker compose logs -f redis + +# Parar serviços +docker compose down + +# Rebuild +docker compose build --no-cache + +# Limpar volumes (remove todos os dados, exceto outputs que está no host) +docker compose down -v + +# Acessar arquivos gerados (agora estão diretamente na pasta outputs/ do projeto) +ls outputs/ +# ou no Windows: +dir outputs\ +``` + +**Nota:** Os arquivos gerados são salvos diretamente na pasta `outputs/` na raiz do projeto. Apenas cache, uploads e dados temporários ficam em volumes Docker nomeados. + +### Variáveis de Ambiente + +Crie um arquivo `.env` na raiz: + +```bash +OPENAI_API_KEY=sua_chave_aqui +``` + +## Estrutura do Projeto + +``` +listener/ +├── lazier/ # Módulo principal +│ ├── api/ # API FastAPI +│ ├── web/ # Frontend web +│ ├── core/ # Lógica de negócio +│ └── ... # Módulos existentes +├── docker/ # Arquivos Docker +│ ├── Dockerfile +│ └── docker-compose.yml +├── data/ # Dados persistentes +├── cache/ # Cache local (backup) +├── uploads/ # Uploads temporários +├── outputs/ # Arquivos gerados +└── ... +``` + +## Limitações + +- Arquivos de áudio maiores que 25MB precisam ser divididos (limite da API Whisper) +- Requer conexão com internet para usar APIs da OpenAI +- Vídeos do YouTube são sempre deletados após processamento (use `--keep-files` para manter temporários locais) +- Upload máximo: 500MB por arquivo (configurável) + +## Troubleshooting + +### Erro: "OPENAI_API_KEY não encontrada" + +Configure a variável de ambiente ou crie um arquivo `.env` com sua chave da API. + +### Erro: "Redis não disponível" + +O cache é opcional. Se Redis não estiver disponível, o sistema funcionará sem cache. Para usar cache, inicie Redis: + +```bash +docker run -d -p 6379:6379 redis:7-alpine +``` + +### Erro: "Arquivo muito grande" + +A API Whisper tem limite de 25MB por arquivo. Considere dividir arquivos grandes ou usar um formato de áudio mais compacto. + +### Erro no Docker: "ffmpeg não encontrado" + +O Dockerfile instala ffmpeg automaticamente. Se houver problemas, verifique o build do container. + +## Desenvolvimento + +Para desenvolver ou contribuir: + +```bash +# Instalar em modo desenvolvimento +pip install -e . + +# Executar servidor web localmente +lazier web + +# Executar testes (quando implementados) +pytest tests/ +``` + +## Licença + +Este projeto é desenvolvido por Pablo Murad. Versão 0.01. + +## Contato + +Pablo Murad - pablomurad@pm.me diff --git a/docker/Dockerfile b/docker/Dockerfile @@ -0,0 +1,54 @@ +FROM python:3.11-slim + +# Configurar variáveis de ambiente para evitar prompts +ENV DEBIAN_FRONTEND=noninteractive +ENV PYTHONUNBUFFERED=1 + +# Atualizar repositórios e instalar dependências do sistema +# Usar && para garantir que cada comando seja executado sequencialmente +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ffmpeg \ + curl \ + git \ + ca-certificates \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /tmp/* \ + && rm -rf /var/tmp/* + +# Verificar se ffmpeg foi instalado corretamente (ffprobe vem junto com ffmpeg) +RUN ffmpeg -version && ffprobe -version || (echo "ERRO: ffmpeg não foi instalado" && exit 1) + +# Instalar yt-dlp como executável +RUN curl -L --fail --retry 3 --retry-delay 5 \ + https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \ + -o /usr/local/bin/yt-dlp && \ + chmod a+rx /usr/local/bin/yt-dlp && \ + yt-dlp --version || (echo "ERRO: yt-dlp não foi instalado" && exit 1) + +WORKDIR /app + +# Copiar requirements primeiro (para aproveitar cache do Docker) +COPY requirements.txt . + +# Atualizar pip e instalar dependências Python +RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \ + pip install --no-cache-dir -r requirements.txt && \ + playwright install chromium && \ + playwright install-deps chromium + +# Copiar código da aplicação +COPY . . + +# Criar diretórios necessários com permissões adequadas +RUN mkdir -p /app/data /app/cache /app/uploads /app/outputs && \ + chmod -R 755 /app/data /app/cache /app/uploads /app/outputs + +EXPOSE 19283 + +# Healthcheck +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:19283/health', timeout=5)" || exit 1 + +CMD ["uvicorn", "lazier.api.main:app", "--host", "0.0.0.0", "--port", "19283"] diff --git a/docker/Dockerfile.alternative b/docker/Dockerfile.alternative @@ -0,0 +1,52 @@ +FROM python:3.11-slim + +ENV DEBIAN_FRONTEND=noninteractive +ENV PYTHONUNBUFFERED=1 + +# Instalar dependências básicas primeiro +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Instalar ffmpeg de forma separada para melhor debug +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ffmpeg \ + ffprobe \ + && rm -rf /var/lib/apt/lists/* || \ + (echo "Aviso: Falha ao instalar ffmpeg via apt, tentando alternativa..." && \ + apt-get update && \ + apt-get install -y --no-install-recommends software-properties-common && \ + apt-get update && \ + apt-get install -y --no-install-recommends ffmpeg ffprobe && \ + rm -rf /var/lib/apt/lists/*) + +# Verificar instalação +RUN which ffmpeg && ffmpeg -version || echo "AVISO: ffmpeg pode não estar disponível" + +# Instalar yt-dlp +RUN curl -L --fail --retry 3 --retry-delay 5 \ + https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \ + -o /usr/local/bin/yt-dlp && \ + chmod a+rx /usr/local/bin/yt-dlp && \ + yt-dlp --version + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /app/data /app/cache /app/uploads /app/outputs + +EXPOSE 8473 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8473/health', timeout=5)" || exit 1 + +CMD ["uvicorn", "lazier.api.main:app", "--host", "0.0.0.0", "--port", "8473"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml @@ -0,0 +1,56 @@ +services: + lazier: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: lazier-app + ports: + - "19283:19283" + volumes: + - lazier-data:/app/data + - lazier-cache:/app/cache + - lazier-uploads:/app/uploads + - ../outputs:/app/outputs + env_file: + - ../.env + environment: + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DB=0 + - LAZIER_PORT=19283 + - LAZIER_HOST=0.0.0.0 + - LAZIER_MAX_UPLOAD_SIZE=524288000 + - LAZIER_CACHE_TTL=604800 + - LAZIER_UPLOAD_DIR=/app/uploads + - LAZIER_OUTPUT_DIR=/app/outputs + - LAZIER_DATA_DIR=/app/data + depends_on: + - redis + healthcheck: + test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:19283/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: lazier-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + restart: unless-stopped + +volumes: + redis-data: + lazier-data: + lazier-cache: + lazier-uploads: diff --git a/lazier/__init__.py b/lazier/__init__.py @@ -0,0 +1,9 @@ +""" +Listen - Sistema CLI para transcrição e sumarização de áudios/vídeos +Versão 0.01 +Desenvolvido por Pablo Murad - pablomurad@pm.me +""" + +__version__ = "0.01" +__author__ = "Pablo Murad" +__email__ = "pablomurad@pm.me" diff --git a/lazier/api/__init__.py b/lazier/api/__init__.py @@ -0,0 +1,3 @@ +""" +Módulos da API FastAPI +""" diff --git a/lazier/api/main.py b/lazier/api/main.py @@ -0,0 +1,78 @@ +""" +App FastAPI principal +""" + +import os +from pathlib import Path +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from dotenv import load_dotenv + +from .routes import router +from .websocket import websocket_router + +load_dotenv() + + +def create_app() -> FastAPI: + """Cria e configura aplicação FastAPI""" + + app = FastAPI( + title="Lazier", + description="Sistema CLI e WebGUI para transcrição e sumarização de áudios/vídeos/textos/PDFs", + version="0.01", + ) + + # CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Em produção, especificar origens + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Inicializa cache + try: + from ..core.cache import get_cache_manager + cache = get_cache_manager() + app.state.cache = cache + except Exception as e: + print(f"Aviso: Cache Redis não disponível: {e}") + app.state.cache = None + + # Rotas da API + app.include_router(router, prefix="/api") + app.include_router(websocket_router, prefix="/ws") + + # Arquivos estáticos + static_dir = Path(__file__).parent.parent / "web" / "static" + templates_dir = Path(__file__).parent.parent / "web" / "templates" + + if static_dir.exists(): + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + + # Rota raiz - serve página principal + @app.get("/") + async def root(): + from fastapi.responses import FileResponse + index_file = templates_dir / "index.html" + if index_file.exists(): + return FileResponse(str(index_file)) + return {"message": "Lazier API", "version": "0.01"} + + # Healthcheck + @app.get("/health") + async def health(): + cache_status = "ok" if app.state.cache else "unavailable" + return { + "status": "ok", + "cache": cache_status, + } + + return app + + +# Instância da app +app = create_app() diff --git a/lazier/api/routes.py b/lazier/api/routes.py @@ -0,0 +1,830 @@ +""" +Rotas da API FastAPI +""" + +import os +import uuid +import asyncio +import logging +from pathlib import Path +from typing import List, Optional +from fastapi import APIRouter, UploadFile, File, HTTPException, BackgroundTasks, Form +from fastapi.responses import FileResponse, JSONResponse +from pydantic import BaseModel +from datetime import datetime + +from ..core.cache import get_cache_manager, calculate_file_hash, calculate_url_hash +from ..core.batch import get_batch_processor +from ..core.formats import export +from ..utils import validate_input, get_output_filename, is_youtube_url, get_lazier_filename +from ..downloader import download_youtube_audio +from ..audio_processor import prepare_audio_file +from ..transcriber import transcribe_audio +from ..summarizer import summarize_text, summarize_text_file, summarize_web_page, summarize_pdf +from ..web.extractor import extract_web_content, extract_text_file_content +from .websocket import broadcast_progress + +# Configurar logging +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Diretórios +UPLOAD_DIR = Path(os.getenv('LAZIER_UPLOAD_DIR', '/app/uploads')) +OUTPUT_DIR = Path(os.getenv('LAZIER_OUTPUT_DIR', '/app/outputs')) +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +# Jobs em memória (em produção, usar Redis ou banco de dados) +jobs: dict = {} + + +class ProcessRequest(BaseModel): + """Request para processar URL""" + url: str + format: str = "docx" + transcribe: bool = True + summarize: bool = True + + +class JobStatus(BaseModel): + """Status de um job""" + id: str + status: str + progress: int + result_path: Optional[str] = None + error: Optional[str] = None + + +def process_file_async( + file_path: str, + job_id: str, + output_format: str = "docx", + should_transcribe: bool = True, + should_summarize: bool = True +): + """Processa arquivo de forma assíncrona""" + try: + # Inicializa campos do job se não existirem + if 'transcription' not in jobs[job_id]: + jobs[job_id]['transcription'] = None + jobs[job_id]['summary'] = None + jobs[job_id]['transcription_path'] = None + jobs[job_id]['summary_path'] = None + jobs[job_id]['metadata'] = {} + + jobs[job_id]['status'] = 'processing' + jobs[job_id]['progress'] = 10 + broadcast_progress(job_id, 10, 'processing', 'Iniciando processamento...') + + # Valida input + is_valid, input_type, error_msg = validate_input(file_path) + if not is_valid: + raise Exception(error_msg) + + jobs[job_id]['progress'] = 20 + broadcast_progress(job_id, 20, 'processing', 'Arquivo validado') + + # Determina tipo de processamento + cache = get_cache_manager() + file_hash = calculate_file_hash(file_path) + + transcription = None + summary = None + metadata = {} + transcription_path = None + summary_path = None + transcription_internal = None # Para uso interno quando apenas sumarizar + + if input_type in ['audio', 'video']: + # Processa áudio/vídeo + # Para sumarizar áudio/vídeo, sempre precisa transcrever primeiro + needs_transcription = should_transcribe or should_summarize + + if needs_transcription: + jobs[job_id]['progress'] = 30 + broadcast_progress(job_id, 30, 'processing', 'Preparando áudio/vídeo...') + + # Verifica cache + cached = cache.get('transcription', file_hash) if cache else None + if cached: + transcription_internal = cached.get('transcription') + metadata = cached.get('metadata', {}) + broadcast_progress(job_id, 50, 'processing', 'Transcrição encontrada no cache') + else: + # Prepara áudio + audio_file = prepare_audio_file(file_path, is_video=(input_type == 'video')) + jobs[job_id]['progress'] = 50 + broadcast_progress(job_id, 50, 'processing', 'Transcrevendo áudio...') + + # Transcreve + transcription_internal = transcribe_audio(audio_file, language='pt', model='whisper-1') + jobs[job_id]['progress'] = 70 + broadcast_progress(job_id, 70, 'processing', 'Transcrição concluída') + + # Salva no cache + if cache: + cache.set('transcription', file_hash, { + 'transcription': transcription_internal, + 'metadata': metadata, + 'timestamp': datetime.now().isoformat(), + }) + + # Se usuário pediu transcrição, armazena no job + if should_transcribe: + transcription = transcription_internal + + # Gera arquivo só com transcrição se apenas transcrever + if transcription and should_transcribe and not should_summarize: + transcription_path = Path(get_lazier_filename(OUTPUT_DIR, output_format, "_transcription")) + export( + transcription=transcription, + summary=None, + metadata=metadata, + output_path=str(transcription_path), + format_type=output_format + ) + + # Sumariza se solicitado + if should_summarize: + jobs[job_id]['progress'] = 80 + broadcast_progress(job_id, 80, 'processing', 'Gerando sumário...') + if transcription_internal: + text_hash = calculate_url_hash(transcription_internal) + cached_summary = cache.get('summary', text_hash) if cache else None + + if cached_summary: + summary = cached_summary.get('summary') + broadcast_progress(job_id, 85, 'processing', 'Sumário encontrado no cache') + else: + summary = summarize_text(transcription_internal, model='gpt-4o-mini', language='pt-BR') + if cache: + cache.set('summary', text_hash, { + 'summary': summary, + 'timestamp': datetime.now().isoformat(), + }) + jobs[job_id]['progress'] = 90 + broadcast_progress(job_id, 90, 'processing', 'Sumário concluído') + + # Gera arquivo só com sumário se apenas sumarizar + if summary and not should_transcribe: + summary_path = Path(get_lazier_filename(OUTPUT_DIR, output_format, "_summary")) + export( + transcription="", + summary=summary, + metadata=metadata, + output_path=str(summary_path), + format_type=output_format + ) + + elif input_type == 'text' or Path(file_path).suffix.lower() == '.pdf': + # Processa texto/PDF + jobs[job_id]['progress'] = 40 + broadcast_progress(job_id, 40, 'processing', 'Extraindo conteúdo do arquivo...') + + # Para textos/PDFs, extrai conteúdo (equivalente a transcrição) + # Mas só armazena se usuário pediu transcrição + if Path(file_path).suffix.lower() == '.pdf': + content_data = extract_pdf_content(file_path) + content_extracted = content_data['content'] + if should_summarize: + broadcast_progress(job_id, 60, 'processing', 'Sumarizando PDF...') + summary = summarize_pdf(file_path) + else: + content_data = extract_text_file_content(file_path) + content_extracted = content_data['content'] + if should_summarize: + broadcast_progress(job_id, 60, 'processing', 'Sumarizando texto...') + summary = summarize_text_file(file_path) + + # Só armazena transcription se usuário pediu + if should_transcribe: + transcription = content_extracted + + metadata = {'title': content_data.get('title', 'Documento'), 'file_path': file_path} + + # Gera arquivo só com sumário se apenas sumarizar + if should_summarize and summary and not should_transcribe: + summary_path = Path(get_lazier_filename(OUTPUT_DIR, output_format, "_summary")) + export( + transcription="", + summary=summary, + metadata=metadata, + output_path=str(summary_path), + format_type=output_format + ) + + jobs[job_id]['progress'] = 90 + broadcast_progress(job_id, 90, 'processing', 'Conteúdo processado') + + # Gera arquivo consolidado apenas se ambos foram solicitados OU se apenas um foi solicitado mas não tem arquivo separado + # Não gera consolidado quando apenas sumarizar (já tem arquivo separado) + should_generate_consolidated = False + if should_transcribe and should_summarize: + # Ambos solicitados: sempre gera consolidado + should_generate_consolidated = True + elif should_transcribe and not should_summarize: + # Apenas transcrever: gera consolidado se não tem arquivo separado + should_generate_consolidated = not transcription_path + elif should_summarize and not should_transcribe: + # Apenas sumarizar: NÃO gera consolidado (já tem arquivo separado) + should_generate_consolidated = False + + if should_generate_consolidated: + broadcast_progress(job_id, 95, 'processing', 'Gerando arquivo de saída...') + output_path = Path(get_lazier_filename(OUTPUT_DIR, output_format)) + + export( + transcription=transcription or "", + summary=summary if should_summarize else None, + metadata=metadata, + output_path=str(output_path), + format_type=output_format + ) + + jobs[job_id]['result_path'] = str(output_path) + + # Armazena dados separados - só armazena transcription se foi solicitado + jobs[job_id]['transcription'] = transcription if should_transcribe else None + jobs[job_id]['summary'] = summary + jobs[job_id]['transcription_path'] = str(transcription_path) if transcription_path else None + jobs[job_id]['summary_path'] = str(summary_path) if summary_path else None + jobs[job_id]['metadata'] = metadata + + jobs[job_id]['status'] = 'completed' + jobs[job_id]['progress'] = 100 + broadcast_progress(job_id, 100, 'completed', 'Processamento concluído') + + except Exception as e: + jobs[job_id]['status'] = 'failed' + jobs[job_id]['error'] = str(e) + broadcast_progress(job_id, 0, 'failed', f'Erro: {str(e)}') + + +@router.post("/upload") +async def upload_files( + files: List[UploadFile] = File(...), + format: str = Form("docx"), + transcribe: bool = Form(True), + summarize: bool = Form(True) +): + """Upload de arquivos para processamento""" + job_ids = [] + + for file in files: + # Valida tipo de arquivo + ext = Path(file.filename).suffix.lower() + valid_extensions = {'.mp3', '.wav', '.m4a', '.mp4', '.avi', '.mkv', '.pdf', '.txt', '.md', '.html'} + if ext not in valid_extensions: + raise HTTPException(status_code=400, detail=f"Tipo de arquivo não suportado: {ext}") + + # 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) + + # Cria job + job_id = str(uuid.uuid4()) + jobs[job_id] = { + 'id': job_id, + 'status': 'pending', + 'progress': 0, + 'file_path': str(file_path), + 'format': format, + 'transcribe': transcribe, + 'summarize': summarize, + 'transcription': None, + 'summary': None, + 'transcription_path': None, + 'summary_path': None, + 'metadata': {}, + 'created_at': datetime.now().isoformat(), + } + + # Processa em background + process_file_async(str(file_path), job_id, format, transcribe, summarize) + + job_ids.append(job_id) + + return {"job_ids": job_ids, "message": f"{len(job_ids)} arquivo(s) enviado(s)"} + + +@router.post("/process") +async def process_url(request: ProcessRequest, background_tasks: BackgroundTasks): + """Processa URL (YouTube ou página web)""" + job_id = str(uuid.uuid4()) + + jobs[job_id] = { + 'id': job_id, + 'status': 'pending', + 'progress': 0, + 'url': request.url, + 'format': request.format, + 'transcribe': request.transcribe, + 'summarize': request.summarize, + 'transcription': None, + 'summary': None, + 'transcription_path': None, + 'summary_path': None, + 'metadata': {}, + 'created_at': datetime.now().isoformat(), + } + + # Processa em background + if is_youtube_url(request.url): + # Processa YouTube + background_tasks.add_task(process_youtube_async, request.url, job_id, request.format, request.transcribe, request.summarize) + else: + # Processa página web + background_tasks.add_task(process_web_async, request.url, job_id, request.format, request.transcribe, request.summarize) + + return {"job_id": job_id, "status": "processing"} + + +def process_youtube_async(url: str, job_id: str, output_format: str, should_transcribe: bool, should_summarize: bool): + """Processa vídeo do YouTube""" + try: + # Inicializa campos do job se não existirem + if 'transcription' not in jobs[job_id]: + jobs[job_id]['transcription'] = None + jobs[job_id]['summary'] = None + jobs[job_id]['transcription_path'] = None + jobs[job_id]['summary_path'] = None + jobs[job_id]['metadata'] = {} + + jobs[job_id]['status'] = 'processing' + jobs[job_id]['progress'] = 10 + broadcast_progress(job_id, 10, 'processing', 'Iniciando processamento do YouTube...') + + cache = get_cache_manager() + url_hash = calculate_url_hash(url) + + transcription = None + summary = None + transcription_path = None + summary_path = None + transcription_internal = None # Para uso interno quando apenas sumarizar + + # Para YouTube, sempre precisa transcrever para sumarizar + needs_transcription = should_transcribe or should_summarize + + # Verifica cache + cached = cache.get('youtube', url_hash) if cache else None + if cached: + transcription_internal = cached.get('transcription') + summary = cached.get('summary') + metadata = cached.get('metadata', {}) + # Se usuário pediu transcrição, armazena + if should_transcribe: + transcription = transcription_internal + if transcription_internal and (not should_summarize or summary): + jobs[job_id]['progress'] = 100 + broadcast_progress(job_id, 100, 'completed', 'Dados encontrados no cache') + else: + metadata = {} + if needs_transcription: + # Download + broadcast_progress(job_id, 20, 'processing', 'Baixando vídeo do YouTube...') + audio_file, metadata = download_youtube_audio(url, str(UPLOAD_DIR)) + jobs[job_id]['progress'] = 30 + broadcast_progress(job_id, 30, 'processing', 'Transcrevendo áudio...') + + # Transcreve + transcription_internal = transcribe_audio(audio_file, language='pt', model='whisper-1') + jobs[job_id]['progress'] = 60 + broadcast_progress(job_id, 60, 'processing', 'Transcrição concluída') + + # Se usuário pediu transcrição, armazena no job + if should_transcribe: + transcription = transcription_internal + + # Gera arquivo só com transcrição se apenas transcrever + if transcription and should_transcribe and not should_summarize: + transcription_path = Path(get_lazier_filename(OUTPUT_DIR, output_format, "_transcription")) + export( + transcription=transcription, + summary=None, + metadata=metadata, + output_path=str(transcription_path), + format_type=output_format + ) + + # Sumariza se solicitado + if should_summarize: + if transcription_internal: + broadcast_progress(job_id, 70, 'processing', 'Gerando sumário...') + summary = summarize_text(transcription_internal, model='gpt-4o-mini', language='pt-BR') + + jobs[job_id]['progress'] = 80 + broadcast_progress(job_id, 80, 'processing', 'Sumário concluído') + + # Gera arquivo só com sumário se apenas sumarizar + if summary and not should_transcribe: + summary_path = Path(get_lazier_filename(OUTPUT_DIR, output_format, "_summary")) + export( + transcription="", + summary=summary, + metadata=metadata, + output_path=str(summary_path), + format_type=output_format + ) + + # Salva cache + if cache and transcription_internal: + cache.set('youtube', url_hash, { + 'transcription': transcription_internal, + 'summary': summary, + 'metadata': metadata, + 'timestamp': datetime.now().isoformat(), + }) + + # Gera arquivo consolidado apenas se ambos foram solicitados OU se apenas transcrever (sem arquivo separado) + should_generate_consolidated = False + if should_transcribe and should_summarize: + # Ambos solicitados: sempre gera consolidado + should_generate_consolidated = True + elif should_transcribe and not should_summarize: + # Apenas transcrever: gera consolidado se não tem arquivo separado + should_generate_consolidated = not transcription_path + elif should_summarize and not should_transcribe: + # Apenas sumarizar: NÃO gera consolidado (já tem arquivo separado) + should_generate_consolidated = False + + if should_generate_consolidated: + broadcast_progress(job_id, 90, 'processing', 'Gerando arquivo de saída...') + output_path = Path(get_lazier_filename(OUTPUT_DIR, output_format)) + + export( + transcription=transcription or "", + summary=summary if should_summarize else None, + metadata=metadata, + output_path=str(output_path), + format_type=output_format + ) + + jobs[job_id]['result_path'] = str(output_path) + + # Armazena dados separados - só armazena transcription se foi solicitado + jobs[job_id]['transcription'] = transcription if should_transcribe else None + jobs[job_id]['summary'] = summary + jobs[job_id]['transcription_path'] = str(transcription_path) if transcription_path else None + jobs[job_id]['summary_path'] = str(summary_path) if summary_path else None + jobs[job_id]['metadata'] = metadata + + jobs[job_id]['status'] = 'completed' + jobs[job_id]['progress'] = 100 + broadcast_progress(job_id, 100, 'completed', 'Processamento concluído') + + except Exception as e: + jobs[job_id]['status'] = 'failed' + jobs[job_id]['error'] = str(e) + broadcast_progress(job_id, 0, 'failed', f'Erro: {str(e)}') + + +def process_web_async(url: str, job_id: str, output_format: str, should_transcribe: bool, should_summarize: bool): + """Processa página web""" + try: + # Inicializa campos do job se não existirem + if 'transcription' not in jobs[job_id]: + jobs[job_id]['transcription'] = None + jobs[job_id]['summary'] = None + jobs[job_id]['transcription_path'] = None + jobs[job_id]['summary_path'] = None + jobs[job_id]['metadata'] = {} + + jobs[job_id]['status'] = 'processing' + jobs[job_id]['progress'] = 20 + broadcast_progress(job_id, 20, 'processing', 'Iniciando processamento da página web...') + + cache = get_cache_manager() + url_hash = calculate_url_hash(url) + + content = None + summary = None + summary_path = None + + # Verifica cache + cached = cache.get('web', url_hash) if cache else None + if cached: + content = cached.get('content') + summary = cached.get('summary') + metadata = cached.get('metadata', {}) + if content and (not should_summarize or summary): + jobs[job_id]['progress'] = 100 + broadcast_progress(job_id, 100, 'completed', 'Dados encontrados no cache') + else: + # Extrai conteúdo + broadcast_progress(job_id, 30, 'processing', 'Extraindo conteúdo da página...') + content_data = extract_web_content(url) + content = content_data['content'] + metadata = {'title': content_data.get('title', 'Página Web'), 'webpage_url': url} + jobs[job_id]['progress'] = 50 + broadcast_progress(job_id, 50, 'processing', 'Conteúdo extraído') + + # Sumariza se solicitado + if should_summarize: + broadcast_progress(job_id, 60, 'processing', 'Gerando sumário...') + summary = summarize_web_page(url) + jobs[job_id]['progress'] = 80 + broadcast_progress(job_id, 80, 'processing', 'Sumário concluído') + + # Gera arquivo só com sumário + if summary: + summary_path = Path(get_lazier_filename(OUTPUT_DIR, output_format, "_summary")) + export( + transcription="", + summary=summary, + metadata=metadata, + output_path=str(summary_path), + format_type=output_format + ) + + # Salva cache + if cache: + cache.set('web', url_hash, { + 'content': content, + 'summary': summary, + 'metadata': metadata, + 'timestamp': datetime.now().isoformat(), + }) + + # Para páginas web, sempre gera arquivo consolidado + if content or summary: + broadcast_progress(job_id, 90, 'processing', 'Gerando arquivo de saída...') + output_path = Path(get_lazier_filename(OUTPUT_DIR, output_format)) + + export( + transcription=content or "", + summary=summary if should_summarize else None, + metadata=metadata, + output_path=str(output_path), + format_type=output_format + ) + + jobs[job_id]['result_path'] = str(output_path) + + # Armazena dados separados + jobs[job_id]['transcription'] = content # Para web, conteúdo = transcrição + jobs[job_id]['summary'] = summary + jobs[job_id]['transcription_path'] = None # Web não tem transcrição separada + jobs[job_id]['summary_path'] = str(summary_path) if summary_path else None + jobs[job_id]['metadata'] = metadata + + jobs[job_id]['status'] = 'completed' + jobs[job_id]['progress'] = 100 + broadcast_progress(job_id, 100, 'completed', 'Processamento concluído') + + except Exception as e: + jobs[job_id]['status'] = 'failed' + jobs[job_id]['error'] = str(e) + broadcast_progress(job_id, 0, 'failed', f'Erro: {str(e)}') + + +@router.get("/jobs/{job_id}") +async def get_job_status(job_id: str): + """Retorna status de um job""" + if job_id not in jobs: + raise HTTPException(status_code=404, detail="Job não encontrado") + + job = jobs[job_id] + return { + "id": job['id'], + "status": job['status'], + "progress": job.get('progress', 0), + "result_path": job.get('result_path'), + "transcription_path": job.get('transcription_path'), + "summary_path": job.get('summary_path'), + "has_transcription": bool(job.get('transcription')), + "has_summary": bool(job.get('summary')), + "error": job.get('error'), + } + + +@router.get("/jobs/{job_id}/details") +async def get_job_details(job_id: str): + """Retorna detalhes completos de um job (transcrição, sumário, metadados)""" + if job_id not in jobs: + raise HTTPException(status_code=404, detail="Job não encontrado") + + job = jobs[job_id] + if job['status'] != 'completed': + raise HTTPException(status_code=400, detail="Job ainda não concluído") + + return { + "id": job['id'], + "transcription": job.get('transcription'), + "summary": job.get('summary'), + "metadata": job.get('metadata', {}), + "format": job.get('format', 'docx'), + "result_path": job.get('result_path'), + "transcription_path": job.get('transcription_path'), + "summary_path": job.get('summary_path'), + } + + +@router.get("/jobs/{job_id}/transcription") +async def download_transcription(job_id: str): + """Download apenas da transcrição""" + if job_id not in jobs: + logger.error(f"Job {job_id} não encontrado para download de transcrição") + raise HTTPException(status_code=404, detail="Job não encontrado") + + job = jobs[job_id] + if job['status'] != 'completed': + raise HTTPException(status_code=400, detail="Job ainda não concluído") + + transcription_path = job.get('transcription_path') + if transcription_path and Path(transcription_path).exists(): + logger.info(f"Enviando transcrição de arquivo: {transcription_path}") + filename = Path(transcription_path).name + return FileResponse( + transcription_path, + media_type='application/octet-stream', + filename=filename, + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + + # Se não tem arquivo separado, gera um temporário + transcription = job.get('transcription') + if not transcription: + logger.error(f"Transcrição não disponível para job {job_id}") + raise HTTPException(status_code=404, detail="Transcrição não disponível") + + # Gera arquivo temporário + output_format = job.get('format', 'txt') + temp_path = OUTPUT_DIR / f"{job_id}_transcription_temp.{output_format}" + logger.info(f"Gerando arquivo temporário de transcrição: {temp_path}") + try: + export( + transcription=transcription, + summary=None, + metadata=job.get('metadata', {}), + output_path=str(temp_path), + format_type=output_format + ) + logger.info(f"Arquivo de transcrição gerado com sucesso: {temp_path}") + except Exception as e: + logger.error(f"Erro ao gerar arquivo de transcrição: {e}") + raise HTTPException(status_code=500, detail=f"Erro ao gerar arquivo: {str(e)}") + + filename = f"transcription.{output_format}" + return FileResponse( + str(temp_path), + media_type='application/octet-stream', + filename=filename, + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + + +@router.get("/jobs/{job_id}/summary") +async def download_summary(job_id: str): + """Download apenas do sumário""" + if job_id not in jobs: + logger.error(f"Job {job_id} não encontrado para download de sumário") + raise HTTPException(status_code=404, detail="Job não encontrado") + + job = jobs[job_id] + if job['status'] != 'completed': + raise HTTPException(status_code=400, detail="Job ainda não concluído") + + summary_path = job.get('summary_path') + if summary_path and Path(summary_path).exists(): + logger.info(f"Enviando sumário de arquivo: {summary_path}") + filename = Path(summary_path).name + return FileResponse( + summary_path, + media_type='application/octet-stream', + filename=filename, + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + + # Se não tem arquivo separado, gera um temporário + summary = job.get('summary') + if not summary: + logger.error(f"Sumário não disponível para job {job_id}") + raise HTTPException(status_code=404, detail="Sumário não disponível") + + # Gera arquivo temporário + output_format = job.get('format', 'txt') + temp_path = OUTPUT_DIR / f"{job_id}_summary_temp.{output_format}" + logger.info(f"Gerando arquivo temporário de sumário: {temp_path}") + try: + export( + transcription="", + summary=summary, + metadata=job.get('metadata', {}), + output_path=str(temp_path), + format_type=output_format + ) + logger.info(f"Arquivo de sumário gerado com sucesso: {temp_path}") + except Exception as e: + logger.error(f"Erro ao gerar arquivo de sumário: {e}") + raise HTTPException(status_code=500, detail=f"Erro ao gerar arquivo: {str(e)}") + + filename = f"summary.{output_format}" + return FileResponse( + str(temp_path), + media_type='application/octet-stream', + filename=filename, + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + + +@router.get("/jobs/{job_id}/download") +async def download_result(job_id: str): + """Download do resultado de um job""" + if job_id not in jobs: + logger.error(f"Job {job_id} não encontrado") + raise HTTPException(status_code=404, detail="Job não encontrado") + + job = jobs[job_id] + if job['status'] != 'completed': + logger.warning(f"Job {job_id} ainda não concluído, status: {job['status']}") + raise HTTPException(status_code=400, detail="Job ainda não concluído") + + result_path = job.get('result_path') + logger.info(f"Tentando baixar job {job_id}, result_path: {result_path}") + + if not result_path: + logger.error(f"Job {job_id} não tem result_path definido") + # Tentar gerar arquivo se não existir mas job está completo + if job.get('transcription') or job.get('summary'): + logger.info(f"Regenerando arquivo para job {job_id}") + output_format = job.get('format', 'docx') + output_path = Path(get_lazier_filename(OUTPUT_DIR, output_format)) + try: + export( + transcription=job.get('transcription') or "", + summary=job.get('summary') if job.get('summarize') else None, + metadata=job.get('metadata', {}), + output_path=str(output_path), + format_type=output_format + ) + result_path = str(output_path) + job['result_path'] = result_path + logger.info(f"Arquivo regenerado: {result_path}") + except Exception as e: + logger.error(f"Erro ao regenerar arquivo: {e}") + raise HTTPException(status_code=500, detail=f"Erro ao gerar arquivo: {str(e)}") + else: + raise HTTPException(status_code=404, detail="Arquivo de resultado não encontrado e não há dados para regenerar") + + result_path_obj = Path(result_path) + if not result_path_obj.exists(): + logger.error(f"Arquivo não existe: {result_path}") + # Tentar caminho absoluto se relativo falhou + if not result_path_obj.is_absolute(): + result_path_obj = OUTPUT_DIR / result_path_obj.name + if result_path_obj.exists(): + logger.info(f"Arquivo encontrado em caminho alternativo: {result_path_obj}") + result_path = str(result_path_obj) + else: + raise HTTPException(status_code=404, detail=f"Arquivo de resultado não encontrado: {result_path}") + else: + raise HTTPException(status_code=404, detail=f"Arquivo de resultado não encontrado: {result_path}") + + logger.info(f"Enviando arquivo: {result_path}") + filename = result_path_obj.name + return FileResponse( + result_path, + media_type='application/octet-stream', + filename=filename, + headers={"Content-Disposition": f'attachment; filename="{filename}"'} + ) + + +@router.get("/history") +async def get_history(): + """Retorna histórico de jobs""" + # Formata jobs para incluir campos necessários + formatted_jobs = [] + for job in jobs.values(): + formatted_job = { + 'id': job.get('id'), + 'status': job.get('status'), + 'progress': job.get('progress', 0), + 'url': job.get('url'), + 'file_path': job.get('file_path'), + 'format': job.get('format', 'docx'), + 'result_path': job.get('result_path'), + 'transcription_path': job.get('transcription_path'), + 'summary_path': job.get('summary_path'), + 'has_transcription': bool(job.get('transcription')), + 'has_summary': bool(job.get('summary')), + 'error': job.get('error'), + 'created_at': job.get('created_at'), + } + formatted_jobs.append(formatted_job) + + return {"jobs": formatted_jobs} + + +@router.delete("/cache") +async def clear_cache(): + """Limpa cache""" + cache = get_cache_manager() + if cache: + count = cache.clear_all() + return {"message": f"Cache limpo: {count} chaves removidas"} + return {"message": "Cache não disponível"} diff --git a/lazier/api/websocket.py b/lazier/api/websocket.py @@ -0,0 +1,76 @@ +""" +WebSocket para progress em tempo real +""" + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +import json +import asyncio +from typing import Dict, List + +websocket_router = APIRouter() + +# Armazena conexões WebSocket por job_id +connections: dict = {} + + +@websocket_router.websocket("/progress/{job_id}") +async def websocket_progress(websocket: WebSocket, job_id: str): + """WebSocket para receber atualizações de progresso""" + await websocket.accept() + + if job_id not in connections: + connections[job_id] = [] + + connections[job_id].append(websocket) + + try: + while True: + # Mantém conexão aberta + # O progresso será enviado de outras partes do código + data = await websocket.receive_text() + # Echo para manter conexão viva + await websocket.send_text(json.dumps({"type": "ping"})) + except WebSocketDisconnect: + if job_id in connections: + connections[job_id].remove(websocket) + if not connections[job_id]: + del connections[job_id] + + +async def broadcast_progress_async(job_id: str, progress: int, status: str, message: str = None): + """Versão assíncrona do broadcast""" + if job_id not in connections: + return + + data = { + "job_id": job_id, + "progress": progress, + "status": status, + "message": message, + } + message_json = json.dumps(data) + + active_connections = [] + for ws in connections[job_id]: + try: + await ws.send_text(message_json) + active_connections.append(ws) + except Exception: + pass # Conexão fechada, remove + + connections[job_id] = active_connections + + +def broadcast_progress(job_id: str, progress: int, status: str, message: str = None): + """Wrapper síncrono que agenda tarefa assíncrona""" + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # Se loop já está rodando, cria task + asyncio.create_task(broadcast_progress_async(job_id, progress, status, message)) + else: + # Se não está rodando, executa diretamente + loop.run_until_complete(broadcast_progress_async(job_id, progress, status, message)) + except RuntimeError: + # Sem loop, cria novo + asyncio.run(broadcast_progress_async(job_id, progress, status, message)) diff --git a/lazier/audio_processor.py b/lazier/audio_processor.py @@ -0,0 +1,130 @@ +""" +Módulo para processamento de áudio usando ffmpeg +""" + +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Optional +from .utils import check_ffmpeg + + +def extract_audio_from_video(video_path: str, output_format: str = 'mp3') -> str: + """ + Extrai áudio de um arquivo de vídeo usando ffmpeg + + Args: + video_path: Caminho do arquivo de vídeo + output_format: Formato de saída (mp3, wav, etc.) + + Returns: + Caminho do arquivo de áudio extraído + """ + if not check_ffmpeg(): + raise Exception("ffmpeg não está disponível. Por favor, instale o ffmpeg.") + + if not os.path.exists(video_path): + raise FileNotFoundError(f"Arquivo de vídeo não encontrado: {video_path}") + + # Cria arquivo temporário para o áudio + video_path_obj = Path(video_path) + output_path = video_path_obj.parent / f"{video_path_obj.stem}_audio.{output_format}" + + # Comando ffmpeg para extrair áudio + cmd = [ + 'ffmpeg', + '-i', video_path, + '-vn', # Não incluir vídeo + '-acodec', 'libmp3lame' if output_format == 'mp3' else 'pcm_s16le', + '-ar', '44100', # Sample rate + '-ac', '2', # Canais estéreo + '-y', # Sobrescrever arquivo se existir + str(output_path) + ] + + try: + # Executa ffmpeg silenciosamente + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True + ) + + if not output_path.exists(): + raise Exception("Falha ao extrair áudio: arquivo de saída não foi criado") + + return str(output_path) + + except subprocess.CalledProcessError as e: + error_msg = e.stderr.decode('utf-8', errors='ignore') if e.stderr else str(e) + raise Exception(f"Erro ao executar ffmpeg: {error_msg}") + except Exception as e: + raise Exception(f"Erro ao extrair áudio: {str(e)}") + + +def convert_audio_format(audio_path: str, output_format: str = 'mp3') -> str: + """ + Converte um arquivo de áudio para outro formato + + Args: + audio_path: Caminho do arquivo de áudio + output_format: Formato de saída desejado + + Returns: + Caminho do arquivo convertido + """ + if not check_ffmpeg(): + raise Exception("ffmpeg não está disponível.") + + audio_path_obj = Path(audio_path) + output_path = audio_path_obj.parent / f"{audio_path_obj.stem}.{output_format}" + + # Se já está no formato desejado, retorna o mesmo arquivo + if audio_path_obj.suffix.lower() == f'.{output_format}': + return str(audio_path) + + cmd = [ + 'ffmpeg', + '-i', audio_path, + '-acodec', 'libmp3lame' if output_format == 'mp3' else 'pcm_s16le', + '-ar', '44100', + '-y', + str(output_path) + ] + + try: + subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True + ) + + return str(output_path) + except subprocess.CalledProcessError as e: + error_msg = e.stderr.decode('utf-8', errors='ignore') if e.stderr else str(e) + raise Exception(f"Erro ao converter áudio: {error_msg}") + + +def prepare_audio_file(input_path: str, is_video: bool = False) -> str: + """ + Prepara arquivo de áudio para transcrição + Se for vídeo, extrai áudio. Se já for áudio, garante formato compatível. + + Args: + input_path: Caminho do arquivo de entrada + is_video: Se True, trata como vídeo e extrai áudio + + Returns: + Caminho do arquivo de áudio preparado + """ + if is_video: + return extract_audio_from_video(input_path) + else: + # Tenta converter para mp3 se necessário (formato mais compatível) + audio_ext = Path(input_path).suffix.lower() + if audio_ext not in ['.mp3', '.wav']: + return convert_audio_format(input_path, 'mp3') + return input_path diff --git a/lazier/cli.py b/lazier/cli.py @@ -0,0 +1,390 @@ +""" +Interface de linha de comando (CLI) usando Click +""" + +import os +import sys +from pathlib import Path +from typing import Optional +from datetime import datetime +import click +from dotenv import load_dotenv +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn +from rich.panel import Panel + +from .utils import ( + validate_input, + cleanup_files, + get_output_filename, + check_ffmpeg +) +from .downloader import download_youtube_audio +from .audio_processor import prepare_audio_file +from .transcriber import transcribe_audio +from .summarizer import summarize_text, summarize_text_file, summarize_web_page, summarize_pdf +from .core.formats import export +from .core.playlist import is_playlist_url, process_playlist +from .core.cache import get_cache_manager, calculate_file_hash, calculate_url_hash + +load_dotenv() + +console = Console() + + +@click.group(invoke_without_command=True) +@click.pass_context +@click.version_option(version='0.01', prog_name='lazier') +@click.argument('input_path', required=False) +@click.option('--output', '-o', type=str, help='Nome do arquivo de saída') +@click.option('--format', '-f', type=click.Choice(['docx', 'txt', 'md', 'json']), default='docx', help='Formato de saída (padrão: docx)') +@click.option('--language', '-l', default='pt', help='Idioma para transcrição (padrão: pt)') +@click.option('--model', default='whisper-1', help='Modelo Whisper (padrão: whisper-1)') +@click.option('--gpt-model', default='gpt-4o-mini', help='Modelo GPT para sumarização (padrão: gpt-4o-mini)') +@click.option('--keep-files', is_flag=True, help='Não deletar arquivos temporários') +@click.option('--only-audio', is_flag=True, help='Processar apenas áudio (para vídeos)') +def cli(ctx, input_path, output, format, language, model, gpt_model, keep_files, only_audio): + """ + Lazier - Sistema CLI e WebGUI para transcrição e sumarização de áudios/vídeos/textos/PDFs + + Versão 0.01 - Desenvolvido por Pablo Murad (pablomurad@pm.me) + + Use este comando para transcrever e sumarizar arquivos de áudio/vídeo/texto/PDF locais + ou vídeos do YouTube usando OpenAI API. + + Exemplos: + lazier audio.mp3 + lazier video.mp4 + lazier document.pdf + lazier "https://www.youtube.com/watch?v=VIDEO_ID" + lazier transcribe video.mp4 + lazier web # Iniciar servidor web + """ + if ctx.invoked_subcommand is None: + if input_path: + # Comando padrão: transcreve e sumariza + process_input( + input_path=input_path, + output=output, + format_type=format, + language=language, + model=model, + gpt_model=gpt_model, + keep_files=keep_files, + only_audio=only_audio, + should_summarize=True + ) + else: + # Mostra ajuda se não há argumentos + click.echo(ctx.get_help()) + + +@cli.command() +@click.argument('input_path', type=str) +@click.option('--output', '-o', type=str, help='Nome do arquivo de saída') +@click.option('--format', '-f', type=click.Choice(['docx', 'txt', 'md', 'json']), default='docx', help='Formato de saída') +@click.option('--language', '-l', default='pt', help='Idioma para transcrição (padrão: pt)') +@click.option('--model', default='whisper-1', help='Modelo Whisper (padrão: whisper-1)') +@click.option('--keep-files', is_flag=True, help='Não deletar arquivos temporários') +@click.option('--only-audio', is_flag=True, help='Processar apenas áudio (para vídeos)') +def transcribe(input_path: str, output: str, format: str, language: str, model: str, keep_files: bool, only_audio: bool): + """Transcreve áudio/vídeo sem sumarizar""" + process_input( + input_path=input_path, + output=output, + format_type=format, + language=language, + model=model, + keep_files=keep_files, + only_audio=only_audio, + should_summarize=False + ) + + +@cli.command() +@click.argument('input_path', type=str) +@click.option('--output', '-o', type=str, help='Nome do arquivo DOCX de saída') +@click.option('--language', '-l', default='pt', help='Idioma para transcrição (padrão: pt)') +@click.option('--model', default='whisper-1', help='Modelo Whisper (padrão: whisper-1)') +@click.option('--gpt-model', default='gpt-4o-mini', help='Modelo GPT para sumarização (padrão: gpt-4o-mini)') +@click.option('--keep-files', is_flag=True, help='Não deletar arquivos temporários') +@click.option('--only-audio', is_flag=True, help='Processar apenas áudio (para vídeos)') +def summarize(input_path: str, output: str, language: str, model: str, gpt_model: str, keep_files: bool, only_audio: bool): + """Apenas sumariza (requer arquivo de texto ou transcrição prévia)""" + click.echo("Erro: Comando 'summarize' requer texto pré-transcrito.") + click.echo("Use 'lazier <input>' para transcrição + sumarização ou 'lazier transcribe' para apenas transcrição.") + sys.exit(1) + + + + +def process_input( + input_path: str, + output: Optional[str] = None, + format_type: str = 'docx', + language: str = 'pt', + model: str = 'whisper-1', + gpt_model: str = 'gpt-4o-mini', + keep_files: bool = False, + only_audio: bool = False, + should_summarize: bool = True +): + """Processa o input (arquivo ou URL) com progress bars""" + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TimeElapsedColumn(), + console=console + ) as progress: + + # Validação inicial + task1 = progress.add_task("[cyan]Validando input...", total=100) + is_valid, input_type, error_msg = validate_input(input_path) + + if not is_valid: + console.print(f"[red]Erro: {error_msg}[/red]") + sys.exit(1) + + # Verifica API key + if not os.getenv('OPENAI_API_KEY'): + console.print("[red]Erro: OPENAI_API_KEY não encontrada.[/red]") + console.print("Configure a variável de ambiente ou crie um arquivo .env") + sys.exit(1) + + progress.update(task1, completed=100) + + # Verifica cache + cache = None + try: + cache = get_cache_manager() + except: + pass # Cache opcional + + files_to_cleanup = [] + metadata = {} + transcription = None + summary = None + + try: + # Verifica se é playlist + if input_type == 'youtube' and is_playlist_url(input_path): + task2 = progress.add_task("[yellow]Processando playlist...", total=100) + console.print(f"[yellow]Playlist detectada: {input_path}[/yellow]") + playlist_results = process_playlist(input_path, parallel=False) + console.print(f"[green]Playlist processada: {playlist_results['processed']}/{playlist_results['total_videos']} vídeos[/green]") + progress.update(task2, completed=100) + return + + # Determina arquivo a processar + task3 = progress.add_task("[cyan]Preparando arquivo...", total=100) + audio_file = None + content_data = None + + if input_type == 'youtube': + progress.update(task3, description="[cyan]Baixando vídeo do YouTube...") + url_hash = calculate_url_hash(input_path) if cache else None + + # Verifica cache + if cache: + cached = cache.get('youtube', url_hash) + if cached: + transcription = cached.get('transcription') + summary = cached.get('summary') if should_summarize else None + metadata = cached.get('metadata', {}) + console.print("[green]✓[/green] Usando cache") + progress.update(task3, completed=100) + else: + audio_file, metadata = download_youtube_audio(input_path) + files_to_cleanup.append(audio_file) + progress.update(task3, completed=50) + else: + audio_file, metadata = download_youtube_audio(input_path) + files_to_cleanup.append(audio_file) + progress.update(task3, completed=50) + + elif input_type == 'video': + progress.update(task3, description="[cyan]Extraindo áudio do vídeo...") + audio_file = prepare_audio_file(input_path, is_video=True) + if audio_file != input_path: + files_to_cleanup.append(audio_file) + progress.update(task3, completed=100) + + elif input_type == 'audio': + progress.update(task3, description="[cyan]Preparando áudio...") + audio_file = prepare_audio_file(input_path, is_video=False) + if audio_file != input_path: + files_to_cleanup.append(audio_file) + progress.update(task3, completed=100) + + # Processa texto/PDF/web + elif Path(input_path).suffix.lower() == '.pdf': + progress.update(task3, description="[cyan]Extraindo texto do PDF...") + content_data = extract_pdf_content(input_path) + metadata = {'title': content_data.get('title', 'PDF'), 'file_path': input_path} + progress.update(task3, completed=100) + + elif Path(input_path).suffix.lower() in ['.txt', '.md', '.html']: + progress.update(task3, description="[cyan]Lendo arquivo de texto...") + content_data = extract_text_file_content(input_path) + metadata = {'title': content_data.get('title', 'Texto'), 'file_path': input_path} + progress.update(task3, completed=100) + + # Transcrição (para áudio/vídeo) + if audio_file: + task4 = progress.add_task("[magenta]Transcrevendo áudio...", total=100) + + if not transcription: + file_hash = calculate_file_hash(audio_file) if cache else None + + # Verifica cache + if cache and file_hash: + cached = cache.get('transcription', file_hash) + if cached: + transcription = cached.get('transcription') + metadata = cached.get('metadata', metadata) + console.print("[green]✓[/green] Transcrição do cache") + progress.update(task4, completed=100) + else: + transcription = transcribe_audio(audio_file, language=language, model=model) + progress.update(task4, completed=100) + + # Salva cache + if cache: + cache.set('transcription', file_hash, { + 'transcription': transcription, + 'metadata': metadata, + 'timestamp': datetime.now().isoformat(), + }) + else: + transcription = transcribe_audio(audio_file, language=language, model=model) + progress.update(task4, completed=100) + + # Sumarização + if should_summarize: + task5 = progress.add_task("[green]Gerando sumário...", total=100) + + if transcription: + # Sumariza transcrição + text_hash = calculate_url_hash(transcription) if cache else None + + if cache and text_hash: + cached = cache.get('summary', text_hash) + if cached: + summary = cached.get('summary') + console.print("[green]✓[/green] Sumário do cache") + progress.update(task5, completed=100) + else: + summary = summarize_text(transcription, model=gpt_model, language='pt-BR') + progress.update(task5, completed=100) + + if cache: + cache.set('summary', text_hash, { + 'summary': summary, + 'timestamp': datetime.now().isoformat(), + }) + else: + summary = summarize_text(transcription, model=gpt_model, language='pt-BR') + progress.update(task5, completed=100) + + elif content_data: + # Sumariza conteúdo extraído + if Path(input_path).suffix.lower() == '.pdf': + summary = summarize_pdf(input_path, model=gpt_model, language='pt-BR') + else: + summary = summarize_text_file(input_path, model=gpt_model, language='pt-BR') + transcription = content_data['content'] + progress.update(task5, completed=100) + + # Gera arquivo de saída + task6 = progress.add_task(f"[blue]Gerando arquivo {format_type.upper()}...", total=100) + + if not output: + output = get_output_filename(input_path) + # Adiciona extensão correta + output_path_obj = Path(output) + if output_path_obj.suffix != f'.{format_type}': + output = str(output_path_obj.with_suffix(f'.{format_type}')) + + export( + transcription=transcription or content_data['content'] if content_data else "", + summary=summary if should_summarize else None, + metadata=metadata, + output_path=output, + format_type=format_type + ) + + progress.update(task6, completed=100) + + console.print(f"\n[bold green]✓ Processamento concluído![/bold green]") + console.print(f"[cyan]Arquivo gerado:[/cyan] {output}") + + # Limpeza + if not keep_files: + cleanup_files(files_to_cleanup) + + except KeyboardInterrupt: + console.print("\n[yellow]Operação cancelada pelo usuário.[/yellow]") + cleanup_files(files_to_cleanup) + sys.exit(1) + except Exception as e: + console.print(f"\n[red]Erro durante processamento:[/red] {str(e)}") + cleanup_files(files_to_cleanup) + sys.exit(1) + + +@cli.command() +@click.option('--port', '-p', default=19283, help='Porta do servidor (padrão: 19283)') +@click.option('--host', default='0.0.0.0', help='Host do servidor (padrão: 0.0.0.0)') +def web(port: int, host: str): + """Inicia servidor web FastAPI""" + import uvicorn + from .api.main import app + + console.print(Panel.fit( + f"[bold green]Lazier WebGUI[/bold green]\n\n" + f"Servidor iniciando em [cyan]http://{host}:{port}[/cyan]\n" + f"Pressione Ctrl+C para parar", + title="🚀 Servidor Web" + )) + + uvicorn.run(app, host=host, port=port) + + +@cli.command() +def cache(): + """Comandos de cache""" + pass + + +@cache.command() +def clear(): + """Limpa todo o cache""" + try: + from .core.cache import get_cache_manager + cache = get_cache_manager() + count = cache.clear_all() + console.print(f"[green]Cache limpo: {count} chaves removidas[/green]") + except Exception as e: + console.print(f"[red]Erro ao limpar cache: {e}[/red]") + + +@cache.command() +def stats(): + """Mostra estatísticas do cache""" + try: + from .core.cache import get_cache_manager + cache = get_cache_manager() + stats = cache.stats() + console.print(Panel.fit( + f"[bold]Estatísticas do Cache[/bold]\n\n" + f"Total de chaves: [cyan]{stats.get('total_keys', 0)}[/cyan]\n" + f"Memória usada: [cyan]{stats.get('memory_used', 'N/A')}[/cyan]\n" + f"Memória pico: [cyan]{stats.get('memory_peak', 'N/A')}[/cyan]\n" + f"Clientes conectados: [cyan]{stats.get('connected_clients', 0)}[/cyan]", + title="📊 Cache Redis" + )) + except Exception as e: + console.print(f"[red]Erro ao obter estatísticas: {e}[/red]") diff --git a/lazier/core/__init__.py b/lazier/core/__init__.py @@ -0,0 +1,3 @@ +""" +Módulos core de lógica de negócio +""" diff --git a/lazier/core/batch.py b/lazier/core/batch.py @@ -0,0 +1,126 @@ +""" +Processamento em lote de múltiplos arquivos +""" + +import asyncio +import uuid +from typing import List, Dict, Any, Optional, Callable +from pathlib import Path +from datetime import datetime +from .cache import get_cache_manager, calculate_file_hash, calculate_url_hash + + +class BatchProcessor: + """Processador de múltiplos arquivos em lote""" + + def __init__(self): + self.cache = get_cache_manager() + self.jobs: Dict[str, Dict[str, Any]] = {} + + def create_job(self, inputs: List[str], job_type: str = 'process') -> str: + """ + Cria um novo job de processamento em lote + + Args: + inputs: Lista de caminhos de arquivos ou URLs + job_type: Tipo de job ('process', 'transcribe', 'summarize') + + Returns: + ID do job criado + """ + job_id = str(uuid.uuid4()) + + self.jobs[job_id] = { + 'id': job_id, + 'inputs': inputs, + 'job_type': job_type, + 'status': 'pending', + 'progress': 0, + 'results': [], + 'errors': [], + 'created_at': datetime.now().isoformat(), + 'completed_at': None, + } + + return job_id + + async def process_batch( + self, + job_id: str, + process_func: Callable, + max_concurrent: int = 3 + ) -> Dict[str, Any]: + """ + Processa um job em lote de forma assíncrona + + Args: + job_id: ID do job + process_func: Função de processamento a ser aplicada a cada input + max_concurrent: Número máximo de processamentos simultâneos + + Returns: + Resultado do processamento em lote + """ + if job_id not in self.jobs: + raise ValueError(f"Job {job_id} não encontrado") + + job = self.jobs[job_id] + job['status'] = 'processing' + inputs = job['inputs'] + total = len(inputs) + + # Semáforo para limitar concorrência + semaphore = asyncio.Semaphore(max_concurrent) + + async def process_single(input_item: str, index: int): + """Processa um único item""" + async with semaphore: + try: + result = await asyncio.to_thread(process_func, input_item) + job['results'].append({ + 'input': input_item, + 'index': index, + 'status': 'completed', + 'result': result, + }) + except Exception as e: + job['errors'].append({ + 'input': input_item, + 'index': index, + 'error': str(e), + }) + finally: + # Atualiza progresso + completed = len(job['results']) + len(job['errors']) + job['progress'] = int((completed / total) * 100) + + # Processa todos os inputs + tasks = [process_single(input_item, i) for i, input_item in enumerate(inputs)] + await asyncio.gather(*tasks) + + # Finaliza job + job['status'] = 'completed' + job['completed_at'] = datetime.now().isoformat() + job['progress'] = 100 + + return job + + def get_job(self, job_id: str) -> Optional[Dict[str, Any]]: + """Retorna informações de um job""" + return self.jobs.get(job_id) + + def list_jobs(self) -> List[Dict[str, Any]]: + """Lista todos os jobs""" + return list(self.jobs.values()) + + +# Instância global +_batch_processor: Optional[BatchProcessor] = None + + +def get_batch_processor() -> BatchProcessor: + """Retorna instância singleton do batch processor""" + global _batch_processor + if _batch_processor is None: + _batch_processor = BatchProcessor() + return _batch_processor diff --git a/lazier/core/cache.py b/lazier/core/cache.py @@ -0,0 +1,218 @@ +""" +Sistema de cache usando Redis +""" + +import os +import json +import hashlib +from typing import Optional, Dict, Any, Union +from datetime import timedelta +import redis +from redis.exceptions import ConnectionError, RedisError +from dotenv import load_dotenv + +load_dotenv() + + +class CacheManager: + """Gerenciador de cache usando Redis""" + + def __init__(self): + """Inicializa conexão com Redis""" + self.redis_host = os.getenv('REDIS_HOST', 'localhost') + self.redis_port = int(os.getenv('REDIS_PORT', 6379)) + self.redis_db = int(os.getenv('REDIS_DB', 0)) + self.redis_password = os.getenv('REDIS_PASSWORD', None) + self.default_ttl = int(os.getenv('LAZIER_CACHE_TTL', 604800)) # 7 dias + + try: + self.redis_client = redis.Redis( + host=self.redis_host, + port=self.redis_port, + db=self.redis_db, + password=self.redis_password, + decode_responses=True, + socket_connect_timeout=5, + socket_timeout=5 + ) + # Testa conexão + self.redis_client.ping() + except (ConnectionError, RedisError) as e: + raise Exception(f"Erro ao conectar com Redis: {str(e)}") + + def _make_key(self, prefix: str, identifier: str) -> str: + """Cria chave de cache no formato prefix:identifier""" + return f"cache:{prefix}:{identifier}" + + def _serialize(self, data: Any) -> str: + """Serializa dados para JSON""" + return json.dumps(data, ensure_ascii=False) + + def _deserialize(self, data: str) -> Any: + """Deserializa dados de JSON""" + return json.loads(data) + + def get(self, prefix: str, identifier: str) -> Optional[Dict[str, Any]]: + """ + Busca item no cache + + Args: + prefix: Prefixo da chave (transcription, summary, youtube, web, pdf) + identifier: Identificador único (hash, video_id, etc.) + + Returns: + Dados em cache ou None se não encontrado + """ + try: + key = self._make_key(prefix, identifier) + data = self.redis_client.get(key) + if data: + return self._deserialize(data) + return None + except Exception as e: + # Em caso de erro, retorna None (cache miss) + print(f"Aviso: Erro ao buscar cache: {e}") + return None + + def set( + self, + prefix: str, + identifier: str, + data: Dict[str, Any], + ttl: Optional[int] = None + ) -> bool: + """ + Armazena item no cache + + Args: + prefix: Prefixo da chave + identifier: Identificador único + data: Dados a armazenar + ttl: Time to live em segundos (None usa padrão) + + Returns: + True se armazenado com sucesso + """ + try: + key = self._make_key(prefix, identifier) + serialized = self._serialize(data) + ttl = ttl or self.default_ttl + self.redis_client.setex(key, ttl, serialized) + return True + except Exception as e: + print(f"Aviso: Erro ao armazenar cache: {e}") + return False + + def delete(self, prefix: str, identifier: str) -> bool: + """Remove item do cache""" + try: + key = self._make_key(prefix, identifier) + self.redis_client.delete(key) + return True + except Exception as e: + print(f"Aviso: Erro ao deletar cache: {e}") + return False + + def clear_all(self) -> int: + """ + Limpa todo o cache + + Returns: + Número de chaves deletadas + """ + try: + keys = self.redis_client.keys("cache:*") + if keys: + return self.redis_client.delete(*keys) + return 0 + except Exception as e: + print(f"Aviso: Erro ao limpar cache: {e}") + return 0 + + def clear_prefix(self, prefix: str) -> int: + """Limpa cache de um prefixo específico""" + try: + keys = self.redis_client.keys(f"cache:{prefix}:*") + if keys: + return self.redis_client.delete(*keys) + return 0 + except Exception as e: + print(f"Aviso: Erro ao limpar cache do prefixo {prefix}: {e}") + return 0 + + def stats(self) -> Dict[str, Any]: + """Retorna estatísticas do cache""" + try: + info = self.redis_client.info('memory') + keys = self.redis_client.keys("cache:*") + return { + 'total_keys': len(keys), + 'memory_used': info.get('used_memory_human', 'N/A'), + 'memory_peak': info.get('used_memory_peak_human', 'N/A'), + 'connected_clients': self.redis_client.info('clients').get('connected_clients', 0), + } + except Exception as e: + return {'error': str(e)} + + def exists(self, prefix: str, identifier: str) -> bool: + """Verifica se chave existe no cache""" + try: + key = self._make_key(prefix, identifier) + return self.redis_client.exists(key) > 0 + except Exception: + return False + + +def calculate_file_hash(file_path: str) -> str: + """ + Calcula hash SHA256 de um arquivo + + Args: + file_path: Caminho do arquivo + + Returns: + Hash hexadecimal do arquivo + """ + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + return sha256_hash.hexdigest() + + +def calculate_string_hash(text: str) -> str: + """ + Calcula hash SHA256 de uma string + + Args: + text: Texto a ser hasheado + + Returns: + Hash hexadecimal do texto + """ + return hashlib.sha256(text.encode('utf-8')).hexdigest() + + +def calculate_url_hash(url: str) -> str: + """ + Calcula hash de uma URL + + Args: + url: URL a ser hasheada + + Returns: + Hash hexadecimal da URL + """ + return calculate_string_hash(url) + + +# Instância global do cache manager +_cache_manager: Optional[CacheManager] = None + + +def get_cache_manager() -> CacheManager: + """Retorna instância singleton do cache manager""" + global _cache_manager + if _cache_manager is None: + _cache_manager = CacheManager() + return _cache_manager diff --git a/lazier/core/file_handler.py b/lazier/core/file_handler.py @@ -0,0 +1,119 @@ +""" +Gerenciamento de uploads e validação de arquivos +""" + +import os +from pathlib import Path +from typing import Tuple, Optional +import mimetypes + +# Tipos MIME aceitos +AUDIO_MIMES = { + 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', + 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/flac', + 'audio/ogg', 'audio/opus', 'audio/x-ms-wma' +} + +VIDEO_MIMES = { + 'video/mp4', 'video/x-msvideo', 'video/x-matroska', + 'video/quicktime', 'video/x-ms-wmv', 'video/x-flv', + 'video/webm', 'video/3gpp' +} + +DOCUMENT_MIMES = { + 'application/pdf', 'text/plain', 'text/markdown', + 'text/html', 'application/xhtml+xml' +} + +ALLOWED_EXTENSIONS = { + # Áudio + '.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg', '.opus', '.wma', + # Vídeo + '.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', + # Documentos + '.pdf', '.txt', '.md', '.html', '.htm' +} + +MAX_FILE_SIZE = int(os.getenv('LAZIER_MAX_UPLOAD_SIZE', '524288000')) # 500MB padrão + + +def validate_upload_file(file_path: str, file_size: int) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Valida arquivo de upload + + Args: + file_path: Caminho do arquivo + file_size: Tamanho do arquivo em bytes + + Returns: + Tupla (válido, tipo, mensagem_erro) + tipo pode ser 'audio', 'video', 'document' ou None + """ + # Verifica tamanho + if file_size > MAX_FILE_SIZE: + return False, None, f"Arquivo muito grande. Tamanho máximo: {MAX_FILE_SIZE / 1024 / 1024:.0f}MB" + + # Verifica extensão + ext = Path(file_path).suffix.lower() + if ext not in ALLOWED_EXTENSIONS: + return False, None, f"Extensão não permitida: {ext}" + + # Detecta tipo + mime_type, _ = mimetypes.guess_type(file_path) + + if ext in ['.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg', '.opus', '.wma']: + return True, 'audio', None + elif ext in ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v']: + return True, 'video', None + elif ext in ['.pdf', '.txt', '.md', '.html', '.htm']: + return True, 'document', None + + return False, None, "Tipo de arquivo não reconhecido" + + +def save_upload_file(upload_file, upload_dir: Path) -> Path: + """ + Salva arquivo de upload + + Args: + upload_file: Arquivo FastAPI UploadFile + upload_dir: Diretório de upload + + Returns: + Caminho do arquivo salvo + """ + upload_dir.mkdir(parents=True, exist_ok=True) + + # Gera nome único + import uuid + file_id = str(uuid.uuid4()) + file_path = upload_dir / f"{file_id}_{upload_file.filename}" + + # Salva arquivo + with open(file_path, 'wb') as f: + content = upload_file.file.read() + f.write(content) + + return file_path + + +def cleanup_old_files(directory: Path, max_age_hours: int = 24): + """ + Remove arquivos antigos do diretório + + Args: + directory: Diretório a limpar + max_age_hours: Idade máxima em horas + """ + import time + current_time = time.time() + max_age_seconds = max_age_hours * 3600 + + for file_path in directory.iterdir(): + if file_path.is_file(): + file_age = current_time - file_path.stat().st_mtime + if file_age > max_age_seconds: + try: + file_path.unlink() + except Exception: + pass # Ignora erros de deleção diff --git a/lazier/core/formats.py b/lazier/core/formats.py @@ -0,0 +1,288 @@ +""" +Exportadores de múltiplos formatos (TXT, Markdown, JSON, DOCX) +""" + +import json +from datetime import datetime +from pathlib import Path +from typing import Optional, Dict, Any +from docx import Document +from docx.shared import Pt, Inches, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH + +from ..docx_generator import _format_duration + + +def export_txt( + transcription: str, + summary: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + output_path: str = "output.txt" +) -> str: + """ + Exporta transcrição e sumário em formato TXT + + Args: + transcription: Texto transcrito + summary: Texto sumarizado (opcional) + metadata: Metadados do vídeo/áudio + output_path: Caminho do arquivo de saída + + Returns: + Caminho do arquivo criado + """ + output_path_obj = Path(output_path) + output_path_obj.parent.mkdir(parents=True, exist_ok=True) + + lines = [] + + # Título + title = metadata.get('title', 'Transcrição') if metadata else 'Transcrição' + lines.append("=" * 80) + lines.append(title.center(80)) + lines.append("=" * 80) + lines.append("") + + # Metadados + lines.append(f"Data de processamento: {datetime.now().strftime('%d/%m/%Y %H:%M:%S')}") + + if metadata: + if metadata.get('duration'): + duration_sec = metadata['duration'] + duration_str = _format_duration(duration_sec) + lines.append(f"Duração: {duration_str}") + + if metadata.get('uploader'): + lines.append(f"Canal/Criador: {metadata['uploader']}") + + if metadata.get('webpage_url'): + lines.append(f"URL: {metadata['webpage_url']}") + + lines.append("") + lines.append("-" * 80) + lines.append("") + + # Sumário + if summary: + lines.append("SUMÁRIO") + lines.append("-" * 80) + + # Divide sumário em parágrafos e preserva estrutura + summary_paragraphs = summary.split('\n\n') + if len(summary_paragraphs) == 1: + summary_paragraphs = summary.split('\n') + + for para in summary_paragraphs: + if para.strip(): + lines.append(para.strip()) + lines.append("") # Linha em branco entre parágrafos + + lines.append("-" * 80) + lines.append("") + + # Transcrição + lines.append("TRANSCRIÇÃO COMPLETA") + lines.append("-" * 80) + + # Divide transcrição em parágrafos e preserva estrutura + transcription_paragraphs = transcription.split('\n\n') + if len(transcription_paragraphs) == 1: + transcription_paragraphs = transcription.split('\n') + + for para in transcription_paragraphs: + if para.strip(): + lines.append(para.strip()) + lines.append("") # Linha em branco entre parágrafos + + # Salva arquivo + with open(output_path_obj, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines)) + + return str(output_path_obj) + + +def export_markdown( + transcription: str, + summary: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + output_path: str = "output.md" +) -> str: + """ + Exporta transcrição e sumário em formato Markdown + + Args: + transcription: Texto transcrito + summary: Texto sumarizado (opcional) + metadata: Metadados do vídeo/áudio + output_path: Caminho do arquivo de saída + + Returns: + Caminho do arquivo criado + """ + output_path_obj = Path(output_path) + output_path_obj.parent.mkdir(parents=True, exist_ok=True) + + lines = [] + + # Título + title = metadata.get('title', 'Transcrição') if metadata else 'Transcrição' + lines.append(f"# {title}") + lines.append("") + + # Metadados + lines.append("## Metadados") + lines.append("") + lines.append(f"- **Data de processamento:** {datetime.now().strftime('%d/%m/%Y %H:%M:%S')}") + + if metadata: + if metadata.get('duration'): + duration_sec = metadata['duration'] + duration_str = _format_duration(duration_sec) + lines.append(f"- **Duração:** {duration_str}") + + if metadata.get('uploader'): + lines.append(f"- **Canal/Criador:** {metadata['uploader']}") + + if metadata.get('webpage_url'): + lines.append(f"- **URL:** [{metadata['webpage_url']}]({metadata['webpage_url']})") + + lines.append("") + + # Sumário + if summary: + lines.append("## Sumário") + lines.append("") + + # Divide sumário em parágrafos e formata corretamente + summary_paragraphs = summary.split('\n\n') + if len(summary_paragraphs) == 1: + summary_paragraphs = summary.split('\n') + + for para in summary_paragraphs: + if para.strip(): + lines.append(para.strip()) + lines.append("") # Linha em branco entre parágrafos (Markdown requer) + + lines.append("---") + lines.append("") + + # Transcrição + lines.append("## Transcrição Completa") + lines.append("") + + # Divide transcrição em parágrafos e formata corretamente + transcription_paragraphs = transcription.split('\n\n') + if len(transcription_paragraphs) == 1: + transcription_paragraphs = transcription.split('\n') + + for para in transcription_paragraphs: + if para.strip(): + lines.append(para.strip()) + lines.append("") # Linha em branco entre parágrafos (Markdown requer) + + # Salva arquivo + with open(output_path_obj, 'w', encoding='utf-8') as f: + f.write('\n'.join(lines)) + + return str(output_path_obj) + + +def export_json( + transcription: str, + summary: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + output_path: str = "output.json" +) -> str: + """ + Exporta transcrição e sumário em formato JSON + + Args: + transcription: Texto transcrito + summary: Texto sumarizado (opcional) + metadata: Metadados do vídeo/áudio + output_path: Caminho do arquivo de saída + + Returns: + Caminho do arquivo criado + """ + output_path_obj = Path(output_path) + output_path_obj.parent.mkdir(parents=True, exist_ok=True) + + data = { + 'metadata': { + 'title': metadata.get('title', 'Transcrição') if metadata else 'Transcrição', + 'processed_at': datetime.now().isoformat(), + 'duration': metadata.get('duration') if metadata else None, + 'uploader': metadata.get('uploader') if metadata else None, + 'webpage_url': metadata.get('webpage_url') if metadata else None, + }, + 'transcription': transcription, + 'summary': summary, + } + + # Salva arquivo + with open(output_path_obj, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + return str(output_path_obj) + + +def export_docx( + transcription: str, + summary: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + output_path: str = "output.docx" +) -> str: + """ + Exporta transcrição e sumário em formato DOCX (reutiliza função existente) + + Args: + transcription: Texto transcrito + summary: Texto sumarizado (opcional) + metadata: Metadados do vídeo/áudio + output_path: Caminho do arquivo de saída + + Returns: + Caminho do arquivo criado + """ + from ..docx_generator import create_document + return create_document(transcription, summary, metadata, output_path) + + +def export( + transcription: str, + summary: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + output_path: str = "output", + format_type: str = "docx" +) -> str: + """ + Exporta transcrição e sumário no formato especificado + + Args: + transcription: Texto transcrito + summary: Texto sumarizado (opcional) + metadata: Metadados do vídeo/áudio + output_path: Caminho base do arquivo (sem extensão) + format_type: Tipo de formato (txt, md, json, docx) + + Returns: + Caminho do arquivo criado + """ + # Adiciona extensão se não tiver + output_path_obj = Path(output_path) + if not output_path_obj.suffix: + output_path = f"{output_path}.{format_type}" + + format_type = format_type.lower() + + if format_type == 'txt': + return export_txt(transcription, summary, metadata, output_path) + elif format_type == 'md' or format_type == 'markdown': + return export_markdown(transcription, summary, metadata, output_path) + elif format_type == 'json': + return export_json(transcription, summary, metadata, output_path) + elif format_type == 'docx': + return export_docx(transcription, summary, metadata, output_path) + else: + raise ValueError(f"Formato não suportado: {format_type}. Use: txt, md, json, docx") diff --git a/lazier/core/playlist.py b/lazier/core/playlist.py @@ -0,0 +1,150 @@ +""" +Processamento de playlists do YouTube +""" + +import re +from typing import List, Dict, Any, Optional +import yt_dlp +from .downloader import download_youtube_audio + + +def is_playlist_url(url: str) -> bool: + """ + Verifica se a URL é de uma playlist do YouTube + + Args: + url: URL a verificar + + Returns: + True se for playlist + """ + playlist_patterns = [ + r'list=', + r'playlist\?list=', + r'/playlist', + ] + return any(re.search(pattern, url, re.IGNORECASE) for pattern in playlist_patterns) + + +def extract_playlist_videos(url: str) -> List[Dict[str, Any]]: + """ + Extrai lista de vídeos de uma playlist + + Args: + url: URL da playlist + + Returns: + Lista de dicionários com informações dos vídeos + """ + ydl_opts = { + 'quiet': True, + 'no_warnings': True, + 'extract_flat': True, # Não baixa, apenas extrai informações + } + + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + + videos = [] + if 'entries' in info: + for entry in info['entries']: + if entry: + videos.append({ + 'id': entry.get('id'), + 'title': entry.get('title', 'Sem título'), + 'url': entry.get('url') or f"https://www.youtube.com/watch?v={entry.get('id')}", + 'duration': entry.get('duration'), + }) + + return videos + except Exception as e: + raise Exception(f"Erro ao extrair vídeos da playlist: {str(e)}") + + +def process_playlist( + url: str, + output_dir: Optional[str] = None, + parallel: bool = False, + max_workers: int = 3 +) -> Dict[str, Any]: + """ + Processa todos os vídeos de uma playlist + + Args: + url: URL da playlist + output_dir: Diretório de saída + parallel: Se True, processa em paralelo + max_workers: Número máximo de workers paralelos + + Returns: + Dicionário com resultados consolidados + """ + if not is_playlist_url(url): + raise ValueError("URL não é uma playlist do YouTube") + + # Extrai lista de vídeos + videos = extract_playlist_videos(url) + + if not videos: + raise Exception("Nenhum vídeo encontrado na playlist") + + results = { + 'playlist_url': url, + 'total_videos': len(videos), + 'processed': 0, + 'failed': 0, + 'videos': [], + } + + if parallel: + # Processamento paralelo (usar ThreadPoolExecutor) + from concurrent.futures import ThreadPoolExecutor, as_completed + + def process_video(video_info): + try: + audio_file, metadata = download_youtube_audio(video_info['url'], output_dir) + return { + 'video': video_info, + 'audio_file': audio_file, + 'metadata': metadata, + 'status': 'success', + } + except Exception as e: + return { + 'video': video_info, + 'status': 'failed', + 'error': str(e), + } + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(process_video, video): video for video in videos} + + for future in as_completed(futures): + result = future.result() + results['videos'].append(result) + if result['status'] == 'success': + results['processed'] += 1 + else: + results['failed'] += 1 + else: + # Processamento sequencial + for video in videos: + try: + audio_file, metadata = download_youtube_audio(video['url'], output_dir) + results['videos'].append({ + 'video': video, + 'audio_file': audio_file, + 'metadata': metadata, + 'status': 'success', + }) + results['processed'] += 1 + except Exception as e: + results['videos'].append({ + 'video': video, + 'status': 'failed', + 'error': str(e), + }) + results['failed'] += 1 + + return results diff --git a/lazier/docx_generator.py b/lazier/docx_generator.py @@ -0,0 +1,235 @@ +""" +Módulo para geração de arquivos DOCX com transcrição e sumário +""" + +import re +from datetime import datetime +from pathlib import Path +from docx import Document +from docx.shared import Pt, Inches, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from typing import Optional, Dict, Any + + +def create_document( + transcription: str, + summary: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + output_path: str = "output.docx" +) -> str: + """ + Cria um documento DOCX com transcrição e sumário + + Args: + transcription: Texto transcrito + summary: Texto sumarizado (opcional) + metadata: Metadados do vídeo/áudio (título, duração, etc.) + output_path: Caminho do arquivo de saída + + Returns: + Caminho do arquivo criado + """ + doc = Document() + + # Configuração de estilo padrão + style = doc.styles['Normal'] + font = style.font + font.name = 'Calibri' + font.size = Pt(11) + + # Título + title = metadata.get('title', 'Transcrição') if metadata else 'Transcrição' + title_para = doc.add_heading(title, level=1) + title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + + # Metadados + metadata_section = doc.add_paragraph() + metadata_section.add_run('Data de processamento: ').bold = True + metadata_section.add_run(datetime.now().strftime('%d/%m/%Y %H:%M:%S')) + + if metadata: + if metadata.get('duration'): + duration_sec = metadata['duration'] + duration_str = _format_duration(duration_sec) + metadata_section.add_run('\nDuração: ').bold = True + metadata_section.add_run(duration_str) + + if metadata.get('uploader'): + metadata_section.add_run('\nCanal/Criador: ').bold = True + metadata_section.add_run(metadata['uploader']) + + if metadata.get('webpage_url'): + metadata_section.add_run('\nURL: ').bold = True + metadata_section.add_run(metadata['webpage_url']) + + # Espaçamento + doc.add_paragraph() + + # Seção de Sumário (se disponível) + if summary: + doc.add_heading('Sumário', level=2) + + # Divide sumário em parágrafos + summary_paragraphs = summary.split('\n\n') + if len(summary_paragraphs) == 1: + # Se não tem \n\n, tenta dividir por \n + summary_paragraphs = summary.split('\n') + + for para_text in summary_paragraphs: + if para_text.strip(): + _add_markdown_paragraph(doc, para_text.strip(), is_summary=True) + + # Linha separadora + doc.add_paragraph('_' * 80) + doc.add_paragraph() + + # Seção de Transcrição + doc.add_heading('Transcrição Completa', level=2) + + # Divide transcrição em parágrafos + transcription_paragraphs = transcription.split('\n\n') + if len(transcription_paragraphs) == 1: + # Se não tem \n\n, tenta dividir por \n + transcription_paragraphs = transcription.split('\n') + + for para_text in transcription_paragraphs: + if para_text.strip(): + _add_markdown_paragraph(doc, para_text.strip(), is_summary=False) + + # Rodapé + doc.add_paragraph() + footer_para = doc.add_paragraph() + footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + footer_run = footer_para.add_run('Gerado por Lazier v0.01 - Desenvolvido por Pablo Murad') + footer_run.font.size = Pt(9) + footer_run.font.color.rgb = RGBColor(128, 128, 128) + + # Salva o documento + output_path_obj = Path(output_path) + output_path_obj.parent.mkdir(parents=True, exist_ok=True) + doc.save(str(output_path_obj)) + + return str(output_path_obj) + + +def _format_duration(seconds: int) -> str: + """Formata duração em segundos para formato legível""" + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + secs = seconds % 60 + + if hours > 0: + return f"{hours}h {minutes}min {secs}s" + elif minutes > 0: + return f"{minutes}min {secs}s" + else: + return f"{secs}s" + + +def _parse_markdown_to_runs(text: str, paragraph): + """ + Parse markdown básico e adiciona runs formatados ao parágrafo + Suporta: **negrito**, *itálico*, `código` + """ + # Padrão para negrito **texto** ou __texto__ + bold_pattern = r'\*\*(.*?)\*\*|__(.*?)__' + # Padrão para código inline `código` + code_pattern = r'`([^`]+)`' + # Padrão para itálico *texto* ou _texto_ (mas não **texto** ou __texto__) + italic_pattern = r'(?<!\*)\*(?!\*)([^*]+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)([^_]+?)(?<!_)_(?!_)' + + # Encontra todas as ocorrências de formatação + matches = [] + + # Processa negrito primeiro + for match in re.finditer(bold_pattern, text): + content = match.group(1) if match.group(1) else match.group(2) + matches.append((match.start(), match.end(), content, 'bold')) + + # Processa código (não pode sobrepor negrito) + for match in re.finditer(code_pattern, text): + # Verifica se não está dentro de um match de negrito + is_inside_bold = any(start <= match.start() < end for start, end, _, _ in matches) + if not is_inside_bold: + matches.append((match.start(), match.end(), match.group(1), 'code')) + + # Processa itálico (não pode sobrepor negrito ou código) + for match in re.finditer(italic_pattern, text): + content = match.group(1) if match.group(1) else match.group(2) + # Verifica se não está dentro de um match existente + is_inside_other = any(start <= match.start() < end for start, end, _, _ in matches) + if not is_inside_other: + matches.append((match.start(), match.end(), content, 'italic')) + + # Ordena matches por posição + matches.sort(key=lambda x: x[0]) + + # Reconstrói o texto com formatação + last_pos = 0 + for start, end, content, style in matches: + # Adiciona texto antes do match + if start > last_pos: + paragraph.add_run(text[last_pos:start]) + + # Adiciona run formatado + run = paragraph.add_run(content) + if style == 'bold': + run.bold = True + elif style == 'italic': + run.italic = True + elif style == 'code': + run.font.name = 'Courier New' + run.font.size = Pt(10) + + last_pos = end + + # Adiciona texto restante + if last_pos < len(text): + paragraph.add_run(text[last_pos:]) + + +def _add_markdown_paragraph(doc, text: str, is_summary: bool = False): + """ + Adiciona parágrafo ao documento processando markdown + """ + text = text.strip() + if not text: + return + + # Detecta títulos + if text.startswith('#'): + level = len(text) - len(text.lstrip('#')) + title_text = text.lstrip('#').strip() + if level <= 6 and title_text: + doc.add_heading(title_text, level=min(level, 6)) + return + + # Detecta listas não ordenadas + if text.startswith('- ') or text.startswith('* '): + para = doc.add_paragraph(style='List Bullet') + _parse_markdown_to_runs(text[2:].strip(), para) + if is_summary: + para.paragraph_format.space_after = Pt(12) + para.paragraph_format.line_spacing = 1.15 + return + + # Detecta listas ordenadas + list_match = re.match(r'^\d+\.\s+(.*)$', text) + if list_match: + para = doc.add_paragraph(style='List Number') + _parse_markdown_to_runs(list_match.group(1), para) + if is_summary: + para.paragraph_format.space_after = Pt(12) + para.paragraph_format.line_spacing = 1.15 + return + + # Parágrafo normal com markdown inline + para = doc.add_paragraph() + _parse_markdown_to_runs(text, para) + if is_summary: + para.paragraph_format.space_after = Pt(12) + para.paragraph_format.line_spacing = 1.15 + else: + para.paragraph_format.space_after = Pt(6) + para.paragraph_format.first_line_indent = Inches(0.25) + para.paragraph_format.line_spacing = 1.15 diff --git a/lazier/downloader.py b/lazier/downloader.py @@ -0,0 +1,85 @@ +""" +Módulo para download de vídeos do YouTube usando yt-dlp +""" + +import os +import tempfile +from pathlib import Path +from typing import Optional, Dict, Any +import yt_dlp + + +def download_youtube_audio(url: str, output_dir: Optional[str] = None) -> tuple[str, Dict[str, Any]]: + """ + Baixa o melhor áudio disponível de um vídeo do YouTube + + Args: + url: URL do vídeo do YouTube + output_dir: Diretório de saída (opcional, usa temp se None) + + Returns: + Tupla (caminho_do_arquivo, metadados) + """ + if output_dir is None: + output_dir = tempfile.gettempdir() + + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + metadata = {} + downloaded_file = None + + def progress_hook(d): + """Hook para capturar o nome do arquivo baixado""" + nonlocal downloaded_file + if d['status'] == 'finished': + downloaded_file = d.get('filename') + + # Configuração do yt-dlp para baixar melhor áudio disponível + ydl_opts = { + 'format': 'bestaudio/best', # Melhor áudio disponível + 'outtmpl': str(output_path / '%(title)s.%(ext)s'), + 'quiet': False, + 'no_warnings': False, + 'extractaudio': False, # Não extrair áudio (já vem como áudio) + 'postprocessors': [], + 'progress_hooks': [progress_hook], + } + + try: + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + # Extrai informações sem baixar primeiro + info = ydl.extract_info(url, download=False) + + # Salva metadados importantes + metadata = { + 'title': info.get('title', 'Sem título'), + 'duration': info.get('duration'), + 'uploader': info.get('uploader', 'Desconhecido'), + 'upload_date': info.get('upload_date'), + 'description': info.get('description', ''), + 'webpage_url': info.get('webpage_url', url), + } + + # Agora baixa o arquivo + ydl.download([url]) + + # Se o hook não capturou, tenta encontrar pelo nome esperado + if downloaded_file is None or not os.path.exists(downloaded_file): + # yt-dlp pode ter modificado o nome do arquivo (sanitização) + expected_filename = ydl.prepare_filename(info) + if os.path.exists(expected_filename): + downloaded_file = expected_filename + else: + # Procura o arquivo mais recente no diretório + files = [f for f in output_path.glob('*') if f.is_file()] + if files: + downloaded_file = str(max(files, key=os.path.getmtime)) + + except Exception as e: + raise Exception(f"Erro ao baixar vídeo do YouTube: {str(e)}") + + if downloaded_file is None or not os.path.exists(downloaded_file): + raise Exception("Arquivo baixado não encontrado") + + return downloaded_file, metadata diff --git a/lazier/summarizer.py b/lazier/summarizer.py @@ -0,0 +1,231 @@ +""" +Módulo para sumarização de texto usando OpenAI GPT API +Suporta textos, páginas web e PDFs +""" + +import os +from typing import Optional +from openai import OpenAI +from dotenv import load_dotenv + +from .web.extractor import extract_web_content, extract_pdf_content, extract_text_file_content + +load_dotenv() + + +def summarize_text(text: str, model: str = 'gpt-4o-mini', language: str = 'pt-BR') -> str: + """ + Sumariza um texto usando OpenAI GPT API + + Args: + text: Texto a ser sumarizado + model: Modelo GPT a usar (padrão: gpt-4o-mini) + language: Idioma para o prompt (padrão: pt-BR) + + Returns: + Texto sumarizado + """ + api_key = os.getenv('OPENAI_API_KEY') + if not api_key: + raise Exception( + "OPENAI_API_KEY não encontrada. " + "Configure a variável de ambiente OPENAI_API_KEY ou crie um arquivo .env" + ) + + if not text or not text.strip(): + return "Texto vazio - não é possível gerar sumário." + + # Estratégia de chunking para textos muito longos + # GPT-4o-mini tem contexto de ~128k tokens, mas vamos limitar a ~100k para segurança + # Aproximadamente 1 token = 4 caracteres em português + max_chars = 400000 # ~100k tokens + + if len(text) <= max_chars: + return _summarize_chunk(text, model, language) + else: + # Divide em chunks e sumariza cada um, depois sumariza os sumários + chunks = _split_text_into_chunks(text, max_chars) + chunk_summaries = [] + + for i, chunk in enumerate(chunks): + print(f"Sumarizando chunk {i+1}/{len(chunks)}...") + summary = _summarize_chunk(chunk, model, language) + chunk_summaries.append(summary) + + # Se temos múltiplos chunks, sumariza os sumários + if len(chunk_summaries) > 1: + combined_summaries = "\n\n".join(chunk_summaries) + return _summarize_chunk(combined_summaries, model, language, is_final=True) + else: + return chunk_summaries[0] + + +def _summarize_chunk(text: str, model: str, language: str, is_final: bool = False) -> str: + """Sumariza um chunk de texto""" + api_key = os.getenv('OPENAI_API_KEY') + client = OpenAI(api_key=api_key) + + if language.startswith('pt'): + prompt = """Você é um assistente especializado em criar sumários detalhados e completos. + +Crie um sumário COMPLETO e DETALHADO do seguinte texto em português do Brasil. O sumário DEVE: +- Manter TODOS os pontos importantes, chave e informações relevantes +- Preservar números, datas, nomes, estatísticas e dados técnicos +- Manter a estrutura lógica e sequência do conteúdo original +- Ser detalhado o suficiente para não perder informações essenciais +- Destacar os principais temas e subtemas +- Ser escrito em português do Brasil +- Ter pelo menos 30-40% do tamanho do texto original (para textos longos) + +IMPORTANTE: Não omita informações importantes. O objetivo é condensar mantendo a completude dos pontos-chave. Se o texto contém listas, exemplos específicos, ou dados numéricos, inclua-os no sumário. + +Texto para sumarizar: + +""" + if is_final: + prompt = """Você é um assistente especializado em criar sumários finais detalhados e completos. + +Você recebeu múltiplos sumários parciais de um texto longo. Crie um sumário final unificado e COMPLETO em português do Brasil que: +- Integre TODOS os pontos principais dos sumários parciais +- Mantenha TODAS as informações importantes, números, datas, nomes e dados técnicos +- Seja coerente e bem estruturado +- Destaque os temas e informações mais importantes sem perder detalhes relevantes +- Seja escrito em português do Brasil +- Preserve a completude das informações essenciais + +IMPORTANTE: Não omita informações importantes ao consolidar. O objetivo é criar um sumário final que mantenha a riqueza de detalhes dos sumários parciais. + +Sumários parciais para consolidar: + +""" + else: + prompt = f"""You are an assistant specialized in creating concise and informative summaries. + +Please create a detailed summary of the following text in {language}. The summary should: +- Be clear and objective +- Highlight the main points +- Maintain the logical structure of the content + +Text to summarize: + +""" + + try: + response = client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": "You are a helpful assistant that creates detailed and comprehensive summaries, preserving all important information."}, + {"role": "user", "content": prompt + text} + ], + temperature=0.2, # Temperatura mais baixa para sumários mais precisos e completos + ) + + return response.choices[0].message.content.strip() + + 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.") + else: + raise Exception(f"Erro ao sumarizar texto: {error_msg}") + + +def summarize_text_file(file_path: str, model: str = 'gpt-4o-mini', language: str = 'pt-BR') -> str: + """ + Sumariza conteúdo de um arquivo de texto + + Args: + file_path: Caminho do arquivo de texto + model: Modelo GPT a usar + language: Idioma para o prompt + + Returns: + Texto sumarizado + """ + # Extrai conteúdo do arquivo + content_data = extract_text_file_content(file_path) + text = content_data['content'] + + if not text or not text.strip(): + return "Arquivo vazio - não é possível gerar sumário." + + return summarize_text(text, model=model, language=language) + + +def summarize_web_page(url: str, model: str = 'gpt-4o-mini', language: str = 'pt-BR') -> str: + """ + Sumariza conteúdo de uma página web + + Args: + url: URL da página web + model: Modelo GPT a usar + language: Idioma para o prompt + + Returns: + Texto sumarizado + """ + # Extrai conteúdo da web + content_data = extract_web_content(url) + text = content_data['content'] + + if not text or not text.strip(): + return "Não foi possível extrair conteúdo da página web." + + return summarize_text(text, model=model, language=language) + + +def summarize_pdf(file_path: str, model: str = 'gpt-4o-mini', language: str = 'pt-BR') -> str: + """ + Sumariza conteúdo de um arquivo PDF + + Args: + file_path: Caminho do arquivo PDF + model: Modelo GPT a usar + language: Idioma para o prompt + + Returns: + Texto sumarizado + """ + # Extrai conteúdo do PDF + content_data = extract_pdf_content(file_path) + text = content_data['content'] + + if not text or not text.strip(): + return "PDF vazio ou não foi possível extrair texto." + + return summarize_text(text, model=model, language=language) + + +def _split_text_into_chunks(text: str, max_chars: int) -> list[str]: + """Divide texto em chunks respeitando limites de tamanho""" + chunks = [] + current_chunk = "" + + # Divide por parágrafos primeiro + paragraphs = text.split('\n\n') + + for paragraph in paragraphs: + if len(current_chunk) + len(paragraph) + 2 <= max_chars: + current_chunk += paragraph + '\n\n' + else: + if current_chunk: + chunks.append(current_chunk.strip()) + # Se parágrafo sozinho é maior que max_chars, divide por sentenças + if len(paragraph) > max_chars: + sentences = paragraph.split('. ') + temp_chunk = "" + for sentence in sentences: + if len(temp_chunk) + len(sentence) + 2 <= max_chars: + temp_chunk += sentence + '. ' + else: + if temp_chunk: + chunks.append(temp_chunk.strip()) + temp_chunk = sentence + '. ' + current_chunk = temp_chunk + else: + current_chunk = paragraph + '\n\n' + + if current_chunk: + chunks.append(current_chunk.strip()) + + return chunks diff --git a/lazier/transcriber.py b/lazier/transcriber.py @@ -0,0 +1,73 @@ +""" +Módulo para transcrição de áudio usando OpenAI Whisper API +""" + +import os +from pathlib import Path +from typing import Optional +from openai import OpenAI +from dotenv import load_dotenv + +load_dotenv() + + +def transcribe_audio(audio_path: str, language: str = 'pt', model: str = 'whisper-1') -> str: + """ + Transcreve um arquivo de áudio usando OpenAI Whisper API + + Args: + audio_path: Caminho do arquivo de áudio + language: Código do idioma (pt para português) + model: Modelo Whisper a usar (padrão: whisper-1) + + Returns: + Texto transcrito + """ + api_key = os.getenv('OPENAI_API_KEY') + if not api_key: + raise Exception( + "OPENAI_API_KEY não encontrada. " + "Configure a variável de ambiente OPENAI_API_KEY ou crie um arquivo .env" + ) + + 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) + 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." + ) + + 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 + else: + # Tenta converter para string + 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.") + else: + raise Exception(f"Erro ao transcrever áudio: {error_msg}") diff --git a/lazier/utils.py b/lazier/utils.py @@ -0,0 +1,221 @@ +""" +Utilitários para validação, limpeza e verificação de dependências +""" + +import os +import re +import shutil +import threading +import time +from pathlib import Path +from typing import Optional, Tuple +from urllib.parse import urlparse + + +# Extensões de áudio suportadas +AUDIO_EXTENSIONS = {'.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg', '.opus', '.wma'} +# Extensões de vídeo suportadas +VIDEO_EXTENSIONS = {'.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v'} +# Extensões de texto/documento suportadas +TEXT_EXTENSIONS = {'.txt', '.md', '.html', '.htm'} +PDF_EXTENSIONS = {'.pdf'} + + +def is_youtube_url(url: str) -> bool: + """Verifica se a URL é do YouTube""" + youtube_patterns = [ + r'(?:https?://)?(?:www\.)?(?:youtube\.com|youtu\.be)', + r'youtube\.com/watch', + r'youtube\.com/embed', + r'youtu\.be/', + ] + return any(re.search(pattern, url, re.IGNORECASE) for pattern in youtube_patterns) + + +def is_media_file(file_path: str) -> Tuple[bool, Optional[str]]: + """ + Verifica se o arquivo é um arquivo de mídia suportado + Retorna (é_mídia, tipo) onde tipo é 'audio', 'video' ou None + """ + path = Path(file_path) + if not path.exists(): + return False, None + + ext = path.suffix.lower() + if ext in AUDIO_EXTENSIONS: + return True, 'audio' + elif ext in VIDEO_EXTENSIONS: + return True, 'video' + return False, None + + +def validate_input(input_path: str) -> Tuple[bool, str, Optional[str]]: + """ + Valida o input (arquivo ou URL) + Retorna (válido, tipo, mensagem_erro) + tipo pode ser: 'audio', 'video', 'youtube', 'text', 'pdf', 'web' ou None + """ + if not input_path or not input_path.strip(): + return False, None, "Input vazio" + + # Verifica se é URL + parsed = urlparse(input_path) + if parsed.scheme in ('http', 'https'): + if is_youtube_url(input_path): + return True, 'youtube', None + else: + return True, 'web', None + + # Verifica se é arquivo local + path = Path(input_path) + + if not path.exists(): + return False, None, f"Arquivo não encontrado: {input_path}" + + if not path.is_file(): + return False, None, f"Não é um arquivo: {input_path}" + + ext = path.suffix.lower() + + if ext in AUDIO_EXTENSIONS: + return True, 'audio', None + elif ext in VIDEO_EXTENSIONS: + return True, 'video', None + elif ext in PDF_EXTENSIONS: + return True, 'pdf', None + elif ext in TEXT_EXTENSIONS: + return True, 'text', None + else: + return False, None, f"Tipo de arquivo não suportado: {ext}" + """ + Valida o input (arquivo ou URL) + Retorna (válido, tipo, mensagem_erro) + tipo pode ser 'youtube', 'audio', 'video' ou None + """ + if not input_path: + return False, None, "Input não fornecido" + + # Verifica se é URL do YouTube + if is_youtube_url(input_path): + return True, 'youtube', None + + # Verifica se é arquivo local + is_media, media_type = is_media_file(input_path) + if is_media: + return True, media_type, None + + # Se não é nenhum dos dois + if os.path.exists(input_path): + return False, None, f"Tipo de arquivo não suportado: {Path(input_path).suffix}" + else: + return False, None, f"Arquivo ou URL não encontrado: {input_path}" + + +def check_ffmpeg() -> bool: + """Verifica se ffmpeg está disponível no PATH""" + return shutil.which('ffmpeg') is not None + + +def sanitize_filename(filename: str) -> str: + """Sanitiza nome de arquivo removendo caracteres inválidos""" + # Remove caracteres inválidos para Windows/Linux + invalid_chars = '<>:"/\\|?*' + for char in invalid_chars: + filename = filename.replace(char, '_') + + # Remove espaços múltiplos + filename = re.sub(r'\s+', ' ', filename) + + # Limita tamanho (Windows tem limite de 255 caracteres) + if len(filename) > 200: + filename = filename[:200] + + return filename.strip() + + +def cleanup_files(file_paths: list[str]) -> None: + """Remove arquivos temporários""" + for file_path in file_paths: + try: + path = Path(file_path) + if path.exists(): + if path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + except Exception as e: + # Log erro mas não interrompe execução + print(f"Aviso: Não foi possível remover {file_path}: {e}") + + +def get_output_filename(input_path: str, output_dir: Optional[str] = None) -> str: + """ + Gera nome de arquivo de saída baseado no input + """ + if is_youtube_url(input_path): + # Para YouTube, usa um nome genérico baseado na URL + parsed = urlparse(input_path) + video_id = None + if 'watch' in parsed.path or 'v=' in parsed.query: + match = re.search(r'[?&]v=([^&]+)', input_path) + if match: + video_id = match.group(1) + + base_name = f"youtube_{video_id or 'video'}" if video_id else "youtube_video" + else: + # Para arquivos locais, usa o nome base do arquivo + base_name = Path(input_path).stem + + filename = sanitize_filename(base_name) + ".docx" + + if output_dir: + return str(Path(output_dir) / filename) + return filename + + +def get_next_lazier_number(output_dir: Path, format: str = None) -> int: + """ + Retorna o próximo número sequencial disponível para arquivos lazier_ + Sempre considera todos os arquivos lazier_* para garantir sequência única + """ + if not output_dir.exists(): + return 1 + + # Sempre busca todos os arquivos lazier_* para garantir sequência única + existing_files = list(output_dir.glob("lazier_*")) + + numbers = [] + for file in existing_files: + # Extrai número do padrão lazier_XXX.extensão ou lazier_XXX_suffix.extensão + match = re.search(r'lazier_(\d+)', file.stem) + if match: + numbers.append(int(match.group(1))) + + return max(numbers) + 1 if numbers else 1 + + +def get_lazier_filename(output_dir: Path, format: str, suffix: str = "") -> str: + """ + Gera nome de arquivo no padrão lazier_XXX com numeração sequencial + suffix pode ser "_transcription", "_summary" ou vazio + """ + # Lock para thread safety + if not hasattr(get_lazier_filename, '_lock'): + get_lazier_filename._lock = threading.Lock() + + with get_lazier_filename._lock: + # Tenta até encontrar um número disponível (caso de race condition) + max_attempts = 100 + for _ in range(max_attempts): + number = get_next_lazier_number(output_dir, format) + filename = f"lazier_{number:03d}{suffix}.{format}" + filepath = output_dir / filename + + # Verifica se arquivo já existe (race condition) + if not filepath.exists(): + return str(filepath) + + # Fallback: usar timestamp se todos os números estiverem ocupados + timestamp = int(time.time()) + filename = f"lazier_{timestamp}{suffix}.{format}" + return str(output_dir / filename) diff --git a/lazier/web/__init__.py b/lazier/web/__init__.py @@ -0,0 +1,3 @@ +""" +Módulos do frontend web +""" diff --git a/lazier/web/extractor.py b/lazier/web/extractor.py @@ -0,0 +1,295 @@ +""" +Extração de conteúdo de páginas web e PDFs +""" + +import os +import re +from pathlib import Path +from typing import Optional, Dict, Any +import requests +from bs4 import BeautifulSoup +import pypdf +import pdfplumber + + +""" +Extração de conteúdo de páginas web e PDFs +""" + +import os +import re +from pathlib import Path +from typing import Optional, Dict, Any +import requests +from bs4 import BeautifulSoup +import pypdf +import pdfplumber + + +def _extract_with_bs4(url: str, timeout: int) -> Dict[str, Any]: + """Extrai conteúdo usando BeautifulSoup (método rápido, sem JavaScript)""" + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + + response = requests.get(url, headers=headers, timeout=timeout) + response.raise_for_status() + + soup = BeautifulSoup(response.content, 'html.parser') + + # Remove scripts e styles + for script in soup(["script", "style", "nav", "header", "footer", "aside"]): + script.decompose() + + # Tenta encontrar título + title = None + if soup.title: + title = soup.title.string + elif soup.find('h1'): + title = soup.find('h1').get_text() + elif soup.find('meta', property='og:title'): + title = soup.find('meta', property='og:title').get('content') + + # Extrai texto principal + main_content = None + for tag in ['article', 'main', '[role="main"]', '.content', '.post', '.entry']: + main_content = soup.select_one(tag) + if main_content: + break + + if main_content: + text = main_content.get_text(separator='\n', strip=True) + else: + body = soup.find('body') + if body: + text = body.get_text(separator='\n', strip=True) + else: + text = soup.get_text(separator='\n', strip=True) + + # Limpa texto + lines = [line.strip() for line in text.split('\n') if line.strip()] + text = '\n'.join(lines) + + return { + 'title': title or 'Conteúdo Web', + 'content': text, + 'url': url, + 'length': len(text), + } + + +def _extract_with_playwright(url: str, timeout: int) -> Dict[str, Any]: + """Extrai conteúdo usando Playwright (renderiza JavaScript)""" + try: + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + + # Configurar timeout + page.set_default_timeout(timeout * 1000) + + # Navegar para a página + page.goto(url, wait_until='networkidle', timeout=timeout * 1000) + + # Espera um pouco para garantir que JS terminou de renderizar + page.wait_for_timeout(2000) + + # Extrai conteúdo + title = page.title() + content = page.inner_text('body') + + browser.close() + + # Limpa texto + lines = [line.strip() for line in content.split('\n') if line.strip()] + text = '\n'.join(lines) + + # Limita tamanho + max_length = 500000 + if len(text) > max_length: + text = text[:max_length] + "\n\n[... conteúdo truncado ...]" + + return { + 'title': title or 'Conteúdo Web', + 'content': text, + 'url': url, + 'length': len(text), + } + except ImportError: + raise Exception("Playwright não está instalado. Instale com: pip install playwright && playwright install chromium") + except Exception as e: + raise Exception(f"Erro ao extrair conteúdo com Playwright: {str(e)}") + + +def extract_web_content(url: str, timeout: int = 30, use_js: bool = True) -> Dict[str, Any]: + """ + Extrai conteúdo de texto de uma página web + + Args: + url: URL da página web + timeout: Timeout em segundos + use_js: Se True, tenta usar Playwright para renderizar JavaScript quando necessário + + Returns: + Dicionário com conteúdo extraído e metadados + """ + try: + # Tentar primeiro com BeautifulSoup (mais rápido) + content = _extract_with_bs4(url, timeout) + + # Se conteúdo parece muito pequeno ou vazio, tentar com Playwright + if use_js and len(content['content']) < 500: + try: + playwright_content = _extract_with_playwright(url, timeout) + # Se Playwright retornou mais conteúdo, usar ele + if len(playwright_content['content']) > len(content['content']): + return playwright_content + except Exception as e: + # Se Playwright falhar, usar conteúdo do BeautifulSoup + print(f"Aviso: Não foi possível usar Playwright: {e}") + + # Limita tamanho + max_length = 500000 + if len(content['content']) > max_length: + content['content'] = content['content'][:max_length] + "\n\n[... conteúdo truncado ...]" + content['length'] = len(content['content']) + + return content + + except requests.exceptions.RequestException as e: + # Se requests falhar, tentar com Playwright se use_js estiver habilitado + if use_js: + try: + return _extract_with_playwright(url, timeout) + except Exception: + pass + raise Exception(f"Erro ao acessar URL: {str(e)}") + except Exception as e: + raise Exception(f"Erro ao extrair conteúdo web: {str(e)}") + + +def extract_pdf_content(file_path: str) -> Dict[str, Any]: + """ + Extrai texto de um arquivo PDF + + Args: + file_path: Caminho do arquivo PDF + + Returns: + Dicionário com conteúdo extraído e metadados + """ + if not os.path.exists(file_path): + raise FileNotFoundError(f"Arquivo PDF não encontrado: {file_path}") + + try: + # Tenta primeiro com pdfplumber (melhor para PDFs complexos) + text_parts = [] + metadata = {} + + try: + with pdfplumber.open(file_path) as pdf: + # Metadados + if pdf.metadata: + metadata = { + 'title': pdf.metadata.get('Title', ''), + 'author': pdf.metadata.get('Author', ''), + 'subject': pdf.metadata.get('Subject', ''), + 'creator': pdf.metadata.get('Creator', ''), + } + + # Extrai texto de cada página + for page in pdf.pages: + page_text = page.extract_text() + if page_text: + text_parts.append(page_text) + except Exception: + # Fallback para pypdf se pdfplumber falhar + with open(file_path, 'rb') as file: + pdf_reader = pypdf.PdfReader(file) + + # Metadados + if pdf_reader.metadata: + metadata = { + 'title': pdf_reader.metadata.get('/Title', ''), + 'author': pdf_reader.metadata.get('/Author', ''), + 'subject': pdf_reader.metadata.get('/Subject', ''), + } + + # Extrai texto de cada página + for page in pdf_reader.pages: + page_text = page.extract_text() + if page_text: + text_parts.append(page_text) + + # Combina todo o texto + full_text = '\n\n'.join(text_parts) + + # Limpa texto (remove espaços múltiplos) + lines = [line.strip() for line in full_text.split('\n') if line.strip()] + text = '\n'.join(lines) + + # Limita tamanho + max_length = 500000 + if len(text) > max_length: + text = text[:max_length] + "\n\n[... conteúdo truncado ...]" + + title = metadata.get('title', '') or Path(file_path).stem + + return { + 'title': title, + 'content': text, + 'file_path': file_path, + 'length': len(text), + 'pages': len(text_parts), + 'metadata': metadata, + } + + except Exception as e: + raise Exception(f"Erro ao extrair conteúdo PDF: {str(e)}") + + +def extract_text_file_content(file_path: str) -> Dict[str, Any]: + """ + Extrai conteúdo de arquivo de texto (txt, md, html) + + Args: + file_path: Caminho do arquivo + + Returns: + Dicionário com conteúdo extraído + """ + if not os.path.exists(file_path): + raise FileNotFoundError(f"Arquivo não encontrado: {file_path}") + + try: + # Detecta encoding + encodings = ['utf-8', 'latin-1', 'cp1252'] + content = None + + for encoding in encodings: + try: + with open(file_path, 'r', encoding=encoding) as f: + content = f.read() + break + except UnicodeDecodeError: + continue + + if content is None: + raise Exception("Não foi possível decodificar o arquivo") + + # Limita tamanho + max_length = 500000 + if len(content) > max_length: + content = content[:max_length] + "\n\n[... conteúdo truncado ...]" + + return { + 'title': Path(file_path).stem, + 'content': content, + 'file_path': file_path, + 'length': len(content), + } + + except Exception as e: + raise Exception(f"Erro ao extrair conteúdo do arquivo: {str(e)}") diff --git a/lazier/web/templates/index.html b/lazier/web/templates/index.html @@ -0,0 +1,1036 @@ +<!DOCTYPE html> +<html lang="pt-BR"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Lazier - Transcrição e Sumarização</title> + <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📢</text></svg>"> + <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> + <style> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding-top: 70px; + padding-bottom: 60px; + display: flex; + flex-direction: column; + } + + .container { + flex: 1; + display: flex; + flex-direction: column; + } + + .page { + flex: 1; + display: flex; + flex-direction: column; + } + + /* Menu de Navegação */ + .navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + padding: 15px 40px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + z-index: 1000; + display: flex; + justify-content: space-between; + align-items: center; + } + + .navbar-brand { + color: white; + font-size: 1.5em; + font-weight: 700; + text-decoration: none; + } + + .navbar-nav { + display: flex; + gap: 30px; + list-style: none; + } + + .navbar-nav a { + color: white; + text-decoration: none; + font-weight: 500; + padding: 8px 0; + border-bottom: 2px solid transparent; + transition: border-color 0.3s; + } + + .navbar-nav a:hover, + .navbar-nav a.active { + border-bottom-color: white; + } + + /* Container Principal */ + .container { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; + width: 100%; + } + + .page { + display: none; + background: white; + border-radius: 20px; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); + padding: 40px; + min-height: calc(100vh - 130px); + } + + .page.active { + display: flex; + flex-direction: column; + } + + .page-content { + flex: 1; + overflow-y: auto; + } + + h1 { + color: #333; + margin-bottom: 10px; + font-size: 2.5em; + } + + .subtitle { + color: #666; + margin-bottom: 30px; + } + + /* Upload Area */ + .upload-area { + border: 3px dashed #667eea; + border-radius: 15px; + padding: 60px 20px; + text-align: center; + background: #f8f9ff; + transition: all 0.3s; + cursor: pointer; + margin-bottom: 20px; + } + + .upload-area:hover { + border-color: #764ba2; + background: #f0f2ff; + } + + .upload-area.dragover { + border-color: #764ba2; + background: #e8ebff; + transform: scale(1.02); + } + + .upload-icon { + font-size: 4em; + margin-bottom: 20px; + } + + .file-input { + display: none; + } + + .url-input { + width: 100%; + padding: 15px; + border: 2px solid #ddd; + border-radius: 10px; + font-size: 16px; + margin-bottom: 20px; + } + + /* Opções de Processamento */ + .processing-options { + margin: 30px 0; + padding: 20px; + background: #f8f9ff; + border-radius: 10px; + } + + .processing-options h3 { + margin-bottom: 15px; + color: #333; + } + + .option-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-top: 15px; + } + + .option-card { + padding: 20px; + border: 2px solid #ddd; + border-radius: 10px; + cursor: pointer; + transition: all 0.3s; + text-align: center; + background: white; + } + + .option-card:hover { + border-color: #667eea; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2); + } + + .option-card.selected { + border-color: #667eea; + background: #f0f2ff; + } + + .option-card input[type="radio"] { + margin-right: 8px; + } + + .option-card label { + cursor: pointer; + font-weight: 600; + color: #333; + display: flex; + align-items: center; + justify-content: center; + } + + .option-card .description { + margin-top: 8px; + font-size: 0.9em; + color: #666; + } + + /* Opções Gerais */ + .options { + margin-top: 30px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + } + + .option-group { + display: flex; + flex-direction: column; + } + + .option-group label { + margin-bottom: 8px; + font-weight: 600; + color: #333; + } + + .option-group select { + padding: 12px; + border: 2px solid #ddd; + border-radius: 8px; + font-size: 14px; + } + + .btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 15px 40px; + border-radius: 10px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + margin-top: 30px; + width: 100%; + transition: transform 0.2s; + } + + .btn:hover { + transform: translateY(-2px); + } + + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .btn-secondary { + background: #6c757d; + margin-top: 10px; + } + + .btn-secondary:hover { + background: #5a6268; + } + + /* Jobs List */ + .jobs-list { + margin-top: 40px; + } + + .job-card { + background: #f8f9ff; + border-radius: 10px; + padding: 20px; + margin-bottom: 15px; + border-left: 4px solid #667eea; + } + + .job-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } + + .job-title { + font-weight: 600; + color: #333; + } + + .job-status { + padding: 5px 15px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + } + + .status-pending { background: #ffc107; color: #333; } + .status-processing { background: #17a2b8; color: white; } + .status-completed { background: #28a745; color: white; } + .status-failed { background: #dc3545; color: white; } + + .progress-bar { + width: 100%; + height: 8px; + background: #e0e0e0; + border-radius: 4px; + overflow: hidden; + margin: 10px 0; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + transition: width 0.3s; + } + + .job-actions { + margin-top: 15px; + display: flex; + gap: 10px; + flex-wrap: wrap; + } + + .btn-small { + padding: 8px 16px; + font-size: 14px; + width: auto; + margin: 0; + } + + .file-list { + margin-top: 20px; + } + + .file-item { + background: white; + padding: 10px; + border-radius: 5px; + margin-bottom: 5px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .btn-remove { + background: #dc3545; + color: white; + border: none; + padding: 5px 10px; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + margin-left: 10px; + } + + .btn-remove:hover { + background: #c82333; + } + + /* Footer */ + .footer { + margin-top: auto; + padding-top: 20px; + padding-bottom: 20px; + border-top: 1px solid #e0e0e0; + text-align: center; + color: #666; + font-size: 14px; + } + + /* Histórico */ + .history-filters { + margin-bottom: 20px; + display: flex; + gap: 10px; + flex-wrap: wrap; + } + + .filter-btn { + padding: 8px 16px; + border: 2px solid #ddd; + background: white; + border-radius: 5px; + cursor: pointer; + transition: all 0.3s; + } + + .filter-btn.active { + border-color: #667eea; + background: #f0f2ff; + color: #667eea; + } + + /* Preview Content - Visualização Formatada */ + .preview-content { + background: white; + padding: 20px; + border-radius: 8px; + border: 1px solid #ddd; + max-height: 600px; + overflow-y: auto; + margin-top: 15px; + line-height: 1.6; + } + + .preview-content h1, .preview-content h2, .preview-content h3, .preview-content h4 { + margin-top: 20px; + margin-bottom: 10px; + color: #333; + } + + .preview-content h1 { + font-size: 1.8em; + border-bottom: 2px solid #667eea; + padding-bottom: 10px; + } + + .preview-content h2 { + font-size: 1.5em; + color: #667eea; + } + + .preview-content h3 { + font-size: 1.3em; + } + + .preview-content h4 { + font-size: 1.1em; + margin-top: 15px; + } + + .preview-content p { + margin-bottom: 12px; + line-height: 1.6; + } + + .preview-content pre { + background: #f5f5f5; + padding: 15px; + border-radius: 4px; + overflow-x: auto; + border-left: 4px solid #667eea; + margin: 15px 0; + } + + .preview-content code { + background: #f5f5f5; + padding: 2px 6px; + border-radius: 3px; + font-family: 'Courier New', monospace; + font-size: 0.9em; + } + + .preview-content pre code { + background: transparent; + padding: 0; + } + + .preview-content ul, .preview-content ol { + margin: 10px 0; + padding-left: 30px; + } + + .preview-content li { + margin-bottom: 5px; + } + + .preview-content blockquote { + border-left: 4px solid #667eea; + padding-left: 15px; + margin: 15px 0; + color: #666; + font-style: italic; + } + + .preview-content hr { + border: none; + border-top: 2px solid #e0e0e0; + margin: 20px 0; + } + + .preview-content strong { + font-weight: 600; + color: #333; + } + + .preview-content em { + font-style: italic; + } + + .preview-content a { + color: #667eea; + text-decoration: none; + } + + .preview-content a:hover { + text-decoration: underline; + } + + .preview-text-plain { + white-space: pre-wrap; + word-wrap: break-word; + font-family: inherit; + } + </style> +</head> +<body> + <!-- Menu de Navegação --> + <nav class="navbar"> + <a href="#" class="navbar-brand" onclick="showPage('process'); return false;">🎧 Lazier</a> + <ul class="navbar-nav"> + <li><a href="#" class="active" onclick="showPage('process'); return false;">Processar</a></li> + <li><a href="#" onclick="showPage('history'); return false;">Histórico</a></li> + <li><a href="#" onclick="showPage('downloads'); return false;">Downloads</a></li> + </ul> + </nav> + + <div class="container"> + <!-- Página: Processar --> + <div id="page-process" class="page active"> + <div class="page-content"> + <h1>🎧 Lazier</h1> + <p class="subtitle">Transcrição e Sumarização de Áudios, Vídeos, Textos e PDFs</p> + + <div class="upload-area" id="uploadArea"> + <div class="upload-icon">📁</div> + <h3>Arraste arquivos aqui ou clique para selecionar</h3> + <p>Suporta: Áudio (mp3, wav, m4a...), Vídeo (mp4, avi, mkv...), PDF, Texto (txt, md, html)</p> + <input type="file" id="fileInput" class="file-input" multiple accept=".mp3,.wav,.m4a,.mp4,.avi,.mkv,.pdf,.txt,.md,.html"> + </div> + + <input type="text" id="urlInput" class="url-input" placeholder="Ou cole uma URL do YouTube ou página web aqui..."> + + <!-- Opções de Processamento --> + <div class="processing-options"> + <h3>Modo de Processamento</h3> + <div class="option-cards"> + <div class="option-card selected" onclick="selectProcessingMode('both', this)"> + <label> + <input type="radio" name="processingMode" value="both" checked> + Transcrever + Sumarizar + </label> + <div class="description">Transcreve e gera sumário completo</div> + </div> + <div class="option-card" onclick="selectProcessingMode('transcribe', this)"> + <label> + <input type="radio" name="processingMode" value="transcribe"> + Apenas Transcrever + </label> + <div class="description">Apenas transcrição do conteúdo</div> + </div> + <div class="option-card" onclick="selectProcessingMode('summarize', this)"> + <label> + <input type="radio" name="processingMode" value="summarize"> + Apenas Sumarizar + </label> + <div class="description">Apenas sumário (textos/PDFs)</div> + </div> + </div> + </div> + + <div class="options"> + <div class="option-group"> + <label>Formato de Saída</label> + <select id="formatSelect"> + <option value="docx">DOCX</option> + <option value="txt">TXT</option> + <option value="md">Markdown</option> + <option value="json">JSON</option> + </select> + </div> + <div class="option-group"> + <label>Idioma</label> + <select id="languageSelect"> + <option value="pt">Português (Brasil)</option> + <option value="en">English</option> + </select> + </div> + </div> + + <button class="btn" id="processBtn" onclick="processFiles()">Processar</button> + + <div class="jobs-list" id="jobsList"></div> + </div> + + <footer class="footer"> + Desenvolvido por Pablo Murad - <span id="currentYear"></span> + </footer> + </div> + + <!-- Página: Histórico --> + <div id="page-history" class="page"> + <div class="page-content"> + <h1>Histórico de Processamentos</h1> + <p class="subtitle">Visualize todos os seus jobs processados</p> + + <div class="history-filters"> + <button class="filter-btn active" onclick="filterHistory('all')">Todos</button> + <button class="filter-btn" onclick="filterHistory('completed')">Concluídos</button> + <button class="filter-btn" onclick="filterHistory('processing')">Processando</button> + <button class="filter-btn" onclick="filterHistory('failed')">Falhados</button> + </div> + + <div class="jobs-list" id="historyList"></div> + </div> + + <footer class="footer"> + Desenvolvido por Pablo Murad - <span id="currentYearHistory"></span> + </footer> + </div> + + <!-- Página: Downloads --> + <div id="page-downloads" class="page"> + <div class="page-content"> + <h1>Downloads Disponíveis</h1> + <p class="subtitle">Acesse rapidamente seus arquivos processados</p> + + <div class="jobs-list" id="downloadsList"></div> + </div> + + <footer class="footer"> + Desenvolvido por Pablo Murad - <span id="currentYearDownloads"></span> + </footer> + </div> + </div> + + <script> + // Estado da aplicação + let currentPage = 'process'; + let selectedFiles = []; + let processingMode = 'both'; + let allJobs = []; + let currentFilter = 'all'; + + // Inicialização + document.addEventListener('DOMContentLoaded', () => { + document.getElementById('currentYear').textContent = new Date().getFullYear(); + document.getElementById('currentYearHistory').textContent = new Date().getFullYear(); + document.getElementById('currentYearDownloads').textContent = new Date().getFullYear(); + loadHistory(); + }); + + // Navegação + function showPage(page) { + document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); + document.querySelectorAll('.navbar-nav a').forEach(a => a.classList.remove('active')); + + document.getElementById(`page-${page}`).classList.add('active'); + document.querySelectorAll('.navbar-nav a')[['process', 'history', 'downloads'].indexOf(page)].classList.add('active'); + + currentPage = page; + + if (page === 'history') { + loadHistory(); + } else if (page === 'downloads') { + loadDownloads(); + } + } + + // Seleção de modo de processamento + function selectProcessingMode(mode, element) { + processingMode = mode; + document.querySelectorAll('.option-card').forEach(card => card.classList.remove('selected')); + if (element) { + element.classList.add('selected'); + } else { + event.currentTarget.classList.add('selected'); + } + document.querySelector(`input[value="${mode}"]`).checked = true; + } + + // Upload e Drag & Drop + const uploadArea = document.getElementById('uploadArea'); + const fileInput = document.getElementById('fileInput'); + + uploadArea.addEventListener('click', () => fileInput.click()); + uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadArea.classList.add('dragover'); + }); + uploadArea.addEventListener('dragleave', () => { + uploadArea.classList.remove('dragover'); + }); + uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + uploadArea.classList.remove('dragover'); + selectedFiles = Array.from(e.dataTransfer.files); + updateFileList(); + }); + + fileInput.addEventListener('change', (e) => { + selectedFiles = Array.from(e.target.files); + updateFileList(); + }); + + function updateFileList() { + if (selectedFiles.length > 0) { + const fileListHTML = ` + <div class="file-list"> + ${selectedFiles.map((f, index) => ` + <div class="file-item"> + <span>${f.name}</span> + <span>${(f.size / 1024 / 1024).toFixed(2)} MB</span> + <button onclick="removeFile(${index})" class="btn-remove" title="Remover arquivo">✕</button> + </div> + `).join('')} + </div> + <p style="margin-top: 15px; color: #666;">Clique novamente para selecionar outros arquivos</p> + `; + uploadArea.innerHTML = fileListHTML; + // Re-adiciona event listener após atualizar HTML + uploadArea.addEventListener('click', (e) => { + // Não abrir se clicou no botão de remover + if (!e.target.classList.contains('btn-remove')) { + fileInput.click(); + } + }); + } else { + uploadArea.innerHTML = ` + <div class="upload-icon">📁</div> + <h3>Arraste arquivos aqui ou clique para selecionar</h3> + <p>Suporta: Áudio (mp3, wav, m4a...), Vídeo (mp4, avi, mkv...), PDF, Texto (txt, md, html)</p> + `; + uploadArea.addEventListener('click', () => fileInput.click()); + } + } + + function removeFile(index) { + selectedFiles.splice(index, 1); + updateFileList(); + // Limpa input para permitir re-seleção do mesmo arquivo + fileInput.value = ''; + } + + // Processamento + async function processFiles() { + const format = document.getElementById('formatSelect').value; + const url = document.getElementById('urlInput').value.trim(); + + const transcribe = processingMode === 'both' || processingMode === 'transcribe'; + const summarize = processingMode === 'both' || processingMode === 'summarize'; + + const processBtn = document.getElementById('processBtn'); + processBtn.disabled = true; + processBtn.textContent = 'Processando...'; + + try { + if (url) { + const response = await fetch('/api/process', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({url, format, transcribe, summarize}) + }); + const data = await response.json(); + addJob(data.job_id, url, 'URL'); + startPolling(data.job_id); + } else if (selectedFiles.length > 0) { + const formData = new FormData(); + selectedFiles.forEach(f => formData.append('files', f)); + formData.append('format', format); + formData.append('transcribe', transcribe); + formData.append('summarize', summarize); + + const response = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + const data = await response.json(); + + data.job_ids.forEach(jobId => { + addJob(jobId, selectedFiles[0].name, 'Arquivo'); + startPolling(jobId); + }); + } else { + alert('Selecione arquivos ou informe uma URL'); + } + } catch (error) { + console.error('Erro:', error); + alert('Erro ao processar: ' + error.message); + } finally { + processBtn.disabled = false; + processBtn.textContent = 'Processar'; + } + } + + // Gerenciamento de Jobs + function addJob(jobId, name, type) { + const jobCard = document.createElement('div'); + jobCard.className = 'job-card'; + jobCard.id = `job-${jobId}`; + jobCard.innerHTML = ` + <div class="job-header"> + <div class="job-title"> + <strong>${type}:</strong> ${name} + </div> + <span class="job-status status-pending" id="status-${jobId}">Pendente</span> + </div> + <div class="progress-bar"> + <div class="progress-fill" id="progress-${jobId}" style="width: 0%"></div> + </div> + <div id="result-${jobId}"></div> + `; + document.getElementById('jobsList').appendChild(jobCard); + } + + async function startPolling(jobId) { + const ws = new WebSocket(`ws://${window.location.host}/ws/progress/${jobId}`); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + updateJob(jobId, data); + }; + + const interval = setInterval(async () => { + try { + const response = await fetch(`/api/jobs/${jobId}`); + const data = await response.json(); + updateJob(jobId, data); + + if (data.status === 'completed' || data.status === 'failed') { + clearInterval(interval); + ws.close(); + loadHistory(); + } + } catch (error) { + console.error('Erro ao verificar status:', error); + } + }, 2000); + } + + function updateJob(jobId, data) { + const statusEl = document.getElementById(`status-${jobId}`); + const progressEl = document.getElementById(`progress-${jobId}`); + const resultEl = document.getElementById(`result-${jobId}`); + + if (!statusEl) return; + + statusEl.textContent = data.status === 'completed' ? 'Concluído' : + data.status === 'failed' ? 'Falhou' : + data.status === 'processing' ? 'Processando' : 'Pendente'; + statusEl.className = `job-status status-${data.status}`; + progressEl.style.width = `${data.progress || 0}%`; + + if (data.status === 'completed') { + let actionsHTML = '<div class="job-actions">'; + + if (data.result_path) { + actionsHTML += `<a href="/api/jobs/${jobId}/download" class="btn btn-small">📥 Download Completo</a>`; + } + + if (data.has_transcription) { + actionsHTML += `<a href="/api/jobs/${jobId}/transcription" class="btn btn-small btn-secondary">📄 Download Transcrição</a>`; + } + + if (data.has_summary) { + actionsHTML += `<a href="/api/jobs/${jobId}/summary" class="btn btn-small btn-secondary">📝 Download Sumário</a>`; + } + + actionsHTML += `<button class="btn btn-small btn-secondary" onclick="viewJobDetails('${jobId}')">👁️ Visualizar</button>`; + actionsHTML += '</div>'; + + resultEl.innerHTML = actionsHTML; + } else if (data.status === 'failed') { + resultEl.innerHTML = `<p style="color:red; margin-top:10px;">Erro: ${data.error || 'Erro desconhecido'}</p>`; + } + } + + async function viewJobDetails(jobId) { + try { + const response = await fetch(`/api/jobs/${jobId}/details`); + const data = await response.json(); + + // Usa o formato retornado pelo endpoint + const format = data.format || 'docx'; + + let content = '<div class="preview-content">'; + + if (data.summary) { + content += '<h3>Sumário</h3>'; + // Renderiza Markdown se formato for md/markdown OU se conteúdo contém padrões Markdown + const shouldRenderMarkdown = (format === 'md' || format === 'markdown') || isMarkdownContent(data.summary); + + if (shouldRenderMarkdown && typeof marked !== 'undefined') { + content += marked.parse(data.summary); + } else { + // Formata texto plano preservando quebras de linha + content += '<div class="preview-text-plain">' + escapeHtml(data.summary) + '</div>'; + } + content += '<hr>'; + } + + if (data.transcription) { + content += '<h3>Transcrição</h3>'; + // Renderiza Markdown se formato for md/markdown OU se conteúdo contém padrões Markdown + const shouldRenderMarkdown = (format === 'md' || format === 'markdown') || isMarkdownContent(data.transcription); + + if (shouldRenderMarkdown && typeof marked !== 'undefined') { + content += marked.parse(data.transcription); + } else { + // Formata texto plano preservando quebras de linha + content += '<div class="preview-text-plain">' + escapeHtml(data.transcription) + '</div>'; + } + } + + content += '</div>'; + + document.getElementById(`result-${jobId}`).innerHTML += content; + } catch (error) { + alert('Erro ao carregar detalhes: ' + error.message); + } + } + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function isMarkdownContent(text) { + if (!text) return false; + // Verifica padrões comuns de Markdown + const markdownPatterns = [ + /^\s*#{1,6}\s+.+$/m, // Títulos (# ## ###) + /\*\*[^*]+\*\*/, // Negrito **texto** + /\*[^*]+\*/, // Itálico *texto* + /^\s*[-*+]\s+.+$/m, // Listas não ordenadas + /^\s*\d+\.\s+.+$/m, // Listas ordenadas + /\[.+\]\(.+\)/, // Links [texto](url) + /```[\s\S]*?```/, // Blocos de código ``` + /`[^`]+`/, // Código inline `código` + /^>\s+.+$/m, // Blockquotes > + /^\|.+\|$/m, // Tabelas | + ]; + + return markdownPatterns.some(pattern => pattern.test(text)); + } + + // Histórico + async function loadHistory() { + try { + const response = await fetch('/api/history'); + const data = await response.json(); + allJobs = data.jobs || []; + renderHistory(); + } catch (error) { + console.error('Erro ao carregar histórico:', error); + } + } + + function filterHistory(filter) { + currentFilter = filter; + document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active')); + event.target.classList.add('active'); + renderHistory(); + } + + function renderHistory() { + const historyList = document.getElementById('historyList'); + historyList.innerHTML = ''; + + let filteredJobs = allJobs; + if (currentFilter !== 'all') { + filteredJobs = allJobs.filter(job => job.status === currentFilter); + } + + if (filteredJobs.length === 0) { + historyList.innerHTML = '<p style="text-align:center; color:#666; padding:40px;">Nenhum job encontrado</p>'; + return; + } + + filteredJobs.reverse().forEach(job => { + const jobCard = document.createElement('div'); + jobCard.className = 'job-card'; + jobCard.innerHTML = ` + <div class="job-header"> + <div class="job-title"> + ${job.url || job.file_path || 'Job ' + job.id} + </div> + <span class="job-status status-${job.status}">${job.status}</span> + </div> + ${job.status === 'completed' ? ` + <div class="job-actions"> + ${job.result_path ? `<a href="/api/jobs/${job.id}/download" class="btn btn-small">📥 Download Completo</a>` : ''} + ${job.has_transcription ? `<a href="/api/jobs/${job.id}/transcription" class="btn btn-small btn-secondary">📄 Transcrição</a>` : ''} + ${job.has_summary ? `<a href="/api/jobs/${job.id}/summary" class="btn btn-small btn-secondary">📝 Sumário</a>` : ''} + </div> + ` : ''} + `; + historyList.appendChild(jobCard); + }); + } + + // Downloads + function loadDownloads() { + const downloadsList = document.getElementById('downloadsList'); + downloadsList.innerHTML = ''; + + const completedJobs = allJobs.filter(job => job.status === 'completed'); + + if (completedJobs.length === 0) { + downloadsList.innerHTML = '<p style="text-align:center; color:#666; padding:40px;">Nenhum arquivo disponível para download</p>'; + return; + } + + completedJobs.reverse().forEach(job => { + const jobCard = document.createElement('div'); + jobCard.className = 'job-card'; + jobCard.innerHTML = ` + <div class="job-header"> + <div class="job-title">${job.url || job.file_path || 'Job ' + job.id}</div> + </div> + <div class="job-actions"> + ${job.result_path ? `<a href="/api/jobs/${job.id}/download" class="btn btn-small">📥 Download Completo</a>` : ''} + ${job.has_transcription ? `<a href="/api/jobs/${job.id}/transcription" class="btn btn-small btn-secondary">📄 Download Transcrição</a>` : ''} + ${job.has_summary ? `<a href="/api/jobs/${job.id}/summary" class="btn btn-small btn-secondary">📝 Download Sumário</a>` : ''} + </div> + `; + downloadsList.appendChild(jobCard); + }); + } + </script> +</body> +</html> diff --git a/pyproject.toml b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "lazier" +version = "0.01" +description = "Sistema CLI e WebGUI para transcrição e sumarização de áudios/vídeos/textos/PDFs usando OpenAI API" +authors = [ + {name = "Pablo Murad", email = "pablomurad@pm.me"} +] +readme = "README.md" +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: End Users/Desktop", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Multimedia :: Video", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] + +dependencies = [ + "click>=8.1.0", + "yt-dlp>=2024.0.0", + "openai>=1.0.0", + "python-docx>=1.1.0", + "python-dotenv>=1.0.0", + "fastapi>=0.104.0", + "uvicorn[standard]>=0.24.0", + "websockets>=12.0", + "python-multipart>=0.0.6", + "beautifulsoup4>=4.12.0", + "requests>=2.31.0", + "pypdf>=3.17.0", + "pdfplumber>=0.10.0", + "rich>=13.7.0", + "tqdm>=4.66.0", + "aiofiles>=23.2.0", + "redis>=5.0.0", + "hiredis>=2.2.0", +] + +[project.scripts] +lazier = "lazier.cli:cli" diff --git a/requirements.txt b/requirements.txt @@ -0,0 +1,19 @@ +click>=8.1.0 +yt-dlp>=2024.0.0 +openai>=1.0.0 +python-docx>=1.1.0 +python-dotenv>=1.0.0 +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +websockets>=12.0 +python-multipart>=0.0.6 +beautifulsoup4>=4.12.0 +requests>=2.31.0 +pypdf>=3.17.0 +pdfplumber>=0.10.0 +rich>=13.7.0 +tqdm>=4.66.0 +aiofiles>=23.2.0 +redis>=5.0.0 +hiredis>=2.2.0 +playwright>=1.40.0 diff --git a/setup.py b/setup.py @@ -0,0 +1,64 @@ +""" +Setup script para instalação do pacote Lazier +""" + +from setuptools import setup, find_packages +from pathlib import Path + +# Lê o README para usar como long_description +readme_file = Path(__file__).parent / "README.md" +long_description = "" +if readme_file.exists(): + with open(readme_file, encoding='utf-8') as f: + long_description = f.read() + +setup( + name='lazier', + version='0.01', + author='Pablo Murad', + author_email='pablomurad@pm.me', + description='Sistema CLI e WebGUI para transcrição e sumarização de áudios/vídeos/textos/PDFs usando OpenAI API', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/pablomurad/lazier', + packages=find_packages(), + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: End Users/Desktop', + 'Topic :: Multimedia :: Sound/Audio', + 'Topic :: Multimedia :: Video', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + ], + python_requires='>=3.8', + install_requires=[ + 'click>=8.1.0', + 'yt-dlp>=2024.0.0', + 'openai>=1.0.0', + 'python-docx>=1.1.0', + 'python-dotenv>=1.0.0', + 'fastapi>=0.104.0', + 'uvicorn[standard]>=0.24.0', + 'websockets>=12.0', + 'python-multipart>=0.0.6', + 'beautifulsoup4>=4.12.0', + 'requests>=2.31.0', + 'pypdf>=3.17.0', + 'pdfplumber>=0.10.0', + 'rich>=13.7.0', + 'tqdm>=4.66.0', + 'aiofiles>=23.2.0', + 'redis>=5.0.0', + 'hiredis>=2.2.0', + ], + entry_points={ + 'console_scripts': [ + 'lazier=lazier.cli:cli', + ], + }, +) diff --git a/tests/__init__.py b/tests/__init__.py @@ -0,0 +1,2 @@ +# Diretório de testes +# Testes serão implementados em versões futuras