This commit is contained in:
nose
2025-11-27 10:59:01 -08:00
parent e9b505e609
commit 9eff65d1af
30 changed files with 2099 additions and 1095 deletions

View File

@@ -68,37 +68,6 @@ def group_tags_by_namespace(tags: Sequence[str]) -> Dict[str, List[str]]:
return grouped
def build_metadata_snapshot(file_path: Path) -> Dict[str, Any]:
"""Load any available sidecar metadata for the selected file."""
snapshot: Dict[str, Any] = {
"file": str(file_path),
"tags": group_tags_by_namespace(load_tags(file_path)),
}
try:
sidecar = metadata._derive_sidecar_path(file_path)
if sidecar.is_file():
title, tags, notes = metadata._read_sidecar_metadata(sidecar)
snapshot["sidecar"] = {
"title": title,
"tags": group_tags_by_namespace(tags),
"notes": notes,
}
except Exception:
snapshot["sidecar"] = None
return snapshot
def summarize_result(result: Dict[str, Any]) -> str:
"""Build a one-line summary for a pipeline result row."""
title = result.get("title") or result.get("identifier") or result.get("file_path")
source = result.get("source") or result.get("cmdlet") or "result"
return f"{source}: {title}" if title else source
def normalize_tags(tags: Iterable[str]) -> List[str]:
"""Expose metadata.normalize_tags for callers that imported the old helper."""

View File

@@ -69,33 +69,34 @@ class ExportModal(ModalScreen):
"""
ext_lower = ext.lower() if ext else ''
# Audio formats
audio_exts = {'.mp3', '.flac', '.wav', '.aac', '.ogg', '.m4a', '.wma', '.opus', '.mka'}
audio_formats = [("MKA", "mka"), ("MP3", "mp3"), ("M4A", "m4a"), ("FLAC", "flac"), ("WAV", "wav"), ("AAC", "aac"), ("OGG", "ogg"), ("Opus", "opus")]
from helper.utils_constant import mime_maps
# Video formats (can have audio too)
video_exts = {'.mp4', '.mkv', '.webm', '.avi', '.mov', '.flv', '.wmv', '.m4v', '.ts', '.mpg', '.mpeg'}
video_formats = [("MP4", "mp4"), ("MKV", "mkv"), ("WebM", "webm"), ("AVI", "avi"), ("MOV", "mov")]
found_type = "unknown"
# Image formats
image_exts = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.ico'}
image_formats = [("JPG", "jpg"), ("PNG", "png"), ("WebP", "webp"), ("GIF", "gif"), ("BMP", "bmp")]
# Find type based on extension
for category, formats in mime_maps.items():
for fmt_key, fmt_info in formats.items():
if fmt_info.get("ext") == ext_lower:
found_type = category
break
if found_type != "unknown":
break
# Document formats - no conversion for now
document_exts = {'.pdf', '.epub', '.txt', '.docx', '.doc', '.rtf', '.md', '.html', '.mobi', '.cbz', '.cbr'}
document_formats = []
# Build format options for the found type
format_options = []
if ext_lower in audio_exts:
return ('audio', audio_formats)
elif ext_lower in video_exts:
return ('video', video_formats)
elif ext_lower in image_exts:
return ('image', image_formats)
elif ext_lower in document_exts:
return ('document', document_formats)
else:
# Default to audio if unknown
return ('unknown', audio_formats)
# If unknown, fallback to audio (matching legacy behavior)
target_type = found_type if found_type in mime_maps else "audio"
if target_type in mime_maps:
# Sort formats alphabetically
sorted_formats = sorted(mime_maps[target_type].items())
for fmt_key, fmt_info in sorted_formats:
label = fmt_key.upper()
value = fmt_key
format_options.append((label, value))
return (target_type, format_options)
def _get_library_options(self) -> list:
"""Get available library options from config.json."""

View File

@@ -15,6 +15,8 @@ import asyncio
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import load_config
from result_table import ResultTable
from helper.search_provider import get_provider
logger = logging.getLogger(__name__)
@@ -49,7 +51,8 @@ class SearchModal(ModalScreen):
self.results_table: Optional[DataTable] = None
self.tags_textarea: Optional[TextArea] = None
self.library_source_select: Optional[Select] = None
self.current_results: List[dict] = []
self.current_results: List[Any] = [] # List of SearchResult objects
self.current_result_table: Optional[ResultTable] = None
self.is_searching = False
self.current_worker = None # Track worker for search operations
@@ -125,124 +128,6 @@ class SearchModal(ModalScreen):
# Focus on search input
self.search_input.focus()
async def _search_openlibrary(self, query: str) -> List[dict]:
"""Search OpenLibrary for books."""
try:
from helper.search_provider import get_provider
logger.info(f"[search-modal] Searching OpenLibrary for: {query}")
# Get the OpenLibrary provider (now has smart search built-in)
provider = get_provider("openlibrary")
if not provider:
logger.error("[search-modal] OpenLibrary provider not available")
return []
# Search using the provider (smart search is now default)
search_results = provider.search(query, limit=20)
formatted_results = []
for result in search_results:
# Extract metadata from SearchResult.full_metadata
metadata = result.full_metadata or {}
formatted_results.append({
"title": result.title,
"author": ", ".join(metadata.get("authors", [])) if metadata.get("authors") else "Unknown",
"year": metadata.get("year", ""),
"publisher": metadata.get("publisher", ""),
"isbn": metadata.get("isbn", ""),
"oclc": metadata.get("oclc", ""),
"lccn": metadata.get("lccn", ""),
"openlibrary_id": metadata.get("olid", ""),
"pages": metadata.get("pages", ""),
"language": metadata.get("language", ""),
"source": "openlibrary",
"columns": result.columns,
"raw_data": metadata
})
logger.info(f"[search-modal] Found {len(formatted_results)} OpenLibrary results")
return formatted_results
except Exception as e:
logger.error(f"[search-modal] OpenLibrary search error: {e}", exc_info=True)
import traceback
traceback.print_exc()
return []
async def _search_soulseek(self, query: str) -> List[dict]:
"""Search Soulseek for music with automatic worker tracking."""
try:
from helper.search_provider import get_provider
# Create worker for tracking
worker = None
if self.app_instance and hasattr(self.app_instance, 'create_worker'):
worker = self.app_instance.create_worker(
'soulseek',
title=f"Soulseek Search: {query[:40]}",
description=f"Searching P2P network for music"
)
self.current_worker = worker
if worker:
worker.log_step("Connecting to Soulseek peer network...")
logger.info(f"[search-modal] Searching Soulseek for: {query}")
provider = get_provider("soulseek")
search_results = provider.search(query, limit=20)
if worker:
worker.log_step(f"Search returned {len(search_results)} results")
logger.info(f"[search-modal] Found {len(search_results)} Soulseek results")
# Format results for display
formatted_results = []
for idx, result in enumerate(search_results):
metadata = result.full_metadata or {}
artist = metadata.get('artist', '')
album = metadata.get('album', '')
title = result.title
track_num = metadata.get('track_num', '')
size_bytes = result.size_bytes or 0
# Format size as human-readable
if size_bytes > 1024 * 1024:
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
elif size_bytes > 1024:
size_str = f"{size_bytes / 1024:.1f} KB"
else:
size_str = f"{size_bytes} B"
# Build columns for display
columns = [
("#", str(idx + 1)),
("Title", title[:50] if title else "Unknown"),
("Artist", artist[:30] if artist else "(no artist)"),
("Album", album[:30] if album else ""),
]
formatted_results.append({
"title": title if title else "Unknown",
"artist": artist if artist else "(no artist)",
"album": album,
"track": track_num,
"filesize": size_str,
"bitrate": "", # Not available in Soulseek results
"source": "soulseek",
"columns": columns,
"raw_data": result.to_dict()
})
return formatted_results
except Exception as e:
logger.error(f"[search-modal] Soulseek search error: {e}")
import traceback
traceback.print_exc()
return []
async def _perform_search(self) -> None:
"""Perform the actual search based on selected source."""
if not self.search_input or not self.source_select or not self.results_table:
@@ -257,87 +142,69 @@ class SearchModal(ModalScreen):
source = self.source_select.value
# Clear existing results
self.results_table.clear()
self.results_table.clear(columns=True)
self.current_results = []
self.current_result_table = None
self.is_searching = True
# Create worker for tracking
if self.app_instance and hasattr(self.app_instance, 'create_worker'):
self.current_worker = self.app_instance.create_worker(
source,
title=f"{source.capitalize()} Search: {query[:40]}",
description=f"Searching {source} for: {query}"
)
self.current_worker.log_step(f"Connecting to {source}...")
try:
if source == "openlibrary":
results = await self._search_openlibrary(query)
elif source == "soulseek":
results = await self._search_soulseek(query)
else:
logger.warning(f"[search-modal] Unknown source: {source}")
provider = get_provider(source)
if not provider:
logger.error(f"[search-modal] Provider not available: {source}")
if self.current_worker:
self.current_worker.finish("error", "Unknown search source")
self.current_worker.finish("error", f"Provider not available: {source}")
return
logger.info(f"[search-modal] Searching {source} for: {query}")
results = provider.search(query, limit=20)
self.current_results = results
# Populate table with results
if results:
# Check if first result has columns field
first_result = results[0]
if "columns" in first_result and first_result["columns"]:
# Use dynamic columns from result
# Clear existing columns and rebuild based on result columns
self.results_table.clear()
# Extract column headers from first result's columns field
column_headers = [col[0] for col in first_result["columns"]]
# Remove existing columns (we'll readd them with the right headers)
# Note: This is a workaround since Textual's DataTable doesn't support dynamic column management well
# For now, we just use the dynamic column headers from the result
logger.info(f"[search-modal] Using dynamic columns: {column_headers}")
# Populate rows using the column order from results
for result in results:
if "columns" in result and result["columns"]:
# Extract values in column order
row_data = [col[1] for col in result["columns"]]
self.results_table.add_row(*row_data)
else:
# Fallback for results without columns
logger.warning(f"[search-modal] Result missing columns field: {result.get('title', 'Unknown')}")
else:
# Fallback to original hardcoded behavior if columns not available
logger.info("[search-modal] No dynamic columns found, using default formatting")
for result in results:
if source == "openlibrary":
# Format OpenLibrary results (original hardcoded)
year = str(result.get("year", ""))[:4] if result.get("year") else ""
details = f"ISBN: {result.get('isbn', '')}" if result.get('isbn') else ""
if result.get('openlibrary_id'):
details += f" | OL: {result.get('openlibrary_id')}"
row_data = [
result["title"][:60],
result["author"][:35],
year,
details[:40]
]
else: # soulseek
row_data = [
result["title"][:50],
result["artist"][:30],
result["album"][:30],
result['filesize']
]
self.results_table.add_row(*row_data)
else:
# Add a "no results" message
self.results_table.add_row("No results found", "", "", "")
if self.current_worker:
self.current_worker.log_step(f"Found {len(results)} results")
# Finish worker if tracking
# Create ResultTable
table = ResultTable(f"Search Results: {query}")
for res in results:
row = table.add_row()
# Add columns from result.columns
if res.columns:
for name, value in res.columns:
row.add_column(name, value)
else:
# Fallback if no columns defined
row.add_column("Title", res.title)
row.add_column("Target", res.target)
self.current_result_table = table
# Populate UI
if table.rows:
# Add headers
headers = [col.name for col in table.rows[0].columns]
self.results_table.add_columns(*headers)
# Add rows
for row_vals in table.to_datatable_rows():
self.results_table.add_row(*row_vals)
else:
self.results_table.add_columns("Message")
self.results_table.add_row("No results found")
# Finish worker
if self.current_worker:
self.current_worker.finish("completed", f"Found {len(results)} results")
except Exception as e:
logger.error(f"[search-modal] Search error: {e}")
logger.error(f"[search-modal] Search error: {e}", exc_info=True)
if self.current_worker:
self.current_worker.finish("error", f"Search failed: {str(e)}")
@@ -382,35 +249,58 @@ class SearchModal(ModalScreen):
selected_row = self.results_table.cursor_row
if 0 <= selected_row < len(self.current_results):
result = self.current_results[selected_row]
# Convert to dict if needed for submission
if hasattr(result, 'to_dict'):
result_dict = result.to_dict()
else:
result_dict = result
# Get tags from textarea
tags_text = self.tags_textarea.text if self.tags_textarea else ""
# Get library source (if OpenLibrary)
library_source = self.library_source_select.value if self.library_source_select else "local"
# Add tags and source to result
result["tags_text"] = tags_text
result["library_source"] = library_source
result_dict["tags_text"] = tags_text
result_dict["library_source"] = library_source
# Post message and dismiss
self.post_message(self.SearchSelected(result))
self.dismiss(result)
self.post_message(self.SearchSelected(result_dict))
self.dismiss(result_dict)
else:
logger.warning("[search-modal] No result selected for submission")
elif button_id == "cancel-button":
self.dismiss(None)
def _populate_tags_from_result(self, result: dict) -> None:
def _populate_tags_from_result(self, result: Any) -> None:
"""Populate the tags textarea from a selected result."""
if not self.tags_textarea:
return
# Handle both SearchResult objects and dicts
if hasattr(result, 'full_metadata'):
metadata = result.full_metadata or {}
source = result.origin
title = result.title
else:
# Handle dict (legacy or from to_dict)
if 'full_metadata' in result:
metadata = result['full_metadata'] or {}
elif 'raw_data' in result:
metadata = result['raw_data'] or {}
else:
metadata = result
source = result.get('origin', result.get('source', ''))
title = result.get('title', '')
# Format tags based on result source
if result.get("source") == "openlibrary":
if source == "openlibrary":
# For OpenLibrary: title, author, year
title = result.get("title", "")
author = result.get("author", "")
year = result.get("year", "")
author = ", ".join(metadata.get("authors", [])) if isinstance(metadata.get("authors"), list) else metadata.get("authors", "")
year = str(metadata.get("year", ""))
tags = []
if title:
tags.append(title)
@@ -419,38 +309,51 @@ class SearchModal(ModalScreen):
if year:
tags.append(year)
tags_text = "\n".join(tags)
else: # soulseek
elif source == "soulseek":
# For Soulseek: artist, album, title, track
tags = []
if result.get("artist"):
tags.append(result["artist"])
if result.get("album"):
tags.append(result["album"])
if result.get("track"):
tags.append(f"Track {result['track']}")
if result.get("title"):
tags.append(result["title"])
if metadata.get("artist"):
tags.append(metadata["artist"])
if metadata.get("album"):
tags.append(metadata["album"])
if metadata.get("track_num"):
tags.append(f"Track {metadata['track_num']}")
if title:
tags.append(title)
tags_text = "\n".join(tags)
else:
# Generic fallback
tags = [title]
tags_text = "\n".join(tags)
self.tags_textarea.text = tags_text
logger.info(f"[search-modal] Populated tags textarea from result")
async def _download_book(self, result: dict) -> None:
async def _download_book(self, result: Any) -> None:
"""Download a book from OpenLibrary using unified downloader."""
try:
from helper.unified_book_downloader import UnifiedBookDownloader
from config import load_config
logger.info(f"[search-modal] Starting download for: {result.get('title')}")
# Convert SearchResult to dict if needed
if hasattr(result, 'to_dict'):
result_dict = result.to_dict()
# Ensure raw_data is populated for downloader
if 'raw_data' not in result_dict and result.full_metadata:
result_dict['raw_data'] = result.full_metadata
else:
result_dict = result
logger.info(f"[search-modal] Starting download for: {result_dict.get('title')}")
config = load_config()
downloader = UnifiedBookDownloader(config=config)
# Get download options for this book
options = downloader.get_download_options(result)
options = downloader.get_download_options(result_dict)
if not options['methods']:
logger.warning(f"[search-modal] No download methods available for: {result.get('title')}")
logger.warning(f"[search-modal] No download methods available for: {result_dict.get('title')}")
# Could show a modal dialog here
return

View File

@@ -40,6 +40,7 @@ class PipelineStageResult:
name: str
args: Sequence[str]
emitted: List[Any] = field(default_factory=list)
result_table: Optional[Any] = None # ResultTable object if available
status: str = "pending"
error: Optional[str] = None
@@ -52,6 +53,7 @@ class PipelineRunResult:
success: bool
stages: List[PipelineStageResult] = field(default_factory=list)
emitted: List[Any] = field(default_factory=list)
result_table: Optional[Any] = None # Final ResultTable object if available
stdout: str = ""
stderr: str = ""
error: Optional[str] = None
@@ -146,6 +148,7 @@ class PipelineExecutor:
if index == len(stages) - 1:
result.emitted = stage.emitted
result.result_table = stage.result_table
else:
piped_result = stage.emitted
@@ -211,6 +214,10 @@ class PipelineExecutor:
emitted = list(getattr(pipeline_ctx, "emits", []) or [])
stage.emitted = emitted
# Capture the ResultTable if the cmdlet set one
# Check display table first (overlay), then last result table
stage.result_table = ctx.get_display_table() or ctx.get_last_result_table()
if return_code != 0:
stage.status = "failed"
@@ -224,7 +231,12 @@ class PipelineExecutor:
label = f"[Stage {index + 1}/{total}] {cmd_name} {stage.status}"
self._worker_manager.log_step(worker_id, label)
ctx.set_last_result_table(None, emitted)
# Don't clear the table if we just captured it, but ensure items are set for next stage
# If we have a table, we should probably keep it in ctx for history if needed
# But for pipeline execution, we mainly care about passing items to next stage
# ctx.set_last_result_table(None, emitted) <-- This was clearing it
# Ensure items are available for next stage
ctx.set_last_items(emitted)
return stage

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from typing import Any, List, Optional, Sequence
from textual import work
from textual.app import App, ComposeResult
@@ -32,10 +32,9 @@ for path in (BASE_DIR, ROOT_DIR):
from menu_actions import ( # type: ignore # noqa: E402
PIPELINE_PRESETS,
PipelinePreset,
build_metadata_snapshot,
summarize_result,
)
from pipeline_runner import PipelineExecutor, PipelineRunResult # type: ignore # noqa: E402
from result_table import ResultTable # type: ignore # noqa: E402
class PresetListItem(ListItem):
@@ -73,6 +72,7 @@ class PipelineHubApp(App):
self.worker_table: Optional[DataTable] = None
self.preset_list: Optional[ListView] = None
self.status_panel: Optional[Static] = None
self.current_result_table: Optional[ResultTable] = None
self._pipeline_running = False
# ------------------------------------------------------------------
@@ -81,7 +81,7 @@ class PipelineHubApp(App):
def compose(self) -> ComposeResult: # noqa: D401 - Textual compose hook
yield Header(show_clock=True)
with Container(id="app-shell"):
with Horizontal(id="command-row"):
with Horizontal(id="command-pane"):
self.command_input = Input(
placeholder='download-data "<url>" | merge-file | add-tag | add-file -storage local',
id="pipeline-input",
@@ -174,7 +174,7 @@ class PipelineHubApp(App):
return
index = event.cursor_row
if 0 <= index < len(self.result_items):
self._display_metadata(self.result_items[index])
self._display_metadata(index)
# ------------------------------------------------------------------
# Pipeline execution helpers
@@ -216,6 +216,7 @@ class PipelineHubApp(App):
else:
self.result_items = []
self.current_result_table = run_result.result_table
self._populate_results_table()
self.refresh_workers()
@@ -228,40 +229,45 @@ class PipelineHubApp(App):
def _populate_results_table(self) -> None:
if not self.results_table:
return
self.results_table.clear()
if not self.result_items:
self.results_table.add_row("", "No results", "", "")
return
for idx, item in enumerate(self.result_items, start=1):
if isinstance(item, dict):
title = summarize_result(item)
source = item.get("source") or item.get("cmdlet_name") or item.get("cmdlet") or ""
file_path = item.get("file_path") or item.get("path") or ""
else:
title = str(item)
source = ""
file_path = ""
self.results_table.add_row(str(idx), title, source, file_path, key=str(idx - 1))
self.results_table.clear(columns=True)
def _display_metadata(self, item: Any) -> None:
if self.current_result_table and self.current_result_table.rows:
# Use ResultTable headers from the first row
first_row = self.current_result_table.rows[0]
headers = ["#"] + [col.name for col in first_row.columns]
self.results_table.add_columns(*headers)
rows = self.current_result_table.to_datatable_rows()
for idx, row_values in enumerate(rows, 1):
self.results_table.add_row(str(idx), *row_values, key=str(idx - 1))
else:
# Fallback or empty state
self.results_table.add_columns("Row", "Title", "Source", "File")
if not self.result_items:
self.results_table.add_row("", "No results", "", "")
return
# 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))
def _display_metadata(self, index: int) -> None:
if not self.metadata_tree:
return
root = self.metadata_tree.root
root.label = "Metadata"
root.remove_children()
payload: Dict[str, Any]
if isinstance(item, dict):
file_path = item.get("file_path") or item.get("path")
if file_path:
payload = build_metadata_snapshot(Path(file_path))
if self.current_result_table and 0 <= index < len(self.current_result_table.rows):
row = self.current_result_table.rows[index]
for col in row.columns:
root.add(f"[b]{col.name}[/b]: {col.value}")
elif 0 <= index < len(self.result_items):
item = self.result_items[index]
if isinstance(item, dict):
self._populate_tree_node(root, item)
else:
payload = item
else:
payload = {"value": str(item)}
self._populate_tree_node(root, payload)
root.expand_all()
root.add(str(item))
def _populate_tree_node(self, node, data: Any) -> None:
if isinstance(data, dict):
@@ -278,14 +284,14 @@ class PipelineHubApp(App):
def _clear_log(self) -> None:
self.log_lines = []
if self.log_output:
self.log_output.value = ""
self.log_output.text = ""
def _append_log_line(self, line: str) -> None:
self.log_lines.append(line)
if len(self.log_lines) > 500:
self.log_lines = self.log_lines[-500:]
if self.log_output:
self.log_output.value = "\n".join(self.log_lines)
self.log_output.text = "\n".join(self.log_lines)
def _append_block(self, text: str) -> None:
for line in text.strip().splitlines():

View File

@@ -6,7 +6,7 @@
layout: vertical;
}
#command-row {
#command-pane {
width: 100%;
height: auto;
background: $boost;
@@ -18,7 +18,6 @@
width: 1fr;
min-height: 3;
padding: 0 1;
margin-right: 1;
background: $surface;
color: $text;
border: round $primary;
@@ -30,7 +29,9 @@
}
#status-panel {
min-width: 20;
width: auto;
max-width: 25;
height: 3;
text-style: bold;
content-align: center middle;
padding: 0 1;
@@ -52,7 +53,7 @@
}
#left-pane {
max-width: 48;
max-width: 60;
}
.section-title {
@@ -67,6 +68,11 @@
margin-bottom: 1;
}
#preset-list {
height: 25;
border: solid $secondary;
}
#log-output {
height: 16;
}
@@ -97,4 +103,10 @@
.status-error {
background: $error 20%;
color: $error;
}
#run-button {
width: auto;
min-width: 10;
margin: 0 1;
}