431 lines
17 KiB
Python
431 lines
17 KiB
Python
"""Search modal screen for OpenLibrary and Soulseek."""
|
|
|
|
from textual.app import ComposeResult
|
|
from textual.screen import ModalScreen
|
|
from textual.containers import Container, Horizontal, Vertical
|
|
from textual.widgets import Static, Button, Input, Select, DataTable, TextArea
|
|
from textual.binding import Binding
|
|
from textual.message import Message
|
|
import logging
|
|
from typing import Optional, Any, List
|
|
from pathlib import Path
|
|
import sys
|
|
import asyncio
|
|
|
|
# Add parent directory to path for imports
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
from SYS.config import load_config, resolve_output_dir
|
|
from result_table import ResultTable
|
|
from ProviderCore.registry import get_search_provider
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SearchModal(ModalScreen):
|
|
"""Modal screen for searching OpenLibrary and Soulseek."""
|
|
|
|
BINDINGS = [
|
|
Binding("escape",
|
|
"cancel",
|
|
"Cancel"),
|
|
Binding("enter",
|
|
"search_focused",
|
|
"Search"),
|
|
Binding("ctrl+t",
|
|
"scrape_tags",
|
|
"Scrape Tags"),
|
|
]
|
|
|
|
CSS_PATH = "search.tcss"
|
|
|
|
class SearchSelected(Message):
|
|
"""Posted when user selects a search result."""
|
|
|
|
def __init__(self, result: dict) -> None:
|
|
self.result = result
|
|
super().__init__()
|
|
|
|
def __init__(self, app_instance=None):
|
|
"""Initialize the search modal.
|
|
|
|
Args:
|
|
app_instance: Reference to the main App instance for worker creation
|
|
"""
|
|
super().__init__()
|
|
self.app_instance = app_instance
|
|
self.source_select: Optional[Select] = None
|
|
self.search_input: Optional[Input] = None
|
|
self.results_table: Optional[DataTable] = None
|
|
self.tags_textarea: Optional[TextArea] = None
|
|
self.library_source_select: Optional[Select] = None
|
|
self.current_results: List[Any] = [] # List of SearchResult objects
|
|
self.current_result_table: Optional[ResultTable] = None
|
|
self.is_searching = False
|
|
self.current_worker = None # Track worker for search operations
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create child widgets for the search modal."""
|
|
with Vertical(id="search-container"):
|
|
yield Static("Search Books & Music", id="search-title")
|
|
|
|
with Horizontal(id="search-controls"):
|
|
# Source selector
|
|
self.source_select = Select(
|
|
[("OpenLibrary",
|
|
"openlibrary"),
|
|
("Soulseek",
|
|
"soulseek")],
|
|
value="openlibrary",
|
|
id="source-select",
|
|
)
|
|
yield self.source_select
|
|
|
|
# Search input
|
|
self.search_input = Input(
|
|
placeholder="Enter search query...",
|
|
id="search-input"
|
|
)
|
|
yield self.search_input
|
|
|
|
# Search button
|
|
yield Button("Search", id="search-button", variant="primary")
|
|
|
|
# Results table
|
|
self.results_table = DataTable(id="results-table")
|
|
yield self.results_table
|
|
|
|
# Two-column layout: tags on left, source/submit on right
|
|
with Horizontal(id="bottom-controls"):
|
|
# Left column: Tags textarea
|
|
with Vertical(id="tags-column"):
|
|
self.tags_textarea = TextArea(
|
|
text="",
|
|
id="result-tags-textarea",
|
|
read_only=False
|
|
)
|
|
self.tags_textarea.border_title = "Tags [Ctrl+T: Scrape]"
|
|
yield self.tags_textarea
|
|
|
|
# Right column: Library source and submit button
|
|
with Vertical(id="source-submit-column"):
|
|
# Library source selector (for OpenLibrary results)
|
|
self.library_source_select = Select(
|
|
[("Local",
|
|
"local"),
|
|
("Download",
|
|
"download")],
|
|
value="local",
|
|
id="library-source-select",
|
|
)
|
|
yield self.library_source_select
|
|
|
|
# Submit button
|
|
yield Button("Submit", id="submit-button", variant="primary")
|
|
|
|
# Buttons at bottom
|
|
with Horizontal(id="search-buttons"):
|
|
yield Button("Select", id="select-button", variant="primary")
|
|
yield Button("Download", id="download-button", variant="primary")
|
|
yield Button("Cancel", id="cancel-button", variant="default")
|
|
|
|
def on_mount(self) -> None:
|
|
"""Set up the table columns and focus."""
|
|
# Set up results table columns
|
|
self.results_table.add_columns(
|
|
"Title",
|
|
"Author/Artist",
|
|
"Year/Album",
|
|
"Details"
|
|
)
|
|
|
|
# Focus on search input
|
|
self.search_input.focus()
|
|
|
|
async def _perform_search(self) -> None:
|
|
"""Perform the actual search based on selected source."""
|
|
if not self.search_input or not self.source_select or not self.results_table:
|
|
logger.error("[search-modal] Widgets not initialized")
|
|
return
|
|
|
|
query = self.search_input.value.strip()
|
|
if not query:
|
|
logger.warning("[search-modal] Empty search query")
|
|
return
|
|
|
|
source = self.source_select.value
|
|
|
|
# Clear existing results
|
|
self.results_table.clear(columns=True)
|
|
self.current_results = []
|
|
self.current_result_table = None
|
|
|
|
self.is_searching = True
|
|
|
|
# Create worker for tracking
|
|
if self.app_instance and hasattr(self.app_instance, "create_worker"):
|
|
self.current_worker = self.app_instance.create_worker(
|
|
source,
|
|
title=f"{source.capitalize()} Search: {query[:40]}",
|
|
description=f"Searching {source} for: {query}",
|
|
)
|
|
self.current_worker.log_step(f"Connecting to {source}...")
|
|
|
|
try:
|
|
provider = get_search_provider(source)
|
|
if not provider:
|
|
logger.error(f"[search-modal] Provider not available: {source}")
|
|
if self.current_worker:
|
|
self.current_worker.finish(
|
|
"error",
|
|
f"Provider not available: {source}"
|
|
)
|
|
return
|
|
|
|
logger.info(f"[search-modal] Searching {source} for: {query}")
|
|
results = provider.search(query, limit=20)
|
|
self.current_results = results
|
|
|
|
if self.current_worker:
|
|
self.current_worker.log_step(f"Found {len(results)} results")
|
|
|
|
# Create ResultTable
|
|
table = 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",
|
|
getattr(res,
|
|
"path",
|
|
None) or getattr(res,
|
|
"url",
|
|
None) or getattr(res,
|
|
"target",
|
|
None) or "",
|
|
)
|
|
|
|
self.current_result_table = table
|
|
|
|
# Populate UI
|
|
if table.rows:
|
|
# Add headers
|
|
headers = [col.name for col in table.rows[0].columns]
|
|
self.results_table.add_columns(*headers)
|
|
# Add rows
|
|
for row_vals in table.to_datatable_rows():
|
|
self.results_table.add_row(*row_vals)
|
|
else:
|
|
self.results_table.add_columns("Message")
|
|
self.results_table.add_row("No results found")
|
|
|
|
# Finish worker
|
|
if self.current_worker:
|
|
self.current_worker.finish("completed", f"Found {len(results)} results")
|
|
|
|
except Exception as e:
|
|
logger.error(f"[search-modal] Search error: {e}", exc_info=True)
|
|
if self.current_worker:
|
|
self.current_worker.finish("error", f"Search failed: {str(e)}")
|
|
|
|
finally:
|
|
self.is_searching = False
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
"""Handle button presses."""
|
|
button_id = event.button.id
|
|
|
|
if button_id == "search-button":
|
|
# Run search asynchronously
|
|
asyncio.create_task(self._perform_search())
|
|
|
|
elif button_id == "select-button":
|
|
# Get selected row and populate tags textarea
|
|
if self.results_table and self.results_table.row_count > 0:
|
|
selected_row = self.results_table.cursor_row
|
|
if 0 <= selected_row < len(self.current_results):
|
|
result = self.current_results[selected_row]
|
|
# Populate tags textarea with result metadata
|
|
self._populate_tags_from_result(result)
|
|
else:
|
|
logger.warning("[search-modal] No results to select")
|
|
|
|
elif button_id == "download-button":
|
|
# Download the selected result
|
|
if self.current_results and self.results_table.row_count > 0:
|
|
selected_row = self.results_table.cursor_row
|
|
if 0 <= selected_row < len(self.current_results):
|
|
result = self.current_results[selected_row]
|
|
if getattr(result, "table", "") == "openlibrary":
|
|
asyncio.create_task(self._download_book(result))
|
|
else:
|
|
logger.warning(
|
|
"[search-modal] Download only supported for OpenLibrary results"
|
|
)
|
|
else:
|
|
logger.warning("[search-modal] No result selected for download")
|
|
|
|
elif button_id == "submit-button":
|
|
# Submit the current result with tags and source
|
|
if self.current_results and self.results_table.row_count > 0:
|
|
selected_row = self.results_table.cursor_row
|
|
if 0 <= selected_row < len(self.current_results):
|
|
result = self.current_results[selected_row]
|
|
|
|
# Convert to dict if needed for submission
|
|
if hasattr(result, "to_dict"):
|
|
result_dict = result.to_dict()
|
|
else:
|
|
result_dict = result
|
|
|
|
# Get tags from textarea
|
|
tags_text = self.tags_textarea.text if self.tags_textarea else ""
|
|
# Get library source (if OpenLibrary)
|
|
library_source = (
|
|
self.library_source_select.value
|
|
if self.library_source_select else "local"
|
|
)
|
|
|
|
# Add tags and source to result
|
|
result_dict["tags_text"] = tags_text
|
|
result_dict["library_source"] = library_source
|
|
|
|
# Post message and dismiss
|
|
self.post_message(self.SearchSelected(result_dict))
|
|
self.dismiss(result_dict)
|
|
else:
|
|
logger.warning("[search-modal] No result selected for submission")
|
|
|
|
elif button_id == "cancel-button":
|
|
self.dismiss(None)
|
|
|
|
def _populate_tags_from_result(self, result: Any) -> None:
|
|
"""Populate the tags textarea from a selected result."""
|
|
if not self.tags_textarea:
|
|
return
|
|
|
|
# Handle both SearchResult objects and dicts
|
|
if hasattr(result, "full_metadata"):
|
|
metadata = result.full_metadata or {}
|
|
source = result.table
|
|
title = result.title
|
|
else:
|
|
# Handle dict (legacy or from to_dict)
|
|
if "full_metadata" in result:
|
|
metadata = result["full_metadata"] or {}
|
|
elif "raw_data" in result:
|
|
metadata = result["raw_data"] or {}
|
|
else:
|
|
metadata = result
|
|
|
|
source = result.get("table", "")
|
|
title = result.get("title", "")
|
|
|
|
# Format tags based on result source
|
|
if source == "openlibrary":
|
|
# For OpenLibrary: title, author, year
|
|
author = (
|
|
", ".join(metadata.get("authors",
|
|
[]))
|
|
if isinstance(metadata.get("authors"),
|
|
list) else metadata.get("authors",
|
|
"")
|
|
)
|
|
year = str(metadata.get("year", ""))
|
|
tags = []
|
|
if title:
|
|
tags.append(title)
|
|
if author:
|
|
tags.append(author)
|
|
if year:
|
|
tags.append(year)
|
|
tags_text = "\n".join(tags)
|
|
elif source == "soulseek":
|
|
# For Soulseek: artist, album, title, track
|
|
tags = []
|
|
if metadata.get("artist"):
|
|
tags.append(metadata["artist"])
|
|
if metadata.get("album"):
|
|
tags.append(metadata["album"])
|
|
if metadata.get("track_num"):
|
|
tags.append(f"Track {metadata['track_num']}")
|
|
if title:
|
|
tags.append(title)
|
|
tags_text = "\n".join(tags)
|
|
else:
|
|
# Generic fallback
|
|
tags = [title]
|
|
tags_text = "\n".join(tags)
|
|
|
|
self.tags_textarea.text = tags_text
|
|
logger.info(f"[search-modal] Populated tags textarea from result")
|
|
|
|
async def _download_book(self, result: Any) -> None:
|
|
"""Download a book from OpenLibrary using the provider."""
|
|
if getattr(result, "table", "") != "openlibrary":
|
|
logger.warning(
|
|
"[search-modal] Download only supported for OpenLibrary results"
|
|
)
|
|
return
|
|
|
|
try:
|
|
config = load_config()
|
|
output_dir = resolve_output_dir(config)
|
|
|
|
provider = get_search_provider("openlibrary", config=config)
|
|
if not provider:
|
|
logger.error("[search-modal] Provider not available: openlibrary")
|
|
return
|
|
|
|
title = getattr(result, "title", "")
|
|
logger.info(f"[search-modal] Starting download for: {title}")
|
|
|
|
downloaded = await asyncio.to_thread(provider.download, result, output_dir)
|
|
if downloaded:
|
|
logger.info(f"[search-modal] Download successful: {downloaded}")
|
|
else:
|
|
logger.warning(f"[search-modal] Download failed for: {title}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"[search-modal] Download error: {e}", exc_info=True)
|
|
|
|
def action_search_focused(self) -> None:
|
|
"""Action for Enter key - only search if search input is focused."""
|
|
if self.search_input and self.search_input.has_focus and not self.is_searching:
|
|
asyncio.create_task(self._perform_search())
|
|
|
|
def action_scrape_tags(self) -> None:
|
|
"""Action for Ctrl+T - populate tags from selected result."""
|
|
if self.current_results and self.results_table and self.results_table.row_count > 0:
|
|
try:
|
|
selected_row = self.results_table.cursor_row
|
|
if 0 <= selected_row < len(self.current_results):
|
|
result = self.current_results[selected_row]
|
|
self._populate_tags_from_result(result)
|
|
logger.info(
|
|
f"[search-modal] Ctrl+T: Populated tags from result at row {selected_row}"
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f"[search-modal] Ctrl+T: Invalid row index {selected_row}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"[search-modal] Ctrl+T error: {e}")
|
|
else:
|
|
logger.warning("[search-modal] Ctrl+T: No results selected")
|
|
|
|
def action_cancel(self) -> None:
|
|
"""Action for Escape key - close modal."""
|
|
self.dismiss(None)
|
|
|
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
"""Handle Enter key in search input - only trigger search here."""
|
|
if event.input.id == "search-input":
|
|
if not self.is_searching:
|
|
asyncio.create_task(self._perform_search())
|