From 39a84b3274f90a05ab433d6e0dd16d1486891cca Mon Sep 17 00:00:00 2001 From: Nose Date: Thu, 19 Feb 2026 20:38:54 -0800 Subject: [PATCH] g --- API/data/alldebrid.json | 11 +- Provider/internetarchive.py | 179 ++++- TUI.py | 1280 +++++++++++++++++++++++++++++++++-- TUI/pipeline_runner.py | 7 + TUI/tui.tcss | 67 +- 5 files changed, 1475 insertions(+), 69 deletions(-) diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index 58ea42b..2361a5d 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -92,7 +92,7 @@ "(hitfile\\.net/[a-z0-9A-Z]{4,9})" ], "regexp": "(hitf\\.(to|cc)/([a-z0-9A-Z]{4,9}))|(htfl\\.(net|to|cc)/([a-z0-9A-Z]{4,9}))|(hitfile\\.(net)/download/free/([a-z0-9A-Z]{4,9}))|((hitfile\\.net/[a-z0-9A-Z]{4,9}))", - "status": true + "status": false }, "mega": { "name": "mega", @@ -474,13 +474,14 @@ "domains": [ "katfile.com", "katfile.cloud", - "katfile.online" + "katfile.online", + "katfile.vip" ], "regexps": [ - "katfile\\.(cloud|online)/([0-9a-zA-Z]{12})", + "katfile\\.(cloud|online|vip)/([0-9a-zA-Z]{12})", "(katfile\\.com/[0-9a-zA-Z]{12})" ], - "regexp": "(katfile\\.(cloud|online)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))", + "regexp": "(katfile\\.(cloud|online|vip)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))", "status": false }, "mediafire": { @@ -774,7 +775,7 @@ "(worldbytez\\.(net|com)/[a-zA-Z0-9]{12})" ], "regexp": "(worldbytez\\.(net|com)/[a-zA-Z0-9]{12})", - "status": true + "status": false } }, "streams": { diff --git a/Provider/internetarchive.py b/Provider/internetarchive.py index b30e438..2f57978 100644 --- a/Provider/internetarchive.py +++ b/Provider/internetarchive.py @@ -4,6 +4,7 @@ import importlib import os import re import sys +import requests from pathlib import Path from typing import Any, Dict, List, Optional @@ -11,8 +12,9 @@ from urllib.parse import quote, unquote, urlparse from API.HTTP import _download_direct_file from ProviderCore.base import Provider, SearchResult -from SYS.utils import sanitize_filename +from SYS.utils import sanitize_filename, unique_path from SYS.logger import log +from SYS.config import get_provider_block # Helper for download-file: render selectable formats for a details URL. def maybe_show_formats_table( @@ -184,6 +186,96 @@ def _pick_provider_config(config: Any) -> Dict[str, Any]: return {} +def _pick_archive_credentials(config: Any) -> tuple[Optional[str], Optional[str]]: + """Resolve Archive.org credentials. + + Preference order: + 1) provider.internetarchive (email/username + password) + 2) provider.openlibrary (email + password) + """ + if not isinstance(config, dict): + return None, None + + ia_block = get_provider_block(config, "internetarchive") + if isinstance(ia_block, dict): + email = ( + ia_block.get("email") + or ia_block.get("username") + or ia_block.get("user") + ) + password = ia_block.get("password") + email_text = str(email).strip() if email else "" + password_text = str(password).strip() if password else "" + if email_text and password_text: + return email_text, password_text + + ol_block = get_provider_block(config, "openlibrary") + if isinstance(ol_block, dict): + email = ol_block.get("email") + password = ol_block.get("password") + email_text = str(email).strip() if email else "" + password_text = str(password).strip() if password else "" + if email_text and password_text: + return email_text, password_text + + return None, None + + +def _filename_from_response(url: str, response: requests.Response, suggested_filename: Optional[str] = None) -> str: + suggested = str(suggested_filename or "").strip() + if suggested: + guessed_ext = Path(str(_extract_download_filename_from_url(url) or "")).suffix + if Path(suggested).suffix: + return sanitize_filename(suggested) + merged = f"{suggested}{guessed_ext}" if guessed_ext else suggested + return sanitize_filename(merged) + + content_disposition = "" + try: + content_disposition = str(response.headers.get("content-disposition", "") or "") + except Exception: + content_disposition = "" + + if content_disposition: + m = re.search(r'filename\*?=(?:"([^"]+)"|([^;\s]+))', content_disposition) + if m: + candidate = (m.group(1) or m.group(2) or "").strip().strip('"') + if candidate: + return sanitize_filename(unquote(candidate)) + + extracted = _extract_download_filename_from_url(url) + if extracted: + return sanitize_filename(extracted) + + fallback = Path(urlparse(url).path).name or "download.bin" + return sanitize_filename(unquote(fallback)) + + +def _download_with_requests_session( + *, + session: requests.Session, + url: str, + output_dir: Path, + suggested_filename: Optional[str] = None, +) -> Path: + headers = { + "Referer": "https://archive.org/", + "Accept": "*/*", + } + response = session.get(url, headers=headers, stream=True, allow_redirects=True, timeout=120) + response.raise_for_status() + + filename = _filename_from_response(url, response, suggested_filename=suggested_filename) + out_path = unique_path(Path(output_dir) / filename) + + with open(out_path, "wb") as handle: + for chunk in response.iter_content(chunk_size=1024 * 256): + if chunk: + handle.write(chunk) + + return out_path + + def _looks_fielded_query(q: str) -> bool: low = (q or "").lower() return (":" in low) or (" and " in low) or (" or " @@ -476,6 +568,17 @@ class InternetArchive(Provider): @classmethod def config_schema(cls) -> List[Dict[str, Any]]: return [ + { + "key": "email", + "label": "Archive.org Email (restricted downloads)", + "default": "" + }, + { + "key": "password", + "label": "Archive.org Password (restricted downloads)", + "default": "", + "secret": True + }, { "key": "access_key", "label": "Access Key (for uploads)", @@ -542,6 +645,73 @@ class InternetArchive(Provider): except Exception: return False + def _download_with_archive_auth( + self, + *, + url: str, + output_dir: Path, + suggested_filename: Optional[str] = None, + ) -> Optional[Path]: + email, password = _pick_archive_credentials(self.config or {}) + if not email or not password: + return None + + try: + from Provider.openlibrary import OpenLibrary + except Exception as exc: + log(f"[internetarchive] OpenLibrary auth helper unavailable: {exc}", file=sys.stderr) + return None + + identifier = _extract_identifier_from_any(url) + session: Optional[requests.Session] = None + loaned = False + try: + session = OpenLibrary._archive_login(email, password) + + if identifier: + try: + session.get( + f"https://archive.org/details/{identifier}", + timeout=30, + allow_redirects=True, + ) + except Exception: + pass + try: + session.get( + f"https://archive.org/download/{identifier}", + timeout=30, + allow_redirects=True, + ) + except Exception: + pass + try: + session = OpenLibrary._archive_loan(session, identifier, verbose=False) + loaned = True + except Exception: + loaned = False + + return _download_with_requests_session( + session=session, + url=url, + output_dir=output_dir, + suggested_filename=suggested_filename, + ) + except Exception as exc: + log(f"[internetarchive] authenticated download failed: {exc}", file=sys.stderr) + return None + finally: + if session is not None: + if loaned and identifier: + try: + OpenLibrary._archive_return_loan(session, identifier) + except Exception: + pass + try: + OpenLibrary._archive_logout(session) + except Exception: + pass + @staticmethod def _media_kind_from_mediatype(mediatype: str) -> str: mt = str(mediatype or "").strip().lower() @@ -715,6 +885,13 @@ class InternetArchive(Provider): return None except Exception as exc: log(f"[internetarchive] direct file download failed, falling back to IA API: {exc}", file=sys.stderr) + auth_path = self._download_with_archive_auth( + url=raw_path, + output_dir=output_dir, + suggested_filename=suggested_filename, + ) + if auth_path is not None: + return auth_path ia = _ia() get_item = getattr(ia, "get_item", None) diff --git a/TUI.py b/TUI.py index 3761889..50dccd1 100644 --- a/TUI.py +++ b/TUI.py @@ -3,9 +3,11 @@ from __future__ import annotations import json +import os import re import sys import subprocess +import time from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple from rich.text import Text @@ -27,8 +29,13 @@ from textual.widgets import ( Select, Static, TextArea, + Tree, ) from textual.widgets.option_list import Option +try: + from textual.suggester import SuggestFromList +except Exception: # pragma: no cover - Textual version dependent + SuggestFromList = None # type: ignore[assignment] import logging logger = logging.getLogger(__name__) @@ -140,6 +147,41 @@ class TextPopup(ModalScreen[None]): self.dismiss(None) +class ActionMenuPopup(ModalScreen[Optional[str]]): + + def __init__(self, *, actions: List[Tuple[str, str]]) -> None: + super().__init__() + self._actions = list(actions or []) + + def compose(self) -> ComposeResult: + yield Static("Actions", id="popup-title") + with Vertical(id="actions-list"): + if self._actions: + for index, (label, _key) in enumerate(self._actions): + yield Button(str(label), id=f"actions-btn-{index}") + else: + yield Static("No actions available", id="actions-empty") + with Horizontal(id="actions-footer"): + yield Button("Close", id="actions-close") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "actions-close": + self.dismiss(None) + return + btn_id = str(getattr(event.button, "id", "") or "") + if not btn_id.startswith("actions-btn-"): + return + try: + index = int(btn_id.rsplit("-", 1)[-1]) + except Exception: + self.dismiss(None) + return + if 0 <= index < len(self._actions): + self.dismiss(str(self._actions[index][1])) + else: + self.dismiss(None) + + class TagEditorPopup(ModalScreen[None]): def __init__( @@ -412,6 +454,10 @@ class PipelineHubApp(App): Binding("ctrl+enter", "run_pipeline", "Run Pipeline"), + Binding("ctrl+s", + "save_inline_tags", + "Save Tags", + show=False), Binding("f5", "refresh_workers", "Refresh Workers"), @@ -438,11 +484,23 @@ class PipelineHubApp(App): self.worker_table: Optional[DataTable] = None self.status_panel: Optional[Static] = None self.current_result_table: Optional[Table] = None + self.inline_tags_output: Optional[TextArea] = None + self.metadata_tree: Optional[Tree[Any]] = None self.suggestion_list: Optional[OptionList] = None self._cmdlet_names: List[str] = [] + self._inline_autocomplete_enabled = False + self._inline_tags_original: List[str] = [] + self._inline_tags_store: str = "" + self._inline_tags_hash: str = "" + self._inline_tags_subject: Any = None + self._pending_pipeline_tags: List[str] = [] + self._pending_pipeline_tags_applied: bool = False self._pipeline_running = False self._pipeline_worker: Any = None + self._keep_results_for_current_run: bool = False self._selected_row_index: int = 0 + self._last_row_select_index: int = -1 + self._last_row_select_at: float = 0.0 self._zt_server_proc: Optional[subprocess.Popen] = None self._zt_server_last_config: Optional[str] = None @@ -459,16 +517,24 @@ class PipelineHubApp(App): id="pipeline-input" ) yield Button("Run", id="run-button") + yield Button("Actions", id="actions-button") yield Button("Tags", id="tags-button") yield Button("Metadata", id="metadata-button") yield Button("Relationships", id="relationships-button") yield Button("Config", id="config-button") yield Static("Ready", id="status-panel") - yield OptionList(id="cmd-suggestions") with Vertical(id="results-pane"): - yield Label("Results", classes="section-title") - yield DataTable(id="results-table") + with Horizontal(id="results-layout"): + with Vertical(id="results-list-pane"): + yield Label("Items", classes="section-title") + yield DataTable(id="results-table") + with Vertical(id="results-tags-pane"): + yield Label("Tags", classes="section-title") + yield TextArea(id="inline-tags-output") + with Vertical(id="results-meta-pane"): + yield Label("Metadata", classes="section-title") + yield Tree("Metadata", id="metadata-tree") with Vertical(id="bottom-pane"): yield Label("Store + Output", classes="section-title") @@ -494,25 +560,42 @@ class PipelineHubApp(App): self.log_output = self.query_one("#log-output", TextArea) self.store_select = self.query_one("#store-select", Select) self.path_input = self.query_one("#output-path", Input) - self.suggestion_list = self.query_one("#cmd-suggestions", OptionList) + try: + self.suggestion_list = self.query_one("#cmd-suggestions", OptionList) + except Exception: + self.suggestion_list = None + self.inline_tags_output = self.query_one("#inline-tags-output", TextArea) + self.metadata_tree = self.query_one("#metadata-tree", Tree) if self.suggestion_list: self.suggestion_list.display = False if self.results_table: self.results_table.cursor_type = "row" - self.results_table.zebra_stripes = True + self.results_table.zebra_stripes = False + try: + self.results_table.cell_padding = 0 + except Exception: + pass self.results_table.add_columns("Row", "Title", "Source", "File") if self.worker_table: self.worker_table.add_columns("ID", "Type", "Status", "Details") - def on_unmount(self) -> None: - pass + if self.inline_tags_output: + self.inline_tags_output.text = "" + + if self.metadata_tree: + try: + self.metadata_tree.root.label = "Metadata" + self.metadata_tree.root.remove_children() + self.metadata_tree.root.add("Select an item to view metadata") + self.metadata_tree.root.expand() + except Exception: + pass # Initialize the store choices cache at startup (filters disabled stores) try: from cmdlet._shared import SharedArgs - from SYS.config import load_config config = load_config() SharedArgs._refresh_store_choices_cache(config) except Exception: @@ -520,6 +603,7 @@ class PipelineHubApp(App): self._populate_store_options() self._load_cmdlet_names() + self._configure_inline_autocomplete() if self.executor.worker_manager: self.set_interval(2.0, self.refresh_workers) self.refresh_workers() @@ -540,6 +624,9 @@ class PipelineHubApp(App): except Exception: logger.exception("Failed to produce startup config summary") + def on_unmount(self) -> None: + pass + # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ @@ -579,13 +666,121 @@ class PipelineHubApp(App): return pipeline_text = self._apply_store_path_and_tags(pipeline_text) + pipeline_text = self._apply_pending_pipeline_tags(pipeline_text) + self._start_pipeline_execution(pipeline_text) + + def action_save_inline_tags(self) -> None: + if self._pipeline_running: + self.notify("Pipeline already running", severity="warning", timeout=3) + return + + editor = self.inline_tags_output + if editor is None or not bool(getattr(editor, "has_focus", False)): + return + + store_name = str(self._inline_tags_store or "").strip() + file_hash = str(self._inline_tags_hash or "").strip() + seeds = self._inline_tags_subject + selected_item = self._item_for_row_index(self._selected_row_index) + + item, store_name_fallback, file_hash_fallback = self._resolve_selected_item() + if not seeds: + seeds = item + if not store_name and store_name_fallback: + store_name = str(store_name_fallback) + if not file_hash and file_hash_fallback: + file_hash = str(file_hash_fallback) + file_hash = self._normalize_hash_text(file_hash) + + raw_text = "" + try: + raw_text = str(editor.text or "") + except Exception: + raw_text = "" + + desired = _dedup_preserve_order( + [ + str(line).strip() + for line in raw_text.replace("\r\n", "\n").split("\n") + if str(line).strip() + ] + ) + + # Contextual draft mode: no selected result context yet (e.g., pre-download tagging). + if selected_item is None and not file_hash: + self._pending_pipeline_tags = list(desired) + self._pending_pipeline_tags_applied = False + self._inline_tags_original = list(desired) + self._inline_tags_store = str(self._get_selected_store() or "") + self._inline_tags_hash = "" + self._inline_tags_subject = None + self._set_inline_tags(list(desired)) + self._set_status("Saved pending pipeline tags", level="success") + self.notify(f"Saved {len(desired)} pending tag(s)", timeout=3) + return + + if selected_item is not None and not file_hash: + self.notify( + "Selected item is missing a usable hash; cannot save tags to store", + severity="warning", + timeout=5, + ) + return + + if not store_name: + self.notify("Selected item missing store", severity="warning", timeout=4) + return + + current = _dedup_preserve_order(list(self._inline_tags_original or [])) + desired_set = {t.lower() for t in desired} + current_set = {t.lower() for t in current} + to_add = [t for t in desired if t.lower() not in current_set] + to_del = [t for t in current if t.lower() not in desired_set] + + if not to_add and not to_del: + self.notify("No tag changes", timeout=2) + return + + self._set_status("Saving tags…", level="info") + self._save_inline_tags_background( + store_name=store_name, + file_hash=file_hash, + seeds=seeds, + to_add=to_add, + to_del=to_del, + desired=desired, + ) + + def _start_pipeline_execution( + self, + pipeline_text: str, + *, + seeds: Optional[Any] = None, + seed_table: Optional[Any] = None, + clear_log: bool = True, + clear_results: bool = True, + keep_existing_results: bool = False, + ) -> None: + command = str(pipeline_text or "").strip() + if not command: + self.notify("Empty pipeline", severity="warning", timeout=3) + return self._pipeline_running = True + self._keep_results_for_current_run = bool(keep_existing_results) self._set_status("Running…", level="info") - self._clear_log() - self._append_log_line(f"$ {pipeline_text}") - self._clear_results() - self._pipeline_worker = self._run_pipeline_background(pipeline_text) + if self.suggestion_list: + try: + self.suggestion_list.display = False + self.suggestion_list.clear_options() # type: ignore[attr-defined] + except Exception: + pass + if clear_log: + self._clear_log() + self._append_log_line(f"$ {command}") + if clear_results: + self._clear_results() + self._pipeline_worker = self._run_pipeline_background(command, seeds, seed_table) @on(Input.Changed, "#pipeline-input") def on_pipeline_input_changed(self, event: Input.Changed) -> None: @@ -606,6 +801,7 @@ class PipelineHubApp(App): suggestion ) self.command_input.value = new_text + self._move_command_cursor_to_end() self.suggestion_list.display = False self.command_input.focus() @@ -618,6 +814,8 @@ class PipelineHubApp(App): def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "run-button": self.action_run_pipeline() + elif event.button.id == "actions-button": + self._open_actions_popup() elif event.button.id == "tags-button": self._open_tags_popup() elif event.button.id == "metadata-button": @@ -656,7 +854,9 @@ class PipelineHubApp(App): # Reload cmdlet names (in case new ones were added or indexed) self._load_cmdlet_names(force=True) # Optionally update executor config if needed - self.executor._config_loader.load() + cfg_loader = getattr(self.executor, "_config_loader", None) + if cfg_loader is not None and hasattr(cfg_loader, "load"): + cfg_loader.load() self.notify("Configuration reloaded") @@ -669,6 +869,11 @@ class PipelineHubApp(App): def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id == "pipeline-input": + if self.suggestion_list: + try: + self.suggestion_list.display = False + except Exception: + pass self.action_run_pipeline() def on_key(self, event: Key) -> None: @@ -678,6 +883,10 @@ class PipelineHubApp(App): if not self.command_input or not self.command_input.has_focus: return suggestion = self._get_first_suggestion() + if not suggestion: + suggestion = self._best_cmdlet_match( + self._current_cmd_prefix(str(self.command_input.value or "")) + ) if not suggestion: return @@ -685,6 +894,7 @@ class PipelineHubApp(App): str(self.command_input.value or ""), suggestion ) + self._move_command_cursor_to_end() if self.suggestion_list: self.suggestion_list.display = False event.prevent_default() @@ -705,6 +915,61 @@ class PipelineHubApp(App): logger.exception("Error retrieving first suggestion from suggestion list") return "" + def _move_command_cursor_to_end(self) -> None: + if not self.command_input: + return + value = str(self.command_input.value or "") + end_pos = len(value) + + try: + self.command_input.cursor_position = end_pos + return + except Exception: + pass + + try: + self.command_input.cursor_pos = end_pos # type: ignore[attr-defined] + return + except Exception: + pass + + for method_name in ("action_end", "action_cursor_end", "end"): + method = getattr(self.command_input, method_name, None) + if callable(method): + try: + method() + return + except Exception: + continue + + def _best_cmdlet_match(self, prefix: str) -> str: + pfx = str(prefix or "").strip().lower() + if not pfx: + return "" + for name in self._cmdlet_names: + try: + candidate = str(name) + except Exception: + continue + if candidate.lower().startswith(pfx): + return candidate + return "" + + def _configure_inline_autocomplete(self) -> None: + self._inline_autocomplete_enabled = False + if not self.command_input: + return + if SuggestFromList is None: + return + try: + self.command_input.suggester = SuggestFromList( + list(self._cmdlet_names), + case_sensitive=False, + ) + self._inline_autocomplete_enabled = True + except Exception: + self._inline_autocomplete_enabled = False + def _populate_store_options(self) -> None: """Populate the store dropdown from the configured Store registry.""" if not self.store_select: @@ -816,6 +1081,33 @@ class PipelineHubApp(App): return joined + def _apply_pending_pipeline_tags(self, pipeline_text: str) -> str: + command = str(pipeline_text or "").strip() + pending = list(self._pending_pipeline_tags or []) + if not command or not pending: + self._pending_pipeline_tags_applied = False + return command + + low = command.lower() + if "add-tag" in low: + # User already controls tag stage explicitly. + self._pending_pipeline_tags_applied = False + return command + + # Apply draft tags when pipeline stores/emits files via add-file. + if "add-file" not in low: + self._pending_pipeline_tags_applied = False + return command + + tag_args = " ".join(json.dumps(t) for t in pending if str(t).strip()) + if not tag_args: + self._pending_pipeline_tags_applied = False + return command + + self._pending_pipeline_tags_applied = True + self.notify(f"Applying {len(pending)} pending tag(s) after pipeline", timeout=3) + return f"{command} | add-tag {tag_args}" + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: if not self.results_table or event.control is not self.results_table: return @@ -823,15 +1115,72 @@ class PipelineHubApp(App): if index < 0: index = 0 self._selected_row_index = index + self._refresh_inline_detail_panels(index) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if not self.results_table or event.control is not self.results_table: + return + index = int(event.cursor_row or 0) + if index < 0: + index = 0 + self._selected_row_index = index + self._refresh_inline_detail_panels(index) + if self._is_probable_row_double_click(index): + self._handle_row_double_click(index) + + def _is_probable_row_double_click(self, index: int) -> bool: + now = time.monotonic() + same_row = (int(index) == int(self._last_row_select_index)) + close_in_time = (now - float(self._last_row_select_at)) <= 0.45 + is_double = bool(same_row and close_in_time) + self._last_row_select_index = int(index) + self._last_row_select_at = now + return is_double + + def _handle_row_double_click(self, index: int) -> None: + item = self._item_for_row_index(index) + if item is None: + return + + metadata = self._normalize_item_metadata(item) + table_hint = self._extract_table_hint(metadata) + if not self._is_tidal_artist_context(metadata, table_hint=table_hint): + return + + seed_items = self._current_seed_items() + if not seed_items: + self.notify("No current result items available for artist selection", severity="warning", timeout=3) + return + + row_num = int(index or 0) + 1 + if row_num < 1: + row_num = 1 + + # Mirror CLI behavior: selecting artist row runs @N and opens album list. + self._start_pipeline_execution( + f"@{row_num}", + seeds=seed_items, + seed_table=self.current_result_table, + clear_log=False, + clear_results=False, + keep_existing_results=False, + ) # ------------------------------------------------------------------ # Pipeline execution helpers # ------------------------------------------------------------------ @work(exclusive=True, thread=True) - def _run_pipeline_background(self, pipeline_text: str) -> None: + def _run_pipeline_background( + self, + pipeline_text: str, + seeds: Optional[Any] = None, + seed_table: Optional[Any] = None, + ) -> None: try: run_result = self.executor.run_pipeline( pipeline_text, + seeds=seeds, + seed_table=seed_table, on_log=self._log_from_worker ) except Exception as exc: @@ -847,6 +1196,16 @@ class PipelineHubApp(App): def _on_pipeline_finished(self, run_result: PipelineRunResult) -> None: self._pipeline_running = False self._pipeline_worker = None + keep_existing_results = bool(self._keep_results_for_current_run) + self._keep_results_for_current_run = False + pending_applied = bool(self._pending_pipeline_tags_applied) + pending_count = len(self._pending_pipeline_tags) + if self.suggestion_list: + try: + self.suggestion_list.display = False + self.suggestion_list.clear_options() # type: ignore[attr-defined] + except Exception: + pass status_level = "success" if run_result.success else "error" status_text = "Completed" if run_result.success else "Failed" self._set_status(status_text, level=status_level) @@ -857,8 +1216,16 @@ class PipelineHubApp(App): severity="error", timeout=6 ) + if pending_applied and pending_count: + self.notify("Pending tags were retained (pipeline failed)", severity="warning", timeout=4) else: - self.notify("Pipeline completed", timeout=3) + if pending_applied and pending_count: + self._pending_pipeline_tags = [] + self.notify(f"Pipeline completed; applied {pending_count} pending tag(s)", timeout=4) + else: + self.notify("Pipeline completed", timeout=3) + + self._pending_pipeline_tags_applied = False if run_result.stdout.strip(): self._append_log_line("stdout:") @@ -873,19 +1240,30 @@ class PipelineHubApp(App): summary += f" ({stage.error})" self._append_log_line(summary) - emitted = run_result.emitted - if isinstance(emitted, list): - self.result_items = emitted - elif emitted: - self.result_items = [emitted] - else: - self.result_items = [] + if not keep_existing_results: + emitted = run_result.emitted + if isinstance(emitted, list): + self.result_items = emitted + elif emitted: + self.result_items = [emitted] + else: + self.result_items = [] - self.current_result_table = run_result.result_table - self._populate_results_table() - self.refresh_workers() - if self.result_items: + self.current_result_table = run_result.result_table self._selected_row_index = 0 + self._populate_results_table() + self.refresh_workers() + + def _current_seed_items(self) -> List[Any]: + if self.current_result_table and getattr(self.current_result_table, "rows", None): + items: List[Any] = [] + for idx in range(len(self.current_result_table.rows)): + item = self._item_for_row_index(idx) + if item is not None: + items.append(item) + if items: + return items + return list(self.result_items or []) def _log_from_worker(self, message: str) -> None: self.call_from_thread(self._append_log_line, message) @@ -893,57 +1271,557 @@ class PipelineHubApp(App): # ------------------------------------------------------------------ # UI helpers # ------------------------------------------------------------------ + @staticmethod + def _extract_title_for_item(item: Any) -> str: + if isinstance(item, dict): + for key in ("title", "name", "path", "url", "hash"): + value = item.get(key) + if value is not None: + text = str(value).strip() + if text: + return text + metadata = item.get("metadata") + if isinstance(metadata, dict): + for key in ("title", "name"): + value = metadata.get(key) + if value is not None: + text = str(value).strip() + if text: + return text + return str(item) + try: + for key in ("title", "name", "path", "url", "hash"): + value = getattr(item, key, None) + if value is not None: + text = str(value).strip() + if text: + return text + except Exception: + pass + return str(item) + + def _item_for_row_index(self, index: int) -> Any: + idx = int(index or 0) + if idx < 0: + return None + + if self.current_result_table and 0 <= idx < len(getattr(self.current_result_table, "rows", []) or []): + row = self.current_result_table.rows[idx] + payload = getattr(row, "payload", None) + if payload is not None: + return payload + src_idx = getattr(row, "source_index", None) + if isinstance(src_idx, int) and 0 <= src_idx < len(self.result_items): + return self.result_items[src_idx] + + if 0 <= idx < len(self.result_items): + return self.result_items[idx] + + return None + + @staticmethod + def _split_tag_text(raw: Any) -> List[str]: + text = str(raw or "").strip() + if not text: + return [] + if "\n" in text or "," in text: + out = [] + for part in re.split(r"[\n,]", text): + p = str(part or "").strip() + if p: + out.append(p) + return out + return [text] + + @staticmethod + def _normalize_hash_text(raw_hash: Any) -> str: + value = str(raw_hash or "").strip().lower() + if len(value) == 64 and all(ch in "0123456789abcdef" for ch in value): + return value + return "" + + def _extract_hash_from_nested(self, value: Any) -> str: + target_keys = {"hash", "hash_hex", "file_hash", "sha256"} + + def _scan(node: Any, depth: int = 0) -> str: + if depth > 8: + return "" + if isinstance(node, dict): + for key in target_keys: + if key in node: + normalized = self._normalize_hash_text(node.get(key)) + if normalized: + return normalized + for child in node.values(): + found = _scan(child, depth + 1) + if found: + return found + return "" + if isinstance(node, (list, tuple, set)): + for child in node: + found = _scan(child, depth + 1) + if found: + return found + return "" + + return _scan(value) + + def _fetch_store_tags(self, store_name: str, file_hash: str) -> Optional[List[str]]: + store_key = str(store_name or "").strip() + hash_key = self._normalize_hash_text(file_hash) + if not store_key or not hash_key: + return None + + try: + cfg = load_config() or {} + except Exception: + cfg = {} + + try: + registry = StoreRegistry(config=cfg, suppress_debug=True) + except Exception: + return None + + match = None + normalized_store = store_key.lower() + for name in registry.list_backends(): + if str(name or "").strip().lower() == normalized_store: + match = name + break + + if match is None: + return None + + try: + backend = registry[match] + except KeyError: + return None + + try: + tags, _src = backend.get_tag(hash_key, config=cfg) + if not tags: + return [] + filtered = [str(t).strip() for t in tags if str(t).strip()] + return _dedup_preserve_order(filtered) + except Exception: + return None + + def _extract_tags_from_nested(self, value: Any) -> List[str]: + tags: List[str] = [] + + def _add_tag(candidate: Any) -> None: + if candidate is None: + return + if isinstance(candidate, (list, tuple, set)): + for entry in candidate: + _add_tag(entry) + return + if isinstance(candidate, str): + tags.extend(self._split_tag_text(candidate)) + return + text = str(candidate).strip() + if text: + tags.append(text) + + def _walk(node: Any) -> None: + if isinstance(node, dict): + for key, child in node.items(): + k = str(key or "").strip().lower() + if k in {"tag", "tags"}: + _add_tag(child) + _walk(child) + return + if isinstance(node, (list, tuple, set)): + for child in node: + _walk(child) + + _walk(value) + return _dedup_preserve_order(tags) + + @staticmethod + def _normalize_item_metadata(item: Any) -> Dict[str, Any]: + if isinstance(item, dict): + data: Dict[str, Any] = {} + for key, value in item.items(): + k = str(key or "").strip() + if k in {"columns", "_selection_args", "_selection_action"}: + continue + data[k] = value + return data + + try: + as_dict = getattr(item, "to_dict", None) + if callable(as_dict): + value = as_dict() + if isinstance(value, dict): + return dict(value) + except Exception: + pass + + return {"value": str(item)} + + @staticmethod + def _collect_metadata_keys(value: Any, out: Optional[set[str]] = None, depth: int = 0) -> set[str]: + target = out if out is not None else set() + if depth > 10: + return target + if isinstance(value, dict): + for key, child in value.items(): + key_text = str(key or "").strip().lower() + if key_text: + target.add(key_text) + PipelineHubApp._collect_metadata_keys(child, target, depth + 1) + return target + if isinstance(value, (list, tuple, set)): + for child in value: + PipelineHubApp._collect_metadata_keys(child, target, depth + 1) + return target + + @staticmethod + def _extract_nested_value_for_keys(value: Any, target_keys: set[str], depth: int = 0) -> str: + if depth > 10: + return "" + if isinstance(value, dict): + for key, child in value.items(): + key_text = str(key or "").strip().lower() + if key_text in target_keys: + val_text = str(child or "").strip() + if val_text: + return val_text + nested = PipelineHubApp._extract_nested_value_for_keys(child, target_keys, depth + 1) + if nested: + return nested + return "" + if isinstance(value, (list, tuple, set)): + for child in value: + nested = PipelineHubApp._extract_nested_value_for_keys(child, target_keys, depth + 1) + if nested: + return nested + return "" + + def _extract_table_hint(self, metadata: Dict[str, Any]) -> str: + hint = self._extract_nested_value_for_keys(metadata, {"table", "source_table", "table_name"}) + if hint: + return str(hint).strip().lower() + try: + current = self.current_result_table + table_attr = str(getattr(current, "table", "") or "").strip().lower() if current else "" + if table_attr: + return table_attr + except Exception: + pass + return "" + + def _is_youtube_context(self, metadata: Dict[str, Any], *, table_hint: str, url_value: str) -> bool: + if "youtube" in str(table_hint or ""): + return True + if isinstance(url_value, str): + low_url = url_value.lower() + if ("youtube.com" in low_url) or ("youtu.be" in low_url): + return True + keys = self._collect_metadata_keys(metadata) + return any("youtube" in key for key in keys) + + def _is_tidal_track_context(self, metadata: Dict[str, Any], *, table_hint: str) -> bool: + table_low = str(table_hint or "").strip().lower() + if ("tidal.track" in table_low) or ("tidal.album" in table_low): + return True + keys = self._collect_metadata_keys(metadata) + return "tidal.track" in keys + + def _is_tidal_artist_context(self, metadata: Dict[str, Any], *, table_hint: str) -> bool: + table_low = str(table_hint or "").strip().lower() + if "tidal.artist" in table_low: + return True + keys = self._collect_metadata_keys(metadata) + has_artist = "tidal.artist" in keys + has_track = "tidal.track" in keys + return bool(has_artist and not has_track) + + def _set_inline_tags(self, tags: List[str]) -> None: + if not self.inline_tags_output: + return + if tags: + self.inline_tags_output.text = "\n".join(tags) + else: + self.inline_tags_output.text = "" + + def _set_metadata_tree(self, metadata: Dict[str, Any]) -> None: + if not self.metadata_tree: + return + try: + root = self.metadata_tree.root + root.label = "Metadata" + root.remove_children() + + def _trim(value: Any) -> str: + text = str(value) + if len(text) > 220: + return text[:217] + "..." + return text + + def _render_node(parent: Any, key: str, value: Any, depth: int = 0) -> None: + if depth > 8: + parent.add(f"{key}: ...") + return + + if isinstance(value, dict): + branch = parent.add(f"{key}") + if not value: + branch.add("{}") + return + for child_key, child_value in value.items(): + _render_node(branch, str(child_key), child_value, depth + 1) + return + + if isinstance(value, (list, tuple, set)): + items = list(value) + branch = parent.add(f"{key} [{len(items)}]") + max_items = 50 + for i, child in enumerate(items[:max_items]): + _render_node(branch, f"[{i}]", child, depth + 1) + if len(items) > max_items: + branch.add(f"... {len(items) - max_items} more") + return + + parent.add(f"{key}: {_trim(value)}") + + if metadata: + for key, value in metadata.items(): + _render_node(root, str(key), value) + else: + root.add("No metadata") + + root.expand() + except Exception: + logger.exception("Failed to render metadata tree") + + def _clear_inline_detail_panels(self) -> None: + pending = list(self._pending_pipeline_tags or []) + self._inline_tags_original = list(pending) + self._inline_tags_store = str(self._get_selected_store() or "") + self._inline_tags_hash = "" + self._inline_tags_subject = None + self._set_inline_tags(pending) + self._set_metadata_tree({}) + + def _refresh_inline_detail_panels(self, index: Optional[int] = None) -> None: + idx = int(self._selected_row_index if index is None else index) + item = self._item_for_row_index(idx) + if item is None: + pending = list(self._pending_pipeline_tags or []) + self._inline_tags_original = list(pending) + self._inline_tags_store = str(self._get_selected_store() or "") + self._inline_tags_hash = "" + self._inline_tags_subject = None + self._set_inline_tags(pending) + self._set_metadata_tree({}) + return + + metadata = self._normalize_item_metadata(item) + tags = self._extract_tags_from_nested(metadata) + _, store_name, file_hash = self._resolve_selected_item() + resolved_hash = self._normalize_hash_text(file_hash) + if not resolved_hash: + resolved_hash = self._extract_hash_from_nested(metadata) + self._inline_tags_original = list(tags) + self._inline_tags_store = str(store_name or "").strip() + self._inline_tags_hash = resolved_hash + self._inline_tags_subject = item + self._set_inline_tags(tags) + self._set_metadata_tree(metadata) + + @work(thread=True) + def _save_inline_tags_background( + self, + *, + store_name: str, + file_hash: str, + seeds: Any, + to_add: List[str], + to_del: List[str], + desired: List[str], + ) -> None: + failures: List[str] = [] + runner = self.executor + store_tok = json.dumps(str(store_name)) + normalized_hash = self._normalize_hash_text(file_hash) + query_chunk = ( + f" -query {json.dumps(f'hash:{normalized_hash}')}" if normalized_hash else "" + ) + + try: + if to_del: + del_args = " ".join(json.dumps(t) for t in to_del) + del_cmd = f"delete-tag -store {store_tok}{query_chunk} {del_args}" + del_res = runner.run_pipeline(del_cmd, seeds=seeds, isolate=True) + if not getattr(del_res, "success", False): + failures.append( + str( + getattr(del_res, "error", "") + or getattr(del_res, "stderr", "") + or "delete-tag failed" + ).strip() + ) + + if to_add: + add_args = " ".join(json.dumps(t) for t in to_add) + add_cmd = f"add-tag -store {store_tok}{query_chunk} {add_args}" + add_res = runner.run_pipeline(add_cmd, seeds=seeds, isolate=True) + if not getattr(add_res, "success", False): + failures.append( + str( + getattr(add_res, "error", "") + or getattr(add_res, "stderr", "") + or "add-tag failed" + ).strip() + ) + + if failures: + msg = failures[0] if failures else "Tag save failed" + self.call_from_thread( + lambda: self._set_status(f"Tag save failed: {msg}", level="error") + ) + self.call_from_thread(self.notify, f"Tag save failed: {msg}", severity="error", timeout=6) + return + + verified_tags = self._fetch_store_tags(store_name, normalized_hash) if normalized_hash else None + if verified_tags is not None: + desired_lower = {str(t).strip().lower() for t in desired if str(t).strip()} + verified_lower = { + str(t).strip().lower() + for t in verified_tags + if str(t).strip() + } + missing = [t for t in desired if str(t).strip().lower() not in verified_lower] + if desired_lower and missing: + preview = ", ".join(missing[:3]) + if len(missing) > 3: + preview += ", ..." + msg = f"Save verification failed; missing tag(s): {preview}" + self.call_from_thread( + lambda: self._set_status(msg, level="error") + ) + self.call_from_thread( + self.notify, + msg, + severity="error", + timeout=6, + ) + return + + final_tags = list(verified_tags) if verified_tags is not None else list(desired) + + def _apply_success() -> None: + self._inline_tags_original = list(final_tags) + self._inline_tags_store = str(store_name or "").strip() + self._inline_tags_hash = normalized_hash + self._inline_tags_subject = seeds + self._set_inline_tags(list(final_tags)) + if normalized_hash: + try: + self.refresh_tag_overlay(store_name, normalized_hash, list(final_tags), seeds) + except Exception: + logger.exception("Failed to refresh tag overlay after inline save") + self._set_status("Tags saved", level="success") + self.notify(f"Saved tags (+{len(to_add)}, -{len(to_del)})", timeout=3) + + self.call_from_thread(_apply_success) + except Exception as exc: + self.call_from_thread( + lambda: self._set_status( + f"Tag save failed: {type(exc).__name__}", + level="error", + ) + ) + self.call_from_thread( + self.notify, + f"Tag save failed: {type(exc).__name__}: {exc}", + severity="error", + timeout=6, + ) + def _populate_results_table(self) -> None: if not self.results_table: return self.results_table.clear(columns=True) - if self.current_result_table: - # Determine headers - prefer actual rows if present - headers = ["#"] - if self.current_result_table.rows: - first_row = self.current_result_table.rows[0] - headers += [col.name for col in first_row.columns] - else: - # Fallback headers for empty but known table types - title = str(getattr(self.current_result_table, "title", "") or "").strip() - if title == "Tags": - headers += ["Tag", "Store"] - elif title == "Metadata" or "metadata" in title.lower(): - headers += ["Field", "Value"] - elif title == "URLs": - headers += ["URL", "Type"] - else: - headers += ["Result"] # Generic fallback - - self.results_table.add_columns(*headers) + def _add_columns_with_widths(headers: List[str], widths: List[int]) -> None: + table = self.results_table + if table is None: + return + for i, header in enumerate(headers): + width = max(1, int(widths[i])) if i < len(widths) else max(1, len(str(header))) + try: + table.add_column(str(header), width=width) + except Exception: + table.add_column(str(header)) - if self.current_result_table.rows: - rows = self.current_result_table.to_datatable_rows() - for idx, row_values in enumerate(rows, 1): - row_style = get_result_table_row_style(idx - 1) - self.results_table.add_row( - Text(str(idx), style=row_style), - *[Text(str(value), style=row_style) for value in row_values], - key=str(idx - 1), - ) + def _pad_cell(value: Any, width: int, style: str) -> Text: + text = str(value) + if width > 0 and len(text) < width: + text = text + (" " * (width - len(text))) + return Text(text, style=style) + + if self.current_result_table and self.current_result_table.rows: + headers = ["#", "Title"] + normalized_rows: List[List[str]] = [] + + for idx in range(len(self.current_result_table.rows)): + item = self._item_for_row_index(idx) + title_value = self._extract_title_for_item(item) + normalized_rows.append([str(idx + 1), title_value]) + + widths = [len(h) for h in headers] + for row in normalized_rows: + for col_idx, cell in enumerate(row): + cell_len = len(str(cell)) + if cell_len > widths[col_idx]: + widths[col_idx] = cell_len + + _add_columns_with_widths(headers, widths) + + for idx, row in enumerate(normalized_rows, 1): + row_style = get_result_table_row_style(idx - 1) + styled_cells = [ + _pad_cell(cell, widths[col_idx], row_style) + for col_idx, cell in enumerate(row) + ] + self.results_table.add_row(*styled_cells, key=str(idx - 1)) else: # Fallback or empty state - self.results_table.add_columns("Row", "Title", "Source", "File") + headers = ["Row", "Title", "Source", "File"] if not self.result_items: + self.results_table.add_columns(*headers) self.results_table.add_row("—", "No results", "", "") + self._clear_inline_detail_panels() return # Fallback for items without a table + raw_rows: List[List[str]] = [] for idx, item in enumerate(self.result_items, start=1): + raw_rows.append([str(idx), str(item), "—", "—"]) + + widths = [len(h) for h in headers] + for row in raw_rows: + for col_idx, cell in enumerate(row): + cell_len = len(str(cell)) + if cell_len > widths[col_idx]: + widths[col_idx] = cell_len + + _add_columns_with_widths(headers, widths) + + for idx, row in enumerate(raw_rows, start=1): row_style = get_result_table_row_style(idx - 1) - self.results_table.add_row( - Text(str(idx), style=row_style), - Text(str(item), style=row_style), - Text("—", style=row_style), - Text("—", style=row_style), - key=str(idx - 1) - ) + styled_cells = [ + _pad_cell(cell, widths[col_idx], row_style) + for col_idx, cell in enumerate(row) + ] + self.results_table.add_row(*styled_cells, key=str(idx - 1)) + + if self._item_for_row_index(self._selected_row_index) is None: + self._selected_row_index = 0 + self._refresh_inline_detail_panels(self._selected_row_index) def refresh_tag_overlay(self, store_name: str, @@ -993,6 +1871,7 @@ class PipelineHubApp(App): except Exception: logger.exception("Failed to load cmdlet names") self._cmdlet_names = [] + self._configure_inline_autocomplete() def _update_syntax_status(self, text: str) -> None: if self._pipeline_running: @@ -1011,6 +1890,13 @@ class PipelineHubApp(App): self._set_status("Ready", level="info") def _update_suggestions(self, text: str) -> None: + if self._inline_autocomplete_enabled: + if self.suggestion_list: + try: + self.suggestion_list.display = False + except Exception: + pass + return if not self.suggestion_list: return raw = str(text or "") @@ -1254,6 +2140,275 @@ class PipelineHubApp(App): lines.append(str(relationships)) self.push_screen(TextPopup(title="Relationships", text="\n".join(lines))) + def _build_action_context(self) -> Dict[str, Any]: + item, store_name, file_hash = self._resolve_selected_item() + metadata = self._normalize_item_metadata(item) if item is not None else {} + normalized_hash = self._normalize_hash_text(file_hash) + + def _candidate_values() -> List[str]: + out: List[str] = [] + sources: List[Any] = [item, metadata] + for src in sources: + if not isinstance(src, dict): + continue + for key in ("url", "source_url", "target", "path", "file_path"): + raw = src.get(key) + if raw is None: + continue + text = str(raw).strip() + if text: + out.append(text) + return out + + url_value = "" + path_value = "" + for value in _candidate_values(): + low = value.lower() + if (low.startswith("http://") or low.startswith("https://")) and not url_value: + url_value = value + continue + if not path_value: + try: + p = Path(value) + if p.exists(): + path_value = str(p) + except Exception: + pass + + table_hint = self._extract_table_hint(metadata) + is_youtube_context = self._is_youtube_context(metadata, table_hint=table_hint, url_value=url_value) + is_tidal_track_context = self._is_tidal_track_context(metadata, table_hint=table_hint) + is_tidal_artist_context = self._is_tidal_artist_context(metadata, table_hint=table_hint) + can_play_mpv = bool((str(store_name or "").strip() and normalized_hash) or is_youtube_context or is_tidal_track_context) + + return { + "item": item, + "store": str(store_name or "").strip(), + "hash": normalized_hash, + "url": url_value, + "path": path_value, + "selected_store": str(self._get_selected_store() or "").strip(), + "table_hint": table_hint, + "is_youtube_context": is_youtube_context, + "is_tidal_track_context": is_tidal_track_context, + "is_tidal_artist_context": is_tidal_artist_context, + "can_play_mpv": can_play_mpv, + } + + def _build_context_actions(self, ctx_obj: Dict[str, Any]) -> List[Tuple[str, str]]: + actions: List[Tuple[str, str]] = [] + url_value = str(ctx_obj.get("url") or "").strip() + path_value = str(ctx_obj.get("path") or "").strip() + store_name = str(ctx_obj.get("store") or "").strip() + hash_value = str(ctx_obj.get("hash") or "").strip() + selected_store = str(ctx_obj.get("selected_store") or "").strip() + + if url_value: + actions.append(("Open URL", "open_url")) + + if path_value: + actions.append(("Open File", "open_file")) + actions.append(("Open File Folder", "open_folder")) + actions.append(("Copy File Path", "copy_path")) + + can_play_mpv = bool(ctx_obj.get("can_play_mpv", False)) + + if can_play_mpv: + actions.append(("Play in MPV", "play_mpv")) + + if store_name and hash_value: + actions.append(("Delete from Store", "delete_store_item")) + + if selected_store and selected_store.lower() != store_name.lower(): + actions.append( + (f"Copy to Store ({selected_store})", "copy_to_selected_store") + ) + actions.append( + (f"Move to Store ({selected_store})", "move_to_selected_store") + ) + + return actions + + def _open_actions_popup(self) -> None: + if self._pipeline_running: + self.notify("Pipeline already running", severity="warning", timeout=3) + return + + ctx_obj = self._build_action_context() + actions = self._build_context_actions(ctx_obj) + if not actions: + self.notify("No actions available for selected item", severity="warning", timeout=3) + return + + def _run_selected(action_key: Optional[str]) -> None: + if not action_key: + return + self._execute_context_action(str(action_key), ctx_obj) + + self.push_screen(ActionMenuPopup(actions=actions), callback=_run_selected) + + def _copy_text_to_clipboard(self, text: str) -> bool: + content = str(text or "") + if not content: + return False + try: + self.copy_to_clipboard(content) + return True + except Exception: + pass + + try: + import subprocess as _sp + + _sp.run( + [ + "powershell", + "-NoProfile", + "-Command", + f"Set-Clipboard -Value {json.dumps(content)}", + ], + check=False, + capture_output=True, + text=True, + ) + return True + except Exception: + return False + + def _execute_context_action(self, action_key: str, ctx_obj: Dict[str, Any]) -> None: + action = str(action_key or "").strip().lower() + if not action: + return + + url_value = str(ctx_obj.get("url") or "").strip() + path_value = str(ctx_obj.get("path") or "").strip() + store_name = str(ctx_obj.get("store") or "").strip() + hash_value = str(ctx_obj.get("hash") or "").strip() + selected_store = str(ctx_obj.get("selected_store") or "").strip() + + if action == "open_url": + if not url_value: + self.notify("No URL found on selected item", severity="warning", timeout=3) + return + try: + import webbrowser + + webbrowser.open(url_value) + self.notify("Opened URL", timeout=2) + except Exception as exc: + self.notify(f"Failed to open URL: {exc}", severity="error", timeout=4) + return + + if action == "open_file": + if not path_value: + self.notify("No local file path found", severity="warning", timeout=3) + return + try: + p = Path(path_value) + if not p.exists(): + self.notify("Local file path does not exist", severity="warning", timeout=3) + return + if sys.platform.startswith("win"): + os.startfile(str(p)) # type: ignore[attr-defined] + elif sys.platform == "darwin": + subprocess.Popen(["open", str(p)]) + else: + subprocess.Popen(["xdg-open", str(p)]) + self.notify("Opened file", timeout=2) + except Exception as exc: + self.notify(f"Failed to open file: {exc}", severity="error", timeout=4) + return + + if action == "open_folder": + if not path_value: + self.notify("No local file path found", severity="warning", timeout=3) + return + try: + p = Path(path_value) + folder = p.parent if p.is_file() else p + if not folder.exists(): + self.notify("Folder does not exist", severity="warning", timeout=3) + return + if sys.platform.startswith("win"): + os.startfile(str(folder)) # type: ignore[attr-defined] + elif sys.platform == "darwin": + subprocess.Popen(["open", str(folder)]) + else: + subprocess.Popen(["xdg-open", str(folder)]) + self.notify("Opened folder", timeout=2) + except Exception as exc: + self.notify(f"Failed to open folder: {exc}", severity="error", timeout=4) + return + + if action == "copy_path": + if not path_value: + self.notify("No local file path found", severity="warning", timeout=3) + return + if self._copy_text_to_clipboard(path_value): + self.notify("Copied file path", timeout=2) + else: + self.notify("Failed to copy path", severity="error", timeout=3) + return + + if action == "delete_store_item": + if not store_name or not hash_value: + self.notify("Delete action requires store + hash", severity="warning", timeout=3) + return + query = f"hash:{hash_value}" + cmd = f"delete-file -store {json.dumps(store_name)} -query {json.dumps(query)}" + self._start_pipeline_execution(cmd) + return + + if action == "play_mpv": + if not bool(ctx_obj.get("can_play_mpv", False)): + self.notify("MPV is unavailable for this selected item", severity="warning", timeout=3) + return + selected = self._item_for_row_index(self._selected_row_index) + if selected is None: + self.notify("No selected row for MPV action", severity="warning", timeout=3) + return + seed_items = self._current_seed_items() + if not seed_items: + self.notify("No current result items available for MPV selection", severity="warning", timeout=3) + return + row_num = int(self._selected_row_index or 0) + 1 + if row_num < 1: + row_num = 1 + cmd = f"@{row_num} | .mpv" + self._start_pipeline_execution( + cmd, + seeds=seed_items, + seed_table=self.current_result_table, + clear_log=False, + clear_results=False, + keep_existing_results=True, + ) + return + + if action in {"copy_to_selected_store", "move_to_selected_store"}: + if not store_name or not hash_value or not selected_store: + self.notify("Copy/Move requires source store, hash, and selected target store", severity="warning", timeout=4) + return + if selected_store.lower() == store_name.lower(): + self.notify("Target store must be different from source store", severity="warning", timeout=3) + return + + query = f"hash:{hash_value}" + base_copy = ( + f"search-file -store {json.dumps(store_name)} {json.dumps(query)}" + f" | add-file -store {json.dumps(selected_store)}" + ) + if action == "move_to_selected_store": + delete_cmd = f"delete-file -store {json.dumps(store_name)} -query {json.dumps(query)}" + cmd = f"{base_copy} | @ | {delete_cmd}" + else: + cmd = base_copy + + self._start_pipeline_execution(cmd) + return + + self.notify(f"Unknown action: {action}", severity="warning", timeout=3) + def _clear_log(self) -> None: self.log_lines = [] if self.log_output: @@ -1275,6 +2430,7 @@ class PipelineHubApp(App): if self.results_table: self.results_table.clear() self._selected_row_index = 0 + self._clear_inline_detail_panels() def _set_status(self, message: str, *, level: str = "info") -> None: if not self.status_panel: diff --git a/TUI/pipeline_runner.py b/TUI/pipeline_runner.py index 71ccd19..f34a0af 100644 --- a/TUI/pipeline_runner.py +++ b/TUI/pipeline_runner.py @@ -99,6 +99,7 @@ class PipelineRunner: pipeline_text: str, *, seeds: Optional[Any] = None, + seed_table: Optional[Any] = None, isolate: bool = False, on_log: Optional[Callable[[str], None]] = None, @@ -158,6 +159,12 @@ class PipelineRunner: except Exception: debug(traceback.format_exc()) + if seed_table is not None: + try: + ctx.set_current_stage_table(seed_table) + except Exception: + debug(traceback.format_exc()) + stdout_buffer = io.StringIO() stderr_buffer = io.StringIO() diff --git a/TUI/tui.tcss b/TUI/tui.tcss index aa18ec3..7e79861 100644 --- a/TUI/tui.tcss +++ b/TUI/tui.tcss @@ -55,12 +55,42 @@ #results-pane { width: 100%; height: 2fr; - padding: 1; + 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; @@ -117,6 +147,9 @@ #results-table { height: 1fr; border: solid #ffffff; + background: #ffffff; + color: #000000; + padding: 0; } #results-table > .datatable--header { @@ -125,6 +158,20 @@ 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 { @@ -149,6 +196,7 @@ } #tags-button, +#actions-button, #metadata-button, #relationships-button { width: auto; @@ -177,6 +225,23 @@ 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;