commit 7d63cbdca25f6ae7a27bffef7523257341a54527
parent 5cd8535c25c81479b5e04eb06d4836bd1f8b369b
Author: Pablo Murad <pablo@pablomurad.com>
Date: Mon, 26 Jan 2026 19:47:52 -0300
Improve YouTube download error handling with custom exceptions and Python patterns
- Add custom exception hierarchy (LazierException, YouTubeDownloadError, YouTubeVideoUnavailableError, YouTubeAccessDeniedError)
- Enable remote components EJS support (ejs:github) for JavaScript challenge solving
- Implement error classification function to determine retry strategy
- Optimize retry logic to stop immediately on definitive errors (unavailable, private, removed videos)
- Prioritize tv_embedded client (doesn't require PO Token) as first option
- Improve error handling in routes.py with specific exception types
- Add error code extraction for better error reporting
- Enhance user-facing error messages with more context
Diffstat:
15 files changed, 1707 insertions(+), 291 deletions(-)
diff --git a/.env.example b/.env.example
@@ -1,3 +1,16 @@
# Configuração da API OpenAI
# Obtenha sua chave em: https://platform.openai.com/api-keys
OPENAI_API_KEY=
+
+# Autenticação
+# Gere uma chave secreta com: openssl rand -hex 32
+SESSION_SECRET_KEY=
+
+# Usuário Admin (opcional, mas recomendado)
+# Se o banco estiver vazio e essas variáveis existirem, um usuário admin será criado automaticamente
+ADMIN_USER=
+ADMIN_PASSWORD=
+
+# Opcional: YouTube PO Token para acesso a formatos Android
+# Obtenha em: https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide
+# YOUTUBE_PO_TOKEN=android.gvs+XXX
diff --git a/docker/Dockerfile b/docker/Dockerfile
@@ -12,6 +12,7 @@ RUN apt-get update && \
curl \
git \
ca-certificates \
+ unzip \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /tmp/* \
@@ -27,6 +28,18 @@ RUN curl -L --fail --retry 3 --retry-delay 5 \
chmod a+rx /usr/local/bin/yt-dlp && \
yt-dlp --version || (echo "ERRO: yt-dlp não foi instalado" && exit 1)
+# Instalar Deno (JavaScript runtime para yt-dlp funcionar com YouTube)
+# Baixar binário diretamente do GitHub releases
+RUN DENO_VERSION=$(curl -s https://api.github.com/repos/denoland/deno/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') && \
+ curl -L --fail --retry 3 --retry-delay 5 \
+ "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" \
+ -o /tmp/deno.zip && \
+ unzip /tmp/deno.zip -d /tmp/deno && \
+ mv /tmp/deno/deno /usr/local/bin/deno && \
+ chmod +x /usr/local/bin/deno && \
+ rm -rf /tmp/deno.zip /tmp/deno && \
+ deno --version || (echo "ERRO: Deno não foi instalado" && exit 1)
+
WORKDIR /app
# Copiar requirements primeiro (para aproveitar cache do Docker)
diff --git a/docker/nginx.conf b/docker/nginx.conf
@@ -0,0 +1,75 @@
+# Configuração Nginx para Lazier
+# Suporta uploads grandes e WebSocket
+
+server {
+ listen 80;
+ server_name _; # Substitua pelo seu domínio em produção
+
+ # Tamanho máximo de upload (2GB)
+ client_max_body_size 2048m;
+
+ # Desabilita buffering de request para uploads grandes
+ proxy_request_buffering off;
+
+ # Timeouts aumentados para processamento longo
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ proxy_connect_timeout 60s;
+
+ # Headers para proxy
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # WebSocket support
+ location /ws/ {
+ proxy_pass http://localhost:19283;
+ proxy_http_version 1.1;
+
+ # Headers WebSocket
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # Timeouts para WebSocket
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+
+ # Headers padrão
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ # API e aplicação
+ location / {
+ proxy_pass http://localhost:19283;
+ proxy_http_version 1.1;
+
+ # Headers padrão
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Suporte a streaming para uploads grandes
+ proxy_buffering off;
+ proxy_request_buffering off;
+ }
+
+ # Logs
+ access_log /var/log/nginx/lazier_access.log;
+ error_log /var/log/nginx/lazier_error.log;
+}
+
+# Para HTTPS (descomente e configure em produção)
+# server {
+# listen 443 ssl http2;
+# server_name seu-dominio.com;
+#
+# ssl_certificate /path/to/cert.pem;
+# ssl_certificate_key /path/to/key.pem;
+#
+# # ... mesma configuração acima ...
+# }
diff --git a/lazier/api/auth_routes.py b/lazier/api/auth_routes.py
@@ -0,0 +1,155 @@
+"""
+Rotas de autenticação (login/logout)
+"""
+
+from fastapi import APIRouter, Request, Form, HTTPException
+from fastapi.responses import RedirectResponse, HTMLResponse, FileResponse
+from pathlib import Path
+from typing import Optional
+
+from ..core.auth import verify_password, init_db
+
+router = APIRouter()
+
+# Diretório de templates
+templates_dir = Path(__file__).parent.parent / "web" / "templates"
+
+
+@router.get("/login")
+async def login_page():
+ """Retorna página de login"""
+ login_file = templates_dir / "login.html"
+ if login_file.exists():
+ return FileResponse(str(login_file))
+
+ # Fallback: HTML simples inline
+ html_content = """
+ <!DOCTYPE html>
+ <html lang="pt-BR">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Login - Lazier</title>
+ <style>
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ margin: 0;
+ }
+ .login-container {
+ background: white;
+ padding: 40px;
+ border-radius: 12px;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.2);
+ width: 100%;
+ max-width: 400px;
+ }
+ h1 {
+ color: #283593;
+ margin-bottom: 30px;
+ text-align: center;
+ }
+ .form-group {
+ margin-bottom: 20px;
+ }
+ label {
+ display: block;
+ margin-bottom: 8px;
+ color: #333;
+ font-weight: 500;
+ }
+ input {
+ width: 100%;
+ padding: 12px;
+ border: 2px solid #ddd;
+ border-radius: 8px;
+ font-size: 16px;
+ box-sizing: border-box;
+ }
+ input:focus {
+ outline: none;
+ border-color: #283593;
+ }
+ button {
+ width: 100%;
+ padding: 12px;
+ background: linear-gradient(135deg, #283593 0%, #3949ab 100%);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ margin-top: 10px;
+ }
+ button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(40, 53, 147, 0.3);
+ }
+ .error {
+ color: #d32f2f;
+ margin-top: 10px;
+ text-align: center;
+ font-size: 14px;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="login-container">
+ <h1>🎧 Lazier</h1>
+ <form method="POST" action="/login">
+ <div class="form-group">
+ <label for="username">Usuário</label>
+ <input type="text" id="username" name="username" required autofocus>
+ </div>
+ <div class="form-group">
+ <label for="password">Senha</label>
+ <input type="password" id="password" name="password" required>
+ </div>
+ <button type="submit">Entrar</button>
+ </form>
+ </div>
+ </body>
+ </html>
+ """
+ return HTMLResponse(content=html_content)
+
+
+@router.post("/login")
+async def login(
+ request: Request,
+ username: str = Form(...),
+ password: str = Form(...)
+):
+ """Processa login e cria sessão"""
+ # Verifica credenciais
+ if not verify_password(username, password):
+ # Retorna página de login com erro
+ login_file = templates_dir / "login.html"
+ if login_file.exists():
+ with open(login_file, 'r', encoding='utf-8') as f:
+ html = f.read()
+ html = html.replace('</form>', '<div class="error">Usuário ou senha incorretos</div></form>')
+ return HTMLResponse(content=html, status_code=401)
+
+ # Fallback: redirect com erro
+ return RedirectResponse(url="/login?error=invalid", status_code=303)
+
+ # Cria sessão
+ request.session["username"] = username
+ request.session["authenticated"] = True
+
+ # Redireciona para home
+ return RedirectResponse(url="/", status_code=303)
+
+
+@router.get("/logout")
+@router.post("/logout")
+async def logout(request: Request):
+ """Limpa sessão e redireciona para login"""
+ request.session.clear()
+ return RedirectResponse(url="/login", status_code=303)
diff --git a/lazier/api/main.py b/lazier/api/main.py
@@ -7,10 +7,13 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
+from starlette.middleware.sessions import SessionMiddleware
from dotenv import load_dotenv
from .routes import router
from .websocket import websocket_router
+from .auth_routes import router as auth_router
+from .middleware import AuthMiddleware
load_dotenv()
@@ -18,13 +21,24 @@ load_dotenv()
def create_app() -> FastAPI:
"""Cria e configura aplicação FastAPI"""
+ # Bootstrap admin na inicialização
+ try:
+ from ..core.auth import bootstrap_admin
+ bootstrap_admin()
+ except Exception as e:
+ print(f"ERRO: Falha ao inicializar autenticação: {e}")
+ raise
+
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
+ # Ordem dos middlewares: no FastAPI, o último adicionado executa primeiro
+ # Por isso, adicionamos na ordem inversa da execução desejada
+
+ # 1. CORS (executa por último - adicionado primeiro)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Em produção, especificar origens
@@ -33,6 +47,27 @@ def create_app() -> FastAPI:
allow_headers=["*"],
)
+ # 2. AuthMiddleware (executa no meio - adicionado segundo)
+ app.add_middleware(AuthMiddleware)
+
+ # 3. SessionMiddleware (executa primeiro - adicionado por último)
+ # Deve vir por último para executar primeiro e criar a sessão antes do AuthMiddleware
+ session_secret = os.getenv('SESSION_SECRET_KEY')
+ if not session_secret:
+ raise Exception(
+ "SESSION_SECRET_KEY não configurada. "
+ "Configure esta variável no arquivo .env. "
+ "Gere uma chave com: openssl rand -hex 32"
+ )
+
+ app.add_middleware(
+ SessionMiddleware,
+ secret_key=session_secret,
+ max_age=14400, # 4 horas
+ same_site="lax",
+ https_only=False, # True em produção com HTTPS
+ )
+
# Inicializa cache
try:
from ..core.cache import get_cache_manager
@@ -42,7 +77,10 @@ def create_app() -> FastAPI:
print(f"Aviso: Cache Redis não disponível: {e}")
app.state.cache = None
- # Rotas da API
+ # Rotas de autenticação (públicas)
+ app.include_router(auth_router)
+
+ # Rotas da API (protegidas)
app.include_router(router, prefix="/api")
app.include_router(websocket_router, prefix="/ws")
diff --git a/lazier/api/middleware.py b/lazier/api/middleware.py
@@ -0,0 +1,74 @@
+"""
+Middleware de autenticação
+"""
+
+from fastapi import Request, HTTPException, status
+from fastapi.responses import RedirectResponse, JSONResponse
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.types import ASGIApp
+
+
+class AuthMiddleware(BaseHTTPMiddleware):
+ """Middleware que protege rotas exigindo autenticação"""
+
+ # Rotas públicas que não precisam de autenticação
+ PUBLIC_ROUTES = {
+ '/login',
+ '/health',
+ }
+
+ # Prefixos de rotas públicas
+ PUBLIC_PREFIXES = [
+ '/static',
+ ]
+
+ def __init__(self, app: ASGIApp):
+ super().__init__(app)
+
+ async def dispatch(self, request: Request, call_next):
+ """Verifica autenticação antes de processar request"""
+
+ path = request.url.path
+
+ # Verifica se é rota pública
+ if self._is_public_route(path):
+ return await call_next(request)
+
+ # Verifica se está autenticado
+ authenticated = request.session.get("authenticated", False)
+
+ if not authenticated:
+ # Diferencia comportamento para HTML vs API
+ if path.startswith("/api/") or path.startswith("/ws/"):
+ # API/WebSocket: retorna 401 JSON
+ if path.startswith("/ws/"):
+ # Para WebSocket, retorna erro antes de aceitar conexão
+ return JSONResponse(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ content={"error": "Unauthorized", "message": "Autenticação necessária"}
+ )
+ else:
+ # API: retorna 401 JSON
+ return JSONResponse(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ content={"error": "Unauthorized", "message": "Autenticação necessária"}
+ )
+ else:
+ # HTML: redirect para login
+ return RedirectResponse(url="/login", status_code=303)
+
+ # Request autenticado, continua
+ return await call_next(request)
+
+ def _is_public_route(self, path: str) -> bool:
+ """Verifica se a rota é pública"""
+ # Rotas exatas
+ if path in self.PUBLIC_ROUTES:
+ return True
+
+ # Prefixos públicos
+ for prefix in self.PUBLIC_PREFIXES:
+ if path.startswith(prefix):
+ return True
+
+ return False
diff --git a/lazier/api/routes.py b/lazier/api/routes.py
@@ -16,9 +16,14 @@ 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 ..core.exceptions import (
+ YouTubeDownloadError,
+ YouTubeVideoUnavailableError,
+ YouTubeAccessDeniedError
+)
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 ..audio_processor import prepare_audio_file, extract_audio_from_video
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, extract_pdf_content
@@ -98,12 +103,13 @@ def process_file_async(
if input_type in ['audio', 'video']:
# Processa áudio/vídeo
+ # Nota: Vídeos já foram convertidos para áudio no upload
# 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...')
+ broadcast_progress(job_id, 30, 'processing', 'Preparando áudio...')
# Verifica cache
cached = cache.get('transcription', file_hash) if cache else None
@@ -112,7 +118,8 @@ def process_file_async(
metadata = cached.get('metadata', {})
broadcast_progress(job_id, 50, 'processing', 'Transcrição encontrada no cache')
else:
- # Prepara áudio
+ # Prepara áudio (vídeos já vêm como áudio, então is_video=False)
+ # Mantém fallback para vídeo caso algum chegue aqui (não deveria)
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...')
@@ -269,6 +276,8 @@ async def upload_files(
summarize: bool = Form(True)
):
"""Upload de arquivos para processamento"""
+ from ..utils import VIDEO_EXTENSIONS
+
job_ids = []
for file in files:
@@ -284,6 +293,23 @@ async def upload_files(
content = await file.read()
f.write(content)
+ # Se for vídeo, extrair áudio imediatamente
+ if ext in VIDEO_EXTENSIONS:
+ try:
+ # Extrai áudio do vídeo
+ audio_file = extract_audio_from_video(str(file_path))
+ # Deleta vídeo original
+ file_path.unlink()
+ # Usa áudio como arquivo principal
+ file_path = Path(audio_file)
+ except Exception as e:
+ # Se falhar, mantém vídeo original e reporta erro
+ logger.error(f"Erro ao extrair áudio do vídeo {file.filename}: {e}")
+ raise HTTPException(
+ status_code=500,
+ detail=f"Erro ao extrair áudio do vídeo: {str(e)}"
+ )
+
# Cria job
job_id = str(uuid.uuid4())
jobs[job_id] = {
@@ -386,12 +412,62 @@ def process_youtube_async(url: str, job_id: str, output_format: str, should_tran
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))
+ try:
+ audio_file, metadata = download_youtube_audio(url, str(UPLOAD_DIR))
+ except YouTubeVideoUnavailableError as e:
+ logger.error(f"Vídeo não disponível (job {job_id}): {str(e)}")
+ user_message = (
+ "Erro: O vídeo não está disponível. "
+ "Pode ser que o vídeo seja privado, tenha sido removido ou esteja bloqueado por região."
+ )
+ if e.error_code:
+ user_message += f" (Código: {e.error_code})"
+ jobs[job_id]['status'] = 'failed'
+ jobs[job_id]['error'] = user_message
+ broadcast_progress(job_id, 0, 'failed', user_message)
+ return
+ except YouTubeAccessDeniedError as e:
+ logger.error(f"Acesso negado ao vídeo (job {job_id}): {str(e)}")
+ user_message = (
+ "Erro ao baixar vídeo do YouTube: O YouTube está bloqueando o download. "
+ "Isso pode ser temporário. Tente novamente em alguns minutos ou verifique se o vídeo está disponível."
+ )
+ if e.error_code:
+ user_message += f" (Código: {e.error_code})"
+ jobs[job_id]['status'] = 'failed'
+ jobs[job_id]['error'] = user_message
+ broadcast_progress(job_id, 0, 'failed', user_message)
+ return
+ except YouTubeDownloadError as e:
+ logger.error(f"Erro ao baixar vídeo do YouTube (job {job_id}): {str(e)}")
+ user_message = f"Erro ao baixar vídeo do YouTube: {str(e)}"
+ if e.error_code:
+ user_message += f" (Código: {e.error_code})"
+ jobs[job_id]['status'] = 'failed'
+ jobs[job_id]['error'] = user_message
+ broadcast_progress(job_id, 0, 'failed', user_message)
+ return
+ except Exception as e:
+ logger.error(f"Erro inesperado ao baixar vídeo (job {job_id}): {str(e)}")
+ user_message = f"Erro inesperado ao baixar vídeo: {str(e)}"
+ jobs[job_id]['status'] = 'failed'
+ jobs[job_id]['error'] = user_message
+ broadcast_progress(job_id, 0, 'failed', user_message)
+ return
+
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')
+ try:
+ transcription_internal = transcribe_audio(audio_file, language='pt', model='whisper-1')
+ except Exception as e:
+ logger.error(f"Erro ao transcrever áudio (job {job_id}): {str(e)}")
+ jobs[job_id]['status'] = 'failed'
+ jobs[job_id]['error'] = f"Erro ao transcrever áudio: {str(e)}"
+ broadcast_progress(job_id, 0, 'failed', f"Erro ao transcrever áudio: {str(e)}")
+ return
+
jobs[job_id]['progress'] = 60
broadcast_progress(job_id, 60, 'processing', 'Transcrição concluída')
@@ -476,10 +552,28 @@ def process_youtube_async(url: str, job_id: str, output_format: str, should_tran
jobs[job_id]['progress'] = 100
broadcast_progress(job_id, 100, 'completed', 'Processamento concluído')
+ except (YouTubeVideoUnavailableError, YouTubeAccessDeniedError, YouTubeDownloadError) as e:
+ # Erros de download já foram tratados acima, mas pode haver outros casos
+ error_msg = str(e)
+ logger.error(f"Erro no processamento do YouTube (job {job_id}): {error_msg}")
+
+ # Se o erro já foi tratado acima (download/transcrição), não sobrescreve
+ if jobs[job_id].get('status') != 'failed':
+ user_message = f"Erro no processamento: {error_msg}"
+ if hasattr(e, 'error_code') and e.error_code:
+ user_message += f" (Código: {e.error_code})"
+ jobs[job_id]['status'] = 'failed'
+ jobs[job_id]['error'] = user_message
+ broadcast_progress(job_id, 0, 'failed', user_message)
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)}')
+ error_msg = str(e)
+ logger.error(f"Erro inesperado no processamento do YouTube (job {job_id}): {error_msg}")
+
+ # Se o erro já foi tratado acima (download/transcrição), não sobrescreve
+ if jobs[job_id].get('status') != 'failed':
+ jobs[job_id]['status'] = 'failed'
+ jobs[job_id]['error'] = error_msg
+ broadcast_progress(job_id, 0, 'failed', f'Erro: {error_msg}')
def process_web_async(url: str, job_id: str, output_format: str, should_transcribe: bool, should_summarize: bool):
@@ -850,9 +944,32 @@ async def download_result(job_id: str):
)
+def _get_job_title(job: dict) -> str:
+ """Extrai título descritivo de um job"""
+ metadata = job.get('metadata', {})
+
+ # Prioridade 1: Título dos metadados (YouTube, PDF, Web)
+ if metadata.get('title'):
+ return metadata['title']
+
+ # Prioridade 2: Nome do arquivo (sem extensão)
+ if job.get('file_path'):
+ return Path(job['file_path']).stem
+
+ # Prioridade 3: URL (para YouTube/Web sem título)
+ if job.get('url'):
+ # Para YouTube, pode tentar extrair título da URL
+ if 'youtube.com' in job['url'] or 'youtu.be' in job['url']:
+ return "Vídeo do YouTube"
+ return job['url']
+
+ # Fallback
+ return f"Job {job.get('id', 'desconhecido')}"
+
+
@router.get("/history")
async def get_history():
- """Retorna histórico de jobs"""
+ """Retorna histórico de jobs com títulos descritivos"""
# Formata jobs para incluir campos necessários
formatted_jobs = []
for job in jobs.values():
@@ -860,6 +977,7 @@ async def get_history():
'id': job.get('id'),
'status': job.get('status'),
'progress': job.get('progress', 0),
+ 'title': _get_job_title(job), # Título descritivo
'url': job.get('url'),
'file_path': job.get('file_path'),
'format': job.get('format', 'docx'),
diff --git a/lazier/api/websocket.py b/lazier/api/websocket.py
@@ -13,9 +13,40 @@ websocket_router = APIRouter()
connections: dict = {}
+def _verify_websocket_session(cookies: dict) -> bool:
+ """
+ Verifica se o cookie de sessão existe
+
+ Args:
+ cookies: Dicionário de cookies do WebSocket
+
+ Returns:
+ True se cookie de sessão existe, False caso contrário
+
+ Nota: Validação completa da sessão seria complexa sem acesso ao SessionMiddleware.
+ Como o cookie é httpOnly e só é criado após login bem-sucedido, verificar sua
+ existência já fornece proteção básica. Em produção, considere usar tokens JWT
+ ou implementar validação mais robusta.
+ """
+ session_cookie = cookies.get("session")
+ # Cookie de sessão só existe após login bem-sucedido
+ # Como é httpOnly, não pode ser falsificado via JavaScript
+ return session_cookie is not None and len(session_cookie) > 0
+
+
@websocket_router.websocket("/progress/{job_id}")
async def websocket_progress(websocket: WebSocket, job_id: str):
"""WebSocket para receber atualizações de progresso"""
+ # Verifica autenticação antes de aceitar conexão
+ # WebSocket não passa pelo middleware HTTP, então verificamos manualmente
+ if not _verify_websocket_session(websocket.cookies):
+ await websocket.close(
+ code=1008, # Policy Violation
+ reason="Autenticação necessária"
+ )
+ return
+
+ # Aceita conexão apenas se autenticado
await websocket.accept()
if job_id not in connections:
diff --git a/lazier/core/auth.py b/lazier/core/auth.py
@@ -0,0 +1,243 @@
+"""
+Módulo de autenticação usando SQLite e bcrypt
+"""
+
+import os
+import sqlite3
+from pathlib import Path
+from typing import Optional
+from datetime import datetime
+from passlib.context import CryptContext
+from dotenv import load_dotenv
+
+# Monkey patch para evitar erro durante detect_wrap_bug do passlib
+# O passlib tenta detectar um bug do bcrypt usando uma senha de teste que pode exceder 72 bytes
+try:
+ import bcrypt as _bcrypt_module
+ _original_hashpw = _bcrypt_module.hashpw
+
+ def _safe_hashpw(secret, salt):
+ """Wrapper que trunca secret para 72 bytes antes de chamar bcrypt"""
+ if isinstance(secret, str):
+ secret = secret.encode('utf-8')
+ if len(secret) > 72:
+ secret = secret[:72]
+ return _original_hashpw(secret, salt)
+
+ _bcrypt_module.hashpw = _safe_hashpw
+
+ # Monkey patch para fornecer __about__ ao bcrypt (evita aviso do passlib)
+ if not hasattr(_bcrypt_module, '__about__'):
+ # Cria objeto __about__ com __version__ se não existir
+ class _BcryptAbout:
+ def __init__(self):
+ # Tenta obter versão de diferentes formas
+ try:
+ import importlib.metadata
+ self.__version__ = importlib.metadata.version('bcrypt')
+ except:
+ try:
+ self.__version__ = _bcrypt_module.__version__
+ except:
+ self.__version__ = 'unknown'
+
+ _bcrypt_module.__about__ = _BcryptAbout()
+except ImportError:
+ # Se bcrypt não estiver disponível, ignora o monkey patch
+ pass
+
+load_dotenv()
+
+# Contexto para hash de senhas (inicializado lazy para evitar problemas na importação)
+_pwd_context = None
+
+
+def get_pwd_context():
+ """Retorna o contexto de hash de senhas, inicializando se necessário"""
+ global _pwd_context
+ if _pwd_context is None:
+ _pwd_context = CryptContext(
+ schemes=["bcrypt"],
+ deprecated="auto",
+ bcrypt__ident="2b", # Usa identificador bcrypt 2b (mais moderno)
+ bcrypt__rounds=12, # Número de rounds (padrão seguro)
+ bcrypt__truncate_error=False, # Não levanta erro, trunca silenciosamente durante detecção de bug
+ )
+ return _pwd_context
+
+
+def _normalize_password(password: str) -> str:
+ """
+ Normaliza senha para compatibilidade com bcrypt (limite de 72 bytes)
+
+ Args:
+ password: Senha em texto plano
+
+ Returns:
+ Senha truncada para 72 bytes se necessário (retorna como string)
+
+ Raises:
+ ValueError: Se senha estiver vazia
+ """
+ if not password:
+ raise ValueError("Senha não pode estar vazia")
+
+ # Converte para bytes UTF-8 para calcular tamanho correto
+ password_bytes = password.encode('utf-8')
+
+ # Trunca para 72 bytes se necessário (limite do bcrypt)
+ if len(password_bytes) > 72:
+ # Avisa sobre truncamento (apenas em desenvolvimento/debug)
+ import warnings
+ warnings.warn(
+ "Senha truncada para 72 bytes devido à limitação do bcrypt. "
+ "Considere usar uma senha mais curta.",
+ UserWarning
+ )
+ # Trunca para 72 bytes e converte de volta para string
+ password_bytes = password_bytes[:72]
+ # Decodifica de volta para string, ignorando erros de encoding
+ return password_bytes.decode('utf-8', errors='ignore')
+
+ # Se não precisa truncar, retorna a senha original
+ return password
+
+
+def get_db_path() -> Path:
+ """Retorna o caminho do banco de dados SQLite"""
+ data_dir = Path(os.getenv('LAZIER_DATA_DIR', '/app/data'))
+ data_dir.mkdir(parents=True, exist_ok=True)
+ return data_dir / "users.db"
+
+
+def init_db() -> None:
+ """Inicializa o banco de dados criando a tabela users se não existir"""
+ db_path = get_db_path()
+
+ with sqlite3.connect(db_path) as conn:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS users (
+ username TEXT PRIMARY KEY,
+ password_hash TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ """)
+ conn.commit()
+
+
+def create_user(username: str, password: str) -> bool:
+ """
+ Cria um novo usuário com senha hasheada
+
+ Args:
+ username: Nome de usuário
+ password: Senha em texto plano (será hasheada)
+
+ Returns:
+ True se criado com sucesso, False se usuário já existe
+ """
+ db_path = get_db_path()
+
+ # Verifica se usuário já existe
+ if get_user(username):
+ return False
+
+ # Normaliza senha antes do hash (bcrypt limita a 72 bytes)
+ normalized_password = _normalize_password(password)
+ pwd_context = get_pwd_context()
+ password_hash = pwd_context.hash(normalized_password)
+
+ with sqlite3.connect(db_path) as conn:
+ conn.execute(
+ "INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)",
+ (username, password_hash, datetime.now().isoformat())
+ )
+ conn.commit()
+
+ return True
+
+
+def verify_password(username: str, password: str) -> bool:
+ """
+ Verifica se a senha está correta para o usuário
+
+ Args:
+ username: Nome de usuário
+ password: Senha em texto plano
+
+ Returns:
+ True se senha está correta, False caso contrário
+ """
+ user = get_user(username)
+ if not user:
+ return False
+
+ # Normaliza senha antes da verificação (bcrypt limita a 72 bytes)
+ normalized_password = _normalize_password(password)
+ pwd_context = get_pwd_context()
+ return pwd_context.verify(normalized_password, user['password_hash'])
+
+
+def get_user(username: str) -> Optional[dict]:
+ """
+ Busca um usuário pelo username
+
+ Args:
+ username: Nome de usuário
+
+ Returns:
+ Dicionário com dados do usuário ou None se não encontrado
+ """
+ db_path = get_db_path()
+
+ with sqlite3.connect(db_path) as conn:
+ conn.row_factory = sqlite3.Row
+ cursor = conn.execute(
+ "SELECT username, password_hash, created_at FROM users WHERE username = ?",
+ (username,)
+ )
+ row = cursor.fetchone()
+
+ if row:
+ return {
+ 'username': row['username'],
+ 'password_hash': row['password_hash'],
+ 'created_at': row['created_at']
+ }
+
+ return None
+
+
+def user_count() -> int:
+ """Retorna o número de usuários no banco"""
+ db_path = get_db_path()
+
+ with sqlite3.connect(db_path) as conn:
+ cursor = conn.execute("SELECT COUNT(*) as count FROM users")
+ row = cursor.fetchone()
+ return row[0] if row else 0
+
+
+def bootstrap_admin() -> None:
+ """
+ Cria usuário admin na inicialização se tabela estiver vazia
+
+ Requer ADMIN_USER e ADMIN_PASSWORD nas variáveis de ambiente.
+ Se tabela vazia e variáveis não existem, aborta com erro.
+ """
+ init_db()
+
+ count = user_count()
+
+ if count == 0:
+ admin_user = os.getenv('ADMIN_USER')
+ admin_password = os.getenv('ADMIN_PASSWORD')
+
+ if admin_user and admin_password:
+ create_user(admin_user, admin_password)
+ print(f"Usuário admin '{admin_user}' criado com sucesso")
+ else:
+ raise Exception(
+ "Banco de dados vazio e variáveis ADMIN_USER/ADMIN_PASSWORD não configuradas. "
+ "Configure essas variáveis no arquivo .env para criar o primeiro usuário."
+ )
diff --git a/lazier/core/exceptions.py b/lazier/core/exceptions.py
@@ -0,0 +1,34 @@
+"""
+Exceções customizadas para o Lazier
+"""
+
+from typing import Optional
+
+
+class LazierException(Exception):
+ """Exceção base para erros do Lazier"""
+ pass
+
+
+class YouTubeDownloadError(LazierException):
+ """Erro ao baixar vídeo do YouTube"""
+
+ def __init__(
+ self,
+ message: str,
+ error_code: Optional[str] = None,
+ original_error: Optional[Exception] = None
+ ):
+ self.error_code = error_code
+ self.original_error = original_error
+ super().__init__(message)
+
+
+class YouTubeVideoUnavailableError(YouTubeDownloadError):
+ """Vídeo do YouTube não está disponível (privado, removido, bloqueado por região)"""
+ pass
+
+
+class YouTubeAccessDeniedError(YouTubeDownloadError):
+ """Acesso negado ao vídeo (403, bloqueio)"""
+ pass
diff --git a/lazier/downloader.py b/lazier/downloader.py
@@ -4,14 +4,129 @@ Módulo para download de vídeos do YouTube usando yt-dlp
import os
import tempfile
+import shutil
+import logging
+import re
from pathlib import Path
-from typing import Optional, Dict, Any
+from typing import Optional, Dict, Any, Tuple, Type
import yt_dlp
+from .core.exceptions import (
+ YouTubeDownloadError,
+ YouTubeVideoUnavailableError,
+ YouTubeAccessDeniedError
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _check_deno_available() -> bool:
+ """Verifica se Deno está disponível no sistema"""
+ return shutil.which('deno') is not None
+
+
+def _extract_error_code(error_str: str) -> Optional[str]:
+ """
+ Extrai código de erro do YouTube de uma string de erro
+
+ Args:
+ error_str: String de erro do yt-dlp
+
+ Returns:
+ Código de erro extraído ou None
+ """
+ # Procura por padrão "Error code: XXX - YYY"
+ match = re.search(r'error code:\s*(\d+)\s*-\s*(\d+)', error_str, re.IGNORECASE)
+ if match:
+ return f"{match.group(1)}-{match.group(2)}"
+ return None
+
+
+def _classify_youtube_error(error: Exception) -> Tuple[Type[YouTubeDownloadError], bool]:
+ """
+ Classifica erro do YouTube e retorna (exception_class, should_retry)
+
+ Args:
+ error: Exceção do yt-dlp
+
+ Returns:
+ Tupla (classe de exceção, se deve tentar novamente)
+ """
+ error_str = str(error).lower()
+
+ # Erros definitivos (não deve retry)
+ if 'unavailable' in error_str or 'error code: 152' in error_str:
+ return YouTubeVideoUnavailableError, False
+
+ if 'private' in error_str or 'privado' in error_str:
+ return YouTubeVideoUnavailableError, False
+
+ if 'removed' in error_str or 'removido' in error_str:
+ return YouTubeVideoUnavailableError, False
+
+ # Erros que podem ser temporários (deve retry)
+ if '403' in error_str or 'forbidden' in error_str:
+ return YouTubeAccessDeniedError, True
+
+ # Erro genérico (tenta retry)
+ return YouTubeDownloadError, True
+
+
+def _create_ydl_opts(output_path: Path, format_str: str = 'bestaudio/best', use_deno: bool = True, progress_hook=None) -> Dict[str, Any]:
+ """
+ Cria opções de configuração para yt-dlp
+
+ Args:
+ output_path: Caminho de saída
+ format_str: Formato de áudio desejado
+ use_deno: Se deve tentar usar Deno como JavaScript runtime
+ progress_hook: Hook de progresso (opcional)
+
+ Returns:
+ Dicionário com opções do yt-dlp
+ """
+ # Priorizar tv_embedded como primeiro cliente (não requer PO Token)
+ extractor_args = {
+ 'youtube': {
+ 'player_client': ['tv_embedded', 'ios', 'mweb'],
+ }
+ }
+
+ # Adiciona PO Token se fornecido via variável de ambiente
+ po_token = os.getenv('YOUTUBE_PO_TOKEN')
+ if po_token:
+ extractor_args['youtube']['po_token'] = po_token
+ logger.info("Usando PO Token fornecido via YOUTUBE_PO_TOKEN")
+
+ opts = {
+ 'format': format_str,
+ 'outtmpl': str(output_path / '%(title)s.%(ext)s'),
+ 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'extractor_args': extractor_args,
+ 'remote_components': ['ejs:github'], # Baixa componentes EJS do GitHub
+ 'retries': 3,
+ 'fragment_retries': 3,
+ 'quiet': False,
+ 'no_warnings': False,
+ 'extractaudio': False,
+ 'postprocessors': [],
+ }
+
+ # Adiciona hook de progresso se fornecido
+ if progress_hook:
+ opts['progress_hooks'] = [progress_hook]
+
+ # Adiciona Deno se disponível
+ if use_deno and _check_deno_available():
+ opts['js_runtime'] = 'deno'
+ logger.info("Usando Deno como JavaScript runtime para yt-dlp")
+
+ return opts
+
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
+ Baixa o melhor áudio disponível de um vídeo do YouTube com retry logic e fallbacks
Args:
url: URL do vídeo do YouTube
@@ -19,6 +134,9 @@ def download_youtube_audio(url: str, output_dir: Optional[str] = None) -> tuple[
Returns:
Tupla (caminho_do_arquivo, metadados)
+
+ Raises:
+ Exception: Se o download falhar após todas as tentativas
"""
if output_dir is None:
output_dir = tempfile.gettempdir()
@@ -28,58 +146,183 @@ def download_youtube_audio(url: str, output_dir: Optional[str] = None) -> tuple[
metadata = {}
downloaded_file = None
+ last_error = 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')
+ # Estratégias de fallback: formatos e configurações diferentes
+ strategies = [
+ {
+ 'format': 'bestaudio/best',
+ 'description': 'melhor áudio disponível',
+ 'use_deno': True,
+ },
+ {
+ 'format': '251/250/249/bestaudio/best', # Opus, Opus, Opus, fallback
+ 'description': 'formatos Opus alternativos',
+ 'use_deno': True,
+ },
+ {
+ 'format': 'bestaudio/best',
+ 'description': 'sem JavaScript runtime',
+ 'use_deno': False,
+ },
+ {
+ 'format': 'worstaudio/worst', # Último recurso
+ 'description': 'qualidade mínima',
+ 'use_deno': False,
+ },
+ ]
- # 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)
+ for attempt, strategy in enumerate(strategies, 1):
+ try:
+ logger.info(f"Tentativa {attempt}/{len(strategies)}: {strategy['description']}")
+
+ # Hook de progresso para capturar nome do arquivo
+ def progress_hook(d):
+ """Hook para capturar o nome do arquivo baixado"""
+ nonlocal downloaded_file
+ if d['status'] == 'finished':
+ downloaded_file = d.get('filename')
+
+ ydl_opts = _create_ydl_opts(
+ output_path,
+ format_str=strategy['format'],
+ use_deno=strategy['use_deno'],
+ progress_hook=progress_hook
+ )
+
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+ # Extrai informações sem baixar primeiro
+ try:
+ info = ydl.extract_info(url, download=False)
+ except (yt_dlp.utils.DownloadError, yt_dlp.utils.ExtractorError) as e:
+ logger.warning(f"Erro ao extrair informações (tentativa {attempt}): {str(e)}")
+ last_error = e
+
+ # Classifica o erro
+ exc_class, should_retry = _classify_youtube_error(e)
+
+ if not should_retry:
+ # Erro definitivo, não tenta mais
+ error_code = _extract_error_code(str(e))
+ raise exc_class(
+ f"Vídeo não está disponível: {str(e)}",
+ error_code=error_code,
+ original_error=e
+ )
+
+ # Erro pode ser temporário, continua tentando
+ if attempt >= len(strategies):
+ error_code = _extract_error_code(str(e))
+ raise exc_class(
+ f"Erro ao extrair informações após {len(strategies)} tentativas: {str(e)}",
+ error_code=error_code,
+ original_error=e
+ )
+ continue
+
+ # 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
+ try:
+ ydl.download([url])
+ except yt_dlp.utils.DownloadError as e:
+ logger.warning(f"Erro ao baixar (tentativa {attempt}): {str(e)}")
+ last_error = e
+
+ # Classifica o erro
+ exc_class, should_retry = _classify_youtube_error(e)
+
+ if not should_retry:
+ # Erro definitivo, não tenta mais
+ error_code = _extract_error_code(str(e))
+ raise exc_class(
+ f"Erro ao baixar vídeo: {str(e)}",
+ error_code=error_code,
+ original_error=e
+ )
+
+ # Erro pode ser temporário, continua tentando
+ if attempt >= len(strategies):
+ error_code = _extract_error_code(str(e))
+ raise exc_class(
+ f"Erro ao baixar vídeo após {len(strategies)} tentativas: {str(e)}",
+ error_code=error_code,
+ original_error=e
+ )
+ continue
+
+ # 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))
+
+ # Se encontrou o arquivo, sucesso!
+ if downloaded_file and os.path.exists(downloaded_file):
+ logger.info(f"Download bem-sucedido na tentativa {attempt}: {downloaded_file}")
+ break
+
+ except (YouTubeVideoUnavailableError, YouTubeAccessDeniedError, YouTubeDownloadError) as e:
+ # Re-levanta exceções customizadas
+ raise
+
+ except (yt_dlp.utils.DownloadError, yt_dlp.utils.ExtractorError) as e:
+ logger.warning(f"Erro de download (tentativa {attempt}): {str(e)}")
+ last_error = e
- # 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),
- }
+ # Classifica o erro
+ exc_class, should_retry = _classify_youtube_error(e)
- # Agora baixa o arquivo
- ydl.download([url])
+ if not should_retry:
+ # Erro definitivo, não tenta mais
+ error_code = _extract_error_code(str(e))
+ raise exc_class(
+ f"Erro ao baixar vídeo: {str(e)}",
+ error_code=error_code,
+ original_error=e
+ )
- # 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))
+ # Erro pode ser temporário, continua tentando
+ if attempt >= len(strategies):
+ error_code = _extract_error_code(str(e))
+ raise exc_class(
+ f"Erro ao baixar vídeo após {len(strategies)} tentativas: {str(e)}",
+ error_code=error_code,
+ original_error=e
+ )
+ continue
- except Exception as e:
- raise Exception(f"Erro ao baixar vídeo do YouTube: {str(e)}")
+ except Exception as e:
+ logger.error(f"Erro inesperado (tentativa {attempt}): {str(e)}")
+ last_error = e
+ if attempt >= len(strategies):
+ raise YouTubeDownloadError(
+ f"Erro inesperado ao baixar vídeo: {str(e)}",
+ original_error=e
+ )
+ continue
if downloaded_file is None or not os.path.exists(downloaded_file):
- raise Exception("Arquivo baixado não encontrado")
+ error_msg = "Arquivo baixado não encontrado após download bem-sucedido"
+ if last_error:
+ error_msg += f". Último erro: {str(last_error)}"
+ raise YouTubeDownloadError(
+ error_msg,
+ original_error=last_error
+ )
return downloaded_file, metadata
diff --git a/lazier/web/extractor.py b/lazier/web/extractor.py
@@ -281,77 +281,94 @@ def extract_pdf_content(file_path: str) -> Dict[str, Any]:
Returns:
Dicionário com conteúdo extraído e metadados
"""
+ import warnings
+ import logging
+
+ # Suprime avisos do pdfplumber sobre FontBBox
+ pdfplumber_logger = logging.getLogger('pdfplumber')
+ original_level = pdfplumber_logger.level
+ pdfplumber_logger.setLevel(logging.ERROR) # Só mostra erros, não warnings
+
if not os.path.exists(file_path):
+ pdfplumber_logger.setLevel(original_level) # Restaura nível original
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 ...]"
-
- # Sanitizar antes de retornar
- text = sanitize_xml_string(text)
- title = metadata.get('title', '') or Path(file_path).stem
- title = sanitize_xml_string(title)
-
- return {
- 'title': title,
- 'content': text,
- 'file_path': file_path,
- 'length': len(text),
- 'pages': len(text_parts),
- 'metadata': metadata,
- }
-
+ # Suprime warnings durante extração
+ with warnings.catch_warnings():
+ warnings.filterwarnings('ignore', message='.*FontBBox.*')
+ warnings.filterwarnings('ignore', message='.*font descriptor.*')
+ warnings.filterwarnings('ignore', category=UserWarning)
+
+ # 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 ...]"
+
+ # Sanitizar antes de retornar
+ text = sanitize_xml_string(text)
+ title = metadata.get('title', '') or Path(file_path).stem
+ title = sanitize_xml_string(title)
+
+ 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)}")
+ finally:
+ # Restaura nível de logging original
+ pdfplumber_logger.setLevel(original_level)
def extract_text_file_content(file_path: str) -> Dict[str, Any]:
diff --git a/lazier/web/templates/index.html b/lazier/web/templates/index.html
@@ -27,13 +27,13 @@
--color-border-light: #f5f5f5;
--font-display: 'Playfair Display', serif;
--font-body: 'Work Sans', sans-serif;
- --spacing-xs: 4px;
- --spacing-sm: 8px;
- --spacing-md: 16px;
- --spacing-lg: 24px;
- --spacing-xl: 32px;
- --spacing-2xl: 48px;
- --spacing-3xl: 64px;
+ --spacing-xs: 2px;
+ --spacing-sm: 4px;
+ --spacing-md: 8px;
+ --spacing-lg: 12px;
+ --spacing-xl: 16px;
+ --spacing-2xl: 24px;
+ --spacing-3xl: 32px;
--border-radius-sm: 8px;
--border-radius-md: 12px;
--border-radius-lg: 16px;
@@ -52,20 +52,54 @@
margin: 0;
padding: 0;
box-sizing: border-box;
+ /* Firefox scrollbar */
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-secondary) rgba(255, 255, 255, 0.1);
}
html {
scroll-behavior: smooth;
}
+ /* ===== SCROLLBARS CUSTOMIZADAS ===== */
+ /* Webkit (Chrome, Safari, Edge) */
+ ::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ margin: 4px;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
+ border-radius: 10px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+ transition: all var(--transition-base);
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-accent) 100%);
+ transform: scaleY(1.1);
+ }
+
+ ::-webkit-scrollbar-corner {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ }
+
body {
font-family: var(--font-body);
background: linear-gradient(135deg, #1a237e 0%, #283593 50%, #3949ab 100%);
background-attachment: fixed;
min-height: 100vh;
color: var(--color-text);
- line-height: 1.6;
- padding-top: 80px;
+ line-height: 1.5;
+ padding-top: 60px;
padding-bottom: 0;
display: flex;
flex-direction: column;
@@ -94,11 +128,12 @@
top: 0;
left: 0;
right: 0;
+ height: 60px;
background: rgba(26, 35, 126, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
- padding: var(--spacing-md) var(--spacing-xl);
- box-shadow: var(--shadow-md);
+ padding: var(--spacing-sm) var(--spacing-md);
+ box-shadow: var(--shadow-sm);
z-index: 1000;
display: flex;
justify-content: space-between;
@@ -110,14 +145,14 @@
.navbar-brand {
font-family: var(--font-display);
color: #ffffff;
- font-size: clamp(1.5rem, 4vw, 2rem);
+ font-size: clamp(1.125rem, 3vw, 1.5rem);
font-weight: 700;
text-decoration: none;
letter-spacing: -0.5px;
transition: var(--transition-base);
display: flex;
align-items: center;
- gap: var(--spacing-sm);
+ gap: var(--spacing-xs);
}
.navbar-brand:hover {
@@ -127,7 +162,7 @@
.navbar-nav {
display: flex;
- gap: var(--spacing-xl);
+ gap: var(--spacing-md);
list-style: none;
}
@@ -135,8 +170,8 @@
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
font-weight: 500;
- font-size: 1rem;
- padding: var(--spacing-sm) 0;
+ font-size: 0.9rem;
+ padding: var(--spacing-xs) 0;
position: relative;
transition: var(--transition-base);
}
@@ -177,7 +212,7 @@
flex: 1;
max-width: 1200px;
margin: 0 auto;
- padding: var(--spacing-2xl) var(--spacing-lg);
+ padding: var(--spacing-md) var(--spacing-sm);
width: 100%;
position: relative;
z-index: 1;
@@ -186,10 +221,10 @@
.page {
display: none;
background: var(--color-surface);
- border-radius: var(--border-radius-xl);
- box-shadow: var(--shadow-xl);
- padding: var(--spacing-3xl);
- min-height: calc(100vh - 200px);
+ border-radius: var(--border-radius-md);
+ box-shadow: var(--shadow-sm);
+ padding: var(--spacing-md) var(--spacing-sm);
+ height: calc(100vh - 120px);
animation: fadeIn 0.5s ease;
position: relative;
overflow: hidden;
@@ -201,18 +236,31 @@
top: 0;
left: 0;
right: 0;
- height: 4px;
+ height: 2px;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary), var(--color-accent));
}
.page.active {
display: flex;
flex-direction: column;
+ overflow: hidden;
}
.page-content {
flex: 1;
overflow-y: auto;
+ overflow-x: hidden;
+ padding: var(--spacing-sm);
+ display: flex;
+ flex-direction: column;
+ }
+
+ .page-content::-webkit-scrollbar {
+ width: 10px;
+ }
+
+ .page-content::-webkit-scrollbar-thumb {
+ background: linear-gradient(135deg, rgba(40, 53, 147, 0.3) 0%, rgba(255, 179, 0, 0.3) 100%);
}
@keyframes fadeIn {
@@ -227,11 +275,16 @@
}
/* ===== TIPOGRAFIA ===== */
+ body {
+ font-size: 0.875rem;
+ line-height: 1.5;
+ }
+
h1 {
font-family: var(--font-display);
color: var(--color-primary);
- margin-bottom: var(--spacing-md);
- font-size: clamp(2rem, 5vw, 3.5rem);
+ margin-bottom: var(--spacing-sm);
+ font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -1px;
@@ -240,36 +293,36 @@
h2 {
font-family: var(--font-display);
color: var(--color-primary);
- font-size: clamp(1.5rem, 4vw, 2.5rem);
+ font-size: clamp(1.25rem, 2.5vw, 1.75rem);
font-weight: 600;
- margin-bottom: var(--spacing-md);
+ margin-bottom: var(--spacing-sm);
}
h3 {
font-family: var(--font-display);
color: var(--color-text);
- font-size: clamp(1.25rem, 3vw, 1.75rem);
+ font-size: clamp(1rem, 2vw, 1.25rem);
font-weight: 600;
- margin-bottom: var(--spacing-md);
+ margin-bottom: var(--spacing-sm);
}
.subtitle {
color: var(--color-text-light);
- margin-bottom: var(--spacing-2xl);
- font-size: clamp(1rem, 2vw, 1.25rem);
+ margin-bottom: var(--spacing-lg);
+ font-size: clamp(0.75rem, 1.5vw, 0.9rem);
font-weight: 400;
}
/* ===== UPLOAD AREA ===== */
.upload-area {
- border: 3px dashed var(--color-border);
- border-radius: var(--border-radius-lg);
- padding: var(--spacing-3xl) var(--spacing-xl);
+ border: 2px dashed var(--color-border);
+ border-radius: var(--border-radius-md);
+ padding: var(--spacing-lg) var(--spacing-md);
text-align: center;
background: linear-gradient(135deg, rgba(255, 179, 0, 0.03) 0%, rgba(255, 160, 0, 0.05) 100%);
transition: all var(--transition-base);
cursor: pointer;
- margin-bottom: var(--spacing-xl);
+ margin-bottom: var(--spacing-md);
position: relative;
overflow: hidden;
}
@@ -300,20 +353,20 @@
.upload-area:hover {
border-color: var(--color-secondary);
background: linear-gradient(135deg, rgba(255, 179, 0, 0.08) 0%, rgba(255, 160, 0, 0.12) 100%);
- transform: translateY(-4px);
- box-shadow: var(--shadow-lg);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
}
.upload-area.dragover {
border-color: var(--color-secondary);
background: linear-gradient(135deg, rgba(255, 179, 0, 0.15) 0%, rgba(255, 160, 0, 0.2) 100%);
- transform: scale(1.02);
- box-shadow: var(--shadow-xl);
+ transform: scale(1.01);
+ box-shadow: var(--shadow-md);
}
.upload-icon {
- font-size: clamp(3rem, 8vw, 5rem);
- margin-bottom: var(--spacing-lg);
+ font-size: clamp(2rem, 5vw, 3rem);
+ margin-bottom: var(--spacing-md);
display: block;
animation: float 3s ease-in-out infinite;
}
@@ -324,14 +377,14 @@
}
.upload-area h3 {
- font-size: clamp(1.125rem, 3vw, 1.5rem);
+ font-size: clamp(0.9rem, 2.5vw, 1.125rem);
color: var(--color-text);
- margin-bottom: var(--spacing-sm);
+ margin-bottom: var(--spacing-xs);
}
.upload-area p {
color: var(--color-text-light);
- font-size: clamp(0.875rem, 2vw, 1rem);
+ font-size: clamp(0.75rem, 1.5vw, 0.875rem);
}
.file-input {
@@ -341,12 +394,12 @@
/* ===== URL INPUT ===== */
.url-input {
width: 100%;
- padding: var(--spacing-md) var(--spacing-lg);
- border: 2px solid var(--color-border);
- border-radius: var(--border-radius-md);
- font-size: 1rem;
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ font-size: 0.875rem;
font-family: var(--font-body);
- margin-bottom: var(--spacing-xl);
+ margin-bottom: var(--spacing-md);
transition: all var(--transition-base);
background: var(--color-surface);
color: var(--color-text);
@@ -360,29 +413,29 @@
/* ===== PROCESSING OPTIONS ===== */
.processing-options {
- margin: var(--spacing-2xl) 0;
- padding: var(--spacing-xl);
+ margin: var(--spacing-lg) 0;
+ padding: var(--spacing-md);
background: linear-gradient(135deg, rgba(40, 53, 147, 0.03) 0%, rgba(26, 35, 126, 0.05) 100%);
- border-radius: var(--border-radius-lg);
+ border-radius: var(--border-radius-md);
border: 1px solid var(--color-border-light);
}
.processing-options h3 {
- margin-bottom: var(--spacing-lg);
+ margin-bottom: var(--spacing-md);
color: var(--color-primary);
}
.option-cards {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: var(--spacing-lg);
- margin-top: var(--spacing-lg);
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: var(--spacing-md);
+ margin-top: var(--spacing-md);
}
.option-card {
- padding: var(--spacing-xl);
- border: 2px solid var(--color-border);
- border-radius: var(--border-radius-md);
+ padding: var(--spacing-md) var(--spacing-sm);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all var(--transition-base);
text-align: center;
@@ -397,7 +450,7 @@
top: 0;
left: 0;
right: 0;
- height: 4px;
+ height: 2px;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
transform: scaleX(0);
transition: var(--transition-base);
@@ -405,8 +458,8 @@
.option-card:hover {
border-color: var(--color-secondary);
- transform: translateY(-6px);
- box-shadow: var(--shadow-lg);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-sm);
}
.option-card:hover::before {
@@ -416,7 +469,7 @@
.option-card.selected {
border-color: var(--color-secondary);
background: linear-gradient(135deg, rgba(255, 179, 0, 0.08) 0%, rgba(255, 160, 0, 0.12) 100%);
- box-shadow: var(--shadow-md);
+ box-shadow: var(--shadow-sm);
}
.option-card.selected::before {
@@ -435,22 +488,22 @@
display: flex;
align-items: center;
justify-content: center;
- font-size: 1.125rem;
+ font-size: 0.9rem;
}
.option-card .description {
- margin-top: var(--spacing-sm);
- font-size: 0.9rem;
+ margin-top: var(--spacing-xs);
+ font-size: 0.75rem;
color: var(--color-text-light);
font-weight: 400;
}
/* ===== OPTIONS GRID ===== */
.options {
- margin-top: var(--spacing-2xl);
+ margin-top: var(--spacing-md);
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: var(--spacing-xl);
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: var(--spacing-md);
}
.option-group {
@@ -459,17 +512,17 @@
}
.option-group label {
- margin-bottom: var(--spacing-sm);
+ margin-bottom: var(--spacing-xs);
font-weight: 600;
color: var(--color-text);
- font-size: 0.95rem;
+ font-size: 0.8rem;
}
.option-group select {
- padding: var(--spacing-md);
- border: 2px solid var(--color-border);
- border-radius: var(--border-radius-md);
- font-size: 1rem;
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ font-size: 0.875rem;
font-family: var(--font-body);
background: var(--color-surface);
color: var(--color-text);
@@ -488,18 +541,18 @@
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
color: white;
border: none;
- padding: var(--spacing-md) var(--spacing-2xl);
- border-radius: var(--border-radius-md);
- font-size: 1.125rem;
+ padding: var(--spacing-sm) var(--spacing-lg);
+ border-radius: var(--border-radius-sm);
+ font-size: 0.9rem;
font-weight: 600;
font-family: var(--font-body);
cursor: pointer;
- margin-top: var(--spacing-2xl);
+ margin-top: var(--spacing-md);
width: 100%;
transition: all var(--transition-base);
position: relative;
overflow: hidden;
- box-shadow: var(--shadow-md);
+ box-shadow: var(--shadow-sm);
}
.btn::before {
@@ -521,12 +574,12 @@
}
.btn:hover {
- transform: translateY(-3px);
- box-shadow: var(--shadow-lg);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
}
.btn:active {
- transform: translateY(-1px);
+ transform: translateY(0);
box-shadow: var(--shadow-sm);
}
@@ -542,23 +595,23 @@
}
.btn-small {
- padding: var(--spacing-sm) var(--spacing-lg);
- font-size: 0.95rem;
+ padding: var(--spacing-xs) var(--spacing-md);
+ font-size: 0.8rem;
width: auto;
margin: 0;
}
/* ===== JOBS LIST ===== */
.jobs-list {
- margin-top: var(--spacing-2xl);
+ margin-top: var(--spacing-md);
}
.job-card {
background: var(--color-surface);
- border-radius: var(--border-radius-md);
- padding: var(--spacing-xl);
- margin-bottom: var(--spacing-lg);
- border-left: 4px solid var(--color-primary);
+ border-radius: var(--border-radius-sm);
+ padding: var(--spacing-md) var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+ border-left: 2px solid var(--color-primary);
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
animation: slideIn 0.4s ease;
@@ -576,32 +629,32 @@
}
.job-card:hover {
- box-shadow: var(--shadow-md);
- transform: translateX(4px);
+ box-shadow: var(--shadow-sm);
+ transform: translateX(2px);
}
.job-header {
display: flex;
justify-content: space-between;
align-items: center;
- margin-bottom: var(--spacing-md);
+ margin-bottom: var(--spacing-xs);
flex-wrap: wrap;
- gap: var(--spacing-sm);
+ gap: var(--spacing-xs);
}
.job-title {
font-weight: 600;
color: var(--color-text);
- font-size: 1.125rem;
+ font-size: 0.875rem;
}
.job-status {
- padding: var(--spacing-xs) var(--spacing-md);
- border-radius: 20px;
- font-size: 0.75rem;
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border-radius: 12px;
+ font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
- letter-spacing: 0.5px;
+ letter-spacing: 0.3px;
}
.status-pending {
@@ -623,11 +676,11 @@
.progress-bar {
width: 100%;
- height: 8px;
+ height: 6px;
background: var(--color-border-light);
- border-radius: 4px;
+ border-radius: 3px;
overflow: hidden;
- margin: var(--spacing-md) 0;
+ margin: var(--spacing-xs) 0;
position: relative;
}
@@ -646,9 +699,9 @@
}
.job-actions {
- margin-top: var(--spacing-lg);
+ margin-top: var(--spacing-sm);
display: flex;
- gap: var(--spacing-sm);
+ gap: var(--spacing-xs);
flex-wrap: wrap;
}
@@ -694,27 +747,28 @@
/* ===== HISTORY FILTERS ===== */
.history-filters {
- margin-bottom: var(--spacing-xl);
+ margin-bottom: var(--spacing-md);
display: flex;
- gap: var(--spacing-sm);
+ gap: var(--spacing-xs);
flex-wrap: wrap;
}
.filter-btn {
- padding: var(--spacing-sm) var(--spacing-lg);
- border: 2px solid var(--color-border);
+ padding: var(--spacing-xs) var(--spacing-md);
+ border: 1px solid var(--color-border);
background: var(--color-surface);
- border-radius: var(--border-radius-md);
+ border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all var(--transition-base);
font-family: var(--font-body);
font-weight: 500;
color: var(--color-text);
+ font-size: 0.8rem;
}
.filter-btn:hover {
border-color: var(--color-secondary);
- transform: translateY(-2px);
+ transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
@@ -727,50 +781,106 @@
/* ===== PREVIEW CONTENT ===== */
.preview-content {
- background: var(--color-surface);
- padding: var(--spacing-xl);
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(250, 250, 250, 0.95) 100%);
+ padding: var(--spacing-md);
border-radius: var(--border-radius-md);
- border: 1px solid var(--color-border);
- max-height: 600px;
+ border: 1px solid rgba(40, 53, 147, 0.1);
+ max-height: 400px;
overflow-y: auto;
- margin-top: var(--spacing-lg);
- line-height: 1.8;
+ overflow-x: hidden;
+ margin-top: var(--spacing-md);
+ line-height: 1.6;
+ box-shadow:
+ 0 2px 8px rgba(0, 0, 0, 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.9);
+ position: relative;
+ transition: all var(--transition-base);
+ }
+
+ .preview-content::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: linear-gradient(90deg, var(--color-primary), var(--color-secondary), var(--color-accent));
+ border-radius: var(--border-radius-md) var(--border-radius-md) 0 0;
+ z-index: 1;
+ }
+
+ .preview-content:hover {
+ box-shadow:
+ 0 4px 12px rgba(0, 0, 0, 0.1),
+ inset 0 1px 0 rgba(255, 255, 255, 0.9);
+ transform: translateY(-1px);
}
.preview-content h1, .preview-content h2, .preview-content h3, .preview-content h4 {
- margin-top: var(--spacing-xl);
- margin-bottom: var(--spacing-md);
+ margin-top: var(--spacing-md);
+ margin-bottom: var(--spacing-sm);
color: var(--color-primary);
font-family: var(--font-display);
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ }
+
+ .preview-content h1:first-child,
+ .preview-content h2:first-child,
+ .preview-content h3:first-child {
+ margin-top: 0;
}
.preview-content h1 {
- font-size: 1.875rem;
- border-bottom: 3px solid var(--color-secondary);
- padding-bottom: var(--spacing-md);
+ font-size: 1.25rem;
+ border-bottom: 2px solid var(--color-secondary);
+ padding-bottom: var(--spacing-sm);
}
.preview-content h2 {
- font-size: 1.5rem;
+ font-size: 1.125rem;
color: var(--color-primary);
}
.preview-content h3 {
- font-size: 1.25rem;
+ font-size: 1rem;
}
.preview-content p {
- margin-bottom: var(--spacing-md);
- line-height: 1.8;
+ margin-bottom: var(--spacing-sm);
+ line-height: 1.6;
+ letter-spacing: 0.01em;
}
.preview-content pre {
- background: rgba(40, 53, 147, 0.05);
- padding: var(--spacing-lg);
+ background: linear-gradient(135deg, rgba(40, 53, 147, 0.08) 0%, rgba(40, 53, 147, 0.05) 100%);
+ padding: var(--spacing-md);
border-radius: var(--border-radius-sm);
overflow-x: auto;
- border-left: 4px solid var(--color-secondary);
- margin: var(--spacing-lg) 0;
+ border-left: 2px solid var(--color-secondary);
+ margin: var(--spacing-sm) 0;
+ position: relative;
+ box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.04);
+ }
+
+ .preview-content pre::-webkit-scrollbar {
+ height: 8px;
+ }
+
+ .preview-content pre::-webkit-scrollbar-track {
+ background: rgba(40, 53, 147, 0.05);
+ border-radius: 4px;
+ }
+
+ .preview-content pre::-webkit-scrollbar-thumb {
+ background: var(--color-secondary);
+ border-radius: 4px;
+ border: 1px solid transparent;
+ background-clip: padding-box;
+ }
+
+ .preview-content pre::-webkit-scrollbar-thumb:hover {
+ background: var(--color-accent);
}
.preview-content code {
@@ -787,26 +897,38 @@
}
.preview-content ul, .preview-content ol {
- margin: var(--spacing-md) 0;
- padding-left: var(--spacing-xl);
+ margin: var(--spacing-sm) 0;
+ padding-left: var(--spacing-lg);
}
.preview-content li {
margin-bottom: var(--spacing-xs);
+ line-height: 1.6;
}
.preview-content blockquote {
- border-left: 4px solid var(--color-secondary);
- padding-left: var(--spacing-lg);
- margin: var(--spacing-lg) 0;
+ border-left: 2px solid var(--color-secondary);
+ padding-left: var(--spacing-md);
+ margin: var(--spacing-sm) 0;
color: var(--color-text-light);
font-style: italic;
}
.preview-content hr {
border: none;
- border-top: 2px solid var(--color-border);
- margin: var(--spacing-xl) 0;
+ border-top: 1px solid var(--color-border);
+ margin: var(--spacing-md) 0;
+ position: relative;
+ }
+
+ .preview-content hr::after {
+ content: '';
+ position: absolute;
+ top: -1px;
+ left: 0;
+ width: 60px;
+ height: 2px;
+ background: linear-gradient(90deg, var(--color-secondary), transparent);
}
.preview-content strong {
@@ -828,15 +950,20 @@
.preview-text-plain {
white-space: pre-wrap;
word-wrap: break-word;
- font-family: inherit;
+ word-break: break-word;
+ font-family: var(--font-body);
+ line-height: 1.9;
+ letter-spacing: 0.01em;
+ color: var(--color-text);
+ padding: var(--spacing-md) 0;
}
/* ===== FOOTER ===== */
.main-footer {
background: linear-gradient(135deg, var(--color-primary-dark) 0%, var(--color-primary) 100%);
color: rgba(255, 255, 255, 0.9);
- padding: var(--spacing-3xl) var(--spacing-xl);
- margin-top: var(--spacing-3xl);
+ padding: var(--spacing-md) var(--spacing-lg);
+ margin-top: var(--spacing-md);
position: relative;
overflow: hidden;
}
@@ -855,23 +982,23 @@
max-width: 1200px;
margin: 0 auto;
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: var(--spacing-2xl);
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: var(--spacing-md);
}
.footer-section h4 {
font-family: var(--font-display);
color: var(--color-secondary);
- font-size: 1.25rem;
- margin-bottom: var(--spacing-md);
+ font-size: 0.9rem;
+ margin-bottom: var(--spacing-xs);
font-weight: 600;
}
.footer-section p,
.footer-section li {
color: rgba(255, 255, 255, 0.8);
- font-size: 0.95rem;
- line-height: 1.8;
+ font-size: 0.75rem;
+ line-height: 1.5;
margin-bottom: var(--spacing-xs);
}
@@ -894,33 +1021,34 @@
.footer-bottom {
max-width: 1200px;
- margin: var(--spacing-xl) auto 0;
- padding-top: var(--spacing-xl);
+ margin: var(--spacing-md) auto 0;
+ padding-top: var(--spacing-sm);
border-top: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
color: rgba(255, 255, 255, 0.7);
- font-size: 0.875rem;
+ font-size: 0.75rem;
}
/* ===== RESPONSIVIDADE ===== */
@media (max-width: 768px) {
.navbar {
- padding: var(--spacing-md) var(--spacing-lg);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ height: 50px;
}
.navbar-nav {
position: fixed;
- top: 70px;
+ top: 50px;
left: 0;
right: 0;
background: rgba(26, 35, 126, 0.98);
backdrop-filter: blur(20px);
flex-direction: column;
- padding: var(--spacing-lg);
- gap: var(--spacing-md);
+ padding: var(--spacing-md);
+ gap: var(--spacing-sm);
transform: translateX(-100%);
transition: var(--transition-base);
- box-shadow: var(--shadow-lg);
+ box-shadow: var(--shadow-md);
}
.navbar-nav.active {
@@ -932,25 +1060,28 @@
}
.container {
- padding: var(--spacing-lg) var(--spacing-md);
+ padding: var(--spacing-sm);
}
.page {
- padding: var(--spacing-xl) var(--spacing-lg);
- border-radius: var(--border-radius-lg);
+ padding: var(--spacing-sm);
+ border-radius: var(--border-radius-sm);
+ height: calc(100vh - 90px);
}
.option-cards {
grid-template-columns: 1fr;
+ gap: var(--spacing-sm);
}
.options {
grid-template-columns: 1fr;
+ gap: var(--spacing-sm);
}
.footer-content {
grid-template-columns: 1fr;
- gap: var(--spacing-xl);
+ gap: var(--spacing-md);
}
.job-header {
@@ -962,6 +1093,7 @@
@media (min-width: 769px) and (max-width: 1024px) {
.footer-content {
grid-template-columns: repeat(2, 1fr);
+ gap: var(--spacing-md);
}
}
@@ -991,6 +1123,7 @@
<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>
+ <li><a href="#" onclick="logout(); return false;" style="color: var(--color-secondary);">Sair</a></li>
</ul>
</nav>
@@ -1001,7 +1134,7 @@
<h1>🎧 Lazier</h1>
<p class="subtitle">Transcrição e Sumarização de Áudios, Vídeos, Textos e PDFs usando OpenAI API</p>
- <div class="upload-area" id="uploadArea">
+ <div class="upload-area" id="uploadArea" style="flex-shrink: 0;">
<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>
@@ -1011,7 +1144,7 @@
<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">
+ <div class="processing-options" style="flex-shrink: 0;">
<h3>Modo de Processamento</h3>
<div class="option-cards">
<div class="option-card selected" onclick="selectProcessingMode('both', this)">
@@ -1038,7 +1171,7 @@
</div>
</div>
- <div class="options">
+ <div class="options" style="flex-shrink: 0;">
<div class="option-group">
<label>Formato de Saída</label>
<select id="formatSelect">
@@ -1057,9 +1190,9 @@
</div>
</div>
- <button class="btn" id="processBtn" onclick="processFiles()">Processar</button>
+ <button class="btn" id="processBtn" onclick="processFiles()" style="flex-shrink: 0;">Processar</button>
- <div class="jobs-list" id="jobsList"></div>
+ <div class="jobs-list" id="jobsList" style="flex: 1; overflow-y: auto; min-height: 0;"></div>
</div>
</div>
@@ -1132,6 +1265,23 @@
nav.classList.toggle('active');
}
+ // Logout
+ async function logout() {
+ try {
+ const response = await fetch('/logout', {
+ method: 'POST',
+ credentials: 'include'
+ });
+ if (response.ok || response.redirected) {
+ window.location.href = '/login';
+ }
+ } catch (error) {
+ console.error('Erro ao fazer logout:', error);
+ // Mesmo com erro, redireciona para login
+ window.location.href = '/login';
+ }
+ }
+
// Navegação
function showPage(page) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
@@ -1246,7 +1396,8 @@
const response = await fetch('/api/process', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({url, format, transcribe, summarize})
+ body: JSON.stringify({url, format, transcribe, summarize}),
+ credentials: 'include'
});
const data = await response.json();
addJob(data.job_id, url, 'URL');
@@ -1260,7 +1411,8 @@
const response = await fetch('/api/upload', {
method: 'POST',
- body: formData
+ body: formData,
+ credentials: 'include'
});
const data = await response.json();
@@ -1310,7 +1462,9 @@
const interval = setInterval(async () => {
try {
- const response = await fetch(`/api/jobs/${jobId}`);
+ const response = await fetch(`/api/jobs/${jobId}`, {
+ credentials: 'include'
+ });
const data = await response.json();
updateJob(jobId, data);
@@ -1364,7 +1518,9 @@
async function viewJobDetails(jobId) {
try {
- const response = await fetch(`/api/jobs/${jobId}/details`);
+ const response = await fetch(`/api/jobs/${jobId}/details`, {
+ credentials: 'include'
+ });
const data = await response.json();
const format = data.format || 'docx';
@@ -1430,7 +1586,9 @@
// Histórico
async function loadHistory() {
try {
- const response = await fetch('/api/history');
+ const response = await fetch('/api/history', {
+ credentials: 'include'
+ });
const data = await response.json();
allJobs = data.jobs || [];
renderHistory();
@@ -1466,9 +1624,9 @@
jobCard.innerHTML = `
<div class="job-header">
<div class="job-title">
- ${job.url || job.file_path || 'Job ' + job.id}
+ ${job.title || job.url || job.file_path || 'Job ' + job.id}
</div>
- <span class="job-status status-${job.status}">${job.status}</span>
+ <span class="job-status status-${job.status}">${job.status === 'completed' ? 'Concluído' : job.status === 'failed' ? 'Falhado' : job.status === 'processing' ? 'Processando' : job.status}</span>
</div>
${job.status === 'completed' ? `
<div class="job-actions">
diff --git a/lazier/web/templates/login.html b/lazier/web/templates/login.html
@@ -0,0 +1,202 @@
+<!DOCTYPE html>
+<html lang="pt-BR">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Login - Lazier</title>
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Work+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
+ <style>
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ body {
+ font-family: 'Work Sans', sans-serif;
+ background: linear-gradient(135deg, #1a237e 0%, #283593 50%, #3949ab 100%);
+ background-attachment: fixed;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ padding: 20px;
+ position: relative;
+ overflow: hidden;
+ }
+
+ body::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-image:
+ radial-gradient(circle at 20% 50%, rgba(255, 179, 0, 0.1) 0%, transparent 50%),
+ radial-gradient(circle at 80% 80%, rgba(255, 160, 0, 0.1) 0%, transparent 50%);
+ pointer-events: none;
+ z-index: 0;
+ }
+
+ .login-container {
+ background: white;
+ padding: 48px;
+ border-radius: 24px;
+ box-shadow: 0 12px 40px rgba(0,0,0,0.3);
+ width: 100%;
+ max-width: 420px;
+ position: relative;
+ z-index: 1;
+ animation: fadeIn 0.5s ease;
+ }
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ .login-container::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 4px;
+ background: linear-gradient(90deg, #283593, #ffb300, #ffa000);
+ border-radius: 24px 24px 0 0;
+ }
+
+ h1 {
+ font-family: 'Playfair Display', serif;
+ color: #283593;
+ margin-bottom: 8px;
+ text-align: center;
+ font-size: 2.5rem;
+ font-weight: 700;
+ letter-spacing: -1px;
+ }
+
+ .subtitle {
+ text-align: center;
+ color: #546e7a;
+ margin-bottom: 32px;
+ font-size: 0.95rem;
+ }
+
+ .form-group {
+ margin-bottom: 24px;
+ }
+
+ label {
+ display: block;
+ margin-bottom: 8px;
+ color: #263238;
+ font-weight: 600;
+ font-size: 0.95rem;
+ }
+
+ input {
+ width: 100%;
+ padding: 14px 16px;
+ border: 2px solid #e0e0e0;
+ border-radius: 12px;
+ font-size: 1rem;
+ font-family: 'Work Sans', sans-serif;
+ box-sizing: border-box;
+ transition: all 0.3s ease;
+ }
+
+ input:focus {
+ outline: none;
+ border-color: #ffb300;
+ box-shadow: 0 0 0 3px rgba(255, 179, 0, 0.1);
+ }
+
+ button {
+ width: 100%;
+ padding: 14px;
+ background: linear-gradient(135deg, #283593 0%, #3949ab 100%);
+ color: white;
+ border: none;
+ border-radius: 12px;
+ font-size: 1.125rem;
+ font-weight: 600;
+ font-family: 'Work Sans', sans-serif;
+ cursor: pointer;
+ margin-top: 8px;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 12px rgba(40, 53, 147, 0.2);
+ }
+
+ button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(40, 53, 147, 0.3);
+ }
+
+ button:active {
+ transform: translateY(0);
+ }
+
+ .error {
+ color: #d32f2f;
+ margin-top: 16px;
+ text-align: center;
+ font-size: 0.9rem;
+ padding: 12px;
+ background: rgba(211, 47, 47, 0.1);
+ border-radius: 8px;
+ border: 1px solid rgba(211, 47, 47, 0.2);
+ }
+
+ @media (max-width: 480px) {
+ .login-container {
+ padding: 32px 24px;
+ }
+
+ h1 {
+ font-size: 2rem;
+ }
+ }
+ </style>
+</head>
+<body>
+ <div class="login-container">
+ <h1>🎧 Lazier</h1>
+ <form method="POST" action="/login">
+ <div class="form-group">
+ <label for="username">Usuário</label>
+ <input
+ type="text"
+ id="username"
+ name="username"
+ required
+ autofocus
+ autocomplete="username"
+ placeholder="Digite seu usuário"
+ >
+ </div>
+ <div class="form-group">
+ <label for="password">Senha</label>
+ <input
+ type="password"
+ id="password"
+ name="password"
+ required
+ autocomplete="current-password"
+ placeholder="Digite sua senha"
+ >
+ </div>
+ <button type="submit">Entrar</button>
+ </form>
+ </div>
+</body>
+</html>
diff --git a/requirements.txt b/requirements.txt
@@ -18,3 +18,5 @@ redis>=5.0.0
hiredis>=2.2.0
playwright>=1.40.0
chardet>=5.0.0
+passlib[bcrypt]>=1.7.4
+itsdangerous>=2.1.2