lazier

personal summarizer
Log | Files | Refs | README

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:
M.env.example | 13+++++++++++++
Mdocker/Dockerfile | 13+++++++++++++
Adocker/nginx.conf | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/api/auth_routes.py | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlazier/api/main.py | 42++++++++++++++++++++++++++++++++++++++++--
Alazier/api/middleware.py | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlazier/api/routes.py | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mlazier/api/websocket.py | 31+++++++++++++++++++++++++++++++
Alazier/core/auth.py | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alazier/core/exceptions.py | 34++++++++++++++++++++++++++++++++++
Mlazier/downloader.py | 337++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mlazier/web/extractor.py | 147++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mlazier/web/templates/index.html | 494++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Alazier/web/templates/login.html | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mrequirements.txt | 2++
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