From 717cb13dda65555d4b434e3a76fbccafa90c0ab0 Mon Sep 17 00:00:00 2001 From: Nose Date: Thu, 14 May 2026 20:47:20 -0700 Subject: [PATCH] removed TUI and others --- CLI.py | 148 +- SYS/config.py | 2 +- {TUI => SYS}/pipeline_runner.py | 204 +-- TUI.py | 4 +- TUI/__init__.py | 1 - TUI/menu_actions.py | 79 - TUI/modalscreen/__init__.py | 7 - TUI/modalscreen/access.py | 151 -- TUI/modalscreen/config_modal.py | 1933 ---------------------- TUI/modalscreen/download.py | 2153 ------------------------- TUI/modalscreen/download.tcss | 183 --- TUI/modalscreen/export.py | 593 ------- TUI/modalscreen/export.tcss | 85 - TUI/modalscreen/matrix_room_picker.py | 243 --- TUI/modalscreen/search.py | 433 ----- TUI/modalscreen/search.tcss | 121 -- TUI/modalscreen/selection_modal.py | 69 - TUI/modalscreen/workers.py | 709 -------- TUI/modalscreen/workers.tcss | 119 -- TUI/tui.tcss | 249 --- cmdlet/file/search.py | 2 +- cmdnat/_status_shared.py | 157 +- cmdnat/config.py | 44 +- cmdnat/status.py | 60 +- plugins/mpv/LUA/main.lua | 27 + plugins/mpv/mpv_ipc.py | 2 +- plugins/mpv/pipeline_helper.py | 2 +- plugins/youtube/__init__.py | 218 --- plugins/ytdlp/__init__.py | 58 +- scripts/cli_entry.py | 73 +- tool/ytdlp.py | 39 + 31 files changed, 378 insertions(+), 7790 deletions(-) rename {TUI => SYS}/pipeline_runner.py (64%) delete mode 100644 TUI/__init__.py delete mode 100644 TUI/menu_actions.py delete mode 100644 TUI/modalscreen/__init__.py delete mode 100644 TUI/modalscreen/access.py delete mode 100644 TUI/modalscreen/config_modal.py delete mode 100644 TUI/modalscreen/download.py delete mode 100644 TUI/modalscreen/download.tcss delete mode 100644 TUI/modalscreen/export.py delete mode 100644 TUI/modalscreen/export.tcss delete mode 100644 TUI/modalscreen/matrix_room_picker.py delete mode 100644 TUI/modalscreen/search.py delete mode 100644 TUI/modalscreen/search.tcss delete mode 100644 TUI/modalscreen/selection_modal.py delete mode 100644 TUI/modalscreen/workers.py delete mode 100644 TUI/modalscreen/workers.tcss delete mode 100644 TUI/tui.tcss delete mode 100644 plugins/youtube/__init__.py diff --git a/CLI.py b/CLI.py index 5bf521e..fa00da6 100644 --- a/CLI.py +++ b/CLI.py @@ -60,7 +60,6 @@ from SYS.rich_display import ( from cmdnat._status_shared import ( add_startup_check as _shared_add_startup_check, collect_plugin_startup_checks as _collect_plugin_startup_checks, - has_store_subtype as _has_store_subtype, has_tool as _has_tool, ) @@ -94,7 +93,7 @@ from SYS.cmdlet_catalog import ( list_cmdlet_metadata, list_cmdlet_names, ) -from SYS.config import load_config, resolve_cookies_path +from SYS.config import load_config from SYS.result_table import Table from SYS.worker import WorkerManagerRegistry, WorkerStages, WorkerOutputMirror, WorkerStageSession @@ -2408,7 +2407,7 @@ Come to love it when others take what you share, as there is no greater joy name: str, *, provider: str = "", - store: str = "", + instance: str = "", files: int | str | None = None, detail: str = "", ) -> None: @@ -2417,7 +2416,7 @@ Come to love it when others take what you share, as there is no greater joy status, name, provider=provider, - store=store, + instance=instance, files=files, detail=detail, ) @@ -2439,127 +2438,15 @@ Come to love it when others take what you share, as there is no greater joy except Exception as exc: _add_startup_check("DISABLED", "MPV", detail=str(exc)) - store_registry = None - if config: - try: - from Store import Store as StoreRegistry - - store_registry = StoreRegistry(config=config, suppress_debug=True) - except Exception: - store_registry = None - - if _has_store_subtype(config, "hydrusnetwork"): - store_cfg = config.get("store") - hydrus_cfg = ( - store_cfg.get("hydrusnetwork", - {}) if isinstance(store_cfg, - dict) else {} - ) - if isinstance(hydrus_cfg, dict): - for instance_name, instance_cfg in hydrus_cfg.items(): - if not isinstance(instance_cfg, dict): - continue - name_key = str(instance_cfg.get("NAME") or instance_name) - url_val = str(instance_cfg.get("URL") or "").strip() - - ok = bool( - store_registry - and store_registry.is_available(name_key) - ) - status = "ENABLED" if ok else "DISABLED" - if ok: - total = None - try: - if store_registry: - backend = store_registry[name_key] - total = getattr(backend, "total_count", None) - if total is None: - getter = getattr( - backend, - "get_total_count", - None - ) - if callable(getter): - total = getter() - except Exception: - total = None - detail = url_val - files = total if isinstance( - total, - int - ) and total >= 0 else None - else: - err = None - if store_registry: - err = store_registry.get_backend_error( - instance_name - ) or store_registry.get_backend_error(name_key) - detail = (url_val + (" - " if url_val else "") - ) + (err or "Unavailable") - files = None - _add_startup_check( - status, - name_key, - store="hydrusnetwork", - files=files, - detail=detail - ) - - provider_cfg = None - if isinstance(config, dict): - provider_cfg = config.get("plugin") - if not isinstance(provider_cfg, dict): - provider_cfg = config.get("provider") - if isinstance(provider_cfg, dict) and provider_cfg: - for check in _collect_plugin_startup_checks(config): - _add_startup_check( - str(check.get("status") or "UNKNOWN"), - str(check.get("name") or "Plugin"), - provider=str(check.get("plugin") or ""), - detail=str(check.get("detail") or ""), - files=check.get("files"), - ) - - if _has_store_subtype(config, "debrid"): - try: - from SYS.config import get_debrid_api_key - from plugins.alldebrid.api import AllDebridClient - - api_key = get_debrid_api_key(config) - if not api_key: - _add_startup_check( - "DISABLED", - "Debrid", - store="debrid", - detail="Not configured" - ) - else: - client = AllDebridClient(api_key) - base_url = str(getattr(client, - "base_url", - "") or "").strip() - _add_startup_check( - "ENABLED", - "Debrid", - store="debrid", - detail=base_url or "Connected" - ) - except Exception as exc: - _add_startup_check( - "DISABLED", - "Debrid", - store="debrid", - detail=str(exc) - ) - - try: - cookiefile = resolve_cookies_path(config) - if cookiefile is not None: - _add_startup_check("FOUND", "Cookies", detail=str(cookiefile)) - else: - _add_startup_check("MISSING", "Cookies", detail="Not found") - except Exception as exc: - _add_startup_check("ERROR", "Cookies", detail=str(exc)) + for check in _collect_plugin_startup_checks(config): + _add_startup_check( + str(check.get("status") or "UNKNOWN"), + str(check.get("name") or "Plugin"), + provider=str(check.get("plugin") or ""), + instance=str(check.get("instance") or ""), + detail=str(check.get("detail") or ""), + files=check.get("files"), + ) # Tool checks (configured via [tool=...]) if _has_tool(config, "florencevision"): @@ -2599,13 +2486,12 @@ Come to love it when others take what you share, as there is no greater joy provider="tool", detail=str(exc), ) - - if startup_table.rows: - stdout_console().print() - stdout_console().print(startup_table) except Exception as exc: - if debug_enabled: - debug(f"⚠ Could not check service availability: {exc}") + _add_startup_check("ERROR", "STARTUP", detail=str(exc)) + + if startup_table.rows: + stdout_console().print() + stdout_console().print(startup_table) style = Style.from_dict( { diff --git a/SYS/config.py b/SYS/config.py index 05938ff..638a24f 100644 --- a/SYS/config.py +++ b/SYS/config.py @@ -900,7 +900,7 @@ def _extract_expected_alldebrid_key(config: Dict[str, Any]) -> Optional[str]: return expected_key -def load_config(*, emit_summary: bool = True) -> Dict[str, Any]: +def load_config(*, emit_summary: bool = False) -> Dict[str, Any]: global _CONFIG_CACHE, _LAST_SAVED_CONFIG, _CONFIG_SUMMARY_PENDING if _CONFIG_CACHE: if emit_summary and _CONFIG_SUMMARY_PENDING: diff --git a/TUI/pipeline_runner.py b/SYS/pipeline_runner.py similarity index 64% rename from TUI/pipeline_runner.py rename to SYS/pipeline_runner.py index f34a0af..1329afb 100644 --- a/TUI/pipeline_runner.py +++ b/SYS/pipeline_runner.py @@ -1,7 +1,8 @@ -"""Pipeline execution utilities for the Textual UI. +"""Shared pipeline runner utilities. -The TUI is a frontend to the CLI, so it must use the same pipeline executor -implementation as the CLI (`CLI.PipelineExecutor`). +This module wraps the canonical CLI pipeline executor so non-CLI callers can +execute pipelines and capture the resulting table/items without depending on +the discontinued Textual UI package. """ from __future__ import annotations @@ -9,27 +10,21 @@ from __future__ import annotations import contextlib import io import shlex -import sys import traceback -from pathlib import Path from dataclasses import dataclass, field +from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence -BASE_DIR = Path(__file__).resolve().parent -ROOT_DIR = BASE_DIR.parent -for path in (ROOT_DIR, BASE_DIR): - str_path = str(path) - if str_path not in sys.path: - sys.path.insert(0, str_path) - -from SYS import pipeline as ctx from CLI import ConfigLoader +from SYS import pipeline as ctx +from SYS.logger import debug, set_debug from SYS.pipeline import PipelineExecutor -from SYS.worker import WorkerManagerRegistry -from SYS.logger import set_debug, debug -from SYS.rich_display import capture_rich_output -import traceback from SYS.result_table import Table +from SYS.rich_display import capture_rich_output +from SYS.worker import WorkerManagerRegistry + + +REPO_ROOT = Path(__file__).resolve().parents[1] @dataclass(slots=True) @@ -39,7 +34,7 @@ class PipelineStageResult: name: str args: Sequence[str] emitted: List[Any] = field(default_factory=list) - result_table: Optional[Any] = None # ResultTable object if available + result_table: Optional[Any] = None status: str = "pending" error: Optional[str] = None @@ -52,42 +47,46 @@ class PipelineRunResult: success: bool stages: List[PipelineStageResult] = field(default_factory=list) emitted: List[Any] = field(default_factory=list) - result_table: Optional[Any] = None # Final ResultTable object if available + result_table: Optional[Any] = None stdout: str = "" stderr: str = "" error: Optional[str] = None def to_summary(self) -> Dict[str, Any]: - """Provide a JSON-friendly representation for logging or UI.""" return { - "pipeline": - self.pipeline, - "success": - self.success, - "error": - self.error, + "pipeline": self.pipeline, + "success": self.success, + "error": self.error, "stages": [ { "name": stage.name, "status": stage.status, "error": stage.error, "emitted": len(stage.emitted), - } for stage in self.stages + } + for stage in self.stages ], } class PipelineRunner: - """TUI wrapper that delegates to the canonical CLI pipeline executor.""" + """Wrapper around the canonical CLI pipeline executor.""" - def __init__(self, config_loader: Optional[Any] = None, executor: Optional[Any] = None) -> None: - # Allow dependency injection or lazily construct CLI dependencies so tests - # don't fail due to import-order issues in pytest environments. - self._config_loader = config_loader if config_loader is not None else (ConfigLoader(root=ROOT_DIR) if ConfigLoader else None) - if executor is not None: - self._executor = executor - else: - self._executor = PipelineExecutor(config_loader=self._config_loader) + def __init__( + self, + config_loader: Optional[Any] = None, + executor: Optional[Any] = None, + ) -> None: + self._config_loader = ( + config_loader + if config_loader is not None + else ConfigLoader(root=REPO_ROOT) + ) + self._executor = ( + executor + if executor is not None + else PipelineExecutor(config_loader=self._config_loader) + ) self._worker_manager = None @property @@ -101,8 +100,7 @@ class PipelineRunner: seeds: Optional[Any] = None, seed_table: Optional[Any] = None, isolate: bool = False, - on_log: Optional[Callable[[str], - None]] = None, + on_log: Optional[Callable[[str], None]] = None, ) -> PipelineRunResult: snapshot: Optional[Dict[str, Any]] = None if isolate: @@ -171,8 +169,8 @@ class PipelineRunner: try: with capture_rich_output(stdout=stdout_buffer, stderr=stderr_buffer): with ( - contextlib.redirect_stdout(stdout_buffer), - contextlib.redirect_stderr(stderr_buffer), + contextlib.redirect_stdout(stdout_buffer), + contextlib.redirect_stderr(stderr_buffer), ): if on_log: on_log("Executing pipeline via CLI executor...") @@ -187,11 +185,11 @@ class PipelineRunner: result.stdout = stdout_buffer.getvalue() result.stderr = stderr_buffer.getvalue() - # Pull the canonical state out of pipeline context. table = None try: table = ( - ctx.get_display_table() or ctx.get_current_stage_table() + ctx.get_display_table() + or ctx.get_current_stage_table() or ctx.get_last_result_table() ) except Exception: @@ -226,7 +224,7 @@ class PipelineRunner: ) if result.error: result.success = False - elif any(m in combined for m in failure_markers): + elif any(marker in combined for marker in failure_markers): result.success = False if not result.error: result.error = "Pipeline failed" @@ -237,84 +235,89 @@ class PipelineRunner: try: self._restore_ctx_state(snapshot) except Exception: - # Best-effort; isolation should never break normal operation. pass return result @staticmethod def _snapshot_ctx_state() -> Dict[str, Any]: - """Best-effort snapshot of pipeline context using PipelineState. - - This reads from the active PipelineState (ContextVar or global fallback) - to produce a consistent snapshot that can be restored later. - """ - - def _copy(val: Any) -> Any: - if isinstance(val, list): - return val.copy() - if isinstance(val, dict): - return val.copy() - return val + def _copy(value: Any) -> Any: + if isinstance(value, list): + return value.copy() + if isinstance(value, dict): + return value.copy() + return value state = ctx.get_pipeline_state() - snap: Dict[str, Any] = {} + snapshot: Dict[str, Any] = {} + snapshot["live_progress"] = _copy(state.live_progress) + snapshot["current_context"] = state.current_context + snapshot["last_search_query"] = state.last_search_query + snapshot["pipeline_refreshed"] = state.pipeline_refreshed + snapshot["last_items"] = _copy(state.last_items) + snapshot["last_result_table"] = state.last_result_table + snapshot["last_result_items"] = _copy(state.last_result_items) + snapshot["last_result_subject"] = state.last_result_subject - # Simple scalar/list/dict fields - snap["live_progress"] = _copy(state.live_progress) - snap["current_context"] = state.current_context - snap["last_search_query"] = state.last_search_query - snap["pipeline_refreshed"] = state.pipeline_refreshed - snap["last_items"] = _copy(state.last_items) - snap["last_result_table"] = state.last_result_table - snap["last_result_items"] = _copy(state.last_result_items) - snap["last_result_subject"] = state.last_result_subject - - # Deep-copy history/forward stacks (copy nested item lists) - def _copy_history(hist: Optional[List[tuple]]) -> List[tuple]: + def _copy_history(history: Optional[List[tuple]]) -> List[tuple]: out: List[tuple] = [] try: - for (t, items, subj) in list(hist or []): - items_copy = items.copy() if isinstance(items, list) else list(items) if items else [] - out.append((t, items_copy, subj)) + for table_value, items, subject in list(history or []): + if isinstance(items, list): + items_copy = items.copy() + elif items: + items_copy = list(items) + else: + items_copy = [] + out.append((table_value, items_copy, subject)) except Exception: debug(traceback.format_exc()) return out - snap["result_table_history"] = _copy_history(state.result_table_history) - snap["result_table_forward"] = _copy_history(state.result_table_forward) - - snap["current_stage_table"] = state.current_stage_table - snap["display_items"] = _copy(state.display_items) - snap["display_table"] = state.display_table - snap["display_subject"] = state.display_subject - snap["last_selection"] = _copy(state.last_selection) - snap["pipeline_command_text"] = state.pipeline_command_text - snap["current_cmdlet_name"] = state.current_cmdlet_name - snap["current_stage_text"] = state.current_stage_text - snap["pipeline_values"] = _copy(state.pipeline_values) if isinstance(state.pipeline_values, dict) else state.pipeline_values - snap["pending_pipeline_tail"] = [list(stage) for stage in (state.pending_pipeline_tail or [])] - snap["pending_pipeline_source"] = state.pending_pipeline_source - snap["ui_library_refresh_callback"] = state.ui_library_refresh_callback - snap["pipeline_stop"] = state.pipeline_stop - - return snap + snapshot["result_table_history"] = _copy_history(state.result_table_history) + snapshot["result_table_forward"] = _copy_history(state.result_table_forward) + snapshot["current_stage_table"] = state.current_stage_table + snapshot["display_items"] = _copy(state.display_items) + snapshot["display_table"] = state.display_table + snapshot["display_subject"] = state.display_subject + snapshot["last_selection"] = _copy(state.last_selection) + snapshot["pipeline_command_text"] = state.pipeline_command_text + snapshot["current_cmdlet_name"] = state.current_cmdlet_name + snapshot["current_stage_text"] = state.current_stage_text + snapshot["pipeline_values"] = ( + _copy(state.pipeline_values) + if isinstance(state.pipeline_values, dict) + else state.pipeline_values + ) + snapshot["pending_pipeline_tail"] = [ + list(stage) for stage in (state.pending_pipeline_tail or []) + ] + snapshot["pending_pipeline_source"] = state.pending_pipeline_source + snapshot["ui_library_refresh_callback"] = state.ui_library_refresh_callback + snapshot["pipeline_stop"] = state.pipeline_stop + return snapshot @staticmethod def _restore_ctx_state(snapshot: Dict[str, Any]) -> None: if not snapshot: return + state = ctx.get_pipeline_state() - # Helper for restoring history-like stacks - def _restore_history(key: str, val: Any) -> None: + def _restore_history(key: str, value: Any) -> None: try: - if isinstance(val, list): - out: List[tuple] = [] - for (t, items, subj) in val: - items_copy = items.copy() if isinstance(items, list) else list(items) if items else [] - out.append((t, items_copy, subj)) - setattr(state, key, out) + if not isinstance(value, list): + return + restored: List[tuple] = [] + for table_value, items, subject in value: + if isinstance(items, list): + items_copy = items.copy() + elif items: + items_copy = list(items) + else: + items_copy = [] + restored.append((table_value, items_copy, subject)) + setattr(state, key, restored) except Exception: debug(traceback.format_exc()) @@ -366,7 +369,4 @@ class PipelineRunner: if "pipeline_stop" in snapshot: state.pipeline_stop = snapshot["pipeline_stop"] except Exception: - # Best-effort; don't break the pipeline runner - pass - - + pass \ No newline at end of file diff --git a/TUI.py b/TUI.py index 6e47af9..acd66f8 100644 --- a/TUI.py +++ b/TUI.py @@ -48,7 +48,7 @@ for path in (REPO_ROOT, TUI_DIR): if str_path not in sys.path: sys.path.insert(0, str_path) -from TUI.pipeline_runner import PipelineRunResult # type: ignore # noqa: E402 +from SYS.pipeline_runner import PipelineRunResult # type: ignore # noqa: E402 from SYS.result_table import Table, extract_hash_value, extract_store_value, get_result_table_row_style # type: ignore # noqa: E402 from SYS.config import load_config # type: ignore # noqa: E402 @@ -57,7 +57,7 @@ from Store.registry import Store as StoreRegistry # type: ignore # noqa: E402 from SYS.cmdlet_catalog import ensure_registry_loaded, list_cmdlet_names # type: ignore # noqa: E402 from SYS.cli_syntax import validate_pipeline_text # type: ignore # noqa: E402 -from TUI.pipeline_runner import PipelineRunner # type: ignore # noqa: E402 +from SYS.pipeline_runner import PipelineRunner # type: ignore # noqa: E402 def _dedup_preserve_order(items: List[str]) -> List[str]: diff --git a/TUI/__init__.py b/TUI/__init__.py deleted file mode 100644 index f6b5106..0000000 --- a/TUI/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Medeia-Macina TUI - Terminal User Interface.""" diff --git a/TUI/menu_actions.py b/TUI/menu_actions.py deleted file mode 100644 index 4fe6f51..0000000 --- a/TUI/menu_actions.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Utilities that drive the modern Textual UI menus and presets.""" - -from __future__ import annotations - -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Dict, Iterable, List, Sequence - -BASE_DIR = Path(__file__).resolve().parent -ROOT_DIR = BASE_DIR.parent -for path in (ROOT_DIR, BASE_DIR): - str_path = str(path) - if str_path not in sys.path: - sys.path.insert(0, str_path) - -import SYS.metadata as metadata - - -@dataclass(slots=True) -class PipelinePreset: - """Simple descriptor for a reusable pipeline.""" - - label: str - description: str - pipeline: str - - -PIPELINE_PRESETS: List[PipelinePreset] = [ - PipelinePreset( - label="Download → Merge → Local", - description= - "Use file -download with playlist auto-selection, merge the pieces, tag, then import into local storage.", - pipeline= - 'file -download "" | file -merge | metadata -add -instance local | file -add -storage local', - ), - PipelinePreset( - label="Download → Hydrus", - description="Fetch media, auto-tag, and push directly into Hydrus.", - pipeline= - 'file -download "" | file -merge | metadata -add -instance hydrus | file -add -storage hydrus', - ), - PipelinePreset( - label="Search Local Library", - description= - "Run file -query against the local library and emit a result table for further piping.", - pipeline='file -library local -query ""', - ), -] - - -def load_tags(file_path: Path) -> List[str]: - """Read tags for a file using metadata.py as the single source of truth.""" - - try: - return metadata.read_tags_from_file(file_path) - except Exception: - return [] - - -def group_tags_by_namespace(tags: Sequence[str]) -> Dict[str, List[str]]: - """Return tags grouped by namespace for quick UI summaries.""" - - grouped: Dict[str, - List[str]] = {} - for tag in metadata.normalize_tags(list(tags)): - namespace, value = metadata.split_tag(tag) - key = namespace or "_untagged" - grouped.setdefault(key, []).append(value) - - for items in grouped.values(): - items.sort() - return grouped - - -def normalize_tags(tags: Iterable[str]) -> List[str]: - """Expose metadata.normalize_tags for callers that imported the old helper.""" - - return metadata.normalize_tags(list(tags)) diff --git a/TUI/modalscreen/__init__.py b/TUI/modalscreen/__init__.py deleted file mode 100644 index 1cd127c..0000000 --- a/TUI/modalscreen/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Modal screens for the Downlow Hub UI application.""" - -from .export import ExportModal -from .search import SearchModal -from .workers import WorkersModal - -__all__ = ["ExportModal", "SearchModal", "WorkersModal"] diff --git a/TUI/modalscreen/access.py b/TUI/modalscreen/access.py deleted file mode 100644 index fe35133..0000000 --- a/TUI/modalscreen/access.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Modal for displaying files/url to access in web mode.""" - -from textual.screen import ModalScreen -from textual.containers import Container, Vertical, Horizontal -from textual.widgets import Static, Button, Label -from textual.app import ComposeResult -import logging - -logger = logging.getLogger(__name__) - - -class AccessModal(ModalScreen): - """Modal to display a file/URL that can be accessed from phone browser.""" - - CSS = """ - Screen { - align: center middle; - } - - #access-container { - width: 80; - height: auto; - border: thick $primary; - background: $surface; - } - - #access-header { - dock: top; - height: 3; - background: $boost; - border-bottom: solid $accent; - content-align: center middle; - } - - #access-content { - height: auto; - width: 1fr; - padding: 1 2; - border-bottom: solid $accent; - } - - #access-footer { - dock: bottom; - height: 3; - background: $boost; - border-top: solid $accent; - align: center middle; - } - - .access-url { - width: 1fr; - height: auto; - margin-bottom: 1; - border: solid $accent; - padding: 1; - } - - .access-label { - width: 1fr; - height: auto; - margin-bottom: 1; - } - - Button { - margin-right: 1; - } - """ - - def __init__(self, title: str, content: str, is_url: bool = False): - """Initialize access modal. - - Args: - title: Title of the item being accessed - content: The URL or file path - is_url: Whether this is a URL (True) or file path (False) - """ - super().__init__() - self.item_title = title - self.item_content = content - self.is_url = is_url - - def compose(self) -> ComposeResult: - """Create the modal layout.""" - with Container(id="access-container"): - with Vertical(id="access-header"): - yield Label(f"[bold]{self.item_title}[/bold]") - yield Label("[dim]Click link below to open in your browser[/dim]") - - with Vertical(id="access-content"): - if self.is_url: - yield Label("[bold cyan]Link:[/bold cyan]", classes="access-label") - else: - yield Label("[bold cyan]File:[/bold cyan]", classes="access-label") - - # Display as clickable link using HTML link element for web mode - # Rich link markup `[link=URL]` has parsing issues with url containing special chars - # Instead, use the HTML link markup that Textual-serve renders as tag - # Format: [link=URL "tooltip"]text[/link] - the quotes help with parsing - link_text = f'[link="{self.item_content}"]Open in Browser[/link]' - content_box = Static(link_text, classes="access-url") - yield content_box - - # Also show the URL for reference/copying - yield Label(self.item_content, classes="access-label") - - yield Label( - "\n[yellow]↑ Click the link above to open on your device[/yellow]", - classes="access-label", - ) - - with Horizontal(id="access-footer"): - yield Button("Copy URL", id="copy-btn", variant="primary") - yield Button("Close", id="close-btn", variant="default") - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - if event.button.id == "copy-btn": - # Copy to clipboard (optional - not critical if fails) - logger.info(f"Attempting to copy: {self.item_content}") - try: - # Try to use pyperclip if available - try: - import pyperclip - - pyperclip.copy(self.item_content) - logger.info("URL copied to clipboard via pyperclip") - except ImportError: - # Fallback: try xclip on Linux or pbcopy on Mac - import subprocess - import sys - - if sys.platform == "win32": - # Windows: use clipboard via pyperclip (already tried) - logger.debug( - "Windows clipboard not available without pyperclip" - ) - else: - # Linux/Mac - process = subprocess.Popen( - ["xclip", - "-selection", - "clipboard"], - stdin=subprocess.PIPE - ) - process.communicate(self.item_content.encode("utf-8")) - logger.info("URL copied to clipboard via xclip") - except Exception as e: - logger.debug(f"Clipboard copy not available: {e}") - # Not critical - just informational - elif event.button.id == "close-btn": - self.dismiss() diff --git a/TUI/modalscreen/config_modal.py b/TUI/modalscreen/config_modal.py deleted file mode 100644 index 23e609d..0000000 --- a/TUI/modalscreen/config_modal.py +++ /dev/null @@ -1,1933 +0,0 @@ -import re -from copy import deepcopy -from typing import Any, Dict, List, Optional, Iterable -import traceback - -from textual import on, work -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, Vertical, ScrollableContainer -from textual.screen import ModalScreen -from textual.widgets import Static, Button, Input, Label, ListView, ListItem, Rule, Select, Checkbox, TextArea -from pathlib import Path - -from SYS.config import ( - load_config, - save_config, - save_config_and_verify, - reload_config, - count_changed_entries, - ConfigSaveConflict, - coerce_config_value, - _is_multi_instance_plugin_config, -) -from SYS.database import db -from SYS.logger import log, debug -from SYS.plugin_config import ( - build_default_plugin_config, - build_default_store_config, - build_default_tool_config, - get_configurable_plugin_types, - get_configurable_store_types, - get_configurable_tool_types, - get_global_schema, - get_item_schema_map, - get_required_config_keys, -) -from ProviderCore.registry import get_plugin, get_plugin_class, get_plugin_capabilities -from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker -from TUI.modalscreen.selection_modal import SelectionModal -import logging -logger = logging.getLogger(__name__) - -class ConfigModal(ModalScreen): - """A modal for editing the configuration.""" - - BINDINGS = [ - ("ctrl+v", "paste", "Paste"), - ("ctrl+c", "copy", "Copy"), - ] - - CSS = """ - ConfigModal { - align: center middle; - background: $boost; - } - - #config-container { - width: 90%; - height: 90%; - background: $panel; - border: thick $primary; - padding: 1; - } - - .section-title { - background: $accent; - color: $text; - padding: 0 1; - margin-bottom: 1; - text-align: center; - text-style: bold; - height: 3; - content-align: center middle; - } - - #config-sidebar { - width: 25%; - border-right: solid $surface; - } - - #config-content { - width: 75%; - padding: 1; - } - - .config-field { - margin-bottom: 1; - height: auto; - } - - .config-label { - width: 100%; - text-style: bold; - color: $accent; - } - - .field-row { - height: 5; - margin-bottom: 1; - align: left middle; - } - - .config-input { - width: 1fr; - } - - .config-textarea { - height: 8; - margin-bottom: 1; - } - - .config-group { - color: $accent; - text-style: bold; - margin-top: 1; - margin-bottom: 1; - } - - #config-actions { - height: 3; - align: right middle; - } - - .item-row { - height: 5; - margin-bottom: 1; - padding: 0 1; - border: solid $surface; - } - - .item-label { - width: 1fr; - height: 3; - content-align: left middle; - } - - .item-row Button { - width: 16; - height: 3; - } - - Button { - margin: 0 1; - } - - /* Inline matrix rooms list sizing & style (larger, scrollable) */ - #matrix-rooms-inline { - height: 16; - border: solid $surface; - padding: 1; - margin-bottom: 1; - } - - .matrix-room-row { - border-bottom: solid $surface; - padding: 1 0; - align: left middle; - } - """ - - def __init__(self) -> None: - super().__init__() - # Load config from the workspace root (parent of SYS) - self.config_data = load_config() - self.current_category = "globals" - self.editing_item_type = None # 'store' or 'plugin' - self.editing_item_name = None - self._button_id_map = {} - self._provider_button_map: Dict[str, tuple[str, str]] = {} - self._input_id_map = {} - self._matrix_status: Optional[Static] = None - self._matrix_test_running = False - self._provider_status: Optional[Static] = None - self._provider_action_running = False - self._editor_snapshot: Optional[Dict[str, Any]] = None - # Inline matrix rooms controls - self._matrix_inline_list: Optional[ListView] = None - self._matrix_inline_checkbox_map: Dict[str, str] = {} - # Path to the database file used by this process (for diagnostics) - self._db_path = str(db.db_path) - - def _capture_editor_snapshot(self) -> None: - self._editor_snapshot = deepcopy(self.config_data) - - def _revert_unsaved_editor_changes(self) -> None: - if self._editor_snapshot is not None: - self.config_data = deepcopy(self._editor_snapshot) - self._editor_snapshot = None - - def _editor_has_changes(self) -> bool: - if self._editor_snapshot is None: - return True - return self.config_data != self._editor_snapshot - - def compose(self) -> ComposeResult: - with Container(id="config-container"): - yield Static("CONFIGURATION EDITOR", classes="section-title") - yield Static(f"DB: {self._db_path}", classes="config-label", id="config-db-path") - yield Static("Last saved: unknown", classes="config-label", id="config-last-save") - with Horizontal(): - with Vertical(id="config-sidebar"): - yield Label("Categories", classes="config-label") - with ListView(id="category-list"): - yield ListItem(Label("Global Settings"), id="cat-globals") - yield ListItem(Label("Plugins"), id="cat-providers") - yield ListItem(Label("Tools"), id="cat-tools") - - with Vertical(id="config-content"): - yield ScrollableContainer(id="fields-container") - with Horizontal(id="config-actions"): - yield Button("Save", variant="success", id="save-btn") - # Durable synchronous save: waits and verifies DB persisted critical keys - yield Button("Save (durable)", variant="primary", id="save-durable-btn") - yield Button("Add Plugin", variant="primary", id="add-provider-btn") - yield Button("Add Tool", variant="primary", id="add-tool-btn") - yield Button("Back", id="back-btn") - yield Button("Close", variant="error", id="cancel-btn") - - def on_mount(self) -> None: - self.query_one("#add-provider-btn", Button).display = False - try: - self.query_one("#add-tool-btn", Button).display = False - except Exception: - logger.exception("Failed to hide add-tool button in ConfigModal.on_mount") - # Update DB path and last-saved on mount - try: - self.query_one("#config-db-path", Static).update(self._db_path) - except Exception: - logger.exception("Failed to update config DB path display in ConfigModal.on_mount") - try: - mtime = None - try: - mtime = db.db_path.stat().st_mtime - mtime = __import__('datetime').datetime.utcfromtimestamp(mtime).isoformat() + "Z" - except Exception: - logger.exception("Failed to stat DB path for last-saved time") - mtime = None - self.query_one("#config-last-save", Static).update(f"Last saved: {mtime or '(unknown)'}") - except Exception: - logger.exception("Failed to update last-saved display in ConfigModal.on_mount") - self.refresh_view() - - def refresh_view(self) -> None: - """ - Refresh the content area. We debounce this call and use a render_id - to avoid race conditions with Textual's async widget mounting. - """ - self._render_id = getattr(self, "_render_id", 0) + 1 - - if hasattr(self, "_refresh_timer"): - self._refresh_timer.stop() - self._refresh_timer = self.set_timer(0.02, self._actual_refresh) - - def _actual_refresh(self) -> None: - try: - container = self.query_one("#fields-container", ScrollableContainer) - except Exception: - return - - self._button_id_map.clear() - self._provider_button_map.clear() - self._input_id_map.clear() - - # Clear existing - container.query("*").remove() - - # Update visibility of buttons - try: - self.query_one("#add-provider-btn", Button).display = (self.current_category == "providers" and self.editing_item_name is None) - self.query_one("#add-tool-btn", Button).display = (self.current_category == "tools" and self.editing_item_name is None) - self.query_one("#back-btn", Button).display = (self.editing_item_name is not None) - self.query_one("#save-btn", Button).display = (self.editing_item_name is not None or self.current_category == "globals") - except Exception: - logger.exception("Failed to update visibility of config modal action buttons") - - render_id = self._render_id - - def do_mount(): - # If a new refresh was started, ignore this old mount request - if getattr(self, "_render_id", 0) != render_id: - return - - # Final check that container is empty. remove() is async. - if container.children: - for child in list(container.children): - child.remove() - - if self.editing_item_name: - self.render_item_editor(container) - elif self.current_category == "globals": - self.render_globals(container) - elif self.current_category == "stores": - self.render_stores(container) - elif self.current_category == "providers": - self.render_providers(container) - elif self.current_category == "tools": - self.render_tools(container) - - self.call_after_refresh(do_mount) - - def render_globals(self, container: ScrollableContainer) -> None: - container.mount(Label("General Configuration", classes="config-label")) - - schema = get_global_schema() - schema_map = {f["key"].lower(): f for f in schema} - existing_keys_lower = set() - render_state = {"group": None, "mounted_any": False} - - idx = 0 - # Show fields defined in schema first - for field_def in schema: - key_lower = field_def["key"].lower() - existing_keys_lower.add(key_lower) - self._mount_schema_group(container, field_def, render_state) - - # Find current value (case-insensitive) - current_val = None - found_key = field_def["key"] - for k, v in self.config_data.items(): - if k.lower() == key_lower: - current_val = str(v) - found_key = k - break - - if current_val is None: - current_val = str(field_def.get("default") or "") - - inp_id = f"global-{idx}" - self._input_id_map[inp_id] = found_key - self._mount_schema_field(container, field_def, inp_id, current_val, allow_paste=True) - render_state["mounted_any"] = True - idx += 1 - - # Show any other top-level keys not in schema - for k, v in self.config_data.items(): - if not isinstance(v, dict) and not k.startswith("_"): - if k.lower() in existing_keys_lower: - continue - - inp_id = f"global-{idx}" - self._input_id_map[inp_id] = k - container.mount(Label(k)) - row = Horizontal(classes="field-row") - container.mount(row) - row.mount(Input(value=str(v), id=inp_id, classes="config-input")) - row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn")) - idx += 1 - - def _mount_schema_group(self, container: ScrollableContainer, field_def: Dict[str, Any], state: Dict[str, Any]) -> None: - group_name = str(field_def.get("group") or "").strip() - if not group_name: - return - if state.get("group") == group_name: - return - if state.get("mounted_any"): - container.mount(Rule()) - container.mount(Label(group_name, classes="config-group")) - state["group"] = group_name - - def _normalized_select_options(self, choices: Any, current_val: Any) -> tuple[list[tuple[str, str]], str]: - select_options: list[tuple[str, str]] = [] - choice_values: list[str] = [] - for choice in list(choices or []): - if isinstance(choice, tuple) and len(choice) == 2: - label = str(choice[0]) - value = str(choice[1]) - else: - label = str(choice) - value = str(choice) - if value.lower() in ("true", "false"): - value = value.lower() - label = value - select_options.append((label, value)) - choice_values.append(value) - - normalized_current = str(current_val) if current_val is not None else "" - if normalized_current.lower() in ("true", "false"): - normalized_current = normalized_current.lower() - if normalized_current not in choice_values: - select_options.insert(0, (normalized_current, normalized_current)) - return select_options, normalized_current - - def _mount_schema_field( - self, - container: ScrollableContainer, - field_def: Dict[str, Any], - widget_id: str, - current_value: Any, - *, - allow_paste: bool, - ) -> None: - label_text = str(field_def.get("label") or field_def.get("key") or widget_id) - if field_def.get("required"): - label_text += " *" - container.mount(Label(label_text)) - - choices = field_def.get("choices") - field_type = str(field_def.get("type") or "").strip().lower() - if choices: - select_options, normalized_current = self._normalized_select_options(choices, current_value) - container.mount(Select(select_options, value=normalized_current, id=widget_id)) - return - - if field_type in {"multiline", "textarea"}: - text_area = TextArea(str(current_value or ""), id=widget_id, classes="config-textarea") - placeholder = field_def.get("placeholder") - if placeholder: - try: - text_area.tooltip = str(placeholder) - except Exception: - pass - container.mount(text_area) - return - - row = Horizontal(classes="field-row") - container.mount(row) - input_widget = Input(value=str(current_value or ""), id=widget_id, classes="config-input") - if field_def.get("secret") or field_type == "secret": - input_widget.password = True - if field_def.get("placeholder"): - input_widget.placeholder = str(field_def.get("placeholder") or "") - elif field_type == "path": - input_widget.placeholder = "Path" - row.mount(input_widget) - if allow_paste: - row.mount(Button("Paste", id=f"paste-{widget_id}", classes="paste-btn")) - - def render_stores(self, container: ScrollableContainer) -> None: - container.mount(Label("Configured Stores", classes="config-label")) - stores = self.config_data.get("store", {}) - if not stores: - container.mount(Static("No stores configured.")) - else: - # stores is structured as: {type: {name_key: config}} - idx = 0 - for stype, instances in stores.items(): - if isinstance(instances, dict): - for name_key, conf in instances.items(): - # Use the name field from the config if it exists, otherwise use the key - display_name = name_key - if isinstance(conf, dict): - display_name = ( - conf.get("NAME") - or conf.get("name") - or conf.get("Name") - or name_key - ) - - edit_id = f"edit-store-{idx}" - del_id = f"del-store-{idx}" - self._button_id_map[edit_id] = ("edit", f"store-{stype}", name_key) - self._button_id_map[del_id] = ("del", f"store-{stype}", name_key) - idx += 1 - - row = Horizontal( - Static(f"{display_name} ({stype})", classes="item-label"), - Button("Edit", id=edit_id), - Button("Delete", variant="error", id=del_id), - classes="item-row" - ) - container.mount(row) - - def _plugin_capability_summary(self, plugin_name: str) -> str: - caps = get_plugin_capabilities(plugin_name, self.config_data) - cmdlets = [str(v) for v in (caps.get("supported_cmdlets") or []) if str(v).strip()] - if caps.get("supports_pipe_download") and "pipe-download" not in cmdlets: - cmdlets.append("pipe-download") - if not cmdlets: - return "" - return ", ".join(sorted(set(cmdlets))) - - def _plugin_label_with_capabilities(self, base_label: str, plugin_name: str) -> str: - summary = self._plugin_capability_summary(plugin_name) - if not summary: - return base_label - return f"{base_label} [caps: {summary}]" - - def render_providers(self, container: ScrollableContainer) -> None: - container.mount(Label("Configured Plugins", classes="config-label")) - providers = self.config_data.get("provider", {}) - if not providers: - container.mount(Static("No plugins configured.")) - return - - idx = 0 - for plugin_name, plugin_cfg in providers.items(): - if isinstance(plugin_cfg, dict) and _is_multi_instance_plugin_config(plugin_cfg): - # Multi-instance plugin: show each instance as a separate row - for instance_name, instance_cfg in plugin_cfg.items(): - display_name = instance_name - if isinstance(instance_cfg, dict): - display_name = ( - instance_cfg.get("NAME") - or instance_cfg.get("name") - or instance_name - ) - edit_id = f"edit-provider-{idx}" - del_id = f"del-provider-{idx}" - self._button_id_map[edit_id] = ("edit", f"plugin-{plugin_name}", instance_name) - self._button_id_map[del_id] = ("del", f"plugin-{plugin_name}", instance_name) - row_label = self._plugin_label_with_capabilities( - f"{display_name} ({plugin_name})", - str(plugin_name), - ) - row = Horizontal( - Static(row_label, classes="item-label"), - Button("Edit", id=edit_id), - Button("Delete", variant="error", id=del_id), - classes="item-row" - ) - container.mount(row) - idx += 1 - else: - # Single-instance plugin - edit_id = f"edit-provider-{idx}" - del_id = f"del-provider-{idx}" - self._button_id_map[edit_id] = ("edit", "plugin", plugin_name) - self._button_id_map[del_id] = ("del", "plugin", plugin_name) - row_label = self._plugin_label_with_capabilities(str(plugin_name), str(plugin_name)) - row = Horizontal( - Static(row_label, classes="item-label"), - Button("Edit", id=edit_id), - Button("Delete", variant="error", id=del_id), - classes="item-row" - ) - container.mount(row) - idx += 1 - - def render_tools(self, container: ScrollableContainer) -> None: - container.mount(Label("Configured Tools", classes="config-label")) - tools = self.config_data.get("tool", {}) - if not tools: - container.mount(Static("No tools configured.")) - else: - for i, (name, _) in enumerate(tools.items()): - edit_id = f"edit-tool-{i}" - del_id = f"del-tool-{i}" - self._button_id_map[edit_id] = ("edit", "tool", name) - self._button_id_map[del_id] = ("del", "tool", name) - - row = Horizontal( - Static(name, classes="item-label"), - Button("Edit", id=edit_id), - Button("Delete", variant="error", id=del_id), - classes="item-row" - ) - container.mount(row) - - def render_item_editor(self, container: ScrollableContainer) -> None: - item_type = str(self.editing_item_type or "") - item_name = str(self.editing_item_name or "") - item_schema_map = get_item_schema_map(item_type, item_name) - render_state = {"group": None, "mounted_any": False} - provider_for_caps: Optional[str] = None - - # Parse item_type: plugin-{ptype} (multi-instance) or flat type - if item_type.startswith("plugin-"): - ptype = item_type[len("plugin-"):] - container.mount(Label(f"Editing {ptype}: {item_name}", classes="config-label")) - provider_for_caps = str(ptype) - plugin_block = self.config_data.get("plugin") or self.config_data.get("provider") or {} - plugin_instances = plugin_block.get(ptype, {}) if isinstance(plugin_block, dict) else {} - section = plugin_instances.get(item_name, {}) if isinstance(plugin_instances, dict) else {} - else: - container.mount(Label(f"Editing {item_type.capitalize()}: {item_name}", classes="config-label")) - if item_type in ("plugin", "provider"): - provider_for_caps = str(item_name) - section = self.config_data.get(item_type, {}).get(item_name, {}) - - # Use columns for better layout of inputs with paste buttons - container.mount(Label("Edit Settings")) - # render_item_editor will handle the inputs for us if we set these - # but wait, render_item_editor is called from refresh_view, not here. - # actually we don't need to do anything else here because refresh_view calls render_item_editor - # which now handles the paste buttons. - - if provider_for_caps: - caps_summary = self._plugin_capability_summary(provider_for_caps) - if caps_summary: - container.mount(Static(f"Capabilities: {caps_summary}", classes="config-help")) - - # Show all existing keys - existing_keys_upper = set() - idx = 0 - for k, v in section.items(): - if k.startswith("_"): continue - - # Skip low-level keys that shouldn't be editable via the form UI - if ( - item_type == "provider" - and isinstance(item_name, str) - and item_name.strip().lower() == "matrix" - and str(k or "").strip().lower() in ("rooms", "cached_rooms") - ): - # These are managed by the inline UI and should not be edited directly. - continue - - # Deduplicate keys case-insensitively (e.g. name vs NAME vs Name) - k_upper = k.upper() - if k_upper in existing_keys_upper: - continue - existing_keys_upper.add(k_upper) - - # Determine display props from schema - label_text = k - is_secret = False - choices = None - schema = item_schema_map.get(k_upper) - if schema: - self._mount_schema_group(container, schema, render_state) - if schema.get("secret"): - is_secret = True - choices = schema.get("choices") - - inp_id = f"item-{idx}" - self._input_id_map[inp_id] = k - if schema: - self._mount_schema_field(container, schema, inp_id, v, allow_paste=True) - else: - container.mount(Label(label_text)) - row = Horizontal(classes="field-row") - container.mount(row) - inp = Input(value=str(v), id=inp_id, classes="config-input") - if is_secret: - inp.password = True - row.mount(inp) - render_state["mounted_any"] = True - idx += 1 - - # Add required/optional fields from schema that are missing - for k_upper, field_def in item_schema_map.items(): - if k_upper not in existing_keys_upper: - existing_keys_upper.add(k_upper) - key = field_def["key"] - self._mount_schema_group(container, field_def, render_state) - default_val = str(field_def.get("default") or "") - inp_id = f"item-{idx}" - self._input_id_map[inp_id] = key - self._mount_schema_field(container, field_def, inp_id, default_val, allow_paste=True) - render_state["mounted_any"] = True - idx += 1 - - for required_key in get_required_config_keys(item_type, item_name): - if required_key.upper() in existing_keys_upper: - continue - existing_keys_upper.add(required_key.upper()) - container.mount(Label(required_key)) - inp_id = f"item-{idx}" - self._input_id_map[inp_id] = required_key - row = Horizontal(classes="field-row") - container.mount(row) - row.mount(Input(value="", id=inp_id, classes="config-input")) - row.mount(Button("Paste", id=f"paste-{inp_id}", classes="paste-btn")) - idx += 1 - - if item_type in ("plugin", "provider") and isinstance(item_name, str): - provider = self._instantiate_plugin_for_editor(item_name, self.config_data) - if provider is not None: - provider_actions = provider.config_actions() or [] - if provider_actions: - container.mount(Rule()) - container.mount(Label(f"{provider.label} helpers", classes="config-label")) - helper_text = str(provider.config_helper_text() or "Use these helpers to validate plugin settings.").strip() - status = Static(helper_text, id="provider-status") - container.mount(status) - self._provider_status = status - row = Horizontal(classes="field-row") - container.mount(row) - for action in provider_actions: - action_id = str(action.get("id") or "").strip() - if not action_id: - continue - button_id = f"provider-action-{item_name}-{action_id}".replace(" ", "-") - self._provider_button_map[button_id] = (item_name, action_id) - row.mount( - Button( - str(action.get("label") or action_id.replace("_", " ").title()), - id=button_id, - variant=str(action.get("variant") or "default"), - ) - ) - - if ( - item_type in ("plugin", "provider") - and isinstance(item_name, str) - and item_name.strip().lower() == "matrix" - ): - container.mount(Rule()) - container.mount(Label("Matrix helpers", classes="config-label")) - status = Static("Set homeserver + token, then test before saving", id="matrix-status") - container.mount(status) - row = Horizontal(classes="field-row") - container.mount(row) - row.mount(Button("Test connection", id="matrix-test-btn")) - # Load rooms refreshes the inline list and caches the results (no popup) - row.mount(Button("Load rooms", variant="primary", id="matrix-load-btn")) - self._matrix_status = status - - # Inline rooms list for selecting default rooms (populated after a successful test) - container.mount(Label("Default Rooms", classes="config-label", id="matrix-inline-label")) - # Start with empty list; it will be filled when test loads rooms - container.mount(ListView(id="matrix-rooms-inline")) - # Inline actions - row2 = Horizontal(classes="field-row") - container.mount(row2) - row2.mount(Button("Select All", id="matrix-inline-select-all")) - row2.mount(Button("Clear All", id="matrix-inline-clear")) - save_inline = Button("Save defaults", variant="success", id="matrix-inline-save") - save_inline.disabled = True - row2.mount(save_inline) - # Local bookkeeping maps - try: - self._matrix_inline_checkbox_map = {} - self._matrix_inline_list = self.query_one("#matrix-rooms-inline", ListView) - # Do NOT auto-render cached rooms here; only show explicitly saved defaults - try: - existing_ids = self._parse_matrix_rooms_value() - cached = self._get_cached_matrix_rooms() - rooms_to_render: List[Dict[str, Any]] = [] - - # Start with cached rooms (from last Load). These are shown - # in the inline Default Rooms list but are unselected unless - # they are in the saved defaults list. - if cached: - rooms_to_render.extend(cached) - - # Ensure saved default room ids are present and will be selected - if existing_ids: - cached_ids = {str(r.get("room_id") or "").strip() for r in rooms_to_render if isinstance(r, dict)} - need_resolve = [rid for rid in existing_ids if rid not in cached_ids] - if need_resolve: - try: - resolved = self._resolve_matrix_rooms_by_ids(need_resolve) - if resolved: - rooms_to_render.extend(resolved) - else: - rooms_to_render.extend([{"room_id": rid, "name": ""} for rid in need_resolve]) - except Exception: - rooms_to_render.extend([{"room_id": rid, "name": ""} for rid in need_resolve]) - - # Deduplicate while preserving order - deduped: List[Dict[str, Any]] = [] - seen_ids: set[str] = set() - for r in rooms_to_render: - try: - rid = str(r.get("room_id") or "").strip() - if not rid or rid in seen_ids: - continue - seen_ids.add(rid) - deduped.append(r) - except Exception: - logger.exception("Failed to process a matrix room entry while deduplicating") - continue - - if self._matrix_inline_list is not None and deduped: - try: - self._render_matrix_rooms_inline(deduped) - except Exception: - logger.exception("Failed to render matrix inline rooms") - except Exception: - logger.exception("Failed to fetch or process matrix rooms for inline rendering") - except Exception: - self._matrix_inline_checkbox_map = {} - self._matrix_inline_list = None - - def create_field(self, name: str, value: Any, id: str) -> Vertical: - # This method is now unused - we mount labels and inputs directly - v = Vertical(classes="config-field") - return v - - def on_list_view_selected(self, event: ListView.Selected) -> None: - # Only respond to selections from the left-hand category list. Avoid - # resetting editor state when other ListViews (like the inline rooms - # list) trigger selection events. - if not event.item: - return - item_id = getattr(event.item, "id", None) - if item_id not in ("cat-globals", "cat-providers", "cat-tools"): - return - - if item_id == "cat-globals": - self.current_category = "globals" - elif item_id == "cat-providers": - self.current_category = "providers" - elif item_id == "cat-tools": - self.current_category = "tools" - - # Reset editor state and refresh view for the new category - self.editing_item_name = None - self.editing_item_type = None - self.refresh_view() - - def on_button_pressed(self, event: Button.Pressed) -> None: - bid = event.button.id - if not bid: return - - if bid == "cancel-btn": - self._revert_unsaved_editor_changes() - self.dismiss() - elif bid == "back-btn": - self._revert_unsaved_editor_changes() - self.editing_item_name = None - self.editing_item_type = None - self.refresh_view() - elif bid == "save-btn": - self._synchronize_inputs_to_config() - if not self.validate_current_editor(): - return - if self.editing_item_name and not self._editor_has_changes(): - self.notify("No changes to save", severity="warning", timeout=3) - return - try: - saved = self.save_all() - if saved == 0: - msg = f"Configuration saved (no rows changed) to {db.db_path.name}" - else: - msg = f"Configuration saved ({saved} change(s)) to {db.db_path.name}" - # Make the success notification visible a bit longer so it's not missed - self.notify(msg, timeout=5) - # Return to the main list view within the current category - self.editing_item_name = None - self.editing_item_type = None - self.refresh_view() - self._editor_snapshot = None - except ConfigSaveConflict as exc: - # A concurrent on-disk change was detected; do not overwrite it. - self.notify( - "Save aborted: configuration changed on disk. The editor will refresh.", - severity="error", - timeout=10, - ) - # Refresh our in-memory view from disk and drop the editor snapshot - try: - self.config_data = reload_config() - except Exception: - logger.exception("Failed to reload config after save conflict") - self._editor_snapshot = None - self.editing_item_name = None - self.editing_item_type = None - self.refresh_view() - except Exception as exc: - try: - log(f"Configuration save failed: {exc}") - except Exception: - logger.exception("Failed to write save failure to logs") - self.notify(f"Save failed: {exc}", severity="error", timeout=10) - elif bid == "save-durable-btn": - # Perform a synchronous, verified save and notify status to the user. - self._synchronize_inputs_to_config() - if not self.validate_current_editor(): - return - if self.editing_item_name and not self._editor_has_changes(): - self.notify("No changes to save", severity="warning", timeout=3) - return - try: - from SYS.config import save_config_and_verify - - saved = save_config_and_verify(self.config_data, retries=3, delay=0.1) - try: - self.config_data = reload_config() - except Exception: - logger.exception("Failed to reload config after durable save") - - if saved == 0: - msg = f"Configuration saved (no rows changed) to {db.db_path.name}" - else: - msg = f"Configuration saved ({saved} change(s)) to {db.db_path.name} (verified)" - try: - self.notify(msg, timeout=6) - except Exception: - logger.exception("Failed to show notification message in ConfigModal") - - # Return to the main list view within the current category - self.editing_item_name = None - self.editing_item_type = None - self.refresh_view() - self._editor_snapshot = None - except Exception as exc: - try: - log(f"Durable configuration save failed: {exc}") - except Exception: - logger.exception("Failed to write durable save failure to logs") - self.notify(f"Durable save failed: {exc}", severity="error", timeout=10) - try: - log(f"Durable save failed: {exc}") - except Exception: - logger.exception("Failed to call log() for durable save error") - elif bid in self._button_id_map: - action, itype, name = self._button_id_map[bid] - if action == "edit": - self._capture_editor_snapshot() - self.editing_item_type = itype - self.editing_item_name = name - self.refresh_view() - elif action == "del": - removed = False - if itype.startswith("plugin-"): - ptype = itype[len("plugin-"):] - plugin_block = self.config_data.get("plugin") or self.config_data.get("provider") - if isinstance(plugin_block, dict): - instances = plugin_block.get(ptype) - if isinstance(instances, dict) and name in instances: - del instances[name] - if not instances: - plugin_block.pop(ptype, None) - removed = True - elif itype.startswith("store-"): - stype = itype.replace("store-", "") - if "store" in self.config_data and stype in self.config_data["store"]: - if name in self.config_data["store"][stype]: - del self.config_data["store"][stype][name] - removed = True - elif itype in ("provider", "plugin"): - if "provider" in self.config_data and name in self.config_data["provider"]: - del self.config_data["provider"][name] - removed = True - if str(name).strip().lower() == "alldebrid": - self._remove_alldebrid_store_entry() - elif itype == "tool": - if "tool" in self.config_data and name in self.config_data["tool"]: - del self.config_data["tool"][name] - removed = True - if removed: - try: - saved = self.save_all() - self.notify("Saving configuration...", timeout=3) - except Exception as exc: - try: - log(f"Configuration save failed while deleting config entry: {exc}") - except Exception: - logger.exception("Failed to write config delete save failure to logs") - self.notify(f"Save failed: {exc}", severity="error", timeout=10) - self.refresh_view() - elif bid in self._provider_button_map: - provider_name, action_id = self._provider_button_map[bid] - self._request_plugin_action(provider_name, action_id) - elif bid == "add-provider-btn": - options = get_configurable_plugin_types() - self.app.push_screen(SelectionModal("Select Plugin Type", options), callback=self.on_provider_type_selected) - elif bid == "add-tool-btn": - options = get_configurable_tool_types() or ["ytdlp"] - if options: - options.sort() - self.app.push_screen(SelectionModal("Select Tool", options), callback=self.on_tool_type_selected) - elif bid == "matrix-test-btn": - self._request_matrix_test() - elif bid == "matrix-load-btn": - # Refresh the inline rooms list and cache the results (no popup) - self._request_matrix_load() - elif bid == "matrix-inline-select-all": - for checkbox_id in list(self._matrix_inline_checkbox_map.keys()): - try: - cb = self.query_one(f"#{checkbox_id}", Checkbox) - cb.value = True - except Exception: - logger.exception("Failed to set matrix inline checkbox to True for '%s'", checkbox_id) - try: - self.query_one("#matrix-inline-save", Button).disabled = False - except Exception: - logger.exception("Failed to enable matrix inline save button") - elif bid == "matrix-inline-clear": - for checkbox_id in list(self._matrix_inline_checkbox_map.keys()): - try: - cb = self.query_one(f"#{checkbox_id}", Checkbox) - cb.value = False - except Exception: - logger.exception("Failed to set matrix inline checkbox to False for '%s'", checkbox_id) - try: - self.query_one("#matrix-inline-save", Button).disabled = True - except Exception: - logger.exception("Failed to disable matrix inline save button") - elif bid == "matrix-inline-save": - selected: List[str] = [] - for checkbox_id, room_id in self._matrix_inline_checkbox_map.items(): - try: - cb = self.query_one(f"#{checkbox_id}", Checkbox) - if cb.value and room_id: - selected.append(room_id) - except Exception: - logger.exception("Failed to read matrix inline checkbox '%s'", checkbox_id) - if not selected: - if self._matrix_status: - self._matrix_status.update("No default rooms were saved.") - return - matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {}) - matrix_block["rooms"] = ", ".join(selected) - changed = count_changed_entries(self.config_data) - try: - entries = save_config(self.config_data) - except Exception as exc: - try: - log(f"Saving Matrix default rooms failed: {exc}") - except Exception: - logger.exception("Failed to write Matrix room save failure to logs") - if self._matrix_status: - self._matrix_status.update(f"Saving default rooms failed: {exc}") - return - self.config_data = reload_config() - if self._matrix_status: - status = f"Saved {len(selected)} default room(s) ({changed} change(s)) to {db.db_path.name}." - self._matrix_status.update(status) - try: - self.query_one("#matrix-inline-save", Button).disabled = True - except Exception: - logger.exception("Failed to disable matrix inline save button") - self.refresh_view() - - - async def focus_and_paste(self, inp: Input) -> None: - if hasattr(self.app, "paste_from_clipboard"): - text = await self.app.paste_from_clipboard() - if text: - # Replace selection or append - inp.value = str(inp.value) + text - inp.focus() - self.notify("Pasted from clipboard") - else: - self.notify("Clipboard not supported in this terminal", severity="warning") - - async def action_paste(self) -> None: - focused = self.focused - if isinstance(focused, Input): - await self.focus_and_paste(focused) - - async def action_copy(self) -> None: - focused = self.focused - if isinstance(focused, Input) and focused.value: - if hasattr(self.app, "copy_to_clipboard"): - self.app.copy_to_clipboard(str(focused.value)) - self.notify("Copied to clipboard") - else: - self.notify("Clipboard not supported in this terminal", severity="warning") - - def _instantiate_plugin_for_editor(self, provider_name: str, config_data: Optional[Dict[str, Any]] = None) -> Optional[Any]: - try: - plugin_class = get_plugin_class(provider_name) - except Exception: - plugin_class = None - if plugin_class is None: - return None - try: - return plugin_class(config_data or self.config_data) - except Exception: - logger.exception("Failed to instantiate plugin '%s' for config helper", provider_name) - return None - - def _request_plugin_action(self, provider_name: str, action_id: str) -> None: - if self._provider_action_running: - return - self._synchronize_inputs_to_config() - self._provider_action_running = True - if self._provider_status is not None: - self._provider_status.update(f"Running {action_id.replace('_', ' ')}…") - self._plugin_action_background(provider_name, action_id, deepcopy(self.config_data)) - - @work(thread=True) - def _plugin_action_background(self, provider_name: str, action_id: str, config_snapshot: Dict[str, Any]) -> None: - try: - provider = self._instantiate_plugin_for_editor(provider_name, config_snapshot) - if provider is None: - raise RuntimeError(f"Plugin '{provider_name}' is unavailable") - result = provider.run_config_action(action_id) - if not isinstance(result, dict): - result = {"ok": False, "message": f"Plugin '{provider_name}' returned an invalid config action result."} - except Exception as exc: - result = {"ok": False, "message": str(exc) or f"Plugin action '{action_id}' failed."} - - try: - self.app.call_from_thread(self._plugin_action_complete, provider_name, action_id, result) - except Exception: - self._plugin_action_complete(provider_name, action_id, result) - - def _plugin_action_complete(self, provider_name: str, action_id: str, result: Dict[str, Any]) -> None: - self._provider_action_running = False - ok = bool(result.get("ok")) - message = str(result.get("message") or f"Provider action '{action_id}' finished.") - updates = result.get("config_updates") - - if ok and isinstance(updates, dict): - provider_block = self.config_data.setdefault("provider", {}).setdefault(provider_name, {}) - if isinstance(provider_block, dict): - provider_block.update(updates) - message = f"{message}" - try: - self.refresh_view() - except Exception: - logger.exception("Failed to refresh config view after provider action") - - if self._provider_status is not None: - self._provider_status.update(message) - - try: - self.notify(message, severity="error" if not ok else "information", timeout=8) - except Exception: - logger.exception("Failed to notify provider action result for %s/%s", provider_name, action_id) - - # Backup/restore helpers removed: forensics/audit mode disabled and restore UI removed. - - def on_provider_type_selected(self, ptype: str) -> None: - if not ptype: - return - self._capture_editor_snapshot() - - from ProviderCore.registry import get_plugin_class as _get_cls - plugin_class = _get_cls(ptype) - is_multi = bool(getattr(plugin_class, 'MULTI_INSTANCE', False)) if plugin_class else False - - if is_multi: - # Multi-instance plugin: create a named instance entry in config["plugin"][ptype] - plugin_block = self.config_data.setdefault("plugin", {}) - instances = plugin_block.setdefault(ptype, {}) - # Also keep config["provider"] in sync (they should be the same dict after normalization, - # but if they're not yet, link them) - if "provider" in self.config_data and self.config_data["provider"] is not plugin_block: - self.config_data["provider"].setdefault(ptype, instances) - - existing_names: set[str] = set(instances.keys()) - base_name = f"new_{ptype}" - new_name = base_name - suffix = 1 - while new_name in existing_names: - suffix += 1 - new_name = f"{base_name}_{suffix}" - - instances[new_name] = build_default_store_config(ptype, new_name) - self.editing_item_type = f"plugin-{ptype}" - self.editing_item_name = new_name - else: - # Single-instance plugin - if "provider" not in self.config_data: - self.config_data["provider"] = {} - if ptype not in self.config_data["provider"]: - self.config_data["provider"][ptype] = build_default_plugin_config(ptype) - self.editing_item_type = "plugin" - self.editing_item_name = ptype - - self.refresh_view() - - def on_tool_type_selected(self, tname: str) -> None: - if not tname: - return - self._capture_editor_snapshot() - if "tool" not in self.config_data: - self.config_data["tool"] = {} - - if tname not in self.config_data["tool"]: - self.config_data["tool"][tname] = build_default_tool_config(tname) - - self.editing_item_type = "tool" - self.editing_item_name = tname - self.refresh_view() - - def _update_config_value(self, widget_id: str, value: Any) -> None: - if widget_id not in self._input_id_map: - return - - key = self._input_id_map[widget_id] - raw_value = value - is_blank_string = isinstance(raw_value, str) and not raw_value.strip() - - existing_value: Any = None - item_type = str(self.editing_item_type or "") - item_name = str(self.editing_item_name or "") - - if widget_id.startswith("global-"): - existing_value = self.config_data.get(key) - elif widget_id.startswith("item-") and item_name: - if item_type.startswith("plugin-"): - ptype = item_type[len("plugin-"):] - plugin_block = self.config_data.get("plugin") or self.config_data.get("provider") - if isinstance(plugin_block, dict): - instances = plugin_block.get(ptype) - if isinstance(instances, dict): - section = instances.get(item_name) - if isinstance(section, dict): - existing_value = section.get(key) - else: - section_block = self.config_data.get(item_type) - if isinstance(section_block, dict): - section = section_block.get(item_name) - if isinstance(section, dict): - existing_value = section.get(key) - - if is_blank_string and existing_value is None: - return - - # Try to preserve boolean/integer types - processed_value = coerce_config_value(raw_value, existing_value) - - if widget_id.startswith("global-"): - self.config_data[key] = processed_value - elif widget_id.startswith("item-") and item_name: - if item_type.startswith("plugin-"): - ptype = item_type[len("plugin-"):] - plugin_block = self.config_data.setdefault("plugin", {}) - instances = plugin_block.setdefault(ptype, {}) - if item_name not in instances: - instances[item_name] = {} - # Special case: rename via the NAME field - if key.upper() == "NAME" and processed_value and str(processed_value) != item_name: - new_name = str(processed_value) - instances[new_name] = instances.pop(item_name) - self.editing_item_name = new_name - item_name = new_name - instances[item_name][key] = processed_value - else: - if item_type not in self.config_data: - self.config_data[item_type] = {} - if item_name not in self.config_data[item_type]: - self.config_data[item_type][item_name] = {} - self.config_data[item_type][item_name][key] = processed_value - - def _synchronize_inputs_to_config(self) -> None: - """Capture current input/select values before saving.""" - widgets = list(self.query(Input)) + list(self.query(Select)) + list(self.query(TextArea)) - for widget in widgets: - widget_id = widget.id - if not widget_id or widget_id not in self._input_id_map: - continue - - if isinstance(widget, Select): - if widget.value == Select.BLANK: - continue - value = widget.value - elif isinstance(widget, TextArea): - value = widget.text - else: - value = widget.value - - self._update_config_value(widget_id, value) - - def _remove_alldebrid_store_entry(self) -> bool: - """Remove the mirrored AllDebrid store entry that would recreate the provider.""" - store_block = self.config_data.get("store") - if not isinstance(store_block, dict): - return False - debrid = store_block.get("debrid") - if not isinstance(debrid, dict): - return False - - removed = False - for key in list(debrid.keys()): - if str(key or "").strip().lower() == "all-debrid": - debrid.pop(key, None) - removed = True - - if not debrid: - store_block.pop("debrid", None) - if not store_block: - self.config_data.pop("store", None) - - return removed - - def _get_matrix_provider_block(self) -> Dict[str, Any]: - providers = self.config_data.get("provider") - if not isinstance(providers, dict): - return {} - block = providers.get("matrix") - return block if isinstance(block, dict) else {} - - def _parse_matrix_rooms_value(self) -> List[str]: - block = self._get_matrix_provider_block() - raw = block.get("rooms") - if isinstance(raw, (list, tuple, set)): - return [str(item).strip() for item in raw if str(item).strip()] - text = str(raw or "").strip() - if not text: - return [] - return [segment for segment in re.split(r"[\s,]+", text) if segment] - - def _request_matrix_test(self) -> None: - if self._matrix_test_running: - return - self._synchronize_inputs_to_config() - - # Quick client-side pre-check before attempting to save/test to provide - # immediate guidance when required fields are missing. - try: - matrix_block = self.config_data.get("provider", {}).get("matrix", {}) - hs = matrix_block.get("homeserver") - token = matrix_block.get("access_token") - if not hs or not token: - if self._matrix_status: - self._matrix_status.update("Matrix test skipped: please set both 'homeserver' and 'access_token' before testing.") - return - except Exception: - logger.exception("Failed to check matrix configuration before testing") - - if self._matrix_status: - self._matrix_status.update("Saving configuration before testing…") - changed = count_changed_entries(self.config_data) - try: - entries = save_config(self.config_data) - except Exception as exc: - try: - log(f"Saving configuration before Matrix test failed: {exc}") - except Exception: - logger.exception("Failed to write Matrix test pre-save failure to logs") - if self._matrix_status: - self._matrix_status.update(f"Saving configuration failed: {exc}") - self._matrix_test_running = False - return - self.config_data = reload_config() - if self._matrix_status: - self._matrix_status.update(f"Saved configuration ({changed} change(s)) to {db.db_path.name}. Testing Matrix connection…") - self._matrix_test_running = True - self._matrix_test_background() - - @work(thread=True) - def _matrix_test_background(self) -> None: - try: - provider = get_plugin("matrix", self.config_data) - if provider is None: - raise RuntimeError("Matrix plugin unavailable") - rooms = provider.list_rooms() - self.app.call_from_thread(self._matrix_test_result, True, rooms, None) - except Exception as exc: - # Log full traceback for diagnostics but present a concise, actionable - # message to the user in the UI. - tb = traceback.format_exc() - try: - debug(f"[matrix] Test connection failed: {exc}\n{tb}") - except Exception: - logger.exception("Failed to debug matrix test failure") - - msg = str(exc) or "Matrix test failed" - m_lower = msg.lower() - if "auth" in m_lower or "authentication" in m_lower: - msg = msg + ". Please verify your access token and try again." - elif "homeserver" in m_lower or "missing" in m_lower: - msg = msg + ". Check your homeserver URL (include https://)." - else: - msg = msg + " (see logs for details)" - - self.app.call_from_thread(self._matrix_test_result, False, [], msg) - - def _get_cached_matrix_rooms(self) -> List[Dict[str, Any]]: - """Return cached rooms stored in the provider config (normalized). - - The config value can be a list/dict, a JSON string, or a Python literal - string (repr). This method normalizes the input and returns a list of - dicts containing 'room_id' and 'name'. Malformed inputs are ignored. - """ - try: - block = self._get_matrix_provider_block() - raw = block.get("cached_rooms") - if not raw: - return [] - - # If it's already a list or tuple, normalize each element - if isinstance(raw, (list, tuple)): - return self._normalize_cached_raw(list(raw)) - - # If it's a dict, wrap and normalize - if isinstance(raw, dict): - return self._normalize_cached_raw([raw]) - - # If it's a string, try JSON -> ast.literal_eval -> regex ID extraction - if isinstance(raw, str): - s = str(raw).strip() - if not s: - return [] - - # Try JSON first (strict) - try: - import json - - parsed = json.loads(s) - if isinstance(parsed, (list, tuple, dict)): - return self._normalize_cached_raw(parsed if isinstance(parsed, (list, tuple)) else [parsed]) - except Exception: - logger.exception("Failed to parse cached_rooms JSON for provider matrix") - - # Try Python literal eval (accepts single quotes, repr-style lists) - try: - import ast - - parsed = ast.literal_eval(s) - if isinstance(parsed, (list, tuple, dict)): - return self._normalize_cached_raw(parsed if isinstance(parsed, (list, tuple)) else [parsed]) - except Exception: - logger.exception("Failed to parse cached_rooms as Python literal for provider matrix") - - # Try to extract dict-like pairs for room_id/name when the string looks like - # a Python repr or partial dict fragment (e.g., "'room_id': '!r1', 'name': 'Room'" - try: - import re - - pair_pat = re.compile(r"[\"']room_id[\"']\s*:\s*[\"'](?P[^\"']+)[\"']\s*,\s*[\"']name[\"']\s*:\s*[\"'](?P[^\"']+)[\"']") - pairs = [m.groupdict() for m in pair_pat.finditer(s)] - if pairs: - out = [] - for p in pairs: - rid = str(p.get("id") or "").strip() - name = str(p.get("name") or "").strip() - if rid: - out.append({"room_id": rid, "name": name}) - if out: - return out - - # As a last resort, extract candidate room ids via regex (look for leading '!') - ids = re.findall(r"![-A-Za-z0-9._=]+(?::[-A-Za-z0-9._=]+)?", s) - if ids: - return [{"room_id": rid, "name": ""} for rid in ids] - except Exception: - logger.exception("Failed to extract cached_rooms pairs or ids for provider matrix") - - return [] - except Exception: - logger.exception("Failed to parse cached_rooms for provider matrix") - return [] - - def _normalize_cached_raw(self, parsed: List[Any]) -> List[Dict[str, Any]]: - out: List[Dict[str, Any]] = [] - for it in parsed: - try: - if isinstance(it, dict): - rid = str(it.get("room_id") or "").strip() - name = str(it.get("name") or "").strip() - if rid: - out.append({"room_id": rid, "name": name}) - elif isinstance(it, str): - s = str(it or "").strip() - if s: - out.append({"room_id": s, "name": ""}) - except Exception: - logger.exception("Failed to normalize cached_rooms entry: %r", it) - continue - return out - - def _request_matrix_load(self) -> None: - """Save current config and request a background load of joined rooms. - - This replaces the old "Choose default rooms" popup and instead refreshes - the inline default rooms list and caches the results to config. - """ - if self._matrix_test_running: - return - self._synchronize_inputs_to_config() - - # Quick client-side pre-check for required fields - try: - matrix_block = self.config_data.get("provider", {}).get("matrix", {}) - hs = matrix_block.get("homeserver") - token = matrix_block.get("access_token") - if not hs or not token: - if self._matrix_status: - self._matrix_status.update("Load skipped: please set both 'homeserver' and 'access_token' before loading rooms.") - return - except Exception: - logger.exception("Failed to check matrix configuration before load") - - if self._matrix_status: - self._matrix_status.update("Saving configuration before loading rooms…") - changed = count_changed_entries(self.config_data) - try: - entries = save_config(self.config_data) - except Exception as exc: - try: - log(f"Saving configuration before Matrix room load failed: {exc}") - except Exception: - logger.exception("Failed to write Matrix load pre-save failure to logs") - if self._matrix_status: - self._matrix_status.update(f"Saving configuration failed: {exc}") - self._matrix_test_running = False - return - self.config_data = reload_config() - if self._matrix_status: - self._matrix_status.update(f"Saved configuration ({changed} change(s)) to {db.db_path.name}. Loading Matrix rooms…") - self._matrix_test_running = True - self._matrix_load_background() - - @work(thread=True) - def _matrix_load_background(self) -> None: - try: - provider = get_plugin("matrix", self.config_data) - if provider is None: - raise RuntimeError("Matrix plugin unavailable") - rooms = provider.list_rooms() - self.app.call_from_thread(self._matrix_load_result, True, rooms, None) - except Exception as exc: - tb = traceback.format_exc() - try: - debug(f"[matrix] Load rooms failed: {exc}\n{tb}") - except Exception: - logger.exception("Failed to debug matrix load failure") - msg = str(exc) or "Matrix load failed" - if "auth" in msg.lower(): - msg = msg + ". Please verify your access token and try again." - self.app.call_from_thread(self._matrix_load_result, False, [], msg) - - def _matrix_load_result(self, success: bool, rooms: List[Dict[str, Any]], error: Optional[str]) -> None: - # Called on the main thread via call_from_thread - self._matrix_test_running = False - if not success: - full_msg = f"Matrix load failed: {error or '(error)'}" - if self._matrix_status: - self._matrix_status.update(full_msg) - try: - self.notify(full_msg, severity="error", timeout=8) - except Exception: - logger.exception("Failed to show Matrix load failure notification") - return - - # Populate inline list - try: - self._render_matrix_rooms_inline(rooms) - except Exception: - logger.exception("Failed to render inline matrix rooms") - - # Persist cached rooms so they are available on next editor open - try: - matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {}) - matrix_block["cached_rooms"] = rooms - # Schedule a background save of the config (non-blocking) - try: - self.save_all() - except Exception: - # Fallback to direct save when save_all is unavailable (tests) - try: - save_config(self.config_data) - except Exception: - logger.exception("Failed to persist cached matrix rooms via save_config() fallback") - if self._matrix_status: - self._matrix_status.update(f"Loaded and cached {len(rooms)} room(s).") - try: - self.notify(f"Loaded {len(rooms)} rooms and cached the results", timeout=5) - except Exception: - logger.exception("Failed to notify loaded-and-cached message for Matrix rooms") - except Exception: - logger.exception("Failed to cache Matrix rooms after load") - - def _open_matrix_room_picker( - self, - *, - prefetched_rooms: Optional[List[Dict[str, Any]]] = None, - ) -> None: - existing = self._parse_matrix_rooms_value() - self.app.push_screen( - MatrixRoomPicker( - self.config_data, - existing=existing, - rooms=prefetched_rooms, - ), - callback=self.on_matrix_rooms_selected, - ) - - def _render_matrix_rooms_inline(self, rooms: List[Dict[str, Any]]) -> None: - """ - Populate the inline matrix rooms ListView with checkboxes based on the - list of rooms returned from a successful test. If the inline ListView - is not present in the current editor view, fall back to opening the - MatrixRoomPicker popup with the results. - """ - try: - inline_list = self._matrix_inline_list or self.query_one("#matrix-rooms-inline", ListView) - except Exception: - inline_list = None - - if inline_list is None: - # Inline view isn't available in this context; cache the rooms and persist - try: - matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {}) - matrix_block["cached_rooms"] = rooms - try: - self.save_all() - except Exception: - try: - save_config(self.config_data) - except Exception: - logger.exception("Failed to persist cached matrix rooms via save_config() fallback") - if self._matrix_status: - self._matrix_status.update(f"Loaded {len(rooms)} rooms (cached)") - try: - self.notify(f"Loaded {len(rooms)} rooms and cached the results", timeout=5) - except Exception: - logger.exception("Failed to notify loaded-and-cached message for Matrix rooms") - except Exception: - logger.exception("Failed to cache Matrix rooms when inline view unavailable") - return - - # Clear current entries - for child in list(inline_list.children): - child.remove() - self._matrix_inline_checkbox_map.clear() - self._matrix_inline_list = inline_list - - # Determine existing selection from current config (so saved defaults are pre-selected) - existing = set(self._parse_matrix_rooms_value()) - - if not rooms: - if self._matrix_status: - self._matrix_status.update("Matrix returned no rooms.") - try: - save_btn = self.query_one("#matrix-inline-save", Button) - save_btn.disabled = True - except Exception: - logger.exception("Failed to disable matrix inline save button when no rooms returned") - return - - any_selected = False - # Filter and normalize rooms to avoid malformed cache entries or split-word artifacts. - normalized: List[Dict[str, str]] = [] - for r in rooms: - try: - rid = str(r.get("room_id") or "").strip() - name = str(r.get("name") or "").strip() - except Exception: - continue - - # Ignore obviously malformed tokens coming from bad caching/parsing - le_rid = rid.lower() - le_name = name.lower() - if "room_id" in le_rid or "room_id" in le_name: - continue - - # Require a valid room id (Matrix ids usually start with '!' and often contain ':') - if not rid or (not rid.startswith("!") and ":" not in rid): - # Skip entries without a sensible ID (we rely on IDs for saving) - continue - - normalized.append({"room_id": rid, "name": name}) - - for idx, room in enumerate(normalized): - room_id = room.get("room_id") or "" - name = room.get("name") or "" - checkbox_id = f"matrix-inline-room-{idx}" - - label_text = name or room_id or "Matrix Room" - - checked = bool(room_id and room_id in existing) - if checked: - any_selected = True - - from textual.widgets import Checkbox as _Checkbox # local import to avoid top-level change - checkbox = _Checkbox(label_text, id=checkbox_id, value=checked) - self._matrix_inline_checkbox_map[checkbox_id] = room_id - inline_list.mount(ListItem(checkbox, classes="matrix-room-row")) - - if self._matrix_status: - self._matrix_status.update("Loaded rooms. Select one or more and save.") - try: - save_btn = self.query_one("#matrix-inline-save", Button) - save_btn.disabled = not any_selected - except Exception: - logger.exception("Failed to set matrix inline save button disabled state") - - def _resolve_matrix_rooms_by_ids(self, ids: Iterable[str]) -> List[Dict[str, Any]]: - """ - Resolve room display names for a list of room IDs using the Matrix provider. - Returns a list of dictionaries with keys 'room_id' and 'name' on success, or an - empty list on failure. - """ - try: - ids_list = [str(i).strip() for i in ids if str(i).strip()] - except Exception: - return [] - if not ids_list: - return [] - - # Only attempt network resolution if homeserver + token are present - block = self._get_matrix_provider_block() - hs = block.get("homeserver") - token = block.get("access_token") - if not hs or not token: - return [] - - try: - provider = get_plugin("matrix", self.config_data) - if provider is None: - return [] - rooms = provider.list_rooms(room_ids=ids_list) - return rooms or [] - except Exception as exc: - try: - debug(f"[config] failed to resolve matrix room names: {exc}") - except Exception: - logger.exception("Failed to debug matrix name resolution") - return [] - - def on_matrix_rooms_selected(self, result: Any = None) -> None: - if not isinstance(result, list): - if self._matrix_status: - self._matrix_status.update("Room selection cancelled.") - return - cleaned: List[str] = [] - for item in result: - candidate = str(item or "").strip() - if candidate and candidate not in cleaned: - cleaned.append(candidate) - if not cleaned: - if self._matrix_status: - self._matrix_status.update("No default rooms were saved.") - return - matrix_block = self.config_data.setdefault("provider", {}).setdefault("matrix", {}) - matrix_block["rooms"] = ", ".join(cleaned) - changed = count_changed_entries(self.config_data) - try: - entries = save_config(self.config_data) - except Exception as exc: - if self._matrix_status: - self._matrix_status.update(f"Saving default rooms failed: {exc}") - return - self.config_data = reload_config() - if self._matrix_status: - status = f"Saved {len(cleaned)} default room(s) ({changed} change(s)) to {db.db_path.name}." - self._matrix_status.update(status) - self.refresh_view() - - @on(Input.Changed) - @on(Checkbox.Changed) - def on_checkbox_changed(self, event: Checkbox.Changed) -> None: - # Only respond to inline matrix checkboxes - try: - cbid = event.checkbox.id - except Exception: - cbid = None - if not cbid or cbid not in self._matrix_inline_checkbox_map: - return - - any_selected = False - for checkbox_id in self._matrix_inline_checkbox_map.keys(): - try: - cb = self.query_one(f"#{checkbox_id}", Checkbox) - if cb.value: - any_selected = True - break - except Exception: - continue - try: - self.query_one("#matrix-inline-save", Button).disabled = not any_selected - except Exception: - logger.exception("Failed to update matrix inline save button") - - def on_input_changed(self, event: Input.Changed) -> None: - if event.input.id: - self._update_config_value(event.input.id, event.value) - - @on(TextArea.Changed) - def on_text_area_changed(self, event: TextArea.Changed) -> None: - if event.text_area.id: - self._update_config_value(event.text_area.id, event.text_area.text) - - @on(Select.Changed) - def on_select_changed(self, event: Select.Changed) -> None: - if event.select.id: - # Select value can be the 'Select.BLANK' sentinel - if event.value != Select.BLANK: - self._update_config_value(event.select.id, event.value) - - def save_all(self) -> int: - self._synchronize_inputs_to_config() - # Compute change count prior to persisting so callers can report the number of - # actual changes rather than the total number of rows written to the DB. - changed = count_changed_entries(self.config_data) - # Snapshot config for background save - snapshot = deepcopy(self.config_data) - # Schedule the save to run on a background worker so the UI doesn't block. - try: - # Prefer Textual's worker when running inside the app - self._save_background(snapshot, changed) - except Exception: - # Fallback: start a plain thread that runs the underlying task body - import threading - func = getattr(self._save_background, "__wrapped__", None) - if func: - thread = threading.Thread(target=lambda: func(self, snapshot, changed), daemon=True) - else: - thread = threading.Thread(target=lambda: self._save_background(snapshot, changed), daemon=True) - thread.start() - - # Ensure DB path indicator is current and show saving status - self._db_path = str(db.db_path) - try: - self.query_one("#config-db-path", Static).update(self._db_path) - except Exception: - logger.exception("Failed to update config db path label") - try: - self.query_one("#config-last-save", Static).update("Last saved: (saving...)") - except Exception: - logger.exception("Failed to update config last-save label") - - log(f"ConfigModal scheduled save (changed={changed})") - return changed - - @work(thread=True) - def _save_background(self, cfg: Dict[str, Any], changed: int) -> None: - try: - # Use the verified save path which will check that crucial keys - # (like AllDebrid API keys) persisted to disk. This ensures the UI - # surface reports a failure immediately if post-save verification fails. - saved_entries = save_config_and_verify(cfg) - try: - appobj = self.app - except Exception: - appobj = None - if appobj and hasattr(appobj, 'call_from_thread'): - appobj.call_from_thread(self._on_save_complete, True, None, changed, saved_entries) - else: - # If no app (e.g., running under tests), call completion directly - self._on_save_complete(True, None, changed, saved_entries) - except ConfigSaveConflict as exc: - try: - appobj = self.app - except Exception: - appobj = None - if appobj and hasattr(appobj, 'call_from_thread'): - appobj.call_from_thread(self._on_save_complete, False, str(exc), changed, 0) - else: - self._on_save_complete(False, str(exc), changed, 0) - except Exception as exc: - # Bubble up verification/other save errors back to the UI so the - # user knows persistent storage failed. - try: - appobj = self.app - except Exception: - appobj = None - if appobj and hasattr(appobj, 'call_from_thread'): - appobj.call_from_thread(self._on_save_complete, False, str(exc), changed, 0) - else: - self._on_save_complete(False, str(exc), changed, 0) - except Exception as exc: - try: - appobj = self.app - except Exception: - appobj = None - if appobj and hasattr(appobj, 'call_from_thread'): - appobj.call_from_thread(self._on_save_complete, False, str(exc), changed, 0) - else: - self._on_save_complete(False, str(exc), changed, 0) - - def _on_save_complete(self, success: bool, error: Optional[str], changed: int, saved_entries: int) -> None: - # Safely determine whether a Textual app context is available. Accessing - # `self.app` can raise when not running inside the TUI; handle that. - try: - appobj = self.app - except Exception: - appobj = None - - if success: - try: - self.config_data = reload_config() - except Exception: - logger.exception("Failed to reload config after save completion") - - # Update last-saved label with file timestamp for visibility - db_mtime = None - try: - db_mtime = db.db_path.stat().st_mtime - db_mtime = __import__('datetime').datetime.utcfromtimestamp(db_mtime).isoformat() + "Z" - except Exception: - db_mtime = None - - if appobj: - try: - if changed == 0: - label_text = f"Last saved: (no changes)" - else: - label_text = f"Last saved: {changed} change(s) at {db_mtime or '(unknown)'}" - try: - self.query_one("#config-last-save", Static).update(label_text) - except Exception: - logger.exception("Failed to update last-save label with timestamp") - except Exception: - logger.exception("Failed to compute last-save label text") - - try: - self.refresh_view() - except Exception: - logger.exception("Failed to refresh config editor view after save completion") - - try: - self.notify(f"Configuration saved ({changed} change(s)) to {db.db_path.name}", timeout=5) - except Exception: - logger.exception("Failed to show configuration saved notification") - else: - # No TUI available; log instead of updating UI - log(f"Configuration saved ({changed} change(s)) to {db.db_path.name}") - - log(f"ConfigModal saved {saved_entries} configuration entries (changed={changed})") - else: - # Save failed; notify via UI if available, otherwise log - if appobj: - try: - self.notify(f"Save failed: {error}", severity="error", timeout=10) - except Exception: - logger.exception("Failed to show save failed notification") - - try: - self.config_data = reload_config() - except Exception: - logger.exception("Failed to reload config after save failure") - - try: - self.refresh_view() - except Exception: - logger.exception("Failed to refresh view after save failure") - else: - log(f"Save failed: {error}") - - def validate_current_editor(self) -> bool: - """Ensure all required fields for the current item are filled.""" - if not self.editing_item_name: - return True - - item_type = str(self.editing_item_type or "") - item_name = str(self.editing_item_name or "") - section = {} - - if item_type.startswith("plugin-"): - ptype = item_type[len("plugin-"):] - section = self.config_data.get("plugin", {}).get(ptype, {}).get(item_name, {}) - elif item_type == "provider": - section = self.config_data.get("provider", {}).get(item_name, {}) - elif item_type == "plugin": - section = self.config_data.get("plugin", {}).get(item_name, {}) or self.config_data.get("provider", {}).get(item_name, {}) - elif item_type == "tool": - section = self.config_data.get("tool", {}).get(item_name, {}) - - # Check required keys - for rk in get_required_config_keys(item_type, item_name): - # Case-insensitive lookup for the required key in the current section - val = None - rk_upper = rk.upper() - for k, v in section.items(): - if k.upper() == rk_upper: - val = v - break - - if not val or not str(val).strip(): - self.notify(f"Required field '{rk}' cannot be blank", severity="error") - return False - - return True diff --git a/TUI/modalscreen/download.py b/TUI/modalscreen/download.py deleted file mode 100644 index 040473e..0000000 --- a/TUI/modalscreen/download.py +++ /dev/null @@ -1,2153 +0,0 @@ -"""Download request modal screen for initiating new downloads. - -This modal allows users to specify: -- URL or search query (paragraph) -- Tags to apply -- Source (Hydrus, local, AllDebrid, etc.) -- Actions (download, screenshot) -""" - -from textual.app import ComposeResult -from textual.screen import ModalScreen -from textual.containers import Container, Horizontal, Vertical -from textual.widgets import ( - Static, - Button, - Select, - Checkbox, - TextArea, - ProgressBar, - Tree, - Input, -) -from textual.binding import Binding -from textual import work -import logging -from typing import Optional, Callable, Any -from pathlib import Path -import sys - -from SYS.logger import log -import json - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -# Import cmdlet system to call get-tag -try: - from cmdlet import get as get_cmdlet -except ImportError: - get_cmdlet = None - -# Import tag processing helpers -try: - from SYS.metadata import expand_tag_lists, process_tags_from_string -except ImportError: - expand_tag_lists = None - process_tags_from_string = None - -logger = logging.getLogger(__name__) - - -class DownloadModal(ModalScreen): - """Modal screen for initiating new download requests.""" - - BINDINGS = [ - Binding("escape", - "cancel", - "Cancel"), - Binding("ctrl+enter", - "submit", - "Submit"), - ] - - CSS_PATH = "download.tcss" - - def __init__( - self, - on_submit: Optional[Callable[[dict], - None]] = None, - available_sources: Optional[list] = None, - config: Optional[dict] = None, - ): - """Initialize the download modal. - - Args: - on_submit: Callback function that receives download request dict - available_sources: List of available source names (e.g., ['hydrus', 'local', 'alldebrid']) - config: Configuration dict with download settings - """ - super().__init__() - self.on_submit = on_submit - self.available_sources = available_sources or ["hydrus", "local", "alldebrid"] - self.config = config or {} - - # UI Component references - self.paragraph_textarea: TextArea = None # type: ignore - self.tags_textarea: TextArea = None # type: ignore - self.source_select: Select = None # type: ignore - self.files_select: Select = None # type: ignore - self.download_checkbox: Checkbox = None # type: ignore - self.screenshot_checkbox: Checkbox = None # type: ignore - self.progress_bar: ProgressBar = None # type: ignore - self.selected_files: set = set() # Track selected files - - # Playlist support - self.playlist_tree: Tree = None # type: ignore - self.playlist_input: Input = None # type: ignore - self.playlist_merge_checkbox: Checkbox = None # type: ignore - self.is_playlist: bool = False # Track if current URL is a playlist - self.playlist_items: list = [] # Store playlist items - - def compose(self) -> ComposeResult: - """Compose the download request modal.""" - yield Vertical( - # Title - Static("📥 New Download Request", id="download_title"), - # Main layout: Horizontal split into left and right columns - Horizontal( - # Left column: URL (top) and Tags (bottom) - Vertical( - Container( - TextArea( - id="paragraph_textarea", - language="", - show_line_numbers=True, - ), - id="url_container", - classes="grid_container", - ), - Container( - TextArea( - id="tags_textarea", - language="", - show_line_numbers=True, - ), - id="tags_container", - classes="grid_container", - ), - id="left_column", - ), - # Right column: Files/Playlist - Vertical( - # Formats Select (for single files) - Container( - Select( - id="files_select", - options=[], # Populated dynamically - ), - id="files_container", - classes="grid_container", - ), - # Playlist Tree + Input + Merge (for playlists) - Container( - Vertical( - Tree( - "Playlist", - id="playlist_tree", - ), - Horizontal( - Input( - placeholder="Track selection (e.g., 1-3, all, merge, 1 5 8)", - id="playlist_input", - ), - Checkbox( - label="Merge", - id="playlist_merge_checkbox", - value=False, - ), - id="playlist_input_row", - ), - ), - id="playlist_container", - classes="grid_container", - ), - id="right_column", - ), - id="main_layout", - ), - # Footer: All on one row - Checkboxes left, Source middle, Buttons right - Horizontal( - # Left: Checkboxes - Container( - Checkbox(label="Download", id="download_checkbox"), - Checkbox(label="Screenshot", id="screenshot_checkbox"), - id="checkbox_row", - ), - # Middle: Source selector - Select(id="source_select", options=self._build_source_options()), - # Progress bar (shown during download) - ProgressBar(id="progress_bar"), - # Right: Buttons - Horizontal( - Button("Cancel", id="cancel_btn", variant="default"), - Button("Submit", id="submit_btn", variant="primary"), - id="button_row", - ), - id="footer_layout", - classes="modal_footer", - ), - id="download_modal", - classes="modal_vertical", - ) - - def _build_source_options(self) -> list[tuple[str, str]]: - """Build source select options. - - Returns: - List of (label, value) tuples for Select widget - """ - source_icons = { - "hydrus": "🗃️ Hydrus", - "local": "📁 Local", - "alldebrid": "☁️ AllDebrid", - "debrid": "☁️ Debrid", - "soulseek": "🎵 Soulseek", - "libgen": "📚 LibGen", - } - - options = [] - for source in self.available_sources: - label = source_icons.get(source.lower(), source) - options.append((label, source)) - - return options - - def on_mount(self) -> None: - """Called when the modal is mounted.""" - # Get references to widgets - self.paragraph_textarea = self.query_one("#paragraph_textarea", TextArea) - self.tags_textarea = self.query_one("#tags_textarea", TextArea) - self.source_select = self.query_one("#source_select", Select) - self.files_select = self.query_one("#files_select", Select) - self.download_checkbox = self.query_one("#download_checkbox", Checkbox) - self.screenshot_checkbox = self.query_one("#screenshot_checkbox", Checkbox) - self.progress_bar = self.query_one("#progress_bar", ProgressBar) - self.playlist_tree = self.query_one("#playlist_tree", Tree) - self.playlist_input = self.query_one("#playlist_input", Input) - self.playlist_merge_checkbox = self.query_one( - "#playlist_merge_checkbox", - Checkbox - ) - - # Set default actions - self.download_checkbox.value = True - self.screenshot_checkbox.value = False - self.playlist_merge_checkbox.value = False - - # Initialize PDF playlist url (set by _handle_pdf_playlist) - self.pdf_url = [] - self.is_pdf_playlist = False - - # Hide playlist by default (show format select) - self._show_format_select() - - # Focus on tags textarea - self.tags_textarea.focus() - - logger.debug("Download modal mounted") - - def action_submit(self) -> None: - """Submit the download request by executing cmdlet pipeline in background.""" - # Validate and get values first (on main thread) - url = self.paragraph_textarea.text.strip() - tags_str = self.tags_textarea.text.strip() - source = self.source_select.value or "local" - download_enabled = self.download_checkbox.value - merge_enabled = self.playlist_merge_checkbox.value if self.is_playlist else False - - if not url: - logger.warning("Download request missing URL") - self.app.notify( - "URL is required", - title="Missing Input", - severity="warning" - ) - return - - # Parse tags (one per line) - tags = [] - if tags_str: - tags = [tag.strip() for tag in tags_str.split("\n") if tag.strip()] - - # Get playlist selection if this is a playlist - playlist_selection = "" - if self.is_playlist and not self.is_pdf_playlist: - # Regular playlist (non-PDF) - playlist_selection = self.playlist_input.value.strip() - if not playlist_selection: - # No selection provided - default to downloading all tracks - playlist_selection = f"1-{len(self.playlist_items)}" - logger.info( - f"No selection provided, defaulting to all tracks: {playlist_selection}" - ) - elif self.is_playlist and self.is_pdf_playlist: - # PDF playlist - handle selection - playlist_selection = self.playlist_input.value.strip() - if not playlist_selection: - # No selection provided - default to all PDFs - playlist_selection = f"1-{len(self.playlist_items)}" - logger.info( - f"PDF playlist: no selection provided, defaulting to all PDFs: {playlist_selection}" - ) - merge_enabled = True # Always merge PDFs if multiple selected - - # Launch the background worker with PDF playlist info - self._submit_worker( - url, - tags, - source, - download_enabled, - playlist_selection, - merge_enabled, - is_pdf_playlist=self.is_pdf_playlist, - pdf_url=self.pdf_url if self.is_pdf_playlist else [], - ) - - @work(thread=True) - def _submit_worker( - self, - url: str, - tags: list, - source: str, - download_enabled: bool, - playlist_selection: str = "", - merge_enabled: bool = False, - is_pdf_playlist: bool = False, - pdf_url: Optional[list] = None, - ) -> None: - """Background worker to execute the cmdlet pipeline. - - Args: - url: URL to download - tags: List of tags to apply - source: Source for metadata - download_enabled: Whether to download the file - playlist_selection: Playlist track selection (e.g., "1-3", "all", "merge") - merge_enabled: Whether to merge playlist files after download - is_pdf_playlist: Whether this is a PDF pseudo-playlist - pdf_url: List of PDF url if is_pdf_playlist is True - """ - if pdf_url is None: - pdf_url = [] - - # Initialize worker to None so outer exception handler can check it - worker = None - try: - # Show progress bar on main thread - self.app.call_from_thread(self._show_progress) - - logger.info( - f"Building cmdlet pipeline: URL={url}, tags={len(tags)}, source={source}, download={download_enabled}, playlist_selection={playlist_selection}" - ) - - # Create a worker instance using the app's helper method - worker = None - try: - if hasattr(self.app, "create_worker"): - worker = self.app.create_worker( - "download", - title=f"Download: {url[:50]}", - description=f"Tags: {', '.join(tags) if tags else 'None'}", - ) - else: - # Fallback if helper not available - import uuid - from SYS.worker_manager import Worker - - worker_id = f"dl_{uuid.uuid4().hex[:8]}" - worker = Worker( - worker_id, - "download", - f"Download: {url[:50]}", - f"Tags: {', '.join(tags) if tags else 'None'}", - None, - ) - except Exception as e: - logger.error(f"Error creating worker: {e}") - worker = None - - # Log initial step - if worker: - worker.log_step("Download initiated") - - # Handle PDF playlist specially - if is_pdf_playlist and pdf_url: - logger.info(f"Processing PDF playlist with {len(pdf_url)} PDFs") - self._handle_pdf_playlist_download( - pdf_url, - tags, - playlist_selection, - merge_enabled - ) - self.app.call_from_thread(self._hide_progress) - self.app.call_from_thread(self.dismiss) - return - - # Build the cmdlet pipeline - # Start with URL as initial object - result_obj = self._create_url_result(url) - - # Import cmdlet system - if not get_cmdlet: - logger.error("cmdlet module not available") - self.app.call_from_thread( - self.app.notify, - "cmdlet system unavailable", - title="Error", - severity="error" - ) - self.app.call_from_thread(self._hide_progress) - return - - # Stage 1: Download if enabled - download_succeeded = False - download_stderr_text = "" # Store for merge stage - if download_enabled: - download_cmdlet_name = "file" - download_cmdlet = get_cmdlet(download_cmdlet_name) - if download_cmdlet: - logger.info(f"📥 Executing {download_cmdlet_name} stage") - logger.info(f"download_cmdlet object: {download_cmdlet}") - logger.info(f"result_obj: {result_obj}") - - # Log step to worker - if worker: - worker.log_step(f"Starting {download_cmdlet_name} stage...") - - # Build yt-dlp playlist arguments for download-file streaming (if applicable). - cmdlet_args = ["-download"] - if self.is_playlist: - # Always use yt-dlp's native --playlist-items for playlists - if playlist_selection: - # User provided specific selection - ytdlp_selection = self._convert_selection_to_ytdlp( - playlist_selection - ) - logger.info( - f"Playlist with user selection: {playlist_selection} → {ytdlp_selection}" - ) - else: - # No selection provided, download all - ytdlp_selection = f"1-{len(self.playlist_items)}" - logger.info( - f"Playlist mode: downloading all {len(self.playlist_items)} items" - ) - cmdlet_args = ["--playlist-items", ytdlp_selection] - - logger.info(f"Built cmdlet_args: {cmdlet_args}") - logger.info( - f"About to call download_cmdlet({result_obj}, {cmdlet_args}, {type(self.config).__name__})" - ) - - if worker: - worker.append_stdout(f"📥 Downloading from: {url}\n") - if cmdlet_args: - worker.append_stdout(f" Args: {cmdlet_args}\n") - - try: - # Capture output from the cmdlet using temp files (more reliable than redirect) - - # Try normal redirect first - import io - from contextlib import redirect_stdout, redirect_stderr - - stdout_buf = io.StringIO() - stderr_buf = io.StringIO() - - # Always capture output - try: - with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf): - logger.info("Calling download_cmdlet...") - cmd_config = ( - dict(self.config) - if isinstance(self.config, - dict) else self.config - ) - if isinstance(cmd_config, dict): - cmd_config["_quiet_background_output"] = True - returncode = download_cmdlet( - result_obj, - cmdlet_args, - cmd_config - ) - logger.info(f"download_cmdlet returned: {returncode}") - except Exception as cmdlet_error: - # If cmdlet throws an exception, log it - logger.error( - f"❌ download-cmdlet exception: {cmdlet_error}", - exc_info=True - ) - if worker: - import traceback - - worker.append_stdout( - f"❌ download-cmdlet exception: {cmdlet_error}\n{traceback.format_exc()}\n" - ) - returncode = 1 - - stdout_text = stdout_buf.getvalue() - stderr_text = stderr_buf.getvalue() - download_stderr_text = stderr_text # Save for merge stage - - # Log raw output - logger.info(f"download-cmdlet returncode: {returncode}") - logger.info( - f"stdout ({len(stdout_text)} chars): {stdout_text[:200] if stdout_text else '(empty)'}" - ) - logger.info( - f"stderr ({len(stderr_text)} chars): {stderr_text[:200] if stderr_text else '(empty)'}" - ) - - # Always append output to worker for debugging - if worker: - if stdout_text: - worker.append_stdout( - f"[{download_cmdlet_name} stdout]\n{stdout_text}\n" - ) - if stderr_text: - worker.append_stdout( - f"[{download_cmdlet_name} stderr]\n{stderr_text}\n" - ) - - # Log the output so it gets captured by WorkerLoggingHandler - if stdout_text: - logger.info( - f"[{download_cmdlet_name} output]\n{stdout_text}" - ) - if stderr_text: - logger.info( - f"[{download_cmdlet_name} stderr]\n{stderr_text}" - ) - if returncode != 0: - download_failed_msg = f"❌ {download_cmdlet_name} stage failed with code {returncode}\nstdout: {stdout_text}\nstderr: {stderr_text}" - logger.error(download_failed_msg) - if worker: - worker.append_stdout(f"\n{download_failed_msg}\n") - worker.finish( - "error", - "Download stage failed - see logs above for details" - ) - - # Log to stderr as well so it shows in terminal - log(f"Return code: {returncode}", file=sys.stderr) - log(f"stdout:\n{stdout_text}", file=sys.stderr) - log(f"stderr:\n{stderr_text}", file=sys.stderr) - - # Extract error reason from stderr/stdout for user notification - # Try to extract meaningful error from yt-dlp output - error_reason = "Unknown error" - - # Search for yt-dlp error patterns (case-insensitive) - error_text = (stderr_text + "\n" + stdout_text).lower() - - # Look for specific error keywords in priority order - if "http error 403" in error_text or "error 403" in error_text: - error_reason = "HTTP 403: Access forbidden (YouTube blocked download, may be georestricted or SABR issue)" - elif "http error 401" in error_text or "error 401" in error_text: - error_reason = ( - "HTTP 401: Authentication required (may need login credentials)" - ) - elif "http error 404" in error_text or "error 404" in error_text: - error_reason = ( - "HTTP 404: URL not found (video/content may have been deleted)" - ) - elif "http error" in error_text: - # Extract the actual HTTP error code - import re - - http_match = re.search( - r"HTTP Error (\d{3})", - stderr_text + stdout_text, - re.IGNORECASE - ) - if http_match: - error_reason = f"HTTP Error {http_match.group(1)}: Server returned an error" - else: - error_reason = "HTTP error from server" - elif ("no such file or directory" in error_text - or "file not found" in error_text): - error_reason = ( - "File not found (yt-dlp may not be installed or not in PATH)" - ) - elif "unable to download" in error_text: - error_reason = "Unable to download video (network issue or content unavailable)" - elif ("connection" in error_text or "timeout" in error_text - or "timed out" in error_text): - error_reason = "Network connection failed or timed out" - elif "permission" in error_text or "access denied" in error_text: - error_reason = ( - "Permission denied (may need elevated privileges or login)" - ) - elif "private video" in error_text or "private" in error_text: - error_reason = "Video is private (not accessible)" - elif "age restricted" in error_text or "age gate" in error_text: - error_reason = "Video is age-restricted and requires login" - elif "region restricted" in error_text or "georestrict" in error_text: - error_reason = ( - "Video is region-restricted (not available in your country)" - ) - elif "member-only" in error_text or "members only" in error_text: - error_reason = "Video is available to members only" - - # If still unknown, try to extract last line of stderr as it often contains the actual error - if error_reason == "Unknown error": - stderr_lines = [ - line.strip() for line in stderr_text.split("\n") - if line.strip() - ] - if stderr_lines: - # Look for error-like lines (usually contain "error", "failed", "ERROR", etc) - for line in reversed(stderr_lines): - if any(keyword in line.lower() for keyword in [ - "error", - "failed", - "exception", - "traceback", - "warning", ]): - error_reason = line[:150 - ] # Limit to 150 chars - break - # If no error keyword found, use the last line - if error_reason == "Unknown error": - error_reason = stderr_lines[-1][:150] - - # Log the extracted error reason for debugging - logger.error(f"Extracted error reason: {error_reason}") - - self.app.call_from_thread( - self.app.notify, - f"Download failed: {error_reason}", - title="Download Error", - severity="error", - ) - # Finish worker with error status - try: - self.app.call_from_thread( - self.app.finish_worker, - worker_id, - "error", - f"Download failed: {error_reason}", - ) - except Exception: - logger.exception("Failed to finish worker during download failure handling") - - # Also append detailed error info to worker stdout for visibility - if worker: - worker.append_stdout("\n❌ DOWNLOAD FAILED\n") - worker.append_stdout(f"Reason: {error_reason}\n") - if stderr_text and stderr_text.strip(): - worker.append_stdout( - f"\nFull error output:\n{stderr_text}\n" - ) - if stdout_text and stdout_text.strip(): - worker.append_stdout( - f"\nStandard output:\n{stdout_text}\n" - ) - # Don't try to tag if download failed - self.app.call_from_thread(self._hide_progress) - self.app.call_from_thread(self.dismiss) - return - else: - download_succeeded = True - # Always log output at INFO level so we can see what happened - logger.info( - f"{download_cmdlet_name} stage completed successfully" - ) - if stdout_text: - logger.info( - f"{download_cmdlet_name} stdout:\n{stdout_text}" - ) - if stderr_text: - logger.info( - f"{download_cmdlet_name} stderr:\n{stderr_text}" - ) - - # Log step to worker - if worker: - worker.log_step( - f"Download completed: {len(stdout_text.split('Saved to')) - 1} items downloaded" - ) - - # For playlists with merge enabled, scan the output directory for ALL downloaded files - # instead of trying to parse individual "Saved to" lines - downloaded_files = [] - if self.is_playlist and merge_enabled: - # Get output directory - from pathlib import Path - from SYS.config import resolve_output_dir - - output_dir = resolve_output_dir(self.config) - logger.info( - f"Merge enabled: scanning {output_dir} for downloaded files" - ) - - # First, try to extract filenames from download output - # Look for patterns like "→ filename.mp3" from yt-dlp output - extracted_files = [] - for line in stdout_text.split("\n"): - if "→" in line: - # Extract filename from arrow marker - parts = line.split("→") - if len(parts) > 1: - filename = parts[1].strip() - if filename: - full_path = output_dir / filename - if full_path.exists(): - extracted_files.append( - str(full_path) - ) - logger.debug( - f"Found downloaded file from output: {filename}" - ) - - if extracted_files: - downloaded_files = extracted_files - logger.info( - f"Found {len(downloaded_files)} downloaded files from output markers" - ) - else: - # Fallback: List all recent mp3/m4a files in output directory - if output_dir.exists(): - import time - - current_time = time.time() - recent_files = [] - for f in (list(output_dir.glob("*.mp3")) + - list(output_dir.glob("*.m4a")) + - list(output_dir.glob("*.mp4"))): - # Files modified in last 30 minutes (extended window) - if current_time - f.stat().st_mtime < 1800: - recent_files.append( - (f, - f.stat().st_mtime) - ) - - # Sort by modification time to preserve order - recent_files.sort(key=lambda x: x[1]) - downloaded_files = [ - str(f[0]) for f in recent_files - ] - logger.info( - f"Found {len(downloaded_files)} recently modified files in directory (fallback)" - ) - - if downloaded_files: - logger.info( - f"Found {len(downloaded_files)} files to merge" - ) - if downloaded_files: - logger.info( - f"Files to merge: {downloaded_files[:3]}... (showing first 3)" - ) - else: - # For non-merge or non-playlist, just look for "Saved to" pattern - combined_output = stdout_text + "\n" + stderr_text - for line in combined_output.split("\n"): - if "Saved to" in line: - # Extract path after "Saved to " - saved_idx = line.find("Saved to") - if saved_idx != -1: - path = line[saved_idx + 8:].strip() - if path: - downloaded_files.append(path) - logger.debug( - f"Found downloaded file: {path}" - ) - - # For merge scenarios, DON'T set to first file yet - merge first, then tag - # For non-merge, set to first file for tagging - if downloaded_files: - if not (self.is_playlist and merge_enabled): - # Non-merge case: set to first file for tagging - first_file = downloaded_files[0] - result_obj.target = first_file - result_obj.path = first_file - logger.info( - f"Set result target/path to first file: {first_file}" - ) - else: - # Merge case: save all files, will set to merged file after merge - logger.info( - f"Merge enabled - will merge {len(downloaded_files)} files before tagging" - ) - download_stderr_text = ( - f"DOWNLOADED_FILES:{','.join(downloaded_files)}\n" - + download_stderr_text - ) - - logger.info( - f"{download_cmdlet_name} stage completed successfully" - ) - except Exception as e: - logger.error( - f"{download_cmdlet_name} execution error: {e}", - exc_info=True - ) - self.app.call_from_thread( - self.app.notify, - f"Download error: {e}", - title="Download Error", - severity="error", - ) - # Finish worker with error status - try: - self.app.call_from_thread( - self.app.finish_worker, - worker_id, - "error", - f"Download error: {str(e)}", - ) - except Exception: - logger.exception("Failed to finish worker during download error handling") - self.app.call_from_thread(self._hide_progress) - self.app.call_from_thread(self.dismiss) - return - - # Stage 2: Merge files if enabled and this is a playlist (BEFORE tagging) - merged_file_path = None - if merge_enabled and download_succeeded and self.is_playlist: - merge_cmdlet = get_cmdlet("file") - if merge_cmdlet: - from pathlib import Path - - logger.info("Executing merge-file stage") - - # Log step to worker - if worker: - worker.log_step("Starting merge-file stage...") - - merge_args = [ - "-merge", - "-delete", - "-format", - "mka", - ] # Delete source files, use MKA for speed (stream copy) and chapters - - try: - # For merge, we pass a list of result objects - # The merge-file cmdlet expects objects with 'target' attribute - files_to_merge = [] - - # Check if we have the special marker with downloaded files - if download_stderr_text.startswith("DOWNLOADED_FILES:"): - # Extract file list from marker - files_line = download_stderr_text.split("\n")[0] - if files_line.startswith("DOWNLOADED_FILES:"): - files_str = files_line[len("DOWNLOADED_FILES:"):] - file_list = [ - f.strip() for f in files_str.split(",") if f.strip() - ] - logger.info( - f"Found {len(file_list)} downloaded files from marker" - ) - - # Create result objects with proper attributes - for filepath in file_list: - filepath_obj = Path(filepath) - file_result = type( - "FileResult", - (), - { - "target": str(filepath), - "path": str(filepath), - "media_kind": "audio", - "hash": None, - "url": [], - "title": filepath_obj.stem, - }, - )() - files_to_merge.append(file_result) - - if files_to_merge: - logger.info( - f"Merging {len(files_to_merge)} files: {[f.target for f in files_to_merge]}" - ) - - # Call merge-file with list of results - import io - from contextlib import redirect_stdout, redirect_stderr - - stdout_buf = io.StringIO() - stderr_buf = io.StringIO() - - with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf): - # Pass the list of file results to merge-file - merge_returncode = merge_cmdlet( - files_to_merge, - merge_args, - self.config - ) - - merge_stdout = stdout_buf.getvalue() - merge_stderr = stderr_buf.getvalue() - - # Log the merge output so it gets captured by WorkerLoggingHandler - if merge_stdout: - logger.info(f"[merge-file output]\n{merge_stdout}") - if merge_stderr: - logger.info(f"[merge-file stderr]\n{merge_stderr}") - - if merge_returncode != 0: - logger.error( - f"merge-file stage failed with code {merge_returncode}" - ) - logger.error(f" stderr: {merge_stderr}") - self.app.call_from_thread( - self.app.notify, - f"Merge failed: {merge_stderr[:100] if merge_stderr else 'unknown error'}", - title="Merge Error", - severity="warning", - ) - # Don't fail entirely - files were downloaded - else: - logger.info("merge-file stage completed successfully") - if merge_stdout: - logger.info(f"merge-file stdout: {merge_stdout}") - if merge_stderr: - logger.info(f"merge-file stderr: {merge_stderr}") - - # Log step to worker - if worker: - worker.log_step("Merge completed successfully") - - # Extract merged file path from stderr - # The merge-file cmdlet outputs: "[merge-file] Merged N files into: /path/to/merged.mp3" - for line in merge_stderr.split("\n"): - if "Merged" in line and "into:" in line: - # Extract path after "into: " - into_idx = line.find("into:") - if into_idx != -1: - merged_file_path = line[into_idx + - 5:].strip() - if merged_file_path: - logger.info( - f"Detected merged file path: {merged_file_path}" - ) - break - - # If not found in stderr, try stdout - if not merged_file_path: - for line in merge_stdout.split("\n"): - if ("merged" in line.lower() - or line.endswith(".mp3") - or line.endswith(".m4a")): - merged_file_path = line.strip() - if (merged_file_path and - not merged_file_path.startswith("[") - ): - logger.info( - f"Detected merged file path: {merged_file_path}" - ) - break - - # If we found the merged file, update result_obj to point to it - if merged_file_path: - result_obj.target = merged_file_path - result_obj.path = merged_file_path - logger.info( - f"Updated result object to point to merged file: {merged_file_path}" - ) - else: - logger.warning( - f"No files found to merge. download_stderr_text length: {len(download_stderr_text)}, content preview: {download_stderr_text[:100]}" - ) - except Exception as e: - logger.error(f"merge-file execution error: {e}", exc_info=True) - self.app.call_from_thread( - self.app.notify, - f"Merge error: {e}", - title="Merge Error", - severity="warning", - ) - # Don't fail entirely - files were downloaded - else: - logger.info("merge-file cmdlet not found") - - # Stage 3: Add metadata tags (now after merge, if merge happened) - # If merge succeeded, result_obj now points to merged file - if tags and (download_succeeded or not download_enabled): - add_tags_cmdlet = get_cmdlet("metadata") - if add_tags_cmdlet: - logger.info(f"Executing add-tags stage with {len(tags)} tags") - logger.info(f" Tags: {tags}") - logger.info(f" Source: {source}") - logger.info(f" Result path: {result_obj.path}") - logger.info(f" Result hash: {result_obj.hash_hex}") - - # Log step to worker - if worker: - worker.log_step( - f"Starting add-tags stage with {len(tags)} tags..." - ) - - # Build metadata-tag arguments. Default to local sidecar tagging for downloads. - tag_args = ( - ["-add", "-instance", - "local"] + [str(t) for t in tags] + ["--source", - str(source)] - ) - logger.info(f" Tag args: {tag_args}") - logger.info( - f" Result object attributes: target={getattr(result_obj, 'target', 'MISSING')}, path={getattr(result_obj, 'path', 'MISSING')}, hash_hex={getattr(result_obj, 'hash_hex', 'MISSING')}" - ) - - try: - # Capture output from the cmdlet - import io - from contextlib import redirect_stdout, redirect_stderr - - stdout_buf = io.StringIO() - stderr_buf = io.StringIO() - - with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf): - returncode = add_tags_cmdlet( - result_obj, - tag_args, - self.config - ) - - stdout_text = stdout_buf.getvalue() - stderr_text = stderr_buf.getvalue() - - # Log the tag output so it gets captured by WorkerLoggingHandler - if stdout_text: - logger.info(f"[add-tags output]\n{stdout_text}") - if stderr_text: - logger.info(f"[add-tags stderr]\n{stderr_text}") - - if returncode != 0: - logger.error( - f"add-tags stage failed with code {returncode}" - ) - logger.error(f" stdout: {stdout_text}") - logger.error(f" stderr: {stderr_text}") - self.app.call_from_thread( - self.app.notify, - f"Failed to add tags: {stderr_text[:100] if stderr_text else stdout_text[:100] if stdout_text else 'unknown error'}", - title="Error", - severity="error", - ) - # Don't dismiss on tag failure - let user retry or cancel, but hide progress - self.app.call_from_thread(self._hide_progress) - return - else: - if stdout_text: - logger.debug(f"add-tags stdout: {stdout_text}") - if stderr_text: - logger.debug(f"add-tags stderr: {stderr_text}") - logger.info("add-tags stage completed successfully") - - # Log step to worker - if worker: - worker.log_step(f"Successfully added {len(tags)} tags") - except Exception as e: - logger.error(f"add-tags execution error: {e}", exc_info=True) - self.app.call_from_thread( - self.app.notify, - f"Error adding tags: {e}", - title="Error", - severity="error", - ) - self.app.call_from_thread(self._hide_progress) - return - else: - logger.error("metadata cmdlet not found for add stage") - else: - if tags and download_enabled and not download_succeeded: - skip_msg = "⚠️ Skipping add-tags stage because download failed" - logger.info(skip_msg) - if worker: - worker.append_stdout(f"\n{skip_msg}\n") - worker.finish( - "error", - "Download stage failed - see logs above for details" - ) - elif tags: - logger.info("No tags to add (tags list is empty)") - - # Success notification - self.app.call_from_thread( - self.app.notify, - f"Download request processed: {url}", - title="Success", - severity="information", - timeout=2, - ) - - # Finish worker with success status - if worker: - worker.finish("completed", "Download completed successfully") - - logger.info("Download request processing complete") - - # Hide progress and dismiss the modal - self.app.call_from_thread(self._hide_progress) - self.app.call_from_thread(self.dismiss) - - except Exception as e: - logger.error(f"Error in download submit: {e}", exc_info=True) - # Ensure worker is marked as finished even on exception - if worker: - try: - worker.finish("error", f"Download failed: {str(e)}") - except Exception: - logger.exception("Failed to finish worker on download submit error") - self.app.call_from_thread(self._hide_progress) - self.app.call_from_thread( - self.app.notify, - f"Error: {e}", - title="Error", - severity="error" - ) - - def _create_url_result(self, url: str): - """Create a result object from a URL for cmdlet processing.""" - - class URLDownloadResult: - - def __init__(self, u): - self.target = u - self.url = u - self.path: str | None = None - self.hash_hex: str | None = None - self.media_kind = "url" - - return URLDownloadResult(url) - - def action_cancel(self) -> None: - """Cancel the download request.""" - self.dismiss() - - def on_key(self, event) -> None: - """Handle key presses to implement context-sensitive Ctrl+T.""" - if event.key == "ctrl+t": - # Check which widget has focus - focused_widget = self.app.focused - if focused_widget and focused_widget.id == "paragraph_textarea": - # URL textarea: scrape fresh metadata, wipe tags and source - self._action_scrape_url_metadata() - event.prevent_default() - elif focused_widget and focused_widget.id == "tags_textarea": - # Tags textarea: scrape special fields and adjectives - self._action_scrape_tags() - event.prevent_default() - - def _action_scrape_url_metadata(self) -> None: - """Scrape metadata from URL(s) in URL textarea - wipes tags and source. - - This is triggered by Ctrl+T when URL textarea is focused. - Supports single URL or multiple url (newline/comma-separated). - For multiple PDF url, creates pseudo-playlist for merge workflow. - """ - try: - text = self.paragraph_textarea.text.strip() - if not text: - logger.warning("No URL to scrape metadata from") - return - - # Parse multiple url (newline or comma-separated) - url = [] - for line in text.split("\n"): - line = line.strip() - if line: - # Handle comma-separated url within a line - for url in line.split(","): - url = url.strip() - if url: - url.append(url) - - # Check if multiple url provided - if len(url) > 1: - logger.info( - f"Detected {len(url)} url - checking for PDF pseudo-playlist" - ) - # Check if all url appear to be PDFs - all_pdfs = all( - url.endswith(".pdf") or "pdf" in url.lower() for url in url - ) - if all_pdfs: - logger.info("All url are PDFs - creating pseudo-playlist") - self._handle_pdf_playlist(url) - return - - # Single URL - proceed with normal metadata scraping - url = url[0] if url else text.strip() - logger.info(f"Scraping fresh metadata from: {url}") - - # Check if tags are already provided in textarea - existing_tags = self.tags_textarea.text.strip() - wipe_tags = not existing_tags # Only wipe if no tags exist - - # Run in background to prevent UI freezing - self._scrape_metadata_worker( - url, - wipe_tags_and_source=wipe_tags, - skip_tag_scraping=not wipe_tags - ) - - except Exception as e: - logger.error(f"Error in _action_scrape_url_metadata: {e}", exc_info=True) - - def _action_scrape_tags(self) -> None: - """Process tags from tags textarea, expanding tag lists like {philosophy}. - - This is triggered by Ctrl+T when tags textarea is focused. - Processes tag list references from adjective.json (e.g., {psychology}) - and expands them to the full list of tags. - """ - try: - current_tags = self.tags_textarea.text.strip() - if not current_tags: - logger.warning("No tags to process") - return - - if not expand_tag_lists or not process_tags_from_string: - logger.warning("tag_helpers not available") - self.app.notify( - "Tag processing unavailable", - title="Error", - severity="error", - timeout=2 - ) - return - - logger.info(f"Processing tags: {current_tags[:50]}...") - - # Parse tags from current text - tags_set = process_tags_from_string(current_tags, expand_lists=False) - if not tags_set: - logger.warning("No tags parsed from text") - return - - # Expand tag list references like {psychology} - expanded_tags = expand_tag_lists(tags_set) - - if len(expanded_tags) > len(tags_set): - # Tags were expanded - tags_count_added = len(expanded_tags) - len(tags_set) - logger.info(f"Expanded tags: added {tags_count_added} new tags") - self.app.notify( - f"Expanded: {tags_count_added} new tags added from tag lists", - title="Tags Expanded", - severity="information", - timeout=2, - ) - else: - logger.info("No tag list expansions found") - self.app.notify( - "No {list} references found to expand", - title="Info", - severity="information", - timeout=2, - ) - - # Update textarea with expanded tags (one per line) - self.tags_textarea.text = "\n".join(sorted(expanded_tags)) - logger.info(f"Updated tags textarea with {len(expanded_tags)} tags") - - except Exception as e: - logger.error(f"Error in _action_scrape_tags: {e}", exc_info=True) - self.app.notify( - f"Error processing tags: {e}", - title="Error", - severity="error" - ) - - def _handle_pdf_playlist(self, pdf_url: list) -> None: - """Handle multiple PDF url as a pseudo-playlist. - - Creates a playlist-like structure with PDF metadata for merge workflow. - Extracts title from URL or uses default naming. - - Args: - pdf_url: List of PDF url to process - """ - try: - logger.info(f"Creating PDF pseudo-playlist with {len(pdf_url)} items") - - # Create playlist items from PDF url - playlist_items = [] - for idx, url in enumerate(pdf_url, 1): - # Extract filename from URL for display - try: - # Get filename from URL path - from urllib.parse import urlparse - - parsed = urlparse(url) - filename = parsed.path.split("/")[-1] - if not filename or filename.endswith(".pdf"): - filename = filename or f"pdf_{idx}.pdf" - # Remove .pdf extension for display - title = filename.replace(".pdf", - "").replace("_", - " ").replace("-", - " ") - except Exception as e: - logger.debug(f"Could not extract filename: {e}") - title = f"PDF {idx}" - - item = { - "id": str(idx - 1), # 0-based index - "title": title, - "duration": "", # PDFs don't have duration, leave empty - "url": url, # Store the URL for later download - } - playlist_items.append(item) - - # Build minimal metadata structure for UI population - metadata = { - "title": f"{len(pdf_url)} PDF Documents", - "tags": [], - "formats": [("pdf", "pdf")], # Default format is PDF - "playlist_items": playlist_items, - "is_pdf_playlist": True, # Mark as PDF pseudo-playlist - } - - # Store url for later use during merge - self.pdf_url = pdf_url - self.is_pdf_playlist = True - - # Populate the modal with metadata - logger.info(f"Populating modal with {len(playlist_items)} PDF items") - self._populate_from_metadata(metadata, wipe_tags_and_source=True) - - self.app.notify( - f"Loaded {len(pdf_url)} PDFs as playlist", - title="PDF Playlist", - severity="information", - timeout=3, - ) - - except Exception as e: - logger.error(f"Error handling PDF playlist: {e}", exc_info=True) - self.app.notify( - f"Error loading PDF playlist: {e}", - title="Error", - severity="error", - timeout=3 - ) - - def _handle_pdf_playlist_download( - self, - pdf_url: list, - tags: list, - selection: str, - merge_enabled: bool - ) -> None: - """Download and merge PDF playlist. - - Args: - pdf_url: List of PDF url to download - tags: Tags to apply to the merged PDF - selection: Selection string like "1-3" or "1,3,5" - merge_enabled: Whether to merge the PDFs - """ - # Check if pypdf is available for merge (needed at function start) - try: - from pypdf import PdfWriter, PdfReader - - HAS_PYPDF = True - except ImportError: - HAS_PYPDF = False - PdfWriter = None - PdfReader = None - - try: - from pathlib import Path - from API.requests_client import get_requests_session - from SYS.config import resolve_output_dir - - # Create temporary list of playlist items for selection parsing - # We need this because _parse_playlist_selection uses self.playlist_items - temp_items = [] - for url in pdf_url: - temp_items.append({ - "title": url - }) - self.playlist_items = temp_items - - # Parse selection to get which PDFs to download - selected_indices = self._parse_playlist_selection(selection) - if not selected_indices: - # No valid selection, use all - selected_indices = list(range(len(pdf_url))) - - selected_url = [pdf_url[i] for i in selected_indices] - - logger.info(f"Downloading {len(selected_url)} selected PDFs for merge") - - # Download PDFs to temporary directory - import tempfile - temp_dir = Path(tempfile.mkdtemp(prefix="Medios-Macina-pdfs_")) - - downloaded_files = [] - for idx, url in enumerate(selected_url, 1): - try: - logger.info(f"Downloading PDF {idx}/{len(selected_url)}: {url}") - - response = get_requests_session().get(url, timeout=30) - response.raise_for_status() - - # Generate filename from URL - from urllib.parse import urlparse - - parsed = urlparse(url) - filename = parsed.path.split("/")[-1] - if not filename.endswith(".pdf"): - filename = f"pdf_{idx}.pdf" - - pdf_path = temp_dir / filename - with open(pdf_path, "wb") as f: - f.write(response.content) - - downloaded_files.append(pdf_path) - logger.info(f"Downloaded to: {pdf_path}") - - except Exception as e: - logger.error(f"Failed to download PDF {idx}: {e}") - self.app.call_from_thread( - self.app.notify, - f"Failed to download PDF {idx}: {e}", - title="Download Error", - severity="error", - ) - return - - # Merge PDFs if requested - if merge_enabled and len(downloaded_files) > 1: - if not HAS_PYPDF: - logger.error("pypdf not available for PDF merge") - self.app.call_from_thread( - self.app.notify, - "pypdf required for PDF merge. Install with: pip install pypdf", - title="Missing Dependency", - severity="error", - ) - return - - logger.info(f"Merging {len(downloaded_files)} PDFs") - - try: - writer = PdfWriter() - for pdf_file in downloaded_files: - reader = PdfReader(pdf_file) - for page in reader.pages: - writer.add_page(page) - logger.info( - f"Added {len(reader.pages)} pages from {pdf_file.name}" - ) - - # Save merged PDF to output directory - output_dir = Path(resolve_output_dir(self.config)) - output_dir.mkdir(parents=True, exist_ok=True) - - output_path = output_dir / "merged_pdfs.pdf" - # Make filename unique if it exists - counter = 1 - while output_path.exists(): - output_path = output_dir / f"merged_pdfs_{counter}.pdf" - counter += 1 - - with open(output_path, "wb") as f: - writer.write(f) - - logger.info(f"Merged PDF saved to: {output_path}") - - # Tag the file if tags provided - if tags and get_cmdlet: - tag_cmdlet = get_cmdlet("metadata") - if tag_cmdlet: - logger.info(f"Tagging merged PDF with {len(tags)} tags") - - # Create a result object for the PDF - class PDFResult: - - def __init__(self, p): - self.path = str(p) - self.target = str(p) - self.hash_hex = None - - result_obj = PDFResult(output_path) - - import io - from contextlib import redirect_stdout, redirect_stderr - - stdout_buf = io.StringIO() - stderr_buf = io.StringIO() - - tag_args = ["-add", "-instance", "local"] + [str(t) for t in tags] - with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf): - tag_returncode = tag_cmdlet( - result_obj, - tag_args, - self.config - ) - - if tag_returncode != 0: - logger.warning( - f"Tag stage returned code {tag_returncode}" - ) - - self.app.call_from_thread( - self.app.notify, - f"Successfully merged {len(downloaded_files)} PDFs", - title="Merge Complete", - severity="information", - timeout=3, - ) - - except Exception as e: - logger.error(f"PDF merge error: {e}", exc_info=True) - self.app.call_from_thread( - self.app.notify, - f"PDF merge failed: {e}", - title="Merge Error", - severity="error", - ) - - else: - # Save individual PDFs to output - output_dir = Path(resolve_output_dir(self.config)) - output_dir.mkdir(parents=True, exist_ok=True) - - for pdf_file in downloaded_files: - output_path = output_dir / pdf_file.name - # Make filename unique if it exists - counter = 1 - base_name = pdf_file.stem - while output_path.exists(): - output_path = output_dir / f"{base_name}_{counter}.pdf" - counter += 1 - - import shutil - - shutil.copy2(pdf_file, output_path) - logger.info(f"Saved PDF to: {output_path}") - - self.app.call_from_thread( - self.app.notify, - f"Downloaded {len(downloaded_files)} PDFs", - title="Download Complete", - severity="information", - timeout=3, - ) - - except Exception as e: - logger.error(f"Error in PDF playlist download: {e}", exc_info=True) - self.app.call_from_thread( - self.app.notify, - f"Error processing PDF playlist: {e}", - title="Error", - severity="error", - ) - - @work(thread=True) - def _scrape_metadata_worker( - self, - url: str, - wipe_tags_and_source: bool = False, - skip_tag_scraping: bool = False - ) -> None: - """Background worker to scrape metadata using metadata -get. - - Args: - url: URL to scrape metadata from - wipe_tags_and_source: If True, clear tags and source before populating - skip_tag_scraping: If True, don't scrape tags (only title/formats) - """ - try: - logger.info(f"Metadata worker started for: {url}") - - # Call metadata cmdlet to scrape URL - if not get_cmdlet: - logger.error("cmdlet module not available") - self.app.call_from_thread( - self.app.notify, - "cmdlet module not available", - title="Error", - severity="error" - ) - return - - # Get the metadata cmdlet - get_tag_cmdlet = get_cmdlet("metadata") - if not get_tag_cmdlet: - logger.error("metadata cmdlet not found") - self.app.call_from_thread( - self.app.notify, - "metadata cmdlet not found", - title="Error", - severity="error" - ) - return - - # Create a simple result object for the cmdlet - class URLResult: - - def __init__(self, u): - self.target = u - self.hash_hex = None - self.path = None - - result_obj = URLResult(url) - - # Call the cmdlet with -get/-scrape flags (unless skipping tag scraping) - import io - from contextlib import redirect_stdout, redirect_stderr - - output_buffer = io.StringIO() - error_buffer = io.StringIO() - - # Only scrape if not skipping tag scraping - args = ["-get"] if skip_tag_scraping else ["-get", "-scrape", url] - - with redirect_stdout(output_buffer), redirect_stderr(error_buffer): - returncode = get_tag_cmdlet(result_obj, - args, - {}) - - if returncode != 0: - error_msg = error_buffer.getvalue() - logger.error(f"metadata cmdlet failed: {error_msg}") - try: - self.app.call_from_thread( - self.app.notify, - f"Failed to scrape metadata: {error_msg}", - title="Error", - severity="error", - ) - except Exception as e: - logger.debug(f"Could not notify user: {e}") - return - - # Parse the JSON output - output = output_buffer.getvalue().strip() - if not output: - logger.warning("metadata -get returned no output") - try: - self.app.call_from_thread( - self.app.notify, - "No metadata returned from metadata -get", - title="Error", - severity="error", - ) - except Exception as e: - logger.debug(f"Could not notify user: {e}") - return - - # Extract the JSON line (skip debug messages that start with [tag]) - json_line = None - for line in output.split("\n"): - if line.strip().startswith("{"): - json_line = line.strip() - break - - if not json_line: - logger.error("No JSON found in metadata -get output") - logger.debug(f"Raw output: {output}") - try: - self.app.call_from_thread( - self.app.notify, - "No metadata found in response", - title="Error", - severity="error", - ) - except Exception as e: - logger.debug(f"Could not notify user: {e}") - return - - try: - metadata_result = json.loads(json_line) - except json.JSONDecodeError as e: - logger.error(f"Failed to parse JSON: {e}") - logger.debug(f"JSON line: {json_line}") - try: - self.app.call_from_thread( - self.app.notify, - f"Failed to parse metadata: {e}", - title="Error", - severity="error", - ) - except Exception as ne: - logger.debug(f"Could not notify user: {ne}") - return - - # Build metadata dict in the format expected by _populate_from_metadata - # If skipping tag scraping, preserve existing tags - existing_tags = self.tags_textarea.text.strip( - ).split("\n") if skip_tag_scraping else [] - existing_tags = [tag.strip() for tag in existing_tags if tag.strip()] - - # Extract playlist items if present - playlist_items = metadata_result.get("playlist_items", []) - - metadata = { - "title": metadata_result.get("title", "Unknown"), - "url": url, - "tags": metadata_result.get("tags", []) - or existing_tags, # Use existing if new are empty - "formats": metadata_result.get("formats", []), - "playlist_items": playlist_items, - } - - logger.info( - f"Retrieved metadata: title={metadata['title']}, tags={len(metadata['tags'])}, formats={len(metadata['formats'])}, playlist_items={len(playlist_items)}" - ) - - # Update UI on main thread - self.app.call_from_thread( - self._populate_from_metadata, - metadata, - wipe_tags_and_source - ) - - except Exception as e: - logger.error(f"Metadata worker error: {e}", exc_info=True) - try: - self.app.call_from_thread( - self.app.notify, - f"Failed to scrape metadata: {e}", - title="Error", - severity="error", - ) - except Exception as ne: - logger.debug(f"Could not notify user of error: {ne}") - - def _convert_selection_to_ytdlp(self, selection_str: str) -> str: - """Convert playlist selection string to yt-dlp --playlist-items format. - - Args: - selection_str: Selection string like "1-3", "all", "merge", "1,3,5-8" - Can also include multiple keywords separated by spaces - - Returns: - yt-dlp format string like "1-3,5,8" or "1-10" for all - """ - if not selection_str: - return "" - - selection_str = selection_str.strip().upper() - max_idx = len(self.playlist_items) - - # Handle keywords (all, merge, a, m) - can be space or comma separated - # "ALL MERGE", "A M", "ALL,MERGE" etc all mean download all items - if any(kw in selection_str.replace(",", - " ").split() - for kw in {"A", "ALL", "M", "MERGE"}): - # User said to get all items (merge is same as all in this context) - return f"1-{max_idx}" - - # Parse ranges like "1,3,5-8" and convert to yt-dlp format - # The selection is already in 1-based format from user, keep it that way - # yt-dlp expects 1-based indices - try: - parts = [] - for part in selection_str.split(","): - part = part.strip() - if part: # Skip empty parts - parts.append(part) - - return ",".join(parts) - except (ValueError, AttributeError): - logger.error(f"Failed to convert playlist selection: {selection_str}") - return "" - - def _parse_playlist_selection(self, selection_str: str) -> list: - """Parse playlist selection string into list of track indices (0-based). - - Args: - selection_str: Selection string like "1-3", "all", "merge", "1,3,5-8" - - Returns: - List of 0-based indices, or empty list if invalid - """ - if not selection_str: - return [] - - selection_str = selection_str.strip().upper() - max_idx = len(self.playlist_items) - - # Handle keywords (all, merge, a, m) - can be space or comma separated - # "ALL MERGE", "A M", "ALL,MERGE" etc all mean download all items - if any(kw in selection_str.replace(",", - " ").split() - for kw in {"A", "ALL", "M", "MERGE"}): - # User said to get all items - return list(range(max_idx)) - - # Parse ranges like "1,3,5-8" - indices = set() - try: - for part in selection_str.split(","): - part = part.strip() - if "-" in part: - # Range like "5-8" - start_str, end_str = part.split("-", 1) - start = int(start_str.strip()) - 1 # Convert to 0-based - end = int(end_str.strip()) # end is inclusive in user terms - for i in range(start, end): - if 0 <= i < max_idx: - indices.add(i) - else: - # Single number - idx = int(part.strip()) - 1 # Convert to 0-based - if 0 <= idx < max_idx: - indices.add(idx) - - return sorted(list(indices)) - except (ValueError, AttributeError): - logger.error(f"Failed to parse playlist selection: {selection_str}") - return [] - - def _execute_download_pipeline( - self, - result_obj: Any, - tags: list, - source: str, - download_enabled: bool, - worker=None - ) -> None: - """Execute the download pipeline for a single item. - - Args: - result_obj: URL result object - tags: List of tags to apply - source: Source for metadata - download_enabled: Whether to download the file - worker: Optional Worker instance for logging - """ - # Import cmdlet system - if not get_cmdlet: - error_msg = "cmdlet module not available" - logger.error(error_msg) - if worker: - worker.append_stdout(f"❌ ERROR: {error_msg}\n") - self.app.call_from_thread( - self.app.notify, - "cmdlet system unavailable", - title="Error", - severity="error" - ) - return - - # Stage 1: Download data if enabled - if download_enabled: - download_cmdlet_name = "file" - download_cmdlet = get_cmdlet(download_cmdlet_name) - if download_cmdlet: - stage_msg = f"📥 Executing {download_cmdlet_name} stage" - logger.info(stage_msg) - if worker: - worker.append_stdout(f"{stage_msg}\n") - try: - import io - from contextlib import redirect_stdout, redirect_stderr - - stdout_buf = io.StringIO() - stderr_buf = io.StringIO() - - with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf): - cmd_config = ( - dict(self.config) if isinstance(self.config, - dict) else self.config - ) - if isinstance(cmd_config, dict): - cmd_config["_quiet_background_output"] = True - returncode = download_cmdlet(result_obj, ["-download"], cmd_config) - - stdout_text = stdout_buf.getvalue() - stderr_text = stderr_buf.getvalue() - - if stdout_text: - logger.debug(f"{download_cmdlet_name} stdout: {stdout_text}") - if worker: - worker.append_stdout(stdout_text) - - if stderr_text: - logger.debug(f"{download_cmdlet_name} stderr: {stderr_text}") - if worker: - worker.append_stdout(f"⚠️ stderr: {stderr_text}\n") - - if returncode != 0: - error_msg = f"❌ {download_cmdlet_name} stage failed with code {returncode}\nstderr: {stderr_text}" - logger.error(error_msg) - if worker: - worker.append_stdout(f"{error_msg}\n") - self.app.call_from_thread( - self.app.notify, - f"Download failed: {stderr_text[:100]}", - title="Download Error", - severity="error", - ) - return - else: - success_msg = f"{download_cmdlet_name} completed successfully" - logger.info(success_msg) - if worker: - worker.append_stdout(f"{success_msg}\n") - except Exception as e: - error_msg = f"❌ {download_cmdlet_name} error: {e}" - logger.error(error_msg, exc_info=True) - if worker: - worker.append_stdout( - f"{error_msg}\nTraceback:\n{__import__('traceback').format_exc()}\n" - ) - self.app.call_from_thread( - self.app.notify, - str(e)[:100], - title="Download Error", - severity="error" - ) - return - - # Stage 2: Tag the file if tags provided - if tags: - tag_cmdlet = get_cmdlet("metadata") - if tag_cmdlet and result_obj.get("path"): - stage_msg = f"🏷️ Tagging with {len(tags)} tags" - logger.info(stage_msg) - if worker: - worker.append_stdout(f"{stage_msg}\n") - try: - tag_args = ["-add"] + [str(t) for t in tags] - import io - from contextlib import redirect_stdout, redirect_stderr - - stdout_buf = io.StringIO() - stderr_buf = io.StringIO() - - with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf): - tag_returncode = tag_cmdlet(result_obj, - tag_args, - {}) - - stdout_text = stdout_buf.getvalue() - stderr_text = stderr_buf.getvalue() - - if stdout_text: - logger.debug(f"tag stdout: {stdout_text}") - if worker: - worker.append_stdout(stdout_text) - - if tag_returncode != 0: - warning_msg = f"⚠️ Tag stage returned code {tag_returncode}: {stderr_text}" - logger.warning(warning_msg) - if worker: - worker.append_stdout(f"{warning_msg}\n") - else: - if worker: - worker.append_stdout("Tags applied successfully\n") - except Exception as e: - error_msg = f"❌ Tagging error: {e}" - logger.error(error_msg, exc_info=True) - if worker: - worker.append_stdout(f"{error_msg}\n") - else: - if not result_obj.get("path"): - warning_msg = "⚠️ No file path in result - skipping tagging" - logger.warning(warning_msg) - if worker: - worker.append_stdout(f"{warning_msg}\n") - else: - if worker: - worker.append_stdout("Download complete (no tags to apply)\n") - - def _show_format_select(self) -> None: - """Show format select (always visible for single files).""" - try: - files_container = self.query_one("#files_container", Container) - playlist_container = self.query_one("#playlist_container", Container) - # Format select always visible, playlist hidden by default - files_container.styles.height = "1fr" - playlist_container.styles.height = "0" - self.is_playlist = False - except Exception as e: - logger.error(f"Error showing format select: {e}") - - def _show_playlist_controls(self) -> None: - """Show playlist tree and input alongside format select (for playlists).""" - try: - playlist_container = self.query_one("#playlist_container", Container) - # Just make playlist visible - format select remains visible above it - playlist_container.styles.height = "auto" - self.is_playlist = True - except Exception as e: - logger.error(f"Error showing playlist controls: {e}") - - def _populate_playlist_tree(self, items: list) -> None: - """Populate the playlist tree with track items. - - Args: - items: List of track info dicts with 'id', 'title', 'duration', etc. - """ - try: - self.playlist_tree.clear() - self.playlist_items = items - - for idx, item in enumerate(items, 1): - title = item.get("title", f"Track {idx}") - duration = item.get("duration", "") - # Format: "1. Song Title (3:45)" - label = f"{idx}. {title}" - if duration: - label += f" ({duration})" - - self.playlist_tree.root.add_leaf(label) - - logger.info(f"Populated playlist tree with {len(items)} items") - except Exception as e: - logger.error(f"Error populating playlist tree: {e}") - - def _populate_from_metadata( - self, - metadata: dict, - wipe_tags_and_source: bool = False - ) -> None: - """Populate modal fields from extracted metadata. - - Args: - metadata: Dictionary with title, tags, formats - wipe_tags_and_source: If True, clear tags and source before populating - """ - try: - # Wipe tags and source if requested (fresh scrape from URL) - if wipe_tags_and_source: - self.tags_textarea.text = "" - # Reset source to first available option - try: - # Get all options and select the first one - source_options = self._build_source_options() - if source_options: - self.source_select.value = source_options[0][1] - except Exception as e: - logger.warning(f"Could not reset source select: {e}") - - # Populate tags - using extracted tags (one per line format) - tags = metadata.get("tags", []) - existing_tags = self.tags_textarea.text.strip() - title = metadata.get("title", "Unknown") - - # Extract meaningful tags: - # 1. Freeform tags (tag:value) - # 2. Creator/artist metadata (creator:, artist:, channel:) - # 3. Other meaningful namespaces (genre:, album:, track:, etc.) - meaningful_tags = [] - - # Add title tag first (so user can edit it) - if title and title != "Unknown": - meaningful_tags.append(f"title:{title}") - - # Namespaces to exclude (metadata-only, not user-facing) - excluded_namespaces = { - "hash", # Hash values (internal) - "url", # url (internal) - "relationship", # Internal relationships - "url", # url (internal) - } - - # Add all other tags - for tag in tags: - if ":" in tag: - namespace, value = tag.split(":", 1) - # Skip internal/metadata namespaces - if namespace.lower() not in excluded_namespaces: - meaningful_tags.append(tag) - else: - # Tags without namespace are freeform - always include - meaningful_tags.append(tag) - - # Build tags string (one per line) - tags_str = "\n".join(meaningful_tags) - - if existing_tags: - self.tags_textarea.text = existing_tags + "\n" + tags_str - else: - self.tags_textarea.text = tags_str - - # Check if this is a playlist - playlist_items = metadata.get("playlist_items", []) - formats = metadata.get("formats", []) - - # Always show format select (single file or default for playlist) - self._show_format_select() - if formats: - # formats may be lists (from JSON) or tuples, convert to tuples - format_tuples = [] - for fmt in formats: - if isinstance(fmt, (list, tuple)) and len(fmt) == 2: - format_tuples.append(tuple(fmt)) - - if format_tuples: - self.files_select.set_options(format_tuples) - # Select the first format by default - self.files_select.value = format_tuples[0][1] - self.selected_files = {format_tuples[0][0]} - - # If playlist, also show the tree for track selection - if playlist_items and len(playlist_items) > 0: - logger.info(f"Detected playlist with {len(playlist_items)} items") - self._populate_playlist_tree(playlist_items) - # Show playlist tree alongside format select (height: auto to show) - playlist_container = self.query_one("#playlist_container", Container) - playlist_container.styles.height = "auto" - # SET FLAG SO action_submit() KNOWS THIS IS A PLAYLIST - self.is_playlist = True - - logger.info( - f"Populated modal from metadata: {len(meaningful_tags)} tags, {len(playlist_items)} playlist items, {len(formats)} formats" - ) - - # Notify user - self.app.notify( - f"Scraped metadata: {title}", - title="Metadata Loaded", - severity="information", - timeout=3, - ) - - except Exception as e: - logger.error(f"Error populating metadata: {e}", exc_info=True) - self.app.notify( - f"Failed to populate metadata: {e}", - title="Error", - severity="error" - ) - - def on_select_changed(self, event: Select.Changed) -> None: - """Handle Select widget changes (format selection).""" - if event.select.id == "files_select": - # Update selected_files to track the chosen format value - if event.value: - self.selected_files = {str(event.value)} - logger.debug(f"Selected format: {event.value}") - - def on_button_pressed(self, event) -> None: - """Handle button clicks.""" - if event.button.id == "submit_btn": - self.action_submit() - elif event.button.id == "cancel_btn": - self.action_cancel() - - def _show_progress(self) -> None: - """Show the progress bar and hide buttons.""" - try: - # Show progress bar by setting height - self.progress_bar.styles.height = 1 - self.progress_bar.update(total=100) - # Hide buttons during download - button_row = self.query_one("#button_row", Horizontal) - button_row.display = False - except Exception as e: - logger.error(f"Error showing progress bar: {e}") - - def _hide_progress(self) -> None: - """Hide the progress bar and show buttons again.""" - try: - # Hide progress bar by setting height to 0 - self.progress_bar.styles.height = 0 - # Show buttons again - button_row = self.query_one("#button_row", Horizontal) - button_row.display = True - except Exception as e: - logger.error(f"Error hiding progress bar: {e}") diff --git a/TUI/modalscreen/download.tcss b/TUI/modalscreen/download.tcss deleted file mode 100644 index ec97187..0000000 --- a/TUI/modalscreen/download.tcss +++ /dev/null @@ -1,183 +0,0 @@ -/* Download Modal Screen Stylesheet */ - -Screen { - background: $surface; - overlay: screen; -} - -#download_modal { - width: 100%; - height: 100%; - border: heavy $primary; - background: $boost; -} - -#download_title { - dock: top; - height: 1; - content-align: center middle; - background: $primary; - color: $text; - text-style: bold; - padding: 0 1; -} - -/* Main horizontal layout: 2 columns left/right split */ -#main_layout { - width: 1fr; - height: 1fr; - layout: horizontal; - padding: 1; - border: none; -} - -/* Left column */ -#left_column { - width: 2fr; - height: 1fr; - layout: vertical; -} - -/* Right column */ -#right_column { - width: 1fr; - height: 1fr; - layout: vertical; -} - -/* All containers styling */ -.grid_container { - width: 1fr; - height: 1fr; - padding: 1; - layout: vertical; - margin: 0 0 1 0; -} - -#tags_container { - border: mediumpurple; -} - -#url_container { - border: solid $accent; -} - -#files_container { - border: solid $accent; -} - -#playlist_container { - border: solid $accent; - layout: vertical; - height: 0; -} - -#playlist_tree { - width: 1fr; - height: auto; - border: none; - padding: 0; -} - -#playlist_input { - width: 1fr; - height: 1; - border: none; - padding: 0 1; - margin: 1 0 0 0; -} - -#playlist_input_row { - width: 1fr; - height: auto; - layout: horizontal; - margin: 1 0 0 0; -} - -.section_title { - width: 1fr; - height: 1; - text-align: left; - color: $text-muted; - text-style: bold; - margin: 0 0 0 0; - padding: 0; -} - -/* TextArea widgets in containers */ -#tags_textarea { - width: 1fr; - height: 1fr; - border: none; - padding: 0; -} - -#paragraph_textarea { - width: 1fr; - height: 1fr; - border: none; - padding: 0; -} - -/* Select widgets in containers */ -#files_select { - width: 1fr; - height: 1fr; - border: none; -} - -/* Footer layout - horizontal: checkboxes left, source middle, buttons right */ -#footer_layout { - width: 1fr; - height: auto; - layout: horizontal; - padding: 1; - margin: 0; - background: $boost; -} - -#checkbox_row { - width: auto; - height: auto; - layout: horizontal; - align: left middle; -} - -#source_select { - width: 30; - height: 1; - border: none; - padding: 0 1; - margin: 0; -} - -#button_row { - width: auto; - height: auto; - layout: horizontal; - align: right middle; -} - -/* Progress bar - shown during download */ -#progress_bar { - width: 1fr; - height: 0; -} - -/* Checkbox and Button styling */ -Checkbox { - margin: 0 2 0 0; -} - -Button { - margin: 0 1 0 0; - width: 12; -} - -#cancel_btn { - width: 12; -} - -#submit_btn { - width: 12; -} diff --git a/TUI/modalscreen/export.py b/TUI/modalscreen/export.py deleted file mode 100644 index 11c9faa..0000000 --- a/TUI/modalscreen/export.py +++ /dev/null @@ -1,593 +0,0 @@ -"""Export modal screen for exporting files with metadata.""" - -from textual.app import ComposeResult -from textual.screen import ModalScreen -from textual.containers import Container, Horizontal, Vertical -from textual.widgets import Static, Button, Input, TextArea, Select -from textual.binding import Binding -import logging -from typing import Optional -from pathlib import Path -import sys - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) -from SYS.utils import format_metadata_value - -logger = logging.getLogger(__name__) - - -class ExportModal(ModalScreen): - """Modal screen for exporting files with metadata and tags.""" - - BINDINGS = [ - Binding("escape", - "cancel", - "Cancel"), - ] - - CSS_PATH = "export.tcss" - - def __init__( - self, - result_data: Optional[dict] = None, - hydrus_available: bool = False, - debrid_available: bool = False, - ): - """Initialize the export modal with result data. - - Args: - result_data: Dictionary containing: - - title: str - Item title - - tags: str - Comma-separated tags - - metadata: dict - File metadata (source-specific from item.metadata or local DB) - - source: str - Source identifier ('local', 'hydrus', 'debrid', etc) - - current_result: object - The full search result object - hydrus_available: bool - Whether Hydrus API is available - debrid_available: bool - Whether Debrid API is available - """ - super().__init__() - self.result_data = result_data or {} - self.hydrus_available = hydrus_available - self.debrid_available = debrid_available - self.metadata_display: Optional[Static] = None - self.tags_textarea: Optional[TextArea] = None - self.export_to_select: Optional[Select] = None - self.custom_path_input: Optional[Input] = None - self.libraries_select: Optional[Select] = None - self.size_input: Optional[Input] = None - self.format_select: Optional[Select] = None - self.file_ext: Optional[ - str] = None # Store the file extension for format filtering - self.file_type: Optional[ - str] = None # Store the file type (audio, video, image, document) - self.default_format: Optional[ - str] = None # Store the default format to set after mount - - def _determine_file_type(self, ext: str) -> tuple[str, list]: - """Determine file type from extension and return type and format options. - - Args: - ext: File extension (e.g., '.mp3', '.mp4', '.jpg') - - Returns: - Tuple of (file_type, format_options) where format_options is a list of (label, value) tuples - """ - ext_lower = ext.lower() if ext else "" - - from SYS.utils_constant import mime_maps - - found_type = "unknown" - - # Find type based on extension - for category, formats in mime_maps.items(): - for fmt_key, fmt_info in formats.items(): - if fmt_info.get("ext") == ext_lower: - found_type = category - break - if found_type != "unknown": - break - - # Build format options for the found type - format_options = [] - - # If unknown, fallback to audio (matching legacy behavior) - target_type = found_type if found_type in mime_maps else "audio" - - if target_type in mime_maps: - # Sort formats alphabetically - sorted_formats = sorted(mime_maps[target_type].items()) - for fmt_key, fmt_info in sorted_formats: - label = fmt_key.upper() - value = fmt_key - format_options.append((label, value)) - - return (target_type, format_options) - - def _get_library_options(self) -> list: - """Get available library options from config.conf.""" - options = [("Local", "local")] - - try: - from SYS.config import ( - load_config, - get_hydrus_access_key, - get_hydrus_url, - get_debrid_api_key, - ) - - config = load_config() - - hydrus_url = (get_hydrus_url(config, "home") or "").strip() - hydrus_key = (get_hydrus_access_key(config, "home") or "").strip() - if self.hydrus_available and hydrus_url and hydrus_key: - options.append(("Hydrus Network", "hydrus")) - - debrid_api_key = get_debrid_api_key(config) - if self.debrid_available and debrid_api_key: - options.append(("Debrid", "debrid")) - - except Exception as e: - logger.error(f"Error loading config for libraries: {e}") - - return options - - def _get_metadata_text(self) -> str: - """Format metadata from result data in a consistent display format.""" - metadata = self.result_data.get("metadata", - {}) - source = self.result_data.get("source", "unknown") - logger.info( - f"_get_metadata_text called - source: {source}, metadata type: {type(metadata)}, keys: {list(metadata.keys()) if metadata else 'empty'}" - ) - - if not metadata: - logger.info( - "_get_metadata_text - No metadata found, returning 'No metadata available'" - ) - return "No metadata available" - - lines = [] - - # Only display these specific fields in this order - display_fields = [ - "duration", - "size", - "ext", - "media_type", - "time_imported", - "time_modified", - "hash", - ] - - # Display fields in a consistent order - for field in display_fields: - if field in metadata: - value = metadata[field] - # Skip complex types and None values - if isinstance(value, (dict, list)) or value is None: - continue - # Use central formatting rule - formatted_value = format_metadata_value(field, value) - # Format: "Field Name: value" - field_label = field.replace("_", " ").title() - lines.append(f"{field_label}: {formatted_value}") - - # If we found any fields, display them - if lines: - logger.info( - f"_get_metadata_text - Returning {len(lines)} formatted metadata lines" - ) - return "\n".join(lines) - else: - logger.info("_get_metadata_text - No matching fields found in metadata") - return "No metadata available" - - def compose(self) -> ComposeResult: - """Compose the export modal screen.""" - with Container(id="export-container"): - yield Static("Export File with Metadata", id="export-title") - - # Row 1: Three columns (Tag, Metadata, Export-To Options) - self.tags_textarea = TextArea( - text=self._format_tags(), - id="tags-area", - read_only=False, - ) - yield self.tags_textarea - self.tags_textarea.border_title = "Tag" - - # Metadata display instead of files tree - self.metadata_display = Static( - self._get_metadata_text(), - id="metadata-display", - ) - yield self.metadata_display - self.metadata_display.border = ("solid", "dodgerblue") - - # Right column: Export options - with Vertical(id="export-options"): - # Export To selector - self.export_to_select = Select( - [ - ("0x0", - "0x0"), - ("Libraries", - "libraries"), - ("Custom Path", - "path") - ], - id="export-to-select", - ) - yield self.export_to_select - - # Libraries selector (initially hidden) - library_options = self._get_library_options() - self.libraries_select = Select(library_options, id="libraries-select") - yield self.libraries_select - - # Custom path input (initially hidden) - self.custom_path_input = Input( - placeholder="Enter custom export path", - id="custom-path-input" - ) - yield self.custom_path_input - - # Get metadata for size and format options - metadata = self.result_data.get("metadata", - {}) - original_size = metadata.get("size", "") - ext = metadata.get("ext", "") - - # Store the extension and determine file type - self.file_ext = ext - self.file_type, format_options = self._determine_file_type(ext) - - # Format size in MB for display - if original_size: - size_mb = ( - int(original_size / - (1024 * 1024)) if isinstance(original_size, - (int, - float)) else original_size - ) - size_display = f"{size_mb}Mb" - else: - size_display = "" - - # Size input - self.size_input = Input( - value=size_display, - placeholder="Size (can reduce)", - id="size-input", - disabled=( - self.file_type == "document" - ), # Disable for documents - no resizing needed - ) - yield self.size_input - - # Determine the default format value (match current extension to format options) - default_format = None - if ext and format_options: - # Map extension to format value (e.g., .flac -> "flac", .mp3 -> "mp3", .m4a -> "m4a") - ext_lower = ext.lower().lstrip(".") # Remove leading dot if present - # Try to find matching format option - for _, value in format_options: - if value and (ext_lower == value or f".{ext_lower}" == ext - or ext.endswith(f".{value}")): - default_format = value - logger.debug(f"Matched extension {ext} to format {value}") - break - # If no exact match, use first option - if not default_format and format_options: - default_format = format_options[0][1] - logger.debug( - f"No format match for {ext}, using first option: {default_format}" - ) - - # Store the default format to apply after mount - self.default_format = default_format - - # Format selector based on file type - self.format_select = Select( - format_options if format_options else [("No conversion", "")], - id="format-select", - disabled=not format_options, # Disable if no format options (e.g., documents) - ) - yield self.format_select - - # Row 2: Buttons - with Horizontal(id="export-buttons"): - yield Button("Cancel", id="cancel-btn", variant="default") - yield Button("Export", id="export-btn", variant="primary") - - def _format_tags(self) -> str: - """Format tags from result data.""" - tags = self.result_data.get("tags", "") - if isinstance(tags, str): - # Split by comma and rejoin with newlines - tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()] - return "\n".join(tags_list) - elif isinstance(tags, list): - return "\n".join(tags) - return "" - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button press events.""" - button_id = event.button.id - - if button_id == "export-btn": - self._handle_export() - elif button_id == "cancel-btn": - self.action_cancel() - - def on_select_changed(self, event: Select.Changed) -> None: - """Handle select widget changes.""" - if event.control.id == "export-to-select": - # Show/hide custom path and libraries based on selection - if self.custom_path_input: - self.custom_path_input.display = event.value == "path" - if self.libraries_select: - self.libraries_select.display = event.value == "libraries" - elif event.control.id == "libraries-select": - # Handle library selection (no special action needed currently) - logger.debug(f"Library selected: {event.value}") - - def on_mount(self) -> None: - """Handle mount event.""" - # Initially hide custom path and libraries inputs (default is "0x0") - if self.custom_path_input: - self.custom_path_input.display = False - if self.libraries_select: - self.libraries_select.display = False - - # Set the default format value to show it selected instead of "Select" - if self.default_format and self.format_select: - self.format_select.value = self.default_format - logger.debug(f"Set format selector to default value: {self.default_format}") - - # Refresh metadata display after mount to ensure data is loaded - if self.metadata_display: - metadata_text = self._get_metadata_text() - self.metadata_display.update(metadata_text) - logger.debug( - f"Updated metadata display on mount: {bool(self.result_data.get('metadata'))}" - ) - - def _handle_export(self) -> None: - """Handle the export action.""" - try: - tags_text = self.tags_textarea.text.strip() - export_to = self.export_to_select.value if self.export_to_select else "0x0" - custom_path = self.custom_path_input.value.strip( - ) if self.custom_path_input else "" - - # Get library value - handle Select.BLANK case - library = "local" # default - if self.libraries_select and str(self.libraries_select.value - ) != "Select.BLANK": - library = str(self.libraries_select.value) - elif self.libraries_select and self.libraries_select: - # If value is Select.BLANK, try to get from the options - try: - # Get first available library option as fallback - options = self._get_library_options() - if options: - library = options[0][ - 1] # Get the value part of first option tuple - except Exception: - library = "local" - - size = self.size_input.value.strip() if self.size_input else "" - file_format = self.format_select.value if self.format_select else "mp4" - - # Parse tags from textarea (one per line) - export_tags = set() - for line in tags_text.split("\n"): - tag = line.strip() - if tag: - export_tags.add(tag) - - # For Hydrus export, filter out metadata-only tags (hash:, url:, relationship:) - if export_to == "libraries" and library == "hydrus": - metadata_prefixes = {"hash:", - "url:", - "relationship:"} - export_tags = { - tag - for tag in export_tags if not any( - tag.lower().startswith(prefix) for prefix in metadata_prefixes - ) - } - logger.info( - f"Filtered tags for Hydrus - removed metadata tags, {len(export_tags)} tags remaining" - ) - - # Extract title and add as searchable tags if not already present - title = self.result_data.get("title", "").strip() - if title: - # Add the full title as a tag if not already present - title_tag = f"title:{title}" - if title_tag not in export_tags and not any(t.startswith("title:") - for t in export_tags): - export_tags.add(title_tag) - - # Extract individual words from title as searchable tags (if reasonable length) - # Skip very short words and common stop words - if len(title) < 100: # Only for reasonably short titles - stop_words = { - "the", - "a", - "an", - "and", - "or", - "of", - "in", - "to", - "for", - "is", - "it", - "at", - "by", - "from", - "with", - "as", - "be", - "on", - "that", - "this", - "this", - } - words = title.lower().split() - for word in words: - # Clean up word (remove punctuation) - clean_word = "".join(c for c in word if c.isalnum()) - # Only add if not a stop word and has some length - if clean_word and len( - clean_word) > 2 and clean_word not in stop_words: - if clean_word not in export_tags: - export_tags.add(clean_word) - logger.info( - f"Extracted {len(words)} words from title, added searchable title tags" - ) - - # Validate required fields - allow export to continue for Hydrus even with 0 actual tags - # (metadata tags will still be in the sidecar, and tags can be added later) - if not export_tags and export_to != "libraries": - logger.warning("No tags provided for export") - return - - if export_to == "libraries" and not export_tags: - logger.warning( - "No actual tags for Hydrus export (only metadata was present)" - ) - # Don't return - allow export to continue, file will be added to Hydrus even without tags - - # Determine export path - export_path = None - if export_to == "path": - if not custom_path: - logger.warning("Custom path required but not provided") - return - export_path = custom_path - elif export_to == "libraries": - export_path = library # "local", "hydrus", "debrid" - else: - export_path = export_to # "0x0" - - # Get metadata from result_data - metadata = self.result_data.get("metadata", - {}) - - # Extract file source info from result_data (passed by hub-ui) - file_hash = self.result_data.get("hash") - file_url = self.result_data.get("url") - file_path = self.result_data.get("path") - source = self.result_data.get("source", "unknown") - - # Prepare export data - export_data = { - "export_to": export_to, - "export_path": export_path, - "library": library if export_to == "libraries" else None, - "tags": export_tags, - "size": size if size else None, - "format": file_format, - "metadata": metadata, - "original_data": self.result_data, - "hash": file_hash, - "url": file_url, - "path": file_path, - "source": source, - } - - logger.info( - f"Export initiated: destination={export_path}, format={file_format}, size={size}, tags={export_tags}, source={source}, hash={file_hash}, path={file_path}" - ) - - # Dismiss the modal and return the export data - self.dismiss(export_data) - - except Exception as e: - logger.error(f"Error during export: {e}", exc_info=True) - - def action_cancel(self) -> None: - """Handle cancel action.""" - self.dismiss(None) - - -def create_notes_sidecar(file_path: Path, notes: str) -> None: - """Create a .notes sidecar file with notes text. - - Only creates file if notes are not empty. - - Args: - file_path: Path to the exported file - notes: Notes text - """ - if not notes or not notes.strip(): - return - - notes_path = file_path.with_suffix(file_path.suffix + ".notes") - try: - with open(notes_path, "w", encoding="utf-8") as f: - f.write(notes.strip()) - logger.info(f"Created notes sidecar: {notes_path}") - except Exception as e: - logger.error(f"Failed to create notes sidecar: {e}", exc_info=True) - - -def determine_needs_conversion(current_ext: str, target_format: str) -> bool: - """Determine if conversion is needed between two formats. - - Args: - current_ext: Current file extension (e.g., '.flac') - target_format: Target format name (e.g., 'mp3') or NoSelection object - - Returns: - True if conversion is needed, False if it's already the target format - """ - # Handle NoSelection or None - if (not target_format or target_format == "" - or str(target_format.__class__.__name__) == "NoSelection"): - return False # No conversion requested - - # Normalize the current extension - current_ext_lower = current_ext.lower().lstrip(".") - target_format_lower = str(target_format).lower() - - # Check if they match - return current_ext_lower != target_format_lower - - -def calculate_size_tolerance(metadata: dict, - user_size_mb: Optional[str]) -> tuple[Optional[int], - Optional[int]]: - """Calculate target size with 1MB grace period. - - Args: - metadata: File metadata containing 'size' in bytes - user_size_mb: User-entered size like "756Mb" or empty string - - Returns: - Tuple of (target_bytes, grace_bytes) where grace_bytes is 1MB (1048576), - or (None, None) if no size specified - """ - grace_bytes = 1 * 1024 * 1024 # 1MB grace period - - if not user_size_mb or not user_size_mb.strip(): - return None, grace_bytes - - try: - # Parse the size string (format like "756Mb") - size_str = user_size_mb.strip().lower() - if size_str.endswith("mb"): - size_str = size_str[:-2] - elif size_str.endswith("m"): - size_str = size_str[:-1] - - size_mb = float(size_str) - target_bytes = int(size_mb * 1024 * 1024) - return target_bytes, grace_bytes - except (ValueError, AttributeError): - return None, grace_bytes diff --git a/TUI/modalscreen/export.tcss b/TUI/modalscreen/export.tcss deleted file mode 100644 index d45280f..0000000 --- a/TUI/modalscreen/export.tcss +++ /dev/null @@ -1,85 +0,0 @@ -/* Export Modal Screen Styling */ - -ExportModal { - align: center middle; -} - -#export-container { - width: 140; - height: 55; - background: $panel; - border: solid $primary; - layout: grid; - grid-columns: 1fr 1fr 1fr; - grid-rows: auto 1fr auto; -} - -#export-title { - height: 1; - text-align: center; - text-style: bold; - color: $accent; - background: $boost; - padding: 1 2; - column-span: 3; -} - -/* Row 1: Three columns */ -#tags-area { - height: 1fr; - column-span: 1; - border: solid mediumvioletred; -} - -#metadata-display { - height: 1fr; - column-span: 1; - border: solid dodgerblue; - overflow: auto; - padding: 1; -} - -#export-options { - height: 1fr; - column-span: 1; - border: solid mediumpurple; - layout: vertical; - padding: 1; -} - -#export-options Select, -#export-options Input { - height: 3; - margin: 0 0 1 0; -} - -#custom-path-input { - height: 3; - margin: 0 0 1 0; -} - -#libraries-select { - height: 3; - margin: 0 0 1 0; -} - -#size-input { - height: 3; - margin: 0 0 1 0; -} - -#format-select { - height: 3; -} - -/* Row 2: Buttons */ -#export-buttons { - height: auto; - column-span: 3; - layout: horizontal; -} - -#export-buttons Button { - width: 1fr; - margin: 0 1; -} diff --git a/TUI/modalscreen/matrix_room_picker.py b/TUI/modalscreen/matrix_room_picker.py deleted file mode 100644 index fc6a80b..0000000 --- a/TUI/modalscreen/matrix_room_picker.py +++ /dev/null @@ -1,243 +0,0 @@ -from __future__ import annotations - -from typing import Any, Dict, List, Optional - -from textual.app import ComposeResult -from textual.containers import Container, Horizontal, ScrollableContainer -from textual.screen import ModalScreen -from textual.widgets import Static, Button, Checkbox, ListView, ListItem -from textual import work -from rich.text import Text -from ProviderCore.registry import get_plugin -import logging -logger = logging.getLogger(__name__) - - -class MatrixRoomPicker(ModalScreen[List[str]]): - """Modal that lists Matrix rooms and returns selected defaults.""" - - CSS = """ - MatrixRoomPicker { - align: center middle; - background: rgba(0, 0, 0, 0.45); - } - - #matrix-room-picker { - width: 70%; - height: 70%; - background: $panel; - border: thick $primary; - padding: 1; - } - - #matrix-room-picker-hint { - text-style: italic; - color: $text-muted; - margin-bottom: 1; - } - - #matrix-room-scroll { - height: 1fr; - border: solid $surface; - padding: 1; - margin-bottom: 1; - } - - #matrix-room-list { - padding: 0; - } - - .matrix-room-row { - border-bottom: solid $surface; - padding: 1 0; - align: left middle; - } - - .matrix-room-meta { - padding-left: 1; - content-align: left middle; - } - - #matrix-room-actions { - height: 3; - align: right middle; - } - - #matrix-room-status { - height: 3; - text-style: bold; - } - """ - - def __init__( - self, - config: Dict[str, Any], - *, - existing: Optional[List[str]] = None, - rooms: Optional[List[Dict[str, Any]]] = None, - ) -> None: - super().__init__() - self.config = config - self._prefetched_rooms = rooms - self._existing_ids = {str(r).strip() for r in (existing or []) if str(r).strip()} - self._checkbox_map: Dict[str, str] = {} - self._rooms: List[Dict[str, Any]] = [] - self._status_widget: Optional[Static] = None - self._checklist: Optional[Vertical] = None - self._save_button: Optional[Button] = None - - def compose(self) -> ComposeResult: - with Container(id="matrix-room-picker"): - yield Static("Matrix Default Rooms", classes="section-title") - yield Static( - "Choose rooms to keep in the sharing defaults and autocomplete.", - id="matrix-room-picker-hint", - ) - with ScrollableContainer(id="matrix-room-scroll"): - yield ListView(id="matrix-room-list") - with Horizontal(id="matrix-room-actions"): - yield Button("Cancel", variant="error", id="matrix-room-cancel") - yield Button("Select All", id="matrix-room-select-all") - yield Button("Clear All", id="matrix-room-clear") - yield Button("Save defaults", variant="success", id="matrix-room-save") - yield Static("Loading rooms...", id="matrix-room-status") - - def on_mount(self) -> None: - self._status_widget = self.query_one("#matrix-room-status", Static) - self._checklist = self.query_one("#matrix-room-list", ListView) - self._save_button = self.query_one("#matrix-room-save", Button) - if self._save_button: - self._save_button.disabled = True - if self._prefetched_rooms is not None: - self._apply_room_results(self._prefetched_rooms, None) - else: - if self._status_widget: - self._set_status("Loading rooms...") - self._load_rooms_background() - - def on_list_view_selected(self, event: ListView.Selected) -> None: - """Intercept ListView.Selected events and prevent them from bubbling up - to parent components which may react (e.g., the main config modal). - Selecting an item should not implicitly close the picker or change the - outer editor state.""" - try: - # Stop propagation so parent handlers (ConfigModal) don't react. - event.stop() - except Exception: - logger.exception("Failed to stop ListView.Selected event propagation") - - def _set_status(self, text: str) -> None: - if self._status_widget: - self._status_widget.update(text) - - def on_checkbox_changed(self, event: Checkbox.Changed) -> None: - # Enable Save only when at least one checkbox is selected - any_selected = False - for checkbox_id in self._checkbox_map.keys(): - try: - cb = self.query_one(f"#{checkbox_id}", Checkbox) - if cb.value: - any_selected = True - break - except Exception: - logger.exception("Error querying checkbox in MatrixRoomPicker; skipping") - continue - if self._save_button: - self._save_button.disabled = not any_selected - - def _render_rooms(self, rooms: List[Dict[str, Any]]) -> None: - if not self._checklist: - return - for child in list(self._checklist.children): - child.remove() - self._checkbox_map.clear() - self._rooms = rooms - if not rooms: - self._set_status("Matrix returned no rooms.") - if self._save_button: - self._save_button.disabled = True - return - for idx, room in enumerate(rooms): - room_id = str(room.get("room_id") or "").strip() - name = str(room.get("name") or "").strip() - checkbox_id = f"matrix-room-{idx}" - - # Prefer display name; otherwise fall back to room id - label_text = name or room_id or "Matrix Room" - - checkbox = Checkbox( - label_text, - id=checkbox_id, - value=bool(room_id and room_id in self._existing_ids), - ) - self._checkbox_map[checkbox_id] = room_id - - list_item = ListItem(checkbox, classes="matrix-room-row") - self._checklist.mount(list_item) - - self._set_status("Loaded rooms. Select one or more and save.") - if self._save_button: - self._save_button.disabled = False - - @work(thread=True) - def _load_rooms_background(self) -> None: - try: - provider = get_plugin("matrix", self.config) - if provider is None: - raise RuntimeError("Matrix plugin unavailable") - rooms = provider.list_rooms() - self.app.call_from_thread(self._apply_room_results, rooms, None) - except Exception as exc: - self.app.call_from_thread(self._apply_room_results, [], str(exc)) - - def _apply_room_results(self, rooms: List[Dict[str, Any]], error: Optional[str]) -> None: - if error: - self._set_status(f"Failed to load Matrix rooms: {error}") - if self._save_button: - self._save_button.disabled = True - return - self._render_rooms(rooms) - # Ensure save button is enabled only if at least one checkbox is selected - any_selected = False - for cbid in self._checkbox_map.keys(): - try: - cb = self.query_one(f"#{cbid}", Checkbox) - if cb.value: - any_selected = True - break - except Exception: - continue - if self._save_button: - self._save_button.disabled = not any_selected - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "matrix-room-cancel": - self.dismiss([]) - elif event.button.id == "matrix-room-select-all": - for checkbox_id in self._checkbox_map.keys(): - try: - cb = self.query_one(f"#{checkbox_id}", Checkbox) - cb.value = True - except Exception: - logger.exception("Failed to set checkbox value in MatrixRoomPicker") - if self._save_button: - self._save_button.disabled = False - elif event.button.id == "matrix-room-clear": - for checkbox_id in self._checkbox_map.keys(): - try: - cb = self.query_one(f"#{checkbox_id}", Checkbox) - cb.value = False - except Exception: - logger.exception("Failed to set checkbox value to False in MatrixRoomPicker") - if self._save_button: - self._save_button.disabled = True - elif event.button.id == "matrix-room-save": - selected: List[str] = [] - for checkbox_id, room_id in self._checkbox_map.items(): - try: - cb = self.query_one(f"#{checkbox_id}", Checkbox) - if cb.value and room_id: - selected.append(room_id) - except Exception: - logger.exception("Failed to read checkbox state for '%s' while saving MatrixRoomPicker selection", checkbox_id) - self.dismiss(selected) diff --git a/TUI/modalscreen/search.py b/TUI/modalscreen/search.py deleted file mode 100644 index ee39739..0000000 --- a/TUI/modalscreen/search.py +++ /dev/null @@ -1,433 +0,0 @@ -"""Search modal screen for OpenLibrary and Soulseek.""" - -from textual.app import ComposeResult -from textual.screen import ModalScreen -from textual.containers import Horizontal, Vertical -from textual.widgets import Static, Button, Input, Select, DataTable, TextArea -from textual.binding import Binding -from textual.message import Message -import logging -from typing import Optional, Any, List -from pathlib import Path -import sys -import asyncio - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) -from SYS.config import load_config, resolve_output_dir -from SYS.result_table import Table -from ProviderCore.registry import get_plugin_with_capability - -logger = logging.getLogger(__name__) - - -class SearchModal(ModalScreen): - """Modal screen for searching OpenLibrary and Soulseek.""" - - BINDINGS = [ - Binding("escape", - "cancel", - "Cancel"), - Binding("enter", - "search_focused", - "Search"), - Binding("ctrl+t", - "scrape_tags", - "Scrape Tags"), - ] - - CSS_PATH = "search.tcss" - - class SearchSelected(Message): - """Posted when user selects a search result.""" - - def __init__(self, result: dict) -> None: - self.result = result - super().__init__() - - def __init__(self, app_instance=None): - """Initialize the search modal. - - Args: - app_instance: Reference to the main App instance for worker creation - """ - super().__init__() - self.app_instance = app_instance - self.source_select: Optional[Select] = None - self.search_input: Optional[Input] = None - self.results_table: Optional[DataTable] = None - self.tags_textarea: Optional[TextArea] = None - self.library_source_select: Optional[Select] = None - self.current_results: List[Any] = [] # List of SearchResult objects - self.current_result_table: Optional[Table] = None - self.is_searching = False - self.current_worker = None # Track worker for search operations - - def compose(self) -> ComposeResult: - """Create child widgets for the search modal.""" - with Vertical(id="search-container"): - yield Static("Search Books & Music", id="search-title") - - with Horizontal(id="search-controls"): - # Source selector - self.source_select = Select( - [("OpenLibrary", - "openlibrary"), - ("Soulseek", - "soulseek")], - value="openlibrary", - id="source-select", - ) - yield self.source_select - - # Search input - self.search_input = Input( - placeholder="Enter search query...", - id="search-input" - ) - yield self.search_input - - # Search button - yield Button("Search", id="search-button", variant="primary") - - # Results table - self.results_table = DataTable(id="results-table") - yield self.results_table - - # Two-column layout: tags on left, source/submit on right - with Horizontal(id="bottom-controls"): - # Left column: Tags textarea - with Vertical(id="tags-column"): - self.tags_textarea = TextArea( - text="", - id="result-tags-textarea", - read_only=False - ) - self.tags_textarea.border_title = "Tags [Ctrl+T: Scrape]" - yield self.tags_textarea - - # Right column: Library source and submit button - with Vertical(id="source-submit-column"): - # Library source selector (for OpenLibrary results) - self.library_source_select = Select( - [("Local", - "local"), - ("Download", - "download")], - value="local", - id="library-source-select", - ) - yield self.library_source_select - - # Submit button - yield Button("Submit", id="submit-button", variant="primary") - - # Buttons at bottom - with Horizontal(id="search-buttons"): - yield Button("Select", id="select-button", variant="primary") - yield Button("Download", id="download-button", variant="primary") - yield Button("Cancel", id="cancel-button", variant="default") - - def on_mount(self) -> None: - """Set up the table columns and focus.""" - # Set up results table columns - self.results_table.add_columns( - "Title", - "Author/Artist", - "Year/Album", - "Details" - ) - - # Focus on search input - self.search_input.focus() - - async def _perform_search(self) -> None: - """Perform the actual search based on selected source.""" - if not self.search_input or not self.source_select or not self.results_table: - logger.error("[search-modal] Widgets not initialized") - return - - query = self.search_input.value.strip() - if not query: - logger.warning("[search-modal] Empty search query") - return - - source = self.source_select.value - if not source or not isinstance(source, str): - logger.warning("[search-modal] No source selected") - return - - # Clear existing results - self.results_table.clear(columns=True) - self.current_results = [] - self.current_result_table = None - - self.is_searching = True - - # Create worker for tracking - if self.app_instance and hasattr(self.app_instance, "create_worker"): - self.current_worker = self.app_instance.create_worker( - source, - title=f"{source.capitalize()} Search: {query[:40]}", - description=f"Searching {source} for: {query}", - ) - self.current_worker.log_step(f"Connecting to {source}...") - - try: - provider = get_plugin_with_capability(source, "search") - if not provider: - logger.error(f"[search-modal] Provider not available: {source}") - if self.current_worker: - self.current_worker.finish( - "error", - f"Provider not available: {source}" - ) - return - - logger.info(f"[search-modal] Searching {source} for: {query}") - results = provider.search(query, limit=20) - self.current_results = results - - if self.current_worker: - self.current_worker.log_step(f"Found {len(results)} results") - - # Create ResultTable - table = Table(f"Search Results: {query}") - for res in results: - row = table.add_row() - # Add columns from result.columns - if res.columns: - for name, value in res.columns: - row.add_column(name, value) - else: - # Fallback if no columns defined - row.add_column("Title", res.title) - row.add_column( - "Target", - getattr(res, - "path", - None) or getattr(res, - "url", - None) or getattr(res, - "target", - None) or "", - ) - - self.current_result_table = table - - # Populate UI - if table.rows: - # Add headers - headers = [col.name for col in table.rows[0].columns] - self.results_table.add_columns(*headers) - # Add rows - for row_vals in table.to_datatable_rows(): - self.results_table.add_row(*row_vals) - else: - self.results_table.add_columns("Message") - self.results_table.add_row("No results found") - - # Finish worker - if self.current_worker: - self.current_worker.finish("completed", f"Found {len(results)} results") - - except Exception as e: - logger.error(f"[search-modal] Search error: {e}", exc_info=True) - if self.current_worker: - self.current_worker.finish("error", f"Search failed: {str(e)}") - - finally: - self.is_searching = False - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - if button_id == "search-button": - # Run search asynchronously - asyncio.create_task(self._perform_search()) - - elif button_id == "select-button": - # Get selected row and populate tags textarea - if self.results_table and self.results_table.row_count > 0: - selected_row = self.results_table.cursor_row - if 0 <= selected_row < len(self.current_results): - result = self.current_results[selected_row] - # Populate tags textarea with result metadata - self._populate_tags_from_result(result) - else: - logger.warning("[search-modal] No results to select") - - elif button_id == "download-button": - # Download the selected result - if self.current_results and self.results_table.row_count > 0: - selected_row = self.results_table.cursor_row - if 0 <= selected_row < len(self.current_results): - result = self.current_results[selected_row] - if getattr(result, "table", "") == "openlibrary": - asyncio.create_task(self._download_book(result)) - else: - logger.warning( - "[search-modal] Download only supported for OpenLibrary results" - ) - else: - logger.warning("[search-modal] No result selected for download") - - elif button_id == "submit-button": - # Submit the current result with tags and source - if self.current_results and self.results_table.row_count > 0: - selected_row = self.results_table.cursor_row - if 0 <= selected_row < len(self.current_results): - result = self.current_results[selected_row] - - # Convert to dict if needed for submission - if hasattr(result, "to_dict"): - result_dict = result.to_dict() - else: - result_dict = result - - # Get tags from textarea - tags_text = self.tags_textarea.text if self.tags_textarea else "" - # Get library source (if OpenLibrary) - library_source = ( - self.library_source_select.value - if self.library_source_select else "local" - ) - - # Add tags and source to result - result_dict["tags_text"] = tags_text - result_dict["library_source"] = library_source - - # Post message and dismiss - self.post_message(self.SearchSelected(result_dict)) - self.dismiss(result_dict) - else: - logger.warning("[search-modal] No result selected for submission") - - elif button_id == "cancel-button": - self.dismiss(None) - - def _populate_tags_from_result(self, result: Any) -> None: - """Populate the tags textarea from a selected result.""" - if not self.tags_textarea: - return - - # Handle both SearchResult objects and dicts - if hasattr(result, "full_metadata"): - metadata = result.full_metadata or {} - source = result.table - title = result.title - else: - # Handle dict (legacy or from to_dict) - if "full_metadata" in result: - metadata = result["full_metadata"] or {} - elif "raw_data" in result: - metadata = result["raw_data"] or {} - else: - metadata = result - - source = result.get("table", "") - title = result.get("title", "") - - # Format tags based on result source - if source == "openlibrary": - # For OpenLibrary: title, author, year - author = ( - ", ".join(metadata.get("authors", - [])) - if isinstance(metadata.get("authors"), - list) else metadata.get("authors", - "") - ) - year = str(metadata.get("year", "")) - tags = [] - if title: - tags.append(title) - if author: - tags.append(author) - if year: - tags.append(year) - tags_text = "\n".join(tags) - elif source == "soulseek": - # For Soulseek: artist, album, title, track - tags = [] - if metadata.get("artist"): - tags.append(metadata["artist"]) - if metadata.get("album"): - tags.append(metadata["album"]) - if metadata.get("track_num"): - tags.append(f"Track {metadata['track_num']}") - if title: - tags.append(title) - tags_text = "\n".join(tags) - else: - # Generic fallback - tags = [title] - tags_text = "\n".join(tags) - - self.tags_textarea.text = tags_text - logger.info("[search-modal] Populated tags textarea from result") - - async def _download_book(self, result: Any) -> None: - """Download a book from OpenLibrary using the provider.""" - if getattr(result, "table", "") != "openlibrary": - logger.warning( - "[search-modal] Download only supported for OpenLibrary results" - ) - return - - try: - config = load_config() - output_dir = resolve_output_dir(config) - - provider = get_plugin_with_capability("openlibrary", "search", config=config) - if not provider: - logger.error("[search-modal] Provider not available: openlibrary") - return - - title = getattr(result, "title", "") - logger.info(f"[search-modal] Starting download for: {title}") - - downloaded = await asyncio.to_thread(provider.download, result, output_dir) - if downloaded: - logger.info(f"[search-modal] Download successful: {downloaded}") - else: - logger.warning(f"[search-modal] Download failed for: {title}") - - except Exception as e: - logger.error(f"[search-modal] Download error: {e}", exc_info=True) - - def action_search_focused(self) -> None: - """Action for Enter key - only search if search input is focused.""" - if self.search_input and self.search_input.has_focus and not self.is_searching: - asyncio.create_task(self._perform_search()) - - def action_scrape_tags(self) -> None: - """Action for Ctrl+T - populate tags from selected result.""" - if self.current_results and self.results_table and self.results_table.row_count > 0: - try: - selected_row = self.results_table.cursor_row - if 0 <= selected_row < len(self.current_results): - result = self.current_results[selected_row] - self._populate_tags_from_result(result) - logger.info( - f"[search-modal] Ctrl+T: Populated tags from result at row {selected_row}" - ) - else: - logger.warning( - f"[search-modal] Ctrl+T: Invalid row index {selected_row}" - ) - except Exception as e: - logger.error(f"[search-modal] Ctrl+T error: {e}") - else: - logger.warning("[search-modal] Ctrl+T: No results selected") - - def action_cancel(self) -> None: - """Action for Escape key - close modal.""" - self.dismiss(None) - - def on_input_submitted(self, event: Input.Submitted) -> None: - """Handle Enter key in search input - only trigger search here.""" - if event.input.id == "search-input": - if not self.is_searching: - asyncio.create_task(self._perform_search()) diff --git a/TUI/modalscreen/search.tcss b/TUI/modalscreen/search.tcss deleted file mode 100644 index 4a67ca8..0000000 --- a/TUI/modalscreen/search.tcss +++ /dev/null @@ -1,121 +0,0 @@ -/* Search Modal Screen Styling */ - -SearchModal { - align: center middle; -} - -Screen { - layout: vertical; -} - -#search-container { - width: 140; - height: 40; - background: $panel; - border: solid $primary; - layout: vertical; -} - -Static#search-title { - height: 3; - dock: top; - text-align: center; - text-style: bold; - color: $accent; - background: $boost; - padding: 1 2; -} - -#search-controls { - height: auto; - layout: horizontal; - padding: 1; - border: solid $primary; -} - -#source-select { - width: 20; - margin-right: 1; -} - -#search-input { - width: 1fr; - margin-right: 1; -} - -#search-button { - width: 12; -} - -#results-table { - height: 1fr; - border: solid $primary; -} - -DataTable { - border: solid $accent; -} - -DataTable > .datatable--header { - background: $boost; - color: $accent; - text-style: bold; -} - -DataTable > .datatable--cursor-row { - background: $accent; -} - -#bottom-controls { - height: auto; - layout: horizontal; - padding: 1; - border: solid $primary; -} - -#tags-column { - width: 1fr; - layout: vertical; - padding-right: 1; - height: auto; -} - -#result-tags-textarea { - height: 10; - width: 1fr; - border: solid $accent; -} - -#source-submit-column { - width: 20; - layout: vertical; - padding-left: 1; - height: auto; -} - -#library-source-select { - width: 1fr; - margin-bottom: 1; -} - -#submit-button { - width: 1fr; -} - -#search-buttons { - height: 3; - dock: bottom; - layout: horizontal; - padding: 1; - border: solid $primary; - align: center middle; -} - -#select-button { - width: 12; - margin-right: 2; -} - -#cancel-button { - width: 12; -} diff --git a/TUI/modalscreen/selection_modal.py b/TUI/modalscreen/selection_modal.py deleted file mode 100644 index 4e3fcc7..0000000 --- a/TUI/modalscreen/selection_modal.py +++ /dev/null @@ -1,69 +0,0 @@ -from textual.app import ComposeResult -from textual.screen import ModalScreen -from textual.containers import Container, ScrollableContainer -from textual.widgets import Static, Button -from typing import List - -class SelectionModal(ModalScreen[str]): - """A modal for selecting a type from a list of strings.""" - - CSS = """ - SelectionModal { - align: center middle; - background: rgba(0, 0, 0, 0.5); - } - - #selection-container { - width: 40%; - height: 60%; - background: $panel; - border: thick $primary; - padding: 1; - } - - .selection-title { - background: $accent; - color: $text; - padding: 0 1; - margin-bottom: 1; - text-align: center; - text-style: bold; - height: 3; - content-align: center middle; - } - - .selection-button { - width: 100%; - margin-bottom: 1; - } - - #selection-cancel { - width: 100%; - margin-top: 1; - background: $error; - } - """ - - def __init__(self, title: str, options: List[str]) -> None: - super().__init__() - self.selection_title = title - self.options = sorted(options) - - def compose(self) -> ComposeResult: - with Container(id="selection-container"): - yield Static(self.selection_title, classes="selection-title") - with ScrollableContainer(): - for i, opt in enumerate(self.options): - yield Button(opt, id=f"opt-{i}", classes="selection-button") - yield Button("Cancel", id="selection-cancel") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "selection-cancel": - self.dismiss("") - elif event.button.id and event.button.id.startswith("opt-"): - try: - idx = int(event.button.id.replace("opt-", "")) - selection = self.options[idx] - self.dismiss(selection) - except (ValueError, IndexError): - pass diff --git a/TUI/modalscreen/workers.py b/TUI/modalscreen/workers.py deleted file mode 100644 index f37b435..0000000 --- a/TUI/modalscreen/workers.py +++ /dev/null @@ -1,709 +0,0 @@ -"""Workers modal screen for monitoring and managing background tasks.""" - -from textual.app import ComposeResult -from textual.screen import ModalScreen -from textual.containers import Horizontal, Vertical -from textual.widgets import Static, Button, DataTable, TextArea -from textual.binding import Binding -from textual.message import Message -import logging -from typing import Optional, Dict, List, Any -from pathlib import Path -import sys - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) - -logger = logging.getLogger(__name__) - - -class WorkersModal(ModalScreen): - """Modal screen for monitoring running and finished workers.""" - - BINDINGS = [ - Binding("escape", - "cancel", - "Cancel"), - ] - - CSS_PATH = "workers.tcss" - - class WorkerUpdated(Message): - """Posted when worker list is updated.""" - - def __init__(self, workers: List[Dict[str, Any]]) -> None: - self.workers = workers - super().__init__() - - class WorkerCancelled(Message): - """Posted when user cancels a worker.""" - - def __init__(self, worker_id: str) -> None: - self.worker_id = worker_id - super().__init__() - - def __init__(self, app_instance=None): - """Initialize the workers modal. - - Args: - app_instance: Reference to the hub app for accessing worker info - """ - super().__init__() - self.app_instance = app_instance - self.running_table: Optional[DataTable] = None - self.finished_table: Optional[DataTable] = None - self.stdout_display: Optional[TextArea] = None - self.running_workers: List[Dict[str, Any]] = [] - self.finished_workers: List[Dict[str, Any]] = [] - self.selected_worker_id: Optional[str] = None - self.show_running = False # Start with finished tab - - def compose(self) -> ComposeResult: - """Create child widgets for the workers modal.""" - with Vertical(id="workers-container"): - # Title with toggle buttons - with Horizontal(id="workers-title-bar"): - yield Static("Workers Monitor", id="workers-title") - yield Button("Running", id="toggle-running-btn", variant="primary") - yield Button("Finished", id="toggle-finished-btn", variant="default") - - # Running tab content (initially hidden) - with Vertical(id="running-section"): - self.running_table = DataTable(id="running-table") - yield self.running_table - - with Horizontal(id="running-controls"): - yield Button("Refresh", id="running-refresh-btn", variant="primary") - yield Button( - "Stop Selected", - id="running-stop-btn", - variant="warning" - ) - yield Button("Stop All", id="running-stop-all-btn", variant="error") - - # Finished tab content (initially visible) - with Vertical(id="finished-section"): - self.finished_table = DataTable(id="finished-table") - yield self.finished_table - - with Horizontal(id="finished-controls"): - yield Button( - "Refresh", - id="finished-refresh-btn", - variant="primary" - ) - yield Button( - "Clear Selected", - id="finished-clear-btn", - variant="warning" - ) - yield Button( - "Clear All", - id="finished-clear-all-btn", - variant="error" - ) - - # Shared textarea for displaying worker logs - with Vertical(id="logs-section"): - yield Static("Worker Logs:", id="logs-label") - self.stdout_display = TextArea(id="stdout-display", read_only=True) - yield self.stdout_display - - with Horizontal(id="workers-buttons"): - yield Button("Close", id="close-btn", variant="primary") - - def on_mount(self) -> None: - """Set up the tables and load worker data.""" - # Set up running workers table - if self.running_table: - self.running_table.add_columns( - "ID", - "Type", - "Status", - "Pipe", - "Progress", - "Started", - "Details" - ) - self.running_table.zebra_stripes = True - - # Set up finished workers table - if self.finished_table: - self.finished_table.add_columns( - "ID", - "Type", - "Result", - "Pipe", - "Started", - "Completed", - "Duration", - "Details" - ) - self.finished_table.zebra_stripes = True - - # Set initial view (show finished by default) - self._update_view_visibility() - - # Load initial data - self.refresh_workers() - - # Don't set up periodic refresh - it was causing issues with stdout display - # Users can click the Refresh button to update manually - - def refresh_workers(self) -> None: - """Refresh the workers data from app instance.""" - try: - if not self.app_instance: - logger.warning("[workers-modal] No app instance provided") - return - - # Get running workers from app instance - # This assumes the app has a get_running_workers() method - if hasattr(self.app_instance, "get_running_workers"): - self.running_workers = self.app_instance.get_running_workers() - else: - self.running_workers = [] - - # Get finished workers from app instance - if hasattr(self.app_instance, "get_finished_workers"): - self.finished_workers = self.app_instance.get_finished_workers() - if self.finished_workers: - logger.info( - f"[workers-modal-refresh] Got {len(self.finished_workers)} finished workers from app" - ) - # Log the keys in the first worker to verify structure - if isinstance(self.finished_workers[0], dict): - logger.info( - f"[workers-modal-refresh] First worker keys: {list(self.finished_workers[0].keys())}" - ) - logger.info( - f"[workers-modal-refresh] First worker: {self.finished_workers[0]}" - ) - else: - logger.warning( - f"[workers-modal-refresh] First worker is not a dict: {type(self.finished_workers[0])}" - ) - else: - self.finished_workers = [] - - # Update tables - self._update_running_table() - self._update_finished_table() - - logger.info( - f"[workers-modal] Refreshed: {len(self.running_workers)} running, {len(self.finished_workers)} finished" - ) - except Exception as e: - logger.error(f"[workers-modal] Error refreshing workers: {e}") - - def _update_view_visibility(self) -> None: - """Toggle visibility between running and finished views.""" - try: - running_section = self.query_one("#running-section", Vertical) - finished_section = self.query_one("#finished-section", Vertical) - toggle_running_btn = self.query_one("#toggle-running-btn", Button) - toggle_finished_btn = self.query_one("#toggle-finished-btn", Button) - - if self.show_running: - running_section.display = True - finished_section.display = False - toggle_running_btn.variant = "primary" - toggle_finished_btn.variant = "default" - logger.debug("[workers-modal] Switched to Running view") - else: - running_section.display = False - finished_section.display = True - toggle_running_btn.variant = "default" - toggle_finished_btn.variant = "primary" - logger.debug("[workers-modal] Switched to Finished view") - except Exception as e: - logger.error(f"[workers-modal] Error updating view visibility: {e}") - - def _update_running_table(self) -> None: - """Update the running workers table.""" - try: - if not self.running_table: - logger.error("[workers-modal] Running table not initialized") - return - - self.running_table.clear() - - if not self.running_workers: - self.running_table.add_row( - "---", - "---", - "---", - "---", - "---", - "---", - "No workers running" - ) - logger.debug("[workers-modal] No running workers to display") - return - - logger.debug( - f"[workers-modal] Updating running table with {len(self.running_workers)} workers" - ) - - for idx, worker_info in enumerate(self.running_workers): - try: - worker_id = worker_info.get("id", "unknown") - worker_type = worker_info.get("type", "unknown") - status = worker_info.get("status", "running") - progress = worker_info.get("progress", "") - started = worker_info.get("started", "") - details = worker_info.get("details", "") - pipe = worker_info.get("pipe", "") - - # Ensure values are strings - worker_id = str(worker_id) if worker_id else "unknown" - worker_type = str(worker_type) if worker_type else "unknown" - status = str(status) if status else "running" - progress = str(progress) if progress else "---" - started = str(started) if started else "---" - details = str(details) if details else "---" - pipe_display = self._summarize_pipe(pipe) - - # Truncate long strings - progress = progress[:20] - started = started[:19] - details = details[:30] - pipe_display = pipe_display[:40] - - self.running_table.add_row( - worker_id[:8], - worker_type[:15], - status[:10], - pipe_display, - progress, - started, - details, - ) - - if idx == 0: # Log first entry - logger.debug( - f"[workers-modal] Added running row {idx}: {worker_id[:8]} {worker_type[:15]} {status}" - ) - except Exception as row_error: - logger.error( - f"[workers-modal] Error adding running row {idx}: {row_error}", - exc_info=True, - ) - - logger.debug( - f"[workers-modal] Updated running table with {len(self.running_workers)} workers" - ) - except Exception as e: - logger.error( - f"[workers-modal] Error updating running table: {e}", - exc_info=True - ) - - def _update_finished_table(self) -> None: - """Update the finished workers table.""" - try: - if not self.finished_table: - logger.error("[workers-modal] Finished table not initialized") - return - - self.finished_table.clear() - - if not self.finished_workers: - self.finished_table.add_row( - "---", - "---", - "---", - "---", - "---", - "---", - "---", - "No finished workers" - ) - logger.debug("[workers-modal] No finished workers to display") - return - - logger.info( - f"[workers-modal-update] STARTING to update finished table with {len(self.finished_workers)} workers" - ) - added_count = 0 - error_count = 0 - - for idx, worker_info in enumerate(self.finished_workers): - try: - worker_id = worker_info.get("id", "unknown") - worker_type = worker_info.get("type", "unknown") - result = worker_info.get("result", "unknown") - completed = worker_info.get("completed", "") - duration = worker_info.get("duration", "") - details = worker_info.get("details", "") - pipe = worker_info.get("pipe", "") - started = worker_info.get("started", "") - - # Ensure values are strings - worker_id = str(worker_id) if worker_id else "unknown" - worker_type = str(worker_type) if worker_type else "unknown" - result = str(result) if result else "unknown" - completed = str(completed) if completed else "---" - duration = str(duration) if duration else "---" - details = str(details) if details else "---" - started = str(started) if started else "---" - pipe_display = self._summarize_pipe(pipe) - - # Truncate long strings - result = result[:15] - completed = completed[:19] - started = started[:19] - duration = duration[:10] - details = details[:30] - pipe_display = pipe_display[:40] - - self.finished_table.add_row( - worker_id[:8], - worker_type[:15], - result, - pipe_display, - started, - completed, - duration, - details, - ) - added_count += 1 - - except Exception as row_error: - error_count += 1 - logger.error( - f"[workers-modal-update] Error adding finished row {idx}: {row_error}", - exc_info=True, - ) - - logger.info( - f"[workers-modal-update] COMPLETED: Added {added_count}/{len(self.finished_workers)} finished workers (errors: {error_count})" - ) - logger.debug( - f"[workers-modal-update] Finished table row_count after update: {self.finished_table.row_count}" - ) - except Exception as e: - logger.error( - f"[workers-modal] Error updating finished table: {e}", - exc_info=True - ) - - def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: - """Handle row highlight in tables - display stdout.""" - try: - logger.info( - f"[workers-modal] Row highlighted, cursor_row: {event.cursor_row}" - ) - - # Get the selected worker from the correct table - workers_list = None - if event.control == self.running_table: - workers_list = self.running_workers - logger.debug("[workers-modal] Highlighted in running table") - elif event.control == self.finished_table: - workers_list = self.finished_workers - logger.debug( - f"[workers-modal] Highlighted in finished table, list size: {len(workers_list)}" - ) - else: - logger.warning(f"[workers-modal] Unknown table: {event.control}") - return - - # Get the worker at this row - if workers_list and 0 <= event.cursor_row < len(workers_list): - worker = workers_list[event.cursor_row] - worker_id = worker.get("id", "") - logger.info(f"[workers-modal] Highlighted worker: {worker_id}") - - if worker_id: - self.selected_worker_id = worker_id - # Display the stdout - self._update_stdout_display(worker_id, worker) - else: - logger.warning( - f"[workers-modal] Row {event.cursor_row} out of bounds for list of size {len(workers_list) if workers_list else 0}" - ) - except Exception as e: - logger.error( - f"[workers-modal] Error handling row highlight: {e}", - exc_info=True - ) - - def on_data_table_cell_highlighted(self, event: DataTable.CellHighlighted) -> None: - """Handle cell highlight in tables - display stdout (backup for row selection).""" - try: - # CellHighlighted has coordinate (row, column) not cursor_row - cursor_row = event.coordinate.row - logger.debug( - f"[workers-modal] Cell highlighted, row: {cursor_row}, column: {event.coordinate.column}" - ) - - # Get the selected worker from the correct table - workers_list = None - if event.data_table == self.running_table: - workers_list = self.running_workers - logger.debug("[workers-modal] Cell highlighted in running table") - elif event.data_table == self.finished_table: - workers_list = self.finished_workers - logger.debug( - f"[workers-modal] Cell highlighted in finished table, list size: {len(workers_list)}" - ) - else: - return - - # Get the worker at this row - if workers_list and 0 <= cursor_row < len(workers_list): - worker = workers_list[cursor_row] - worker_id = worker.get("id", "") - - if worker_id and worker_id != self.selected_worker_id: - logger.info(f"[workers-modal] Cell-highlighted worker: {worker_id}") - self.selected_worker_id = worker_id - # Display the stdout - self._update_stdout_display(worker_id, worker) - except Exception as e: - logger.debug(f"[workers-modal] Error handling cell highlight: {e}") - - def _update_stdout_display( - self, - worker_id: str, - worker: Optional[Dict[str, - Any]] = None - ) -> None: - """Update the stdout textarea with logs from the selected worker.""" - try: - if not self.stdout_display: - logger.error("[workers-modal] stdout_display not initialized") - return - logger.debug( - f"[workers-modal] Updating stdout display for worker: {worker_id}" - ) - worker_data = worker or self._locate_worker(worker_id) - stdout_text = self._resolve_worker_stdout(worker_id, worker_data) - pipe_text = self._resolve_worker_pipe(worker_id, worker_data) - events = self._get_worker_events(worker_id) - timeline_text = self._format_worker_timeline(events) - sections = [] - if pipe_text: - sections.append(f"Pipe:\n{pipe_text}") - if timeline_text: - sections.append("Timeline:\n" + timeline_text) - logs_body = (stdout_text or "").strip() - sections.append( - "Logs:\n" + (logs_body if logs_body else "(no logs recorded)") - ) - combined_text = "\n\n".join(sections) - logger.debug( - f"[workers-modal] Setting textarea to {len(combined_text)} chars (stdout_len={len(stdout_text or '')})" - ) - self.stdout_display.text = combined_text - if len(combined_text) > 10: - try: - self.stdout_display.cursor_location = (len(combined_text) - 1, 0) - except Exception: - logger.exception("Failed to set stdout_display cursor location") - logger.info("[workers-modal] Updated stdout display successfully") - except Exception as e: - logger.error( - f"[workers-modal] Error updating stdout display: {e}", - exc_info=True - ) - - def _locate_worker(self, worker_id: str) -> Optional[Dict[str, Any]]: - for worker in self.running_workers or []: - if isinstance(worker, dict) and worker.get("id") == worker_id: - return worker - for worker in self.finished_workers or []: - if isinstance(worker, dict) and worker.get("id") == worker_id: - return worker - return None - - def _resolve_worker_stdout( - self, - worker_id: str, - worker: Optional[Dict[str, - Any]] - ) -> str: - if worker and worker.get("stdout"): - return worker.get("stdout", "") or "" - manager = getattr(self.app_instance, "worker_manager", None) - if manager: - try: - return manager.get_stdout(worker_id) or "" - except Exception as exc: - logger.debug( - f"[workers-modal] Could not fetch stdout for {worker_id}: {exc}" - ) - return "" - - def _resolve_worker_pipe( - self, - worker_id: str, - worker: Optional[Dict[str, - Any]] - ) -> str: - if worker and worker.get("pipe"): - return str(worker.get("pipe")) - record = self._fetch_worker_record(worker_id) - if record and record.get("pipe"): - return str(record.get("pipe")) - return "" - - def _fetch_worker_record(self, worker_id: str) -> Optional[Dict[str, Any]]: - manager = getattr(self.app_instance, "worker_manager", None) - if not manager: - return None - try: - return manager.get_worker(worker_id) - except Exception as exc: - logger.debug( - f"[workers-modal] Could not fetch worker record {worker_id}: {exc}" - ) - return None - - def _get_worker_events(self, - worker_id: str, - limit: int = 250) -> List[Dict[str, - Any]]: - manager = getattr(self.app_instance, "worker_manager", None) - if not manager: - return [] - try: - return manager.get_worker_events(worker_id, limit=limit) - except Exception as exc: - logger.debug( - f"[workers-modal] Could not fetch worker events {worker_id}: {exc}" - ) - return [] - - def _format_worker_timeline(self, events: List[Dict[str, Any]]) -> str: - if not events: - return "" - lines: List[str] = [] - for event in events: - timestamp = self._format_event_timestamp(event.get("created_at")) - label = (event.get("event_type") or "").upper() or "EVENT" - channel = (event.get("channel") or "").upper() - if channel and channel not in label: - label = f"{label}/{channel}" - step = event.get("step") or "" - message = event.get("message") or "" - prefix = "" - if event.get("event_type") == "step" and step: - prefix = f"{step} :: " - elif step and step not in message: - prefix = f"{step} :: " - formatted_message = self._format_message_block(message) - lines.append(f"[{timestamp}] {label}: {prefix}{formatted_message}") - return "\n".join(lines) - - def _format_event_timestamp(self, raw_timestamp: Any) -> str: - if not raw_timestamp: - return "--:--:--" - text = str(raw_timestamp) - if "T" in text: - time_part = text.split("T", 1)[1] - elif " " in text: - time_part = text.split(" ", 1)[1] - else: - time_part = text - return time_part[:8] if len(time_part) >= 8 else time_part - - def _format_message_block(self, message: str) -> str: - clean = (message or "").strip() - if not clean: - return "(empty)" - lines = clean.splitlines() - if len(lines) == 1: - return lines[0] - head, *rest = lines - indented = "\n".join(f" {line}" for line in rest) - return f"{head}\n{indented}" - - def _summarize_pipe(self, pipe_value: Any, limit: int = 40) -> str: - text = str(pipe_value or "").strip() - if not text: - return "(none)" - return text if len(text) <= limit else text[:limit - 3] + "..." - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - button_id = event.button.id - - try: - if button_id == "toggle-running-btn": - self.show_running = True - self._update_view_visibility() - return - - elif button_id == "toggle-finished-btn": - self.show_running = False - self._update_view_visibility() - return - - if button_id == "running-refresh-btn": - self.refresh_workers() - - elif button_id == "running-stop-btn": - # Stop selected running worker - if self.running_table and self.running_table.row_count > 0: - try: - selected_row = self.running_table.cursor_row - if 0 <= selected_row < len(self.running_workers): - worker = self.running_workers[selected_row] - worker_id = worker.get("id") - if self.app_instance and hasattr(self.app_instance, - "stop_worker"): - self.app_instance.stop_worker(worker_id) - logger.info( - f"[workers-modal] Stopped worker: {worker_id}" - ) - self.refresh_workers() - except Exception as e: - logger.error(f"[workers-modal] Error stopping worker: {e}") - - elif button_id == "running-stop-all-btn": - # Stop all running workers - if self.app_instance and hasattr(self.app_instance, "stop_all_workers"): - self.app_instance.stop_all_workers() - logger.info("[workers-modal] Stopped all workers") - self.refresh_workers() - - elif button_id == "finished-refresh-btn": - self.refresh_workers() - - elif button_id == "finished-clear-btn": - # Clear selected finished worker - if self.finished_table and self.finished_table.row_count > 0: - try: - selected_row = self.finished_table.cursor_row - if 0 <= selected_row < len(self.finished_workers): - worker = self.finished_workers[selected_row] - worker_id = worker.get("id") - if self.app_instance and hasattr(self.app_instance, - "clear_finished_worker"): - self.app_instance.clear_finished_worker(worker_id) - logger.info( - f"[workers-modal] Cleared worker: {worker_id}" - ) - self.refresh_workers() - except Exception as e: - logger.error(f"[workers-modal] Error clearing worker: {e}") - - elif button_id == "finished-clear-all-btn": - # Clear all finished workers - if self.app_instance and hasattr(self.app_instance, - "clear_all_finished_workers"): - self.app_instance.clear_all_finished_workers() - logger.info("[workers-modal] Cleared all finished workers") - self.refresh_workers() - - elif button_id == "close-btn": - self.dismiss(None) - - except Exception as e: - logger.error(f"[workers-modal] Error in on_button_pressed: {e}") - - def action_cancel(self) -> None: - """Action for Escape key - close modal.""" - self.dismiss(None) diff --git a/TUI/modalscreen/workers.tcss b/TUI/modalscreen/workers.tcss deleted file mode 100644 index fa528a5..0000000 --- a/TUI/modalscreen/workers.tcss +++ /dev/null @@ -1,119 +0,0 @@ -/* Workers Modal Stylesheet */ - -Screen { - background: $surface; - color: $text; -} - -#workers-container { - width: 100%; - height: 100%; - layout: vertical; - background: $panel; -} - -#workers-title-bar { - dock: top; - height: 3; - layout: horizontal; - background: $boost; - border: solid $accent; - padding: 0 1; -} - -#workers-title { - width: 1fr; - height: 100%; - content-align-vertical: middle; - color: $text; - text-style: bold; -} - -#toggle-running-btn, -#toggle-finished-btn { - width: auto; - height: 100%; - margin: 0; -} - -#running-section, -#finished-section { - width: 100%; - height: 40%; - layout: vertical; - border: solid $accent; -} - -#running-table, -#finished-table { - width: 100%; - height: 1fr; - border: solid $accent; -} - -#running-controls, -#finished-controls { - width: 100%; - height: auto; - min-height: 3; - layout: horizontal; - background: $boost; - padding: 1; - border-top: solid $accent; -} - -#running-controls Button, -#finished-controls Button { - margin-right: 1; - min-width: 15; -} - -#logs-label { - height: 1; - margin: 0 1; - text-style: bold; -} - -#logs-section { - width: 100%; - height: 1fr; - layout: vertical; - border: solid $accent; - background: $panel; -} - -#stdout-display { - width: 100%; - height: 1fr; - border: solid $accent; - margin: 1; -} - -#workers-buttons { - dock: bottom; - height: auto; - min-height: 3; - layout: horizontal; - border: solid $accent; - padding: 1; -} - -#workers-buttons Button { - margin-right: 1; - min-width: 15; -} - -DataTable { - border: solid $accent; -} - -DataTable > .datatable--header { - background: $boost; - color: $text; - text-style: bold; -} - -DataTable > .datatable--cursor { - background: $accent; - color: $panel; -} diff --git a/TUI/tui.tcss b/TUI/tui.tcss deleted file mode 100644 index 7e79861..0000000 --- a/TUI/tui.tcss +++ /dev/null @@ -1,249 +0,0 @@ -#app-shell { - width: 100%; - height: 100%; - padding: 1 2; - background: $surface; - layout: vertical; -} - -#command-pane { - width: 100%; - height: auto; - background: $boost; - padding: 1; - border: round $primary; -} - -#command-row { - width: 100%; - height: auto; -} - -#pipeline-input { - width: 1fr; - min-height: 3; - padding: 0 1; - background: $surface; - color: $text; - border: round $primary; -} - -#pipeline-input:focus { - border: double $primary; - background: $surface; -} - -#status-panel { - width: auto; - max-width: 25; - height: 3; - text-style: bold; - content-align: center middle; - padding: 0 1; - border: solid $panel-darken-1; -} - -#cmd-suggestions { - width: 100%; - height: auto; - max-height: 8; - margin-top: 1; - background: $surface; - border: round $panel-darken-2; -} - -#results-pane { - width: 100%; - height: 2fr; - padding: 0 1 1 1; - background: $panel; - border: round $panel-darken-2; - margin-top: 1; -} - -#results-pane .section-title { - margin-top: 0; - margin-bottom: 0; -} - -#results-layout { - width: 100%; - height: 1fr; -} - -#results-list-pane { - width: 2fr; - height: 1fr; - padding-right: 1; -} - -#results-tags-pane { - width: 1fr; - height: 1fr; - padding: 0 1; - border-left: solid $panel-darken-2; -} - -#results-meta-pane { - width: 1fr; - height: 1fr; - padding-left: 1; - border-left: solid $panel-darken-2; -} - -#store-select { - width: 24; - margin-right: 2; - height: 3; -} - -#output-path { - width: 1fr; - height: 3; -} - - -#bottom-pane { - width: 100%; - height: 1fr; - padding: 1; - background: $panel; - border: round $panel-darken-2; -} - - -#store-row { - width: 100%; - height: auto; -} - -#logs-workers-row { - width: 100%; - height: 1fr; - margin-top: 1; -} - -#logs-pane, -#workers-pane { - width: 1fr; - height: 100%; - padding: 0 1; -} - -.section-title { - text-style: bold; - color: $text-muted; - margin-top: 1; -} - -#log-output { - height: 1fr; -} - -#workers-table { - height: 1fr; -} - -#results-table { - height: 1fr; - border: solid #ffffff; - background: #ffffff; - color: #000000; - padding: 0; -} - -#results-table > .datatable--header { - background: #ffffff; - color: #000000; - text-style: bold; -} - -#inline-tags-output { - height: 1fr; - border: solid #ffffff; - background: #ffffff; - color: #000000; -} - -#metadata-tree { - height: 1fr; - border: solid #ffffff; - background: #ffffff; - color: #000000; -} - - - -.status-info { - background: $boost; - color: $text; -} - -.status-success { - background: $success 20%; - color: $success; -} - -.status-error { - background: $error 20%; - color: $error; -} - -#run-button { - width: auto; - min-width: 10; - margin: 0 1; -} - -#tags-button, -#actions-button, -#metadata-button, -#relationships-button { - width: auto; - min-width: 12; - margin: 0 1; -} - -#popup-title { - width: 100%; - height: 3; - text-style: bold; - content-align: center middle; - border: round $panel-darken-2; - background: $boost; -} - -#popup-text, -#tags-editor { - height: 1fr; - border: round $panel-darken-2; -} - -#tags-buttons { - width: 100%; - height: auto; - margin-top: 1; -} - -#actions-list { - width: 100%; - height: auto; - margin-top: 1; -} - -#actions-list Button { - width: 100%; - margin-bottom: 1; -} - -#actions-footer { - width: 100%; - height: auto; - margin-top: 1; -} - -#tags-status { - width: 1fr; - height: 3; - content-align: left middle; -} \ No newline at end of file diff --git a/cmdlet/file/search.py b/cmdlet/file/search.py index fd125da..b0e4aef 100644 --- a/cmdlet/file/search.py +++ b/cmdlet/file/search.py @@ -207,7 +207,7 @@ class search_file(Cmdlet): "search-file -query 'site:example.com filetype:epub history' # Web: site-scoped search", "", "Plugin search (-plugin):", - "search-file -plugin youtube 'tutorial' # Search YouTube plugin", + "search-file -plugin ytdlp -query 'search:tutorial' # Search YouTube via yt-dlp", "search-file -plugin ftp -instance work '*' # Search a named FTP/SCP plugin instance", "search-file -plugin alldebrid '*' # List AllDebrid magnets", "search-file -plugin alldebrid -open 123 '*' # Show files for a magnet", diff --git a/cmdnat/_status_shared.py b/cmdnat/_status_shared.py index aca6f05..d026356 100644 --- a/cmdnat/_status_shared.py +++ b/cmdnat/_status_shared.py @@ -16,7 +16,7 @@ def add_startup_check( name: str, *, provider: str = "", - store: str = "", + instance: str = "", files: int | str | None = None, detail: str = "", ) -> None: @@ -24,11 +24,82 @@ def add_startup_check( row.add_column("STATUS", upper_text(status)) row.add_column("NAME", upper_text(name)) row.add_column("PLUGIN", upper_text(provider or "")) - row.add_column("STORE", upper_text(store or "")) + row.add_column("INSTANCE", upper_text(instance or "")) row.add_column("FILES", "" if files is None else str(files)) row.add_column("DETAIL", upper_text(detail or "")) +def _provider_config_map(config: dict) -> dict[str, Any]: + if not isinstance(config, dict): + return {} + + provider_cfg = config.get("plugin") + if not isinstance(provider_cfg, dict): + provider_cfg = config.get("provider") + return provider_cfg if isinstance(provider_cfg, dict) else {} + + +def _iter_registered_plugin_infos() -> tuple[Any, ...]: + try: + from ProviderCore.registry import REGISTRY + + return tuple( + sorted( + REGISTRY.iter_plugins(), + key=lambda info: str( + getattr(info, "canonical_name", "") or "" + ).lower(), + ) + ) + except Exception: + return () + + +def _extract_configured_instance_names(raw_entry: Any) -> list[str]: + if not isinstance(raw_entry, dict) or not raw_entry: + return [] + if not all(isinstance(value, dict) for value in raw_entry.values()): + return [] + + names: list[str] = [] + for key in raw_entry.keys(): + name = str(key or "").strip() + if not name or name.lower() == "default": + continue + names.append(name) + return names + + +def _resolve_startup_instance_text( + plugin: Any, + summary: dict[str, Any], + configured_entry: Any, +) -> str: + instance_text = str(summary.get("instance") or "").strip() + if instance_text: + return instance_text + + raw_instances = summary.get("instances") + if isinstance(raw_instances, (list, tuple, set)): + values = [str(value).strip() for value in raw_instances if str(value).strip()] + if values: + return ", ".join(values) + elif raw_instances is not None: + instance_text = str(raw_instances).strip() + if instance_text: + return instance_text + + try: + configured_instances = plugin.configured_instances() if plugin is not None else [] + except Exception: + configured_instances = [] + + if configured_instances: + return ", ".join(str(value).strip() for value in configured_instances if str(value).strip()) + + return ", ".join(_extract_configured_instance_names(configured_entry)) + + def has_store_subtype(cfg: dict, subtype: str) -> bool: store_cfg = cfg.get("store") if not isinstance(store_cfg, dict): @@ -113,61 +184,73 @@ def ping_first(urls: list[str]) -> tuple[bool, str]: def collect_plugin_startup_checks(config: dict) -> list[dict[str, Any]]: - provider_cfg = None - if isinstance(config, dict): - provider_cfg = config.get("plugin") - if not isinstance(provider_cfg, dict): - provider_cfg = config.get("provider") - if not isinstance(provider_cfg, dict) or not provider_cfg: - return [] - - try: - from ProviderCore.registry import get_plugin_class - except Exception: - return [] + provider_cfg = _provider_config_map(config) checks: list[dict[str, Any]] = [] - for plugin_name in provider_cfg.keys(): - plugin_key = str(plugin_name or "").strip().lower() + seen_plugin_keys: set[str] = set() + + for info in _iter_registered_plugin_infos(): + plugin_key = str(getattr(info, "canonical_name", "") or "").strip().lower() if not plugin_key: continue + seen_plugin_keys.add(plugin_key) - plugin_class = None - try: - plugin_class = get_plugin_class(plugin_key) - except Exception: - plugin_class = None - - if plugin_class is None: - checks.append( - { - "status": "UNKNOWN", - "name": provider_display_name(plugin_key), - "plugin": plugin_key, - "detail": "Not registered", - } - ) - continue + plugin = None + summary: dict[str, Any] + display_name = provider_display_name(plugin_key) + configured_entry: Any = None try: - plugin = plugin_class(config) + plugin = info.plugin_class(config) + configured_entry = plugin.plugin_config_root() summary = plugin.status_summary() except Exception as exc: summary = { "status": "DISABLED", - "name": provider_display_name(plugin_key), + "name": display_name, "plugin": plugin_key, "detail": str(exc), } + status = str(summary.get("status") or "UNKNOWN").strip().upper() or "UNKNOWN" + name = str(summary.get("name") or display_name) + detail = str(summary.get("detail") or "").strip() + if detail.lower() == "configured" and not configured_entry: + detail = "Available" + if not detail: + if status == "ENABLED": + detail = "Configured" if configured_entry else "Available" + else: + detail = "Not configured" if not configured_entry else "Unavailable" + checks.append( { - "status": str(summary.get("status") or "UNKNOWN"), - "name": str(summary.get("name") or provider_display_name(plugin_key)), + "status": status, + "name": name, "plugin": str(summary.get("plugin") or plugin_key), - "detail": str(summary.get("detail") or ""), + "instance": _resolve_startup_instance_text( + plugin, + summary, + configured_entry if configured_entry else provider_cfg.get(plugin_key), + ), + "detail": detail, "files": summary.get("files"), } ) + for plugin_name, raw_entry in sorted(provider_cfg.items()): + plugin_key = str(plugin_name or "").strip().lower() + if not plugin_key or plugin_key in seen_plugin_keys: + continue + checks.append( + { + "status": "UNKNOWN", + "name": provider_display_name(plugin_key), + "plugin": plugin_key, + "instance": ", ".join(_extract_configured_instance_names(raw_entry)), + "detail": "Not registered", + "files": None, + } + ) + return checks \ No newline at end of file diff --git a/cmdnat/config.py b/cmdnat/config.py index c43b41b..cedab9a 100644 --- a/cmdnat/config.py +++ b/cmdnat/config.py @@ -291,47 +291,11 @@ def _run(piped_result: Any, args: List[str], config: Dict[str, Any]) -> int: return 1 if not args: - # Check if we're in an interactive terminal and can launch a Textual modal if sys.stdin.isatty() and not piped_result: - try: - from textual.app import App - from TUI.modalscreen.config_modal import ConfigModal - - class ConfigApp(App): - def on_mount(self) -> None: - self.title = "Config Editor" - # We push the modal screen. It will sit on top of the main (blank) screen. - # Using a callback to exit the app when the modal is dismissed. - self.push_screen(ConfigModal(), callback=self.exit_on_close) - - def exit_on_close(self, result: Any = None) -> None: - self.exit() - - with ctx.suspend_live_progress(): - app = ConfigApp() - app.run() - - # After modal exits, show the new status table if possible - try: - from cmdlet._shared import SharedArgs - from cmdnat.status import CMDLET as STATUS_CMDLET - # We reload the config one more time because it might have changed on disk - fresh_config = load_config() - - # Force refresh of shared caches (especially stores) - SharedArgs._refresh_store_choices_cache(fresh_config) - # Update the global SharedArgs choices so cmdlets pick up new stores - SharedArgs.STORE.choices = SharedArgs.get_store_choices(fresh_config, force=True) - - return STATUS_CMDLET.exec(None, [], fresh_config) - except Exception: - pass - return 0 - except Exception as exc: - # Fall back to table display if Textual modal fails - print(f"Note: Could not launch interactive editor ({exc}). Showing configuration table:") - return _show_config_table(current_config) - + print( + "Interactive TUI config editor has been discontinued. " + "Showing configuration table instead." + ) return _show_config_table(current_config) key = args[0] diff --git a/cmdnat/status.py b/cmdnat/status.py index 910b435..51a66dc 100644 --- a/cmdnat/status.py +++ b/cmdnat/status.py @@ -4,14 +4,12 @@ import shutil from typing import Any, Dict, List from SYS.cmdlet_spec import Cmdlet -from SYS.config import resolve_cookies_path from SYS import pipeline as ctx from SYS.result_table import Table -from SYS.logger import set_debug, debug +from SYS.logger import set_debug from cmdnat._status_shared import ( add_startup_check as _add_startup_check, collect_plugin_startup_checks as _collect_plugin_startup_checks, - has_store_subtype as _has_store_subtype, ) CMDLET = Cmdlet( @@ -35,7 +33,6 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int: set_debug(debug_enabled) except Exception: pass - debug(f"Status check: debug_enabled={debug_enabled}") _add_startup_check(startup_table, "ENABLED" if debug_enabled else "DISABLED", "DEBUGGING") try: @@ -45,51 +42,8 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int: MPV() mpv_path = shutil.which("mpv") _add_startup_check(startup_table, "ENABLED", "MPV", detail=mpv_path or "Available") - debug(f"MPV check OK: path={mpv_path or 'Available'}") except Exception as exc: _add_startup_check(startup_table, "DISABLED", "MPV", detail=str(exc)) - debug(f"MPV check failed: {exc}") - - # Store Registry - store_registry = None - try: - from Store import Store as StoreRegistry - store_registry = StoreRegistry(config=config, suppress_debug=True) - try: - backends = store_registry.list_backends() - except Exception: - backends = [] - debug(f"StoreRegistry initialized. backends={backends}") - except Exception as exc: - debug(f"StoreRegistry initialization failed: {exc}") - store_registry = None - - # Hydrus - if _has_store_subtype(config, "hydrusnetwork"): - hcfg = config.get("store", {}).get("hydrusnetwork", {}) - for iname, icfg in hcfg.items(): - if not isinstance(icfg, dict): continue - nkey = str(icfg.get("NAME") or iname) - uval = str(icfg.get("URL") or "").strip() - debug(f"Hydrus network check: name={nkey}, url={uval}") - ok = bool(store_registry and store_registry.is_available(nkey)) - status = "ENABLED" if ok else "DISABLED" - files = None - detail = uval - if ok and store_registry: - try: - backend = store_registry[nkey] - files = getattr(backend, "total_count", None) - if files is None and hasattr(backend, "get_total_count"): - files = backend.get_total_count() - debug(f"Hydrus backend '{nkey}' available: files={files}") - except Exception as exc: - debug(f"Hydrus backend '{nkey}' check failed: {exc}") - else: - err = store_registry.get_backend_error(iname) if store_registry else None - debug(f"Hydrus backend '{nkey}' not available: {err}") - detail = f"{uval} - {err or 'Unavailable'}" - _add_startup_check(startup_table, status, nkey, store="hydrusnetwork", files=files, detail=detail) for check in _collect_plugin_startup_checks(config): _add_startup_check( @@ -97,27 +51,19 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int: str(check.get("status") or "UNKNOWN"), str(check.get("name") or "Plugin"), provider=str(check.get("plugin") or ""), + instance=str(check.get("instance") or ""), files=check.get("files"), detail=str(check.get("detail") or ""), ) - # Cookies - try: - cf = resolve_cookies_path(config) - _add_startup_check(startup_table, "FOUND" if cf else "MISSING", "Cookies", detail=str(cf) if cf else "Not found") - debug(f"Cookies: resolved cookiefile={cf}") - except Exception as exc: - debug(f"Cookies check failed: {exc}") - except Exception as exc: - debug(f"Status check failed: {exc}") + _add_startup_check(startup_table, "ERROR", "STATUS", detail=str(exc)) if startup_table.rows: # Mark as rendered to prevent CLI.py from auto-printing it to stdout # (avoiding duplication in TUI logs, while keeping it in TUI Results) setattr(startup_table, "_rendered_by_cmdlet", True) ctx.set_current_stage_table(startup_table) - debug(f"Status check completed: {len(startup_table.rows)} checks recorded") return 0 diff --git a/plugins/mpv/LUA/main.lua b/plugins/mpv/LUA/main.lua index 50832ea..a3bad49 100644 --- a/plugins/mpv/LUA/main.lua +++ b/plugins/mpv/LUA/main.lua @@ -4608,6 +4608,25 @@ function M._raw_format_selection_id(fmt) return display_id end +function M._raw_info_has_audio_variant_selectors(raw) + if type(raw) ~= 'table' or type(raw.formats) ~= 'table' then + return false + end + + for _, fmt in ipairs(raw.formats) do + if type(fmt) == 'table' then + local format_id = trim(tostring(fmt.format_id or '')) + local vcodec = tostring(fmt.vcodec or 'none') + local acodec = tostring(fmt.acodec or 'none') + if format_id:match('^%d+%-%w+$') and vcodec == 'none' and acodec ~= 'none' then + return true + end + end + end + + return false +end + function M._raw_format_picker_score(fmt) local note = trim(tostring(fmt and (fmt.format_note or fmt.format) or '')):lower() local format_id = trim(tostring(fmt and fmt.format_id or '')):lower() @@ -4715,6 +4734,14 @@ local function _cache_formats_from_raw_info(url, raw, source_label) return nil, 'missing url' end + if raw == nil then + raw = mp.get_property_native('ytdl-raw-info') + end + + if M._raw_info_has_audio_variant_selectors(raw) then + return nil, 'raw info requires validated probe' + end + local tbl, err = _build_formats_table_from_raw_info(url, raw) if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' then return nil, err or 'raw format conversion failed' diff --git a/plugins/mpv/mpv_ipc.py b/plugins/mpv/mpv_ipc.py index a4a4787..c6392fa 100644 --- a/plugins/mpv/mpv_ipc.py +++ b/plugins/mpv/mpv_ipc.py @@ -477,7 +477,7 @@ class MPV: pipeline += f" | file -add -plugin local -instance {_q(path or '')}" try: - from TUI.pipeline_runner import PipelineRunner # noqa: WPS433 + from SYS.pipeline_runner import PipelineRunner # noqa: WPS433 runner = PipelineRunner() result = runner.run_pipeline(pipeline) diff --git a/plugins/mpv/pipeline_helper.py b/plugins/mpv/pipeline_helper.py index 6f892ba..0b4c7ee 100644 --- a/plugins/mpv/pipeline_helper.py +++ b/plugins/mpv/pipeline_helper.py @@ -305,7 +305,7 @@ def _run_pipeline( json_output: bool = False, ) -> Dict[str, Any]: # Import after sys.path fix. - from TUI.pipeline_runner import PipelineRunner # noqa: WPS433 + from SYS.pipeline_runner import PipelineRunner # noqa: WPS433 def _json_safe(value: Any) -> Any: if value is None or isinstance(value, (str, int, float, bool)): diff --git a/plugins/youtube/__init__.py b/plugins/youtube/__init__.py deleted file mode 100644 index 7aaa6a9..0000000 --- a/plugins/youtube/__init__.py +++ /dev/null @@ -1,218 +0,0 @@ -from __future__ import annotations - -import sys -from typing import Any, Dict, Iterable, List, Optional - -from ProviderCore.base import Provider, SearchResult -from SYS.provider_helpers import TableProviderMixin -from SYS.logger import log - - -class YouTube(TableProviderMixin, Provider): - """YouTube video search provider using yt_dlp. - - This provider uses the new table system (strict ResultTable adapter pattern) for - consistent selection and auto-stage integration. It exposes videos as SearchResult - rows with metadata enrichment for: - - video_id: Unique YouTube video identifier - - uploader: Channel/creator name - - duration: Video length in seconds - - view_count: Number of views - - _selection_args: For @N expansion control and download-file routing - - SELECTION FLOW: - 1. User runs: search-file -plugin youtube "linux tutorial" - 2. Results show video rows with uploader, duration, views - 3. User selects a video: @1 - 4. Selection metadata routes to download-file with the YouTube URL - 5. download-file uses yt_dlp to download the video - """ - - TABLE_AUTO_STAGES = { - "youtube": ["download-file"], - } - # If the user provides extra args on the selection stage, forward them to download-file. - AUTO_STAGE_USE_SELECTION_ARGS = True - - @property - def preserve_order(self) -> bool: - return True - - def search( - self, - query: str, - limit: int = 10, - filters: Optional[Dict[str, - Any]] = None, - **kwargs: Any, - ) -> List[SearchResult]: - # Use the yt_dlp Python module (installed via requirements.txt). - try: - import yt_dlp # type: ignore - - ydl_opts: Dict[str, - Any] = { - "quiet": True, - "skip_download": True, - "extract_flat": True - } - with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[arg-type] - search_query = f"ytsearch{limit}:{query}" - info = ydl.extract_info(search_query, download=False) - entries = info.get("entries") or [] - results: List[SearchResult] = [] - for video_data in entries[:limit]: - title = video_data.get("title", "Unknown") - video_id = video_data.get("id", "") - url = video_data.get( - "url" - ) or f"https://youtube.com/watch?v={video_id}" - uploader = video_data.get("uploader", "Unknown") - duration = video_data.get("duration", 0) - view_count = video_data.get("view_count", 0) - - duration_str = ( - f"{int(duration // 60)}:{int(duration % 60):02d}" - if duration else "" - ) - views_str = f"{view_count:,}" if view_count else "" - - results.append( - SearchResult( - table="youtube", - title=title, - path=url, - detail=f"By: {uploader}", - annotations=[duration_str, - f"{views_str} views"], - media_kind="video", - columns=[ - ("Title", - title), - ("Uploader", - uploader), - ("Duration", - duration_str), - ("Views", - views_str), - ], - full_metadata={ - "video_id": video_id, - "uploader": uploader, - "duration": duration, - "view_count": view_count, - # Selection metadata for table system and @N expansion - "_selection_args": ["-url", url], - }, - ) - ) - return results - except Exception: - log("[youtube] yt_dlp import failed", file=sys.stderr) - return [] - - def validate(self) -> bool: - try: - - return True - except Exception: - return False - - -# Minimal provider registration for the new table system -try: - from SYS.result_table_adapters import register_plugin - from SYS.result_table_api import ResultModel, ColumnSpec, metadata_column, title_column - - def _convert_search_result_to_model(sr: Any) -> ResultModel: - """Convert YouTube SearchResult to ResultModel for strict table display.""" - d = sr.to_dict() if hasattr(sr, "to_dict") else (sr if isinstance(sr, dict) else {"title": getattr(sr, "title", str(sr))}) - title = d.get("title") or "" - path = d.get("path") or None - columns = d.get("columns") or getattr(sr, "columns", None) or [] - - # Extract metadata from columns and full_metadata - metadata: Dict[str, Any] = {} - for name, value in columns: - key = str(name or "").strip().lower() - if key in ("uploader", "duration", "views", "video_id"): - metadata[key] = value - - try: - fm = d.get("full_metadata") or {} - if isinstance(fm, dict): - for k, v in fm.items(): - metadata[str(k).strip().lower()] = v - except Exception: - pass - - return ResultModel( - title=str(title), - path=str(path) if path else None, - ext=None, - size_bytes=None, - metadata=metadata, - source="youtube" - ) - - def _adapter(items: Iterable[Any]) -> Iterable[ResultModel]: - """Adapter to convert SearchResults to ResultModels.""" - for it in items: - try: - yield _convert_search_result_to_model(it) - except Exception: - continue - - def _has_metadata(rows: List[ResultModel], key: str) -> bool: - """Check if any row has a given metadata key with a non-empty value.""" - for row in rows: - md = row.metadata or {} - if key in md: - val = md[key] - if val is None: - continue - if isinstance(val, str) and not val.strip(): - continue - return True - return False - - def _columns_factory(rows: List[ResultModel]) -> List[ColumnSpec]: - """Build column specifications from available metadata in rows.""" - cols = [title_column()] - if _has_metadata(rows, "uploader"): - cols.append(metadata_column("uploader", "Uploader")) - if _has_metadata(rows, "duration"): - cols.append(metadata_column("duration", "Duration")) - if _has_metadata(rows, "views"): - cols.append(metadata_column("views", "Views")) - return cols - - def _selection_fn(row: ResultModel) -> List[str]: - """Return selection args for @N expansion and auto-download integration. - - Uses explicit -url flag to ensure the YouTube URL is properly routed - to download-file for yt_dlp download processing. - """ - metadata = row.metadata or {} - - # Check for explicit selection args first - args = metadata.get("_selection_args") or metadata.get("selection_args") - if isinstance(args, (list, tuple)) and args: - return [str(x) for x in args if x is not None] - - # Fallback to direct URL - if row.path: - return ["-url", row.path] - - return ["-title", row.title or ""] - - register_plugin( - "youtube", - _adapter, - columns=_columns_factory, - selection_fn=_selection_fn, - metadata={"description": "YouTube video search using yt_dlp"}, - ) -except Exception: - # best-effort registration - pass diff --git a/plugins/ytdlp/__init__.py b/plugins/ytdlp/__init__.py index e1cdd9c..2f30941 100644 --- a/plugins/ytdlp/__init__.py +++ b/plugins/ytdlp/__init__.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple from urllib.parse import urlparse -from ProviderCore.base import Provider, SearchResult +from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments from SYS.provider_helpers import TableProviderMixin from SYS.logger import debug, log from SYS.models import DownloadError, DownloadMediaResult, DownloadOptions @@ -32,6 +32,7 @@ from tool.ytdlp import ( _read_text_file, collapse_picker_formats, format_for_table_selection, + get_display_format_id, get_selection_format_id, is_browseable_format, is_url_supported_by_ytdlp, @@ -357,6 +358,12 @@ def _format_id_for_query_index( return normalized or s_val candidate_formats = collapse_picker_formats(fmts, video_audio_suffix="bestaudio") + if s_val and not s_val.startswith("#"): + for item in candidate_formats: + if get_display_format_id(item) == s_val: + normalized = get_selection_format_id(item, video_audio_suffix="bestaudio") + return normalized or s_val + filtered_formats = candidate_formats if candidate_formats else list(fmts) if idx <= 0 or idx > len(filtered_formats): raise ValueError(f"Format index {idx} out of range") @@ -497,6 +504,10 @@ def _build_pipe_objects( class ytdlp(TableProviderMixin, Provider): """yt-dlp-backed search and direct download plugin.""" + PLUGIN_NAME = "ytdlp" + PLUGIN_ALIASES = ("youtube",) + SEARCH_QUERY_KEYS = ("search", "q") + @classmethod def url_patterns(cls) -> Tuple[str, ...]: try: @@ -529,10 +540,47 @@ class ytdlp(TableProviderMixin, Provider): TABLE_AUTO_STAGES = { "ytdlp.formatlist": ["download-file"], - "ytdlp.search": ["download-file"], + "youtube": ["download-file"], } AUTO_STAGE_USE_SELECTION_ARGS = True + def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]: + normalized_query, inline_args = parse_inline_query_arguments(query) + search_parts: List[str] = [] + + for key in self.SEARCH_QUERY_KEYS: + value = str(inline_args.pop(key, "") or "").strip() + if value: + search_parts.append(value) + + if normalized_query: + search_parts.append(normalized_query) + + resolved_query = " ".join(part for part in search_parts if part).strip() + if not resolved_query: + resolved_query = str(query or "").strip() + + filters: Dict[str, Any] = dict(inline_args or {}) + filters.setdefault("search_provider", "youtube") + return resolved_query, filters + + def get_table_type( + self, + query: str, + filters: Optional[Dict[str, Any]] = None, + ) -> str: + _ = query, filters + return "youtube" + + def get_table_title( + self, + query: str, + filters: Optional[Dict[str, Any]] = None, + ) -> str: + _ = filters + q = str(query or "").strip() or "*" + return f"YouTube: {q}" + def search( self, query: str, @@ -567,7 +615,7 @@ class ytdlp(TableProviderMixin, Provider): results.append( SearchResult( - table="ytdlp.search", + table="youtube", title=title, path=url, detail=f"By: {uploader}", @@ -1443,11 +1491,11 @@ try: return ["-title", row.title or ""] _register_table_plugin_once( - "ytdlp.search", + "youtube", _search_adapter, columns=_search_columns_factory, selection_fn=_search_selection_fn, - metadata={"description": "ytdlp video search using yt-dlp"}, + metadata={"description": "YouTube search using yt-dlp"}, ) except Exception as exc: debug(f"[ytdlp] Provider registration note: {exc}") diff --git a/scripts/cli_entry.py b/scripts/cli_entry.py index cc7dbcc..2c9141b 100644 --- a/scripts/cli_entry.py +++ b/scripts/cli_entry.py @@ -222,72 +222,13 @@ def _run_cli(clean_args: List[str]) -> int: def _run_gui(clean_args: List[str]) -> int: - """Run the TUI runner (PipelineHubApp). - - The TUI is imported lazily; if Textual or the TUI code is unavailable we - give a helpful error message and exit non‑zero. - """ - try: - repo_root = _ensure_repo_root_on_sys_path() - tui_mod: Optional[ModuleType] = None - selected_module: Optional[str] = None - last_exc: Optional[Exception] = None - - def _load_from_file(path: Path) -> ModuleType: - spec = importlib.util.spec_from_file_location( - "medeia_tui_entry", - path, - submodule_search_locations=[str(path.parent)], - ) - if spec is None or spec.loader is None: - raise ModuleNotFoundError(f"Cannot load TUI from {path}") - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) # type: ignore[attr-defined] - return module - - if repo_root is not None: - tui_file = repo_root / "TUI.py" - if tui_file.exists(): - try: - tui_mod = _load_from_file(tui_file) - selected_module = str(tui_file) - except Exception as exc: - last_exc = exc - if tui_mod is None: - for candidate in ("TUI.tui", "TUI"): - try: - tui_mod = importlib.import_module(candidate) - selected_module = candidate - break - except ModuleNotFoundError as exc: - last_exc = exc - if tui_mod is None: - raise last_exc or ModuleNotFoundError("No TUI module could be imported") - except Exception as exc: - print( - "Error: Unable to import TUI (Textual may not be installed):", - exc, - file=sys.stderr, - ) - return 2 - - try: - PipelineHubApp = getattr(tui_mod, "PipelineHubApp") - except AttributeError: - module_hint = selected_module or "TUI" - print( - f"Error: '{module_hint}' does not expose 'PipelineHubApp'", - file=sys.stderr, - ) - return 2 - - try: - app = PipelineHubApp() - app.run() - return 0 - except SystemExit as exc: - return int(getattr(exc, "code", 0) or 0) + """Report that the discontinued GUI/TUI mode is no longer available.""" + _ = clean_args + print( + "Error: GUI/TUI mode has been discontinued and is no longer available.", + file=sys.stderr, + ) + return 2 def main(argv: Optional[List[str]] = None) -> int: diff --git a/tool/ytdlp.py b/tool/ytdlp.py index 28ccb9f..9a28b74 100644 --- a/tool/ytdlp.py +++ b/tool/ytdlp.py @@ -386,6 +386,25 @@ def is_url_supported_by_ytdlp(url: str) -> bool: _FORMATS_CACHE: Dict[str, tuple[float, List[Dict[str, Any]]]] = {} + +def _audio_variant_base_selector(fmt: Any) -> Optional[str]: + if not isinstance(fmt, dict): + return None + + format_id = str(fmt.get("format_id") or "").strip() + if not format_id: + return None + + vcodec = str(fmt.get("vcodec", "none")) + acodec = str(fmt.get("acodec", "none")) + if vcodec != "none" or acodec == "none": + return None + + match = re.fullmatch(r"(?P\d+)-[A-Za-z0-9]+", format_id) + if not match: + return None + return match.group("base") + def list_formats( url: str, *, @@ -449,9 +468,26 @@ def list_formats( result_container[0] = None return + selector_cache: Dict[str, bool] = {} + + def _selector_has_matches(selector: str) -> bool: + cached = selector_cache.get(selector) + if cached is not None: + return cached + try: + matches = ydl.build_format_selector(selector)(info) + cached = any(True for _ in matches) + except Exception: + cached = False + selector_cache[selector] = cached + return cached + out: List[Dict[str, Any]] = [] for fmt in formats: if isinstance(fmt, dict): + base_selector = _audio_variant_base_selector(fmt) + if base_selector and not _selector_has_matches(base_selector): + fmt["_medios_selector_valid"] = False out.append(fmt) result_container[0] = out except Exception as exc: @@ -596,6 +632,9 @@ def is_browseable_format(fmt: Any) -> bool: """ if not isinstance(fmt, dict): return False + + if fmt.get("_medios_selector_valid") is False: + return False format_id = str(fmt.get("format_id") or "").strip() if not format_id: