lazier

personal summarizer
Log | Files | Refs | README

commit 808e4a594e22817556eb798cef3e7831dc158cd1
parent 00df7cfc642828ddaf597dfd0bc3650c9e7903e8
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Fri,  8 May 2026 20:25:18 -0300

refatoração

Diffstat:
Mlazier/api/routes.py | 8++++----
Mlazier/core/cache.py | 7+++++++
Mlazier/core/file_handler.py | 28++++++++++++++--------------
Mlazier/utils.py | 82++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mlazier/web/templates/index.html | 2+-
Mpyproject.toml | 4++++
Atests/conftest.py | 20++++++++++++++++++++
Mtests/test_api.py | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 171 insertions(+), 55 deletions(-)

diff --git a/lazier/api/routes.py b/lazier/api/routes.py @@ -19,7 +19,7 @@ from ..core.jobs import build_job_artifact_path, get_job_store from ..core.processing import process_source from ..core.supported_sites import SUPPORTED_VIDEO_SITES from ..core.playlist import is_playlist_url -from ..utils import AUDIO_EXTENSIONS, TEXT_EXTENSIONS, VIDEO_EXTENSIONS +from ..utils import VIDEO_EXTENSIONS, is_upload_extension_allowed from .websocket import broadcast_progress logger = logging.getLogger(__name__) @@ -276,13 +276,13 @@ async def upload_files( smart=smart, chapters=chapters, ) - valid_extensions = AUDIO_EXTENSIONS | VIDEO_EXTENSIONS | TEXT_EXTENSIONS | {".pdf"} jobs = [] for file in files: ext = Path(file.filename).suffix.lower() - if ext not in valid_extensions: - raise HTTPException(status_code=400, detail=f"Tipo de arquivo nao suportado: {ext}") + ok, err_msg = is_upload_extension_allowed(ext) + if not ok: + raise HTTPException(status_code=400, detail=err_msg or f"Tipo de arquivo nao suportado: {ext}") file_path = UPLOAD_DIR / f"{uuid.uuid4()}_{file.filename}" try: diff --git a/lazier/core/cache.py b/lazier/core/cache.py @@ -225,9 +225,16 @@ def calculate_url_hash(url: str) -> str: _cache_manager: Optional[CacheManager] = None +def _redis_opted_out() -> bool: + """Evita abrir socket ao Redis (ex.: testes ou CI sem Redis).""" + return os.getenv("LAZIER_SKIP_REDIS", "").strip().lower() in {"1", "true", "yes", "on"} + + def get_cache_manager() -> CacheManager: """Retorna instância singleton do cache manager""" global _cache_manager + if _redis_opted_out(): + raise RuntimeError("Redis desabilitado (LAZIER_SKIP_REDIS)") if _cache_manager is None: _cache_manager = CacheManager() return _cache_manager diff --git a/lazier/core/file_handler.py b/lazier/core/file_handler.py @@ -7,7 +7,12 @@ from pathlib import Path from typing import Tuple, Optional import mimetypes -from ..utils import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PDF_EXTENSIONS, TEXT_EXTENSIONS +from ..utils import ( + AUDIO_EXTENSIONS, + BLOCKED_UPLOAD_EXTENSIONS, + DOCUMENT_EXTENSIONS, + VIDEO_EXTENSIONS, +) # Tipos MIME aceitos AUDIO_MIMES = { @@ -35,9 +40,6 @@ DOCUMENT_MIMES = { 'text/html', 'application/xhtml+xml' } -# Combina todas as extensões permitidas -ALLOWED_EXTENSIONS = AUDIO_EXTENSIONS | VIDEO_EXTENSIONS | PDF_EXTENSIONS | TEXT_EXTENSIONS - MAX_FILE_SIZE = int(os.getenv('LAZIER_MAX_UPLOAD_SIZE', '524288000')) # 500MB padrão @@ -57,20 +59,18 @@ def validate_upload_file(file_path: str, file_size: int) -> Tuple[bool, Optional if file_size > MAX_FILE_SIZE: return False, None, f"Arquivo muito grande. Tamanho máximo: {MAX_FILE_SIZE / 1024 / 1024:.0f}MB" - # Verifica extensão ext = Path(file_path).suffix.lower() - if ext not in ALLOWED_EXTENSIONS: + if ext in BLOCKED_UPLOAD_EXTENSIONS: return False, None, f"Extensão não permitida: {ext}" - - # Detecta tipo usando as constantes de utils.py + + if ext in DOCUMENT_EXTENSIONS: + return True, 'document', None + if ext in VIDEO_EXTENSIONS: + return True, 'video', None if ext in AUDIO_EXTENSIONS: return True, 'audio', None - elif ext in VIDEO_EXTENSIONS: - return True, 'video', None - elif ext in PDF_EXTENSIONS or ext in TEXT_EXTENSIONS: - return True, 'document', None - - return False, None, "Tipo de arquivo não reconhecido" + # Qualquer outra extensão: tentar como áudio (ffmpeg) + return True, 'audio', None def save_upload_file(upload_file, upload_dir: Path) -> Path: diff --git a/lazier/utils.py b/lazier/utils.py @@ -18,8 +18,10 @@ AUDIO_EXTENSIONS = { # Formatos comuns '.mp3', '.wav', '.m4a', '.aac', '.flac', '.ogg', '.opus', '.wma', # Formatos menos comuns - '.3gp', '.3g2', '.amr', '.au', '.caf', '.mka', '.ra', '.rm', - '.spx', '.tta', '.wv' + '.3gp', '.3g2', '.amr', '.au', '.caf', '.mka', '.ra', '.rm', + '.spx', '.tta', '.wv', + # MPEG / PCM / HiFi + '.mp2', '.mpa', '.aiff', '.aif', '.dsf', '.dff', } # Extensões de vídeo suportadas VIDEO_EXTENSIONS = { @@ -27,12 +29,24 @@ VIDEO_EXTENSIONS = { '.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', # Formatos menos comuns '.3gp', '.3g2', '.asf', '.f4v', '.m2v', '.mts', '.m2ts', '.ogv', - '.rmvb', '.ts', '.vob' + '.rmvb', '.ts', '.vob', '.mpeg', '.mpg', '.divx', '.xvid', } # Extensões de texto/documento suportadas TEXT_EXTENSIONS = {'.txt', '.md', '.html', '.htm'} PDF_EXTENSIONS = {'.pdf'} +# Documentos aceites para sumarização (whitelist) +DOCUMENT_EXTENSIONS = TEXT_EXTENSIONS | PDF_EXTENSIONS + +# Extensões que não são mídia áudio/vídeo nem documentos aceites — rejeitar no upload Web/API +BLOCKED_UPLOAD_EXTENSIONS = { + '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', + '.odt', '.ods', '.odp', + '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', + '.exe', '.dll', '.msi', '.deb', '.rpm', '.app', '.dmg', + '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tif', '.tiff', '.svg', '.ico', '.heic', +} + def is_youtube_url(url: str) -> bool: """Verifica se a URL é do YouTube""" @@ -53,13 +67,16 @@ def is_media_file(file_path: str) -> Tuple[bool, Optional[str]]: path = Path(file_path) if not path.exists(): return False, None - + ext = path.suffix.lower() + if ext in DOCUMENT_EXTENSIONS: + return False, None + if ext in VIDEO_EXTENSIONS: + return True, 'video' if ext in AUDIO_EXTENSIONS: return True, 'audio' - elif ext in VIDEO_EXTENSIONS: - return True, 'video' - return False, None + # Extensão desconhecida: candidata a mídia (ffmpeg decide depois) + return True, 'audio' def validate_input(input_path: str) -> Tuple[bool, str, Optional[str]]: @@ -89,39 +106,32 @@ def validate_input(input_path: str) -> Tuple[bool, str, Optional[str]]: return False, None, f"Não é um arquivo: {input_path}" ext = path.suffix.lower() - + + if ext in TEXT_EXTENSIONS: + return True, 'text', None + if ext in PDF_EXTENSIONS: + return True, 'pdf', None + if ext in VIDEO_EXTENSIONS: + return True, 'video', None if ext in AUDIO_EXTENSIONS: return True, 'audio', None - elif ext in VIDEO_EXTENSIONS: - return True, 'video', None - elif ext in PDF_EXTENSIONS: - return True, 'pdf', None - elif ext in TEXT_EXTENSIONS: - return True, 'text', None - else: - return False, None, f"Tipo de arquivo não suportado: {ext}" + # Qualquer outro ficheiro local existente: tentar como mídia áudio (ffmpeg demux) + return True, 'audio', None + + +def is_upload_extension_allowed(ext: str) -> Tuple[bool, Optional[str]]: """ - Valida o input (arquivo ou URL) - Retorna (válido, tipo, mensagem_erro) - tipo pode ser 'youtube', 'audio', 'video' ou None + Valida extensão para upload Web/API. + Permite documentos aceites, extensão vazia ou mídia conhecida/genérica; + bloqueia extensões explicitamente não suportadas (office, arquivo, imagem, binário). + Retorna (permitido, mensagem_erro). """ - if not input_path: - return False, None, "Input não fornecido" - - # Verifica se é URL do YouTube - if is_youtube_url(input_path): - return True, 'youtube', None - - # Verifica se é arquivo local - is_media, media_type = is_media_file(input_path) - if is_media: - return True, media_type, None - - # Se não é nenhum dos dois - if os.path.exists(input_path): - return False, None, f"Tipo de arquivo não suportado: {Path(input_path).suffix}" - else: - return False, None, f"Arquivo ou URL não encontrado: {input_path}" + e = ext.lower() + if e in BLOCKED_UPLOAD_EXTENSIONS: + return False, f"Tipo de arquivo não suportado: {ext}" + if e in DOCUMENT_EXTENSIONS: + return True, None + return True, None def check_ffmpeg() -> bool: diff --git a/lazier/web/templates/index.html b/lazier/web/templates/index.html @@ -333,7 +333,7 @@ <p class="section-lead">A saída final será em português do Brasil.</p> <div class="stack"> - <div class="upload" id="uploadArea"><input type="file" id="fileInput" hidden multiple accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.3gp,.3g2,.amr,.au,.caf,.mka,.ra,.rm,.spx,.tta,.wv,.mp4,.avi,.mkv,.mov,.wmv,.flv,.webm,.m4v,.asf,.f4v,.m2v,.mts,.m2ts,.ogv,.rmvb,.ts,.vob,.pdf,.txt,.md,.html,.htm"></div> + <div class="upload" id="uploadArea"><input type="file" id="fileInput" hidden multiple accept="audio/*,video/*,application/pdf,text/plain,text/markdown,text/html,.md,.htm,.txt"></div> <div class="field"><label for="urlInput">URL (opcional)</label><input type="text" id="urlInput" placeholder="YouTube, página, áudio em linha…"></div> <div class="field"> <label>Modo</label> diff --git a/pyproject.toml b/pyproject.toml @@ -45,3 +45,7 @@ dependencies = [ [project.scripts] lazier = "lazier.cli:cli" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-p no:qt" diff --git a/tests/conftest.py b/tests/conftest.py @@ -0,0 +1,20 @@ +""" +Evita que `create_app()` (import de `lazier.api.main`) tente conectar ao Redis +durante a coleta/execução dos testes — ligações lentas ou semi-abertas faziam +o pytest parecer travado após `ssssss..........`. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("LAZIER_SKIP_REDIS", "1") + + +def pytest_configure(config) -> None: # noqa: ARG001 + try: + from lazier.core import cache as lazier_cache + + lazier_cache._cache_manager = None + except Exception: + pass diff --git a/tests/test_api.py b/tests/test_api.py @@ -183,3 +183,78 @@ class ApiTests(unittest.TestCase): self.assertEqual(persisted_job["status"], "completed") self.assertEqual(persisted_job["mode"], "summarize") + + def test_upload_accepts_mpeg_and_generic_extension(self): + output_dir = Path(os.environ["LAZIER_OUTPUT_DIR"]) / "2026" / "06" / "01" / "mpeg-job" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "transcricao.txt" + output_path.write_text("ok", encoding="utf-8") + + fake_result = { + "mode": "transcribe", + "input_type": "audio", + "source_name": "clip.mpeg", + "metadata": {"title": "Clip"}, + "transcription": "ok", + "summary": None, + "result_path": str(output_path), + "transcription_path": str(output_path), + "summary_path": None, + } + + with patch("lazier.api.routes.process_source", return_value=fake_result): + r1 = self.client.post( + "/api/upload", + files={"files": ("clip.mpeg", b"fake", "video/mpeg")}, + data={"format": "txt", "mode": "transcribe"}, + ) + r2 = self.client.post( + "/api/upload", + files={"files": ("audio.weird_ext", b"fake", "application/octet-stream")}, + data={"format": "txt", "mode": "transcribe"}, + ) + + self.assertEqual(r1.status_code, 200) + self.assertEqual(r2.status_code, 200) + + def test_upload_rejects_blocked_extensions(self): + response = self.client.post( + "/api/upload", + files={"files": ("memo.docx", b"x", "application/vnd.ms-word")}, + data={"format": "txt", "mode": "summarize"}, + ) + self.assertEqual(response.status_code, 400) + + +class UtilsUploadExtensionTests(unittest.TestCase): + def test_is_upload_extension_allows_mpeg_and_unknown(self): + from lazier.utils import is_upload_extension_allowed + + self.assertTrue(is_upload_extension_allowed(".mpeg")[0]) + self.assertTrue(is_upload_extension_allowed(".mpg")[0]) + self.assertTrue(is_upload_extension_allowed(".xyz")[0]) + self.assertTrue(is_upload_extension_allowed("")[0]) + + def test_is_upload_extension_blocks_office_and_images(self): + from lazier.utils import is_upload_extension_allowed + + self.assertFalse(is_upload_extension_allowed(".docx")[0]) + self.assertFalse(is_upload_extension_allowed(".png")[0]) + + def test_validate_input_unknown_extension_is_audio(self): + from lazier.utils import validate_input + + tmp = Path(os.getcwd()) / ".tmp-tests" / f"odd-{uuid.uuid4().hex[:8]}.xyz" + tmp.parent.mkdir(parents=True, exist_ok=True) + try: + tmp.write_bytes(b"\x00") + ok, typ, err = validate_input(str(tmp)) + self.assertTrue(ok) + self.assertEqual(typ, "audio") + self.assertIsNone(err) + finally: + tmp.unlink(missing_ok=True) + try: + tmp.parent.rmdir() + except OSError: + pass