Add YAPF style + ignore, and format tracked Python files

This commit is contained in:
2025-12-29 18:42:02 -08:00
parent c019c00aed
commit 507946a3e4
108 changed files with 11664 additions and 6494 deletions

View File

@@ -29,17 +29,21 @@ class PipelinePreset:
PIPELINE_PRESETS: List[PipelinePreset] = [
PipelinePreset(
label="Download → Merge → Local",
description="Use download-media with playlist auto-selection, merge the pieces, tag, then import into local storage.",
pipeline='download-media "<url>" | merge-file | add-tags -store local | add-file -storage local',
description=
"Use download-media with playlist auto-selection, merge the pieces, tag, then import into local storage.",
pipeline=
'download-media "<url>" | merge-file | add-tags -store local | add-file -storage local',
),
PipelinePreset(
label="Download → Hydrus",
description="Fetch media, auto-tag, and push directly into Hydrus.",
pipeline='download-media "<url>" | merge-file | add-tags -store hydrus | add-file -storage hydrus',
pipeline=
'download-media "<url>" | merge-file | add-tags -store hydrus | add-file -storage hydrus',
),
PipelinePreset(
label="Search Local Library",
description="Run search-file against the local library and emit a result table for further piping.",
description=
"Run search-file against the local library and emit a result table for further piping.",
pipeline='search-file -library local -query "<keywords>"',
),
]
@@ -57,7 +61,8 @@ def load_tags(file_path: Path) -> List[str]:
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]] = {}
grouped: Dict[str,
List[str]] = {}
for tag in metadata.normalize_tags(list(tags)):
namespace, value = metadata.split_tag(tag)
key = namespace or "_untagged"

View File

@@ -131,11 +131,16 @@ class AccessModal(ModalScreen):
if sys.platform == "win32":
# Windows: use clipboard via pyperclip (already tried)
logger.debug("Windows clipboard not available without pyperclip")
logger.debug(
"Windows clipboard not available without pyperclip"
)
else:
# Linux/Mac
process = subprocess.Popen(
["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE
["xclip",
"-selection",
"clipboard"],
stdin=subprocess.PIPE
)
process.communicate(self.item_content.encode("utf-8"))
logger.info("URL copied to clipboard via xclip")

View File

@@ -54,15 +54,20 @@ class DownloadModal(ModalScreen):
"""Modal screen for initiating new download requests."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("ctrl+enter", "submit", "Submit"),
Binding("escape",
"cancel",
"Cancel"),
Binding("ctrl+enter",
"submit",
"Submit"),
]
CSS_PATH = "download.tcss"
def __init__(
self,
on_submit: Optional[Callable[[dict], None]] = None,
on_submit: Optional[Callable[[dict],
None]] = None,
available_sources: Optional[list] = None,
config: Optional[dict] = None,
):
@@ -221,7 +226,10 @@ class DownloadModal(ModalScreen):
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)
self.playlist_merge_checkbox = self.query_one(
"#playlist_merge_checkbox",
Checkbox
)
# Set default actions
self.download_checkbox.value = True
@@ -251,7 +259,11 @@ class DownloadModal(ModalScreen):
if not url:
logger.warning("Download request missing URL")
self.app.notify("URL is required", title="Missing Input", severity="warning")
self.app.notify(
"URL is required",
title="Missing Input",
severity="warning"
)
return
# Parse tags (one per line)
@@ -363,7 +375,12 @@ class DownloadModal(ModalScreen):
# 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._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
@@ -376,7 +393,10 @@ class DownloadModal(ModalScreen):
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.notify,
"cmdlet system unavailable",
title="Error",
severity="error"
)
self.app.call_from_thread(self._hide_progress)
return
@@ -402,7 +422,9 @@ class DownloadModal(ModalScreen):
# 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)
ytdlp_selection = self._convert_selection_to_ytdlp(
playlist_selection
)
logger.info(
f"Playlist with user selection: {playlist_selection}{ytdlp_selection}"
)
@@ -442,17 +464,22 @@ class DownloadModal(ModalScreen):
logger.info(f"Calling download_cmdlet...")
cmd_config = (
dict(self.config)
if isinstance(self.config, dict)
else 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)
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
f"❌ download-cmdlet exception: {cmdlet_error}",
exc_info=True
)
if worker:
import traceback
@@ -488,16 +515,21 @@ class DownloadModal(ModalScreen):
# Log the output so it gets captured by WorkerLoggingHandler
if stdout_text:
logger.info(f"[{download_cmdlet_name} output]\n{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}")
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"
"error",
"Download stage failed - see logs above for details"
)
# Log to stderr as well so it shows in terminal
@@ -528,26 +560,23 @@ class DownloadModal(ModalScreen):
import re
http_match = re.search(
r"HTTP Error (\d{3})", stderr_text + stdout_text, re.IGNORECASE
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
):
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
):
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 = (
@@ -567,22 +596,20 @@ class DownloadModal(ModalScreen):
# 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()
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 [
if any(keyword in line.lower() for keyword in [
"error",
"failed",
"exception",
"traceback",
"warning",
]
):
error_reason = line[:150] # Limit to 150 chars
"warning", ]):
error_reason = line[:150
] # Limit to 150 chars
break
# If no error keyword found, use the last line
if error_reason == "Unknown error":
@@ -613,9 +640,13 @@ class DownloadModal(ModalScreen):
worker.append_stdout(f"\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")
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")
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)
@@ -623,11 +654,17 @@ class DownloadModal(ModalScreen):
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")
logger.info(
f"{download_cmdlet_name} stage completed successfully"
)
if stdout_text:
logger.info(f"{download_cmdlet_name} stdout:\n{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}")
logger.info(
f"{download_cmdlet_name} stderr:\n{stderr_text}"
)
# Log step to worker
if worker:
@@ -641,7 +678,7 @@ class DownloadModal(ModalScreen):
if self.is_playlist and merge_enabled:
# Get output directory
from pathlib import Path
from config import resolve_output_dir
from SYS.config import resolve_output_dir
output_dir = resolve_output_dir(self.config)
logger.info(
@@ -660,7 +697,9 @@ class DownloadModal(ModalScreen):
if filename:
full_path = output_dir / filename
if full_path.exists():
extracted_files.append(str(full_path))
extracted_files.append(
str(full_path)
)
logger.debug(
f"Found downloaded file from output: {filename}"
)
@@ -677,24 +716,29 @@ class DownloadModal(ModalScreen):
current_time = time.time()
recent_files = []
for f in (
list(output_dir.glob("*.mp3"))
+ list(output_dir.glob("*.m4a"))
+ list(output_dir.glob("*.mp4"))
):
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))
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]
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")
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)"
@@ -707,10 +751,12 @@ class DownloadModal(ModalScreen):
# Extract path after "Saved to "
saved_idx = line.find("Saved to")
if saved_idx != -1:
path = line[saved_idx + 8 :].strip()
path = line[saved_idx + 8:].strip()
if path:
downloaded_files.append(path)
logger.debug(f"Found downloaded file: {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
@@ -733,9 +779,14 @@ class DownloadModal(ModalScreen):
+ download_stderr_text
)
logger.info(f"{download_cmdlet_name} stage completed successfully")
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)
logger.error(
f"{download_cmdlet_name} execution error: {e}",
exc_info=True
)
self.app.call_from_thread(
self.app.notify,
f"Download error: {e}",
@@ -785,9 +836,13 @@ class DownloadModal(ModalScreen):
# 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")
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:
@@ -821,7 +876,9 @@ class DownloadModal(ModalScreen):
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
files_to_merge,
merge_args,
self.config
)
merge_stdout = stdout_buf.getvalue()
@@ -863,7 +920,8 @@ class DownloadModal(ModalScreen):
# Extract path after "into: "
into_idx = line.find("into:")
if into_idx != -1:
merged_file_path = line[into_idx + 5 :].strip()
merged_file_path = line[into_idx +
5:].strip()
if merged_file_path:
logger.info(
f"Detected merged file path: {merged_file_path}"
@@ -873,16 +931,13 @@ class DownloadModal(ModalScreen):
# 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")
):
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("[")
):
if (merged_file_path and
not merged_file_path.startswith("[")
):
logger.info(
f"Detected merged file path: {merged_file_path}"
)
@@ -924,11 +979,15 @@ class DownloadModal(ModalScreen):
# Log step to worker
if worker:
worker.log_step(f"Starting add-tags stage with {len(tags)} tags...")
worker.log_step(
f"Starting add-tags stage with {len(tags)} tags..."
)
# Build add-tags arguments. add-tags requires a store; for downloads, default to local sidecar tagging.
tag_args = (
["-store", "local"] + [str(t) for t in tags] + ["--source", str(source)]
["-store",
"local"] + [str(t) for t in tags] + ["--source",
str(source)]
)
logger.info(f" Tag args: {tag_args}")
logger.info(
@@ -944,7 +1003,11 @@ class DownloadModal(ModalScreen):
stderr_buf = io.StringIO()
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
returncode = add_tags_cmdlet(result_obj, tag_args, self.config)
returncode = add_tags_cmdlet(
result_obj,
tag_args,
self.config
)
stdout_text = stdout_buf.getvalue()
stderr_text = stderr_buf.getvalue()
@@ -956,7 +1019,9 @@ class DownloadModal(ModalScreen):
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"add-tags stage failed with code {returncode}"
)
logger.error(f" stdout: {stdout_text}")
logger.error(f" stderr: {stderr_text}")
self.app.call_from_thread(
@@ -996,7 +1061,10 @@ class DownloadModal(ModalScreen):
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")
worker.finish(
"error",
"Download stage failed - see logs above for details"
)
elif tags:
logger.info("No tags to add (tags list is empty)")
@@ -1029,13 +1097,17 @@ class DownloadModal(ModalScreen):
pass
self.app.call_from_thread(self._hide_progress)
self.app.call_from_thread(
self.app.notify, f"Error: {e}", title="Error", severity="error"
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
@@ -1089,9 +1161,13 @@ class DownloadModal(ModalScreen):
# Check if multiple url provided
if len(url) > 1:
logger.info(f"Detected {len(url)} url - checking for PDF pseudo-playlist")
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)
all_pdfs = all(
url.endswith(".pdf") or "pdf" in url.lower() for url in url
)
if all_pdfs:
logger.info(f"All url are PDFs - creating pseudo-playlist")
self._handle_pdf_playlist(url)
@@ -1107,7 +1183,9 @@ class DownloadModal(ModalScreen):
# Run in background to prevent UI freezing
self._scrape_metadata_worker(
url, wipe_tags_and_source=wipe_tags, skip_tag_scraping=not wipe_tags
url,
wipe_tags_and_source=wipe_tags,
skip_tag_scraping=not wipe_tags
)
except Exception as e:
@@ -1129,7 +1207,10 @@ class DownloadModal(ModalScreen):
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
"Tag processing unavailable",
title="Error",
severity="error",
timeout=2
)
return
@@ -1169,7 +1250,11 @@ class DownloadModal(ModalScreen):
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")
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.
@@ -1196,7 +1281,10 @@ class DownloadModal(ModalScreen):
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("-", " ")
title = filename.replace(".pdf",
"").replace("_",
" ").replace("-",
" ")
except Exception as e:
logger.debug(f"Could not extract filename: {e}")
title = f"PDF {idx}"
@@ -1236,11 +1324,18 @@ class DownloadModal(ModalScreen):
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
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
self,
pdf_url: list,
tags: list,
selection: str,
merge_enabled: bool
) -> None:
"""Download and merge PDF playlist.
@@ -1263,13 +1358,15 @@ class DownloadModal(ModalScreen):
try:
from pathlib import Path
import requests
from config import resolve_output_dir
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})
temp_items.append({
"title": url
})
self.playlist_items = temp_items
# Parse selection to get which PDFs to download
@@ -1339,7 +1436,9 @@ class DownloadModal(ModalScreen):
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}")
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))
@@ -1365,6 +1464,7 @@ class DownloadModal(ModalScreen):
# Create a result object for the PDF
class PDFResult:
def __init__(self, p):
self.path = str(p)
self.target = str(p)
@@ -1380,10 +1480,16 @@ class DownloadModal(ModalScreen):
tag_args = ["-store", "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)
tag_returncode = tag_cmdlet(
result_obj,
tag_args,
self.config
)
if tag_returncode != 0:
logger.warning(f"Tag stage returned code {tag_returncode}")
logger.warning(
f"Tag stage returned code {tag_returncode}"
)
self.app.call_from_thread(
self.app.notify,
@@ -1440,7 +1546,10 @@ class DownloadModal(ModalScreen):
@work(thread=True)
def _scrape_metadata_worker(
self, url: str, wipe_tags_and_source: bool = False, skip_tag_scraping: bool = False
self,
url: str,
wipe_tags_and_source: bool = False,
skip_tag_scraping: bool = False
) -> None:
"""Background worker to scrape metadata using get-tag cmdlet.
@@ -1456,7 +1565,10 @@ class DownloadModal(ModalScreen):
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"
self.app.notify,
"cmdlet module not available",
title="Error",
severity="error"
)
return
@@ -1465,12 +1577,16 @@ class DownloadModal(ModalScreen):
if not get_tag_cmdlet:
logger.error("get-tag cmdlet not found")
self.app.call_from_thread(
self.app.notify, "get-tag cmdlet not found", title="Error", severity="error"
self.app.notify,
"get-tag 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
@@ -1489,7 +1605,9 @@ class DownloadModal(ModalScreen):
args = [] if skip_tag_scraping else ["-scrape", url]
with redirect_stdout(output_buffer), redirect_stderr(error_buffer):
returncode = get_tag_cmdlet(result_obj, args, {})
returncode = get_tag_cmdlet(result_obj,
args,
{})
if returncode != 0:
error_msg = error_buffer.getvalue()
@@ -1559,7 +1677,8 @@ class DownloadModal(ModalScreen):
# 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 = 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
@@ -1579,7 +1698,11 @@ class DownloadModal(ModalScreen):
)
# Update UI on main thread
self.app.call_from_thread(self._populate_from_metadata, metadata, wipe_tags_and_source)
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)
@@ -1611,7 +1734,9 @@ class DownloadModal(ModalScreen):
# 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"}):
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}"
@@ -1647,7 +1772,9 @@ class DownloadModal(ModalScreen):
# 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"}):
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))
@@ -1676,7 +1803,12 @@ class DownloadModal(ModalScreen):
return []
def _execute_download_pipeline(
self, result_obj: Any, tags: list, source: str, download_enabled: bool, worker=None
self,
result_obj: Any,
tags: list,
source: str,
download_enabled: bool,
worker=None
) -> None:
"""Execute the download pipeline for a single item.
@@ -1694,7 +1826,10 @@ class DownloadModal(ModalScreen):
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"
self.app.notify,
"cmdlet system unavailable",
title="Error",
severity="error"
)
return
@@ -1716,7 +1851,8 @@ class DownloadModal(ModalScreen):
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
cmd_config = (
dict(self.config) if isinstance(self.config, dict) else self.config
dict(self.config) if isinstance(self.config,
dict) else self.config
)
if isinstance(cmd_config, dict):
cmd_config["_quiet_background_output"] = True
@@ -1760,7 +1896,10 @@ class DownloadModal(ModalScreen):
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"
self.app.notify,
str(e)[:100],
title="Download Error",
severity="error"
)
return
@@ -1781,7 +1920,9 @@ class DownloadModal(ModalScreen):
stderr_buf = io.StringIO()
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
tag_returncode = tag_cmdlet(result_obj, tag_args, {})
tag_returncode = tag_cmdlet(result_obj,
tag_args,
{})
stdout_text = stdout_buf.getvalue()
stderr_text = stderr_buf.getvalue()
@@ -1860,7 +2001,11 @@ class DownloadModal(ModalScreen):
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:
def _populate_from_metadata(
self,
metadata: dict,
wipe_tags_and_source: bool = False
) -> None:
"""Populate modal fields from extracted metadata.
Args:
@@ -1965,7 +2110,11 @@ class DownloadModal(ModalScreen):
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")
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)."""

View File

@@ -16,7 +16,7 @@ from datetime import datetime
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from SYS.utils import format_metadata_value
from config import load_config
from SYS.config import load_config
logger = logging.getLogger(__name__)
@@ -25,7 +25,9 @@ class ExportModal(ModalScreen):
"""Modal screen for exporting files with metadata and tags."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("escape",
"cancel",
"Cancel"),
]
CSS_PATH = "export.tcss"
@@ -59,9 +61,12 @@ class ExportModal(ModalScreen):
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
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.
@@ -108,7 +113,7 @@ class ExportModal(ModalScreen):
options = [("Local", "local")]
try:
from config import (
from SYS.config import (
load_config,
get_hydrus_access_key,
get_hydrus_url,
@@ -133,7 +138,8 @@ class ExportModal(ModalScreen):
def _get_metadata_text(self) -> str:
"""Format metadata from result data in a consistent display format."""
metadata = self.result_data.get("metadata", {})
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'}"
@@ -173,7 +179,9 @@ class ExportModal(ModalScreen):
# If we found any fields, display them
if lines:
logger.info(f"_get_metadata_text - Returning {len(lines)} formatted metadata lines")
logger.info(
f"_get_metadata_text - Returning {len(lines)} formatted metadata lines"
)
return "\n".join(lines)
else:
logger.info(f"_get_metadata_text - No matching fields found in metadata")
@@ -205,7 +213,14 @@ class ExportModal(ModalScreen):
with Vertical(id="export-options"):
# Export To selector
self.export_to_select = Select(
[("0x0", "0x0"), ("Libraries", "libraries"), ("Custom Path", "path")],
[
("0x0",
"0x0"),
("Libraries",
"libraries"),
("Custom Path",
"path")
],
id="export-to-select",
)
yield self.export_to_select
@@ -217,12 +232,14 @@ class ExportModal(ModalScreen):
# Custom path input (initially hidden)
self.custom_path_input = Input(
placeholder="Enter custom export path", id="custom-path-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", {})
metadata = self.result_data.get("metadata",
{})
original_size = metadata.get("size", "")
ext = metadata.get("ext", "")
@@ -233,9 +250,10 @@ class ExportModal(ModalScreen):
# 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
int(original_size /
(1024 * 1024)) if isinstance(original_size,
(int,
float)) else original_size
)
size_display = f"{size_mb}Mb"
else:
@@ -259,11 +277,8 @@ class ExportModal(ModalScreen):
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}")
):
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
@@ -348,11 +363,13 @@ class ExportModal(ModalScreen):
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 ""
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":
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
@@ -360,7 +377,8 @@ class ExportModal(ModalScreen):
# 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
library = options[0][
1] # Get the value part of first option tuple
except Exception:
library = "local"
@@ -376,11 +394,14 @@ class ExportModal(ModalScreen):
# For Hydrus export, filter out metadata-only tags (hash:, url:, relationship:)
if export_to == "libraries" and library == "hydrus":
metadata_prefixes = {"hash:", "url:", "relationship:"}
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)
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"
@@ -391,9 +412,8 @@ class ExportModal(ModalScreen):
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
):
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)
@@ -427,7 +447,8 @@ class ExportModal(ModalScreen):
# 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 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(
@@ -441,7 +462,9 @@ class ExportModal(ModalScreen):
return
if export_to == "libraries" and not export_tags:
logger.warning("No actual tags for Hydrus export (only metadata was present)")
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
@@ -457,7 +480,8 @@ class ExportModal(ModalScreen):
export_path = export_to # "0x0"
# Get metadata from result_data
metadata = self.result_data.get("metadata", {})
metadata = self.result_data.get("metadata",
{})
# Extract file source info from result_data (passed by hub-ui)
file_hash = self.result_data.get("hash")
@@ -528,11 +552,8 @@ def determine_needs_conversion(current_ext: str, target_format: str) -> bool:
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"
):
if (not target_format or target_format == ""
or str(target_format.__class__.__name__) == "NoSelection"):
return False # No conversion requested
# Normalize the current extension
@@ -543,9 +564,9 @@ def determine_needs_conversion(current_ext: str, target_format: str) -> bool:
return current_ext_lower != target_format_lower
def calculate_size_tolerance(
metadata: dict, user_size_mb: Optional[str]
) -> tuple[Optional[int], Optional[int]]:
def calculate_size_tolerance(metadata: dict,
user_size_mb: Optional[str]) -> tuple[Optional[int],
Optional[int]]:
"""Calculate target size with 1MB grace period.
Args:

View File

@@ -14,7 +14,7 @@ import asyncio
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import load_config, resolve_output_dir
from SYS.config import load_config, resolve_output_dir
from result_table import ResultTable
from ProviderCore.registry import get_search_provider
@@ -25,9 +25,15 @@ 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"),
Binding("escape",
"cancel",
"Cancel"),
Binding("enter",
"search_focused",
"Search"),
Binding("ctrl+t",
"scrape_tags",
"Scrape Tags"),
]
CSS_PATH = "search.tcss"
@@ -65,14 +71,20 @@ class SearchModal(ModalScreen):
with Horizontal(id="search-controls"):
# Source selector
self.source_select = Select(
[("OpenLibrary", "openlibrary"), ("Soulseek", "soulseek")],
[("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")
self.search_input = Input(
placeholder="Enter search query...",
id="search-input"
)
yield self.search_input
# Search button
@@ -87,7 +99,9 @@ class SearchModal(ModalScreen):
# Left column: Tags textarea
with Vertical(id="tags-column"):
self.tags_textarea = TextArea(
text="", id="result-tags-textarea", read_only=False
text="",
id="result-tags-textarea",
read_only=False
)
self.tags_textarea.border_title = "Tags [Ctrl+T: Scrape]"
yield self.tags_textarea
@@ -96,7 +110,10 @@ class SearchModal(ModalScreen):
with Vertical(id="source-submit-column"):
# Library source selector (for OpenLibrary results)
self.library_source_select = Select(
[("Local", "local"), ("Download", "download")],
[("Local",
"local"),
("Download",
"download")],
value="local",
id="library-source-select",
)
@@ -114,7 +131,12 @@ class SearchModal(ModalScreen):
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")
self.results_table.add_columns(
"Title",
"Author/Artist",
"Year/Album",
"Details"
)
# Focus on search input
self.search_input.focus()
@@ -153,7 +175,10 @@ class SearchModal(ModalScreen):
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}")
self.current_worker.finish(
"error",
f"Provider not available: {source}"
)
return
logger.info(f"[search-modal] Searching {source} for: {query}")
@@ -176,10 +201,13 @@ class SearchModal(ModalScreen):
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 "",
getattr(res,
"path",
None) or getattr(res,
"url",
None) or getattr(res,
"target",
None) or "",
)
self.current_result_table = table
@@ -259,7 +287,8 @@ class SearchModal(ModalScreen):
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"
self.library_source_select.value
if self.library_source_select else "local"
)
# Add tags and source to result
@@ -301,9 +330,11 @@ class SearchModal(ModalScreen):
if source == "openlibrary":
# For OpenLibrary: title, author, year
author = (
", ".join(metadata.get("authors", []))
if isinstance(metadata.get("authors"), list)
else metadata.get("authors", "")
", ".join(metadata.get("authors",
[]))
if isinstance(metadata.get("authors"),
list) else metadata.get("authors",
"")
)
year = str(metadata.get("year", ""))
tags = []
@@ -337,7 +368,9 @@ class SearchModal(ModalScreen):
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")
logger.warning(
"[search-modal] Download only supported for OpenLibrary results"
)
return
try:
@@ -378,7 +411,9 @@ class SearchModal(ModalScreen):
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}")
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:

View File

@@ -21,7 +21,9 @@ class WorkersModal(ModalScreen):
"""Modal screen for monitoring running and finished workers."""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("escape",
"cancel",
"Cancel"),
]
CSS_PATH = "workers.tcss"
@@ -72,7 +74,11 @@ class WorkersModal(ModalScreen):
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 Selected",
id="running-stop-btn",
variant="warning"
)
yield Button("Stop All", id="running-stop-all-btn", variant="error")
# Finished tab content (initially visible)
@@ -81,9 +87,21 @@ class WorkersModal(ModalScreen):
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")
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"):
@@ -99,14 +117,27 @@ class WorkersModal(ModalScreen):
# Set up running workers table
if self.running_table:
self.running_table.add_columns(
"ID", "Type", "Status", "Pipe", "Progress", "Started", "Details"
"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"
"ID",
"Type",
"Result",
"Pipe",
"Started",
"Completed",
"Duration",
"Details"
)
self.finished_table.zebra_stripes = True
@@ -199,7 +230,13 @@ class WorkersModal(ModalScreen):
if not self.running_workers:
self.running_table.add_row(
"---", "---", "---", "---", "---", "---", "No workers running"
"---",
"---",
"---",
"---",
"---",
"---",
"No workers running"
)
logger.debug(f"[workers-modal] No running workers to display")
return
@@ -257,7 +294,10 @@ class WorkersModal(ModalScreen):
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)
logger.error(
f"[workers-modal] Error updating running table: {e}",
exc_info=True
)
def _update_finished_table(self) -> None:
"""Update the finished workers table."""
@@ -270,7 +310,14 @@ class WorkersModal(ModalScreen):
if not self.finished_workers:
self.finished_table.add_row(
"---", "---", "---", "---", "---", "---", "---", "No finished workers"
"---",
"---",
"---",
"---",
"---",
"---",
"---",
"No finished workers"
)
logger.debug(f"[workers-modal] No finished workers to display")
return
@@ -336,12 +383,17 @@ class WorkersModal(ModalScreen):
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)
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}")
logger.info(
f"[workers-modal] Row highlighted, cursor_row: {event.cursor_row}"
)
# Get the selected worker from the correct table
workers_list = None
@@ -372,7 +424,10 @@ class WorkersModal(ModalScreen):
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)
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)."""
@@ -410,14 +465,19 @@ class WorkersModal(ModalScreen):
logger.debug(f"[workers-modal] Error handling cell highlight: {e}")
def _update_stdout_display(
self, worker_id: str, worker: Optional[Dict[str, Any]] = None
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}")
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)
@@ -429,7 +489,9 @@ class WorkersModal(ModalScreen):
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)"))
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 '')})"
@@ -442,7 +504,10 @@ class WorkersModal(ModalScreen):
pass
logger.info(f"[workers-modal] Updated stdout display successfully")
except Exception as e:
logger.error(f"[workers-modal] Error updating stdout display: {e}", exc_info=True)
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 []:
@@ -453,7 +518,12 @@ class WorkersModal(ModalScreen):
return worker
return None
def _resolve_worker_stdout(self, worker_id: str, worker: Optional[Dict[str, Any]]) -> str:
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)
@@ -461,10 +531,17 @@ class WorkersModal(ModalScreen):
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}")
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:
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)
@@ -479,17 +556,24 @@ class WorkersModal(ModalScreen):
try:
return manager.get_worker(worker_id)
except Exception as exc:
logger.debug(f"[workers-modal] Could not fetch worker record {worker_id}: {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]]:
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}")
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:
@@ -540,7 +624,7 @@ class WorkersModal(ModalScreen):
text = str(pipe_value or "").strip()
if not text:
return "(none)"
return text if len(text) <= limit else text[: limit - 3] + "..."
return text if len(text) <= limit else text[:limit - 3] + "..."
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
@@ -568,9 +652,12 @@ class WorkersModal(ModalScreen):
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"):
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}")
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}")
@@ -593,18 +680,20 @@ class WorkersModal(ModalScreen):
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"
):
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}")
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"):
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()

View File

@@ -56,17 +56,19 @@ class PipelineRunResult:
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
],
}
@@ -89,7 +91,8 @@ class PipelineRunner:
*,
seeds: 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:
@@ -102,7 +105,7 @@ class PipelineRunner:
return result
try:
from cli_syntax import validate_pipeline_text
from SYS.cli_syntax import validate_pipeline_text
syntax_error = validate_pipeline_text(normalized)
if syntax_error:
@@ -151,8 +154,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...")
@@ -171,8 +174,7 @@ class PipelineRunner:
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:
@@ -234,7 +236,8 @@ class PipelineRunner:
return val.copy()
return val
snap: Dict[str, Any] = {}
snap: Dict[str,
Any] = {}
keys = [
"_LIVE_PROGRESS",
"_CURRENT_CONTEXT",
@@ -269,11 +272,13 @@ class PipelineRunner:
snap["_RESULT_TABLE_HISTORY"] = [
(
t,
(items.copy() if isinstance(items, list) else list(items) if items else []),
(
items.copy()
if isinstance(items,
list) else list(items) if items else []
),
subj,
)
for (t, items, subj) in hist
if isinstance((t, items, subj), tuple)
) for (t, items, subj) in hist if isinstance((t, items, subj), tuple)
]
except Exception:
pass
@@ -283,11 +288,13 @@ class PipelineRunner:
snap["_RESULT_TABLE_FORWARD"] = [
(
t,
(items.copy() if isinstance(items, list) else list(items) if items else []),
(
items.copy()
if isinstance(items,
list) else list(items) if items else []
),
subj,
)
for (t, items, subj) in fwd
if isinstance((t, items, subj), tuple)
) for (t, items, subj) in fwd if isinstance((t, items, subj), tuple)
]
except Exception:
pass

View File

@@ -38,10 +38,10 @@ for path in (BASE_DIR, ROOT_DIR):
from pipeline_runner import PipelineRunResult # type: ignore # noqa: E402
from result_table import ResultTable # type: ignore # noqa: E402
from config import load_config # type: ignore # noqa: E402
from SYS.config import load_config # type: ignore # noqa: E402
from Store.registry import Store as StoreRegistry # type: ignore # noqa: E402
from cmdlet_catalog import ensure_registry_loaded, list_cmdlet_names # type: ignore # noqa: E402
from cli_syntax import validate_pipeline_text # 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 pipeline_runner import PipelineRunner # type: ignore # noqa: E402
@@ -84,6 +84,7 @@ def _extract_tag_names(emitted: Sequence[Any]) -> List[str]:
class TextPopup(ModalScreen[None]):
def __init__(self, *, title: str, text: str) -> None:
super().__init__()
self._title = str(title)
@@ -100,7 +101,14 @@ class TextPopup(ModalScreen[None]):
class TagEditorPopup(ModalScreen[None]):
def __init__(self, *, seeds: Any, store_name: str, file_hash: Optional[str]) -> None:
def __init__(
self,
*,
seeds: Any,
store_name: str,
file_hash: Optional[str]
) -> None:
super().__init__()
self._seeds = seeds
self._store = str(store_name or "").strip()
@@ -138,7 +146,10 @@ class TagEditorPopup(ModalScreen[None]):
except Exception as exc:
tags = []
try:
app.call_from_thread(self._set_status, f"Error: {type(exc).__name__}: {exc}")
app.call_from_thread(
self._set_status,
f"Error: {type(exc).__name__}: {exc}"
)
except Exception:
self._set_status(f"Error: {type(exc).__name__}: {exc}")
self._original_tags = tags
@@ -172,8 +183,10 @@ class TagEditorPopup(ModalScreen[None]):
desired = self._parse_editor_tags()
current = _dedup_preserve_order(list(self._original_tags or []))
desired_set = {t.lower() for t in desired}
current_set = {t.lower() for t in current}
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]
@@ -187,7 +200,10 @@ class TagEditorPopup(ModalScreen[None]):
@work(thread=True)
def _save_tags_background(
self, to_add: List[str], to_del: List[str], desired: List[str]
self,
to_add: List[str],
to_del: List[str],
desired: List[str]
) -> None:
app = self.app # PipelineHubApp
try:
@@ -204,9 +220,11 @@ class TagEditorPopup(ModalScreen[None]):
if not getattr(del_res, "success", False):
failures.append(
str(
getattr(del_res, "error", "")
or getattr(del_res, "stderr", "")
or "delete-tag failed"
getattr(del_res,
"error",
"") or getattr(del_res,
"stderr",
"") or "delete-tag failed"
).strip()
)
@@ -217,9 +235,11 @@ class TagEditorPopup(ModalScreen[None]):
if not getattr(add_res, "success", False):
failures.append(
str(
getattr(add_res, "error", "")
or getattr(add_res, "stderr", "")
or "add-tag failed"
getattr(add_res,
"error",
"") or getattr(add_res,
"stderr",
"") or "add-tag failed"
).strip()
)
@@ -233,12 +253,18 @@ class TagEditorPopup(ModalScreen[None]):
self._original_tags = list(desired)
try:
app.call_from_thread(self._set_status, f"Saved (+{len(to_add)}, -{len(to_del)})")
app.call_from_thread(
self._set_status,
f"Saved (+{len(to_add)}, -{len(to_del)})"
)
except Exception:
self._set_status(f"Saved (+{len(to_add)}, -{len(to_del)})")
except Exception as exc:
try:
app.call_from_thread(self._set_status, f"Error: {type(exc).__name__}: {exc}")
app.call_from_thread(
self._set_status,
f"Error: {type(exc).__name__}: {exc}"
)
except Exception:
self._set_status(f"Error: {type(exc).__name__}: {exc}")
@@ -248,10 +274,20 @@ class PipelineHubApp(App):
CSS_PATH = "tui.tcss"
BINDINGS = [
Binding("ctrl+enter", "run_pipeline", "Run Pipeline"),
Binding("f5", "refresh_workers", "Refresh Workers"),
Binding("ctrl+l", "focus_command", "Focus Input", show=False),
Binding("ctrl+g", "focus_logs", "Focus Logs", show=False),
Binding("ctrl+enter",
"run_pipeline",
"Run Pipeline"),
Binding("f5",
"refresh_workers",
"Refresh Workers"),
Binding("ctrl+l",
"focus_command",
"Focus Input",
show=False),
Binding("ctrl+g",
"focus_logs",
"Focus Logs",
show=False),
]
def __init__(self) -> None:
@@ -281,7 +317,10 @@ class PipelineHubApp(App):
with Container(id="app-shell"):
with Vertical(id="command-pane"):
with Horizontal(id="command-row"):
yield Input(placeholder="Enter pipeline command...", id="pipeline-input")
yield Input(
placeholder="Enter pipeline command...",
id="pipeline-input"
)
yield Button("Run", id="run-button")
yield Button("Tags", id="tags-button")
yield Button("Metadata", id="metadata-button")
@@ -392,7 +431,10 @@ class PipelineHubApp(App):
suggestion = str(event.option.prompt)
except Exception:
return
new_text = self._apply_suggestion_to_text(str(self.command_input.value or ""), suggestion)
new_text = self._apply_suggestion_to_text(
str(self.command_input.value or ""),
suggestion
)
self.command_input.value = new_text
self.suggestion_list.display = False
self.command_input.focus()
@@ -428,7 +470,8 @@ class PipelineHubApp(App):
return
self.command_input.value = self._apply_suggestion_to_text(
str(self.command_input.value or ""), suggestion
str(self.command_input.value or ""),
suggestion
)
if self.suggestion_list:
self.suggestion_list.display = False
@@ -436,7 +479,9 @@ class PipelineHubApp(App):
event.stop()
def _get_first_suggestion(self) -> str:
if not self.suggestion_list or not bool(getattr(self.suggestion_list, "display", False)):
if not self.suggestion_list or not bool(getattr(self.suggestion_list,
"display",
False)):
return ""
# Textual OptionList API differs across versions; handle best-effort.
try:
@@ -525,9 +570,9 @@ class PipelineHubApp(App):
first_stage_cmd = ""
try:
first_stage_cmd = (
str(stages[0].split()[0]).replace("_", "-").strip().lower()
if stages[0].split()
else ""
str(stages[0].split()[0]).replace("_",
"-").strip().lower()
if stages[0].split() else ""
)
except Exception:
first_stage_cmd = ""
@@ -536,7 +581,8 @@ class PipelineHubApp(App):
if output_path:
first = stages[0]
low = first.lower()
if low.startswith("download-media") and " -path" not in low and " --path" not in low:
if low.startswith("download-media"
) and " -path" not in low and " --path" not in low:
stages[0] = f"{first} -path {json.dumps(output_path)}"
joined = " | ".join(stages)
@@ -545,9 +591,12 @@ class PipelineHubApp(App):
# Only auto-append add-file for download pipelines.
should_auto_add_file = bool(
selected_store
and ("add-file" not in low_joined)
and (first_stage_cmd in {"download-media", "download-file", "download-torrent"})
selected_store and ("add-file" not in low_joined) and (
first_stage_cmd
in {"download-media",
"download-file",
"download-torrent"}
)
)
if should_auto_add_file:
@@ -570,7 +619,10 @@ class PipelineHubApp(App):
@work(exclusive=True, thread=True)
def _run_pipeline_background(self, pipeline_text: str) -> None:
try:
run_result = self.executor.run_pipeline(pipeline_text, on_log=self._log_from_worker)
run_result = self.executor.run_pipeline(
pipeline_text,
on_log=self._log_from_worker
)
except Exception as exc:
# Ensure the UI never gets stuck in "running" state.
run_result = PipelineRunResult(
@@ -589,7 +641,11 @@ class PipelineHubApp(App):
self._set_status(status_text, level=status_level)
if not run_result.success:
self.notify(run_result.error or "Pipeline failed", severity="error", timeout=6)
self.notify(
run_result.error or "Pipeline failed",
severity="error",
timeout=6
)
else:
self.notify("Pipeline completed", timeout=3)
@@ -649,13 +705,23 @@ class PipelineHubApp(App):
# Fallback for items without a table
for idx, item in enumerate(self.result_items, start=1):
self.results_table.add_row(str(idx), str(item), "", "", key=str(idx - 1))
self.results_table.add_row(
str(idx),
str(item),
"",
"",
key=str(idx - 1)
)
def _load_cmdlet_names(self) -> None:
try:
ensure_registry_loaded()
names = list_cmdlet_names() or []
self._cmdlet_names = sorted({str(n).replace("_", "-") for n in names if str(n).strip()})
self._cmdlet_names = sorted(
{str(n).replace("_",
"-")
for n in names if str(n).strip()}
)
except Exception:
self._cmdlet_names = []
@@ -702,10 +768,14 @@ class PipelineHubApp(App):
pass
try:
self.suggestion_list.add_options([Option(m) for m in matches]) # type: ignore[attr-defined]
self.suggestion_list.add_options(
[Option(m) for m in matches]
) # type: ignore[attr-defined]
except Exception:
try:
self.suggestion_list.options = [Option(m) for m in matches] # type: ignore[attr-defined]
self.suggestion_list.options = [
Option(m) for m in matches
] # type: ignore[attr-defined]
except Exception:
pass
@@ -743,7 +813,11 @@ class PipelineHubApp(App):
parts[-1] = leading + replaced
return "|".join(parts)
def _resolve_selected_item(self) -> Tuple[Optional[Any], Optional[str], Optional[str]]:
def _resolve_selected_item(
self
) -> Tuple[Optional[Any],
Optional[str],
Optional[str]]:
"""Return (item, store_name, hash) for the currently selected row."""
index = int(getattr(self, "_selected_row_index", 0) or 0)
if index < 0:
@@ -753,8 +827,9 @@ class PipelineHubApp(App):
# Prefer mapping displayed table row -> source item.
if self.current_result_table and 0 <= index < len(
getattr(self.current_result_table, "rows", []) or []
):
getattr(self.current_result_table,
"rows",
[]) or []):
row = self.current_result_table.rows[index]
src_idx = getattr(row, "source_index", None)
if isinstance(src_idx, int) and 0 <= src_idx < len(self.result_items):
@@ -807,7 +882,11 @@ class PipelineHubApp(App):
except Exception:
pass
self.push_screen(TagEditorPopup(seeds=seeds, store_name=store_name, file_hash=file_hash))
self.push_screen(
TagEditorPopup(seeds=seeds,
store_name=store_name,
file_hash=file_hash)
)
def _open_metadata_popup(self) -> None:
item, _store_name, _file_hash = self._resolve_selected_item()
@@ -817,10 +896,13 @@ class PipelineHubApp(App):
text = ""
idx = int(getattr(self, "_selected_row_index", 0) or 0)
if self.current_result_table and 0 <= idx < len(
getattr(self.current_result_table, "rows", []) or []
):
getattr(self.current_result_table,
"rows",
[]) or []):
row = self.current_result_table.rows[idx]
lines = [f"{col.name}: {col.value}" for col in getattr(row, "columns", []) or []]
lines = [
f"{col.name}: {col.value}" for col in getattr(row, "columns", []) or []
]
text = "\n".join(lines)
elif isinstance(item, dict):
try:
@@ -911,7 +993,8 @@ class PipelineHubApp(App):
worker_type = str(worker.get("worker_type") or worker.get("type") or "?")
status = str(worker.get("status") or worker.get("result") or "running")
details = (
worker.get("current_step") or worker.get("description") or worker.get("pipe") or ""
worker.get("current_step") or worker.get("description")
or worker.get("pipe") or ""
)
self.worker_table.add_row(worker_id, worker_type, status, str(details)[:80])