diff --git a/abogen/utils.py b/abogen/utils.py index acd580e..a41cd66 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -8,6 +8,7 @@ import subprocess import sys import warnings from threading import Thread +from typing import Dict, Optional from functools import lru_cache @@ -29,12 +30,22 @@ warnings.filterwarnings("ignore") def detect_encoding(file_path): - import chardet - import charset_normalizer + try: + import chardet # type: ignore[import-not-found] + except ImportError: # pragma: no cover - optional dependency + chardet = None # type: ignore[assignment] + + try: + import charset_normalizer # type: ignore[import-not-found] + except ImportError: # pragma: no cover - optional dependency + charset_normalizer = None # type: ignore[assignment] + with open(file_path, "rb") as f: raw_data = f.read() detected_encoding = None for detectors in (charset_normalizer, chardet): + if detectors is None: + continue try: result = detectors.detect(raw_data)["encoding"] except Exception: @@ -92,7 +103,10 @@ def get_resource_path(package, resource): def get_version(): """Return the current version of the application.""" try: - with open(get_resource_path("/", "VERSION"), "r") as f: + version_path = get_resource_path("/", "VERSION") + if not version_path: + raise FileNotFoundError("VERSION resource missing") + with open(version_path, "r") as f: return f.read().strip() except Exception: return "Unknown" @@ -158,7 +172,14 @@ def get_user_cache_root(): if last_error is not None: raise last_error - def _configure_cache_env(): + def _configure_cache_env(root: Optional[str]) -> None: + temp_root = None + if root: + try: + temp_root = ensure_directory(root) + except OSError: + temp_root = None + home_dir = os.environ.get("HOME") if not home_dir: home_dir = ensure_directory(os.path.join("/tmp", "abogen-home")) @@ -169,6 +190,9 @@ def get_user_cache_root(): cache_base = os.environ.get("XDG_CACHE_HOME") if cache_base: cache_base = ensure_directory(cache_base) + elif temp_root: + cache_base = temp_root + os.environ["XDG_CACHE_HOME"] = cache_base else: cache_base = ensure_directory(os.path.join(home_dir, ".cache")) os.environ["XDG_CACHE_HOME"] = cache_base @@ -176,6 +200,9 @@ def get_user_cache_root(): hf_cache = os.environ.get("HF_HOME") if hf_cache: hf_cache = ensure_directory(hf_cache) + elif temp_root: + hf_cache = ensure_directory(os.path.join(temp_root, "huggingface")) + os.environ["HF_HOME"] = hf_cache else: hf_cache = ensure_directory(os.path.join(cache_base, "huggingface")) os.environ["HF_HOME"] = hf_cache @@ -185,7 +212,7 @@ def get_user_cache_root(): os.environ.setdefault("ABOGEN_INTERNAL_CACHE_ROOT", cache_base) - cache_root = None + cache_root: Optional[str] = None override = os.environ.get("ABOGEN_TEMP_DIR") if override: @@ -215,7 +242,10 @@ def get_user_cache_root(): logger.warning("Falling back to temp cache directory %s", tmp_candidate) cache_root = ensure_directory(tmp_candidate) - _configure_cache_env() + if cache_root is None: + raise RuntimeError("Unable to determine cache directory") + + _configure_cache_env(cache_root) return cache_root @@ -260,7 +290,7 @@ def get_user_output_path(folder=None): return base -_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes +_sleep_procs: Dict[str, Optional[subprocess.Popen[str]]] = {"Darwin": None, "Linux": None} # Store sleep prevention processes def clean_text(text, *args, **kwargs): @@ -319,11 +349,14 @@ def create_process(cmd, stdin=None, text=True, capture_output=False): kwargs["stdin"] = stdin if platform.system() == "Windows": - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = subprocess.SW_HIDE + startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined] + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined] + startupinfo.wShowWindow = subprocess.SW_HIDE # type: ignore[attr-defined] kwargs.update( - {"startupinfo": startupinfo, "creationflags": subprocess.CREATE_NO_WINDOW} + { + "startupinfo": startupinfo, + "creationflags": subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined] + } ) # Print the command being executed @@ -398,8 +431,8 @@ def calculate_text_length(text): def get_gpu_acceleration(enabled): try: - import torch - from torch.cuda import is_available as cuda_available + import torch # type: ignore[import-not-found] + from torch.cuda import is_available as cuda_available # type: ignore[import-not-found] if not enabled: return "GPU available but using CPU.", False @@ -437,7 +470,7 @@ def prevent_sleep_start(): if system == "Windows": import ctypes - ctypes.windll.kernel32.SetThreadExecutionState( + ctypes.windll.kernel32.SetThreadExecutionState( # type: ignore[attr-defined] 0x80000000 | 0x00000001 | 0x00000040 ) elif system == "Darwin": @@ -471,18 +504,21 @@ def prevent_sleep_end(): if system == "Windows": import ctypes - ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS - elif system in ("Darwin", "Linux") and _sleep_procs[system]: - try: - _sleep_procs[system].terminate() - _sleep_procs[system] = None - except Exception: - pass + ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # type: ignore[attr-defined] + elif system in ("Darwin", "Linux"): + proc = _sleep_procs.get(system) + if proc: + try: + proc.terminate() + except Exception: + pass + finally: + _sleep_procs[system] = None def load_numpy_kpipeline(): import numpy as np - from kokoro import KPipeline + from kokoro import KPipeline # type: ignore[import-not-found] return np, KPipeline diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index 0d75aaf..313e4c6 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -7,6 +7,9 @@ const initDashboard = () => { const readerTitle = readerModal?.querySelector('#reader-modal-title') || null; const defaultReaderHint = readerHint?.textContent || ""; const scope = uploadModal || document; + const sourceFileInput = scope.querySelector('#source_file'); + const dropzone = document.querySelector('[data-role="upload-dropzone"]'); + const dropzoneFilename = document.querySelector('[data-role="upload-dropzone-filename"]'); const parseJSONScript = (id) => { const element = document.getElementById(id); @@ -32,6 +35,71 @@ const initDashboard = () => { const previewAudio = scope.querySelector('[data-role="voice-preview-audio"]'); const sampleVoiceTexts = parseJSONScript('voice-sample-texts') || {}; + const setDropzoneStatus = (message, state = "") => { + if (!dropzoneFilename) return; + if (!message) { + dropzoneFilename.hidden = true; + dropzoneFilename.textContent = ""; + dropzoneFilename.removeAttribute("data-state"); + return; + } + dropzoneFilename.hidden = false; + dropzoneFilename.textContent = message; + if (state) { + dropzoneFilename.dataset.state = state; + } else { + dropzoneFilename.removeAttribute("data-state"); + } + }; + + const updateDropzoneFilename = () => { + if (!sourceFileInput) { + setDropzoneStatus(""); + return; + } + const file = sourceFileInput.files && sourceFileInput.files[0]; + if (file) { + setDropzoneStatus(`Selected: ${file.name}`); + } else { + setDropzoneStatus(""); + } + }; + + const assignDroppedFile = (file) => { + if (!sourceFileInput || !file) { + return false; + } + try { + if (typeof DataTransfer === "undefined") { + throw new Error("DataTransfer API unavailable"); + } + const transfer = new DataTransfer(); + transfer.items.add(file); + sourceFileInput.files = transfer.files; + sourceFileInput.dispatchEvent(new Event("change", { bubbles: true })); + try { + sourceFileInput.focus({ preventScroll: true }); + } catch (error) { + // Ignore focus errors + } + return true; + } catch (error) { + console.warn("Unable to assign dropped file to input", error); + setDropzoneStatus("Drag & drop isn't supported here. Click to choose a file instead.", "error"); + return false; + } + }; + + const setDropzoneActive = (isActive) => { + if (!dropzone) return; + dropzone.classList.toggle("is-dragging", isActive); + if (isActive) { + dropzone.dataset.state = "drag"; + } else { + delete dropzone.dataset.state; + } + }; + let lastTrigger = null; let readerTrigger = null; let previewAbortController = null; @@ -151,6 +219,13 @@ const initDashboard = () => { } }); + if (sourceFileInput) { + sourceFileInput.addEventListener("change", updateDropzoneFilename); + updateDropzoneFilename(); + } else { + setDropzoneStatus(""); + } + const resolveSampleText = (language) => { const fallback = typeof sampleVoiceTexts === "object" && sampleVoiceTexts?.a ? sampleVoiceTexts.a @@ -352,6 +427,68 @@ const initDashboard = () => { }); } + if (dropzone) { + let dragDepth = 0; + + dropzone.addEventListener("dragenter", (event) => { + event.preventDefault(); + dragDepth += 1; + setDropzoneActive(true); + }); + + dropzone.addEventListener("dragover", (event) => { + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "copy"; + } + }); + + const handleDragLeave = (event) => { + if (event && dropzone.contains(event.relatedTarget)) { + return; + } + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) { + setDropzoneActive(false); + } + }; + + dropzone.addEventListener("dragleave", (event) => { + handleDragLeave(event); + }); + + dropzone.addEventListener("dragend", () => { + dragDepth = 0; + setDropzoneActive(false); + }); + + dropzone.addEventListener("drop", (event) => { + event.preventDefault(); + dragDepth = 0; + setDropzoneActive(false); + const files = event.dataTransfer && event.dataTransfer.files; + if (!files || !files.length) { + return; + } + openUploadModal(dropzone); + assignDroppedFile(files[0]); + }); + + dropzone.addEventListener("click", (event) => { + if (event.target.closest('[data-role="open-upload-modal"]')) { + return; + } + openUploadModal(dropzone); + }); + + dropzone.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openUploadModal(dropzone); + } + }); + } + [voiceSelect, profileSelect, formulaInput, languageSelect, speedInput].forEach((input) => { if (!input) return; const eventName = input === formulaInput ? "input" : "change"; diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 799d2b0..817de9e 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -130,12 +130,90 @@ body { gap: 1.25rem; } +.upload-card__dropzone { + border: 1.5px dashed rgba(148, 163, 184, 0.3); + border-radius: 24px; + background: rgba(15, 23, 42, 0.28); + padding: 2.75rem 2rem; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; + outline: none; +} + +.upload-card__dropzone:hover, +.upload-card__dropzone:focus-visible { + border-color: rgba(56, 189, 248, 0.6); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12); +} + +.upload-card__dropzone.is-dragging { + border-color: rgba(56, 189, 248, 0.85); + background: rgba(15, 23, 42, 0.45); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); +} + +.upload-card__dropzone-content { + display: grid; + gap: 0.75rem; + justify-items: center; + max-width: 420px; +} + +.upload-card__icon { + font-size: 2rem; + line-height: 1; + padding: 0.55rem 0.7rem; + border-radius: 999px; + background: rgba(56, 189, 248, 0.12); + color: var(--accent); +} + +.upload-card__headline { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.upload-card__hint { + margin: 0; + color: var(--muted); +} + +.upload-card__filename { + margin: 0; + color: var(--accent); + font-weight: 500; +} + +.upload-card__filename[data-state="error"] { + color: var(--danger); +} + +.upload-card__dropzone .button { + margin-top: 0.25rem; +} + .card__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; } +.card__actions .button { + padding: 0.7rem 1.1rem; + font-size: 0.95rem; + border-radius: 12px; + box-shadow: 0 8px 22px rgba(56, 189, 248, 0.18); +} + +.card__actions .button:hover { + box-shadow: 0 12px 26px rgba(14, 165, 233, 0.24); +} + .card--modal { padding: 0; border-radius: 28px; @@ -213,6 +291,7 @@ body { flex-direction: column; gap: 1.5rem; flex: 1 1 auto; + min-height: 0; } .modal__footer { diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index 98e470b..cc7be16 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -4,25 +4,16 @@ {% block content %}
-
- - 1 - Upload & settings - - - 2 - Chapters - - - 3 - Speakers - -

Create a New Audiobook

Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.

-
- - View queue +
+
+ +

Drop your manuscript to begin

+

Drag & drop a supported file here, or click to choose one in the next step.

+ + +
diff --git a/tests/test_utils_cache.py b/tests/test_utils_cache.py new file mode 100644 index 0000000..2d35d18 --- /dev/null +++ b/tests/test_utils_cache.py @@ -0,0 +1,55 @@ +import os +import sys +from pathlib import Path +from typing import Iterable + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +@pytest.fixture(autouse=True) +def clear_utils_cache(): + import abogen.utils as utils + + getattr(utils.get_user_cache_root, "cache_clear")() + yield + getattr(utils.get_user_cache_root, "cache_clear")() + + +def _clear_env(monkeypatch: pytest.MonkeyPatch, keys: Iterable[str]) -> None: + for key in keys: + monkeypatch.delenv(key, raising=False) + + +def test_abogen_temp_dir_configures_hf_cache(monkeypatch, tmp_path): + import abogen.utils as utils + + cache_root = tmp_path / "cache-root" + home_dir = tmp_path / "home" + + monkeypatch.setenv("ABOGEN_TEMP_DIR", str(cache_root)) + monkeypatch.setenv("HOME", str(home_dir)) + _clear_env( + monkeypatch, + ( + "XDG_CACHE_HOME", + "HF_HOME", + "HUGGINGFACE_HUB_CACHE", + "TRANSFORMERS_CACHE", + "ABOGEN_INTERNAL_CACHE_ROOT", + ), + ) + + root = utils.get_user_cache_root() + + expected_root = os.path.abspath(str(cache_root)) + expected_hf = os.path.join(expected_root, "huggingface") + + assert root == expected_root + assert os.environ["XDG_CACHE_HOME"] == expected_root + assert os.environ["HF_HOME"] == expected_hf + assert os.environ["HUGGINGFACE_HUB_CACHE"] == expected_hf + assert os.environ["TRANSFORMERS_CACHE"] == expected_hf