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:
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