Add YAPF style + ignore, and format tracked Python files
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user