Files
Medios-Macina/TUI/modalscreen/search.py

396 lines
16 KiB
Python
Raw Normal View History

2025-11-25 20:09:33 -08:00
"""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))
2025-12-12 21:55:38 -08:00
from config import load_config, resolve_output_dir
2025-11-27 10:59:01 -08:00
from result_table import ResultTable
2025-12-12 21:55:38 -08:00
from ProviderCore.registry import get_search_provider
2025-11-25 20:09:33 -08:00
logger = logging.getLogger(__name__)
class SearchModal(ModalScreen):
"""Modal screen for searching OpenLibrary and Soulseek."""
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("enter", "search_focused", "Search"),
Binding("ctrl+t", "scrape_tags", "Scrape Tags"),
]
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
CSS_PATH = "search.tcss"
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
class SearchSelected(Message):
"""Posted when user selects a search result."""
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
def __init__(self, result: dict) -> None:
self.result = result
super().__init__()
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
def __init__(self, app_instance=None):
"""Initialize the search modal.
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
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
2025-11-27 10:59:01 -08:00
self.current_results: List[Any] = [] # List of SearchResult objects
self.current_result_table: Optional[ResultTable] = None
2025-11-25 20:09:33 -08:00
self.is_searching = False
self.current_worker = None # Track worker for search operations
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
def compose(self) -> ComposeResult:
"""Create child widgets for the search modal."""
with Vertical(id="search-container"):
yield Static("Search Books & Music", id="search-title")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
with Horizontal(id="search-controls"):
# Source selector
self.source_select = Select(
[("OpenLibrary", "openlibrary"), ("Soulseek", "soulseek")],
value="openlibrary",
2025-12-29 17:05:03 -08:00
id="source-select",
2025-11-25 20:09:33 -08:00
)
yield self.source_select
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
# Search input
2025-12-29 17:05:03 -08:00
self.search_input = Input(placeholder="Enter search query...", id="search-input")
2025-11-25 20:09:33 -08:00
yield self.search_input
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
# Search button
yield Button("Search", id="search-button", variant="primary")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
# Results table
self.results_table = DataTable(id="results-table")
yield self.results_table
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
# 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(
2025-12-29 17:05:03 -08:00
text="", id="result-tags-textarea", read_only=False
2025-11-25 20:09:33 -08:00
)
self.tags_textarea.border_title = "Tags [Ctrl+T: Scrape]"
yield self.tags_textarea
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
# 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",
2025-12-29 17:05:03 -08:00
id="library-source-select",
2025-11-25 20:09:33 -08:00
)
yield self.library_source_select
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
# Submit button
yield Button("Submit", id="submit-button", variant="primary")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
# 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")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
def on_mount(self) -> None:
"""Set up the table columns and focus."""
# Set up results table columns
2025-12-29 17:05:03 -08:00
self.results_table.add_columns("Title", "Author/Artist", "Year/Album", "Details")
2025-11-25 20:09:33 -08:00
# Focus on search input
self.search_input.focus()
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
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
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
query = self.search_input.value.strip()
if not query:
logger.warning("[search-modal] Empty search query")
return
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
source = self.source_select.value
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
# Clear existing results
2025-11-27 10:59:01 -08:00
self.results_table.clear(columns=True)
2025-11-25 20:09:33 -08:00
self.current_results = []
2025-11-27 10:59:01 -08:00
self.current_result_table = None
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
self.is_searching = True
2025-12-29 17:05:03 -08:00
2025-11-27 10:59:01 -08:00
# Create worker for tracking
2025-12-29 17:05:03 -08:00
if self.app_instance and hasattr(self.app_instance, "create_worker"):
2025-11-27 10:59:01 -08:00
self.current_worker = self.app_instance.create_worker(
source,
title=f"{source.capitalize()} Search: {query[:40]}",
2025-12-29 17:05:03 -08:00
description=f"Searching {source} for: {query}",
2025-11-27 10:59:01 -08:00
)
self.current_worker.log_step(f"Connecting to {source}...")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
try:
2025-12-11 19:04:02 -08:00
provider = get_search_provider(source)
2025-11-27 10:59:01 -08:00
if not provider:
logger.error(f"[search-modal] Provider not available: {source}")
2025-11-25 20:09:33 -08:00
if self.current_worker:
2025-11-27 10:59:01 -08:00
self.current_worker.finish("error", f"Provider not available: {source}")
2025-11-25 20:09:33 -08:00
return
2025-11-27 10:59:01 -08:00
logger.info(f"[search-modal] Searching {source} for: {query}")
results = provider.search(query, limit=20)
2025-11-25 20:09:33 -08:00
self.current_results = results
2025-12-29 17:05:03 -08:00
2025-11-27 10:59:01 -08:00
if self.current_worker:
self.current_worker.log_step(f"Found {len(results)} results")
2025-12-29 17:05:03 -08:00
2025-11-27 10:59:01 -08:00
# 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)
2025-11-25 20:09:33 -08:00
else:
2025-11-27 10:59:01 -08:00
# Fallback if no columns defined
row.add_column("Title", res.title)
2025-12-29 17:05:03 -08:00
row.add_column(
"Target",
getattr(res, "path", None)
or getattr(res, "url", None)
or getattr(res, "target", None)
or "",
)
2025-11-27 10:59:01 -08:00
self.current_result_table = table
2025-12-29 17:05:03 -08:00
2025-11-27 10:59:01 -08:00
# 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)
2025-11-25 20:09:33 -08:00
else:
2025-11-27 10:59:01 -08:00
self.results_table.add_columns("Message")
self.results_table.add_row("No results found")
2025-12-29 17:05:03 -08:00
2025-11-27 10:59:01 -08:00
# Finish worker
2025-11-25 20:09:33 -08:00
if self.current_worker:
self.current_worker.finish("completed", f"Found {len(results)} results")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
except Exception as e:
2025-11-27 10:59:01 -08:00
logger.error(f"[search-modal] Search error: {e}", exc_info=True)
2025-11-25 20:09:33 -08:00
if self.current_worker:
self.current_worker.finish("error", f"Search failed: {str(e)}")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
finally:
self.is_searching = False
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
button_id = event.button.id
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
if button_id == "search-button":
# Run search asynchronously
asyncio.create_task(self._perform_search())
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
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")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
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]
2025-12-12 21:55:38 -08:00
if getattr(result, "table", "") == "openlibrary":
2025-11-25 20:09:33 -08:00
asyncio.create_task(self._download_book(result))
else:
2025-12-29 17:05:03 -08:00
logger.warning(
"[search-modal] Download only supported for OpenLibrary results"
)
2025-11-25 20:09:33 -08:00
else:
logger.warning("[search-modal] No result selected for download")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
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]
2025-12-29 17:05:03 -08:00
2025-11-27 10:59:01 -08:00
# Convert to dict if needed for submission
2025-12-29 17:05:03 -08:00
if hasattr(result, "to_dict"):
2025-11-27 10:59:01 -08:00
result_dict = result.to_dict()
else:
result_dict = result
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
# Get tags from textarea
tags_text = self.tags_textarea.text if self.tags_textarea else ""
# Get library source (if OpenLibrary)
2025-12-29 17:05:03 -08:00
library_source = (
self.library_source_select.value if self.library_source_select else "local"
)
2025-11-25 20:09:33 -08:00
# Add tags and source to result
2025-11-27 10:59:01 -08:00
result_dict["tags_text"] = tags_text
result_dict["library_source"] = library_source
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
# Post message and dismiss
2025-11-27 10:59:01 -08:00
self.post_message(self.SearchSelected(result_dict))
self.dismiss(result_dict)
2025-11-25 20:09:33 -08:00
else:
logger.warning("[search-modal] No result selected for submission")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
elif button_id == "cancel-button":
self.dismiss(None)
2025-12-29 17:05:03 -08:00
2025-11-27 10:59:01 -08:00
def _populate_tags_from_result(self, result: Any) -> None:
2025-11-25 20:09:33 -08:00
"""Populate the tags textarea from a selected result."""
if not self.tags_textarea:
return
2025-12-29 17:05:03 -08:00
2025-11-27 10:59:01 -08:00
# Handle both SearchResult objects and dicts
2025-12-29 17:05:03 -08:00
if hasattr(result, "full_metadata"):
2025-11-27 10:59:01 -08:00
metadata = result.full_metadata or {}
2025-12-11 19:04:02 -08:00
source = result.table
2025-11-27 10:59:01 -08:00
title = result.title
else:
# Handle dict (legacy or from to_dict)
2025-12-29 17:05:03 -08:00
if "full_metadata" in result:
metadata = result["full_metadata"] or {}
elif "raw_data" in result:
metadata = result["raw_data"] or {}
2025-11-27 10:59:01 -08:00
else:
metadata = result
2025-12-29 17:05:03 -08:00
source = result.get("table", "")
title = result.get("title", "")
2025-11-25 20:09:33 -08:00
# Format tags based on result source
2025-11-27 10:59:01 -08:00
if source == "openlibrary":
2025-11-25 20:09:33 -08:00
# For OpenLibrary: title, author, year
2025-12-29 17:05:03 -08:00
author = (
", ".join(metadata.get("authors", []))
if isinstance(metadata.get("authors"), list)
else metadata.get("authors", "")
)
2025-11-27 10:59:01 -08:00
year = str(metadata.get("year", ""))
2025-11-25 20:09:33 -08:00
tags = []
if title:
tags.append(title)
if author:
tags.append(author)
if year:
tags.append(year)
tags_text = "\n".join(tags)
2025-11-27 10:59:01 -08:00
elif source == "soulseek":
2025-11-25 20:09:33 -08:00
# For Soulseek: artist, album, title, track
tags = []
2025-11-27 10:59:01 -08:00
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]
2025-11-25 20:09:33 -08:00
tags_text = "\n".join(tags)
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
self.tags_textarea.text = tags_text
logger.info(f"[search-modal] Populated tags textarea from result")
2025-12-29 17:05:03 -08:00
2025-11-27 10:59:01 -08:00
async def _download_book(self, result: Any) -> None:
2025-12-12 21:55:38 -08:00
"""Download a book from OpenLibrary using the provider."""
if getattr(result, "table", "") != "openlibrary":
logger.warning("[search-modal] Download only supported for OpenLibrary results")
return
2025-11-25 20:09:33 -08:00
try:
config = load_config()
2025-12-12 21:55:38 -08:00
output_dir = resolve_output_dir(config)
provider = get_search_provider("openlibrary", config=config)
if not provider:
logger.error("[search-modal] Provider not available: openlibrary")
2025-11-25 20:09:33 -08:00
return
2025-12-12 21:55:38 -08:00
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}")
2025-11-25 20:09:33 -08:00
else:
2025-12-12 21:55:38 -08:00
logger.warning(f"[search-modal] Download failed for: {title}")
2025-11-25 20:09:33 -08:00
except Exception as e:
logger.error(f"[search-modal] Download error: {e}", exc_info=True)
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
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())
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
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)
2025-12-29 17:05:03 -08:00
logger.info(
f"[search-modal] Ctrl+T: Populated tags from result at row {selected_row}"
)
2025-11-25 20:09:33 -08:00
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")
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
def action_cancel(self) -> None:
"""Action for Escape key - close modal."""
self.dismiss(None)
2025-12-29 17:05:03 -08:00
2025-11-25 20:09:33 -08:00
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())