diff --git a/shelfmark/core/request_routes.py b/shelfmark/core/request_routes.py index 07f644ff..b9a379f7 100644 --- a/shelfmark/core/request_routes.py +++ b/shelfmark/core/request_routes.py @@ -495,7 +495,10 @@ def register_request_routes( if resolved_mode == PolicyMode.REQUEST_BOOK: requested_level = str(request_level).strip().lower() if isinstance(request_level, str) else "" - if requested_level != "book": + # Direct search results are already concrete releases, so allow release-level + # request payloads even when the policy default is request_book. + allow_direct_release_payload = source == "direct_download" and requested_level == "release" + if requested_level != "book" and not allow_direct_release_payload: logger.debug( "Request not created for '%s' by %s: policy requires book-level requests", request_title, diff --git a/shelfmark/main.py b/shelfmark/main.py index b051da83..f60d319c 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -2043,6 +2043,7 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: """ try: from shelfmark.metadata_providers import ( + BookMetadata, get_provider, is_provider_registered, get_provider_kwargs, @@ -2072,27 +2073,70 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: if not provider or not book_id: return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400 - if not is_provider_registered(provider): - return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400 + # Direct mode request approvals can open ReleaseModal with provider=direct_download. + # In that flow, treat the direct result as release-search context instead of requiring + # a metadata provider registration. + if provider == "direct_download": + direct_book = backend.get_book_info(book_id) + if not isinstance(direct_book, dict): + return jsonify({"error": "Book not found in direct source"}), 404 - # Get book metadata from provider - kwargs = get_provider_kwargs(provider) - prov = get_provider(provider, **kwargs) - book = prov.get_book(book_id) + resolved_title = title_param or str(direct_book.get("title") or "").strip() or "Unknown title" + resolved_author = author_param or str(direct_book.get("author") or "").strip() + authors = [part.strip() for part in resolved_author.split(",") if part.strip()] + if not authors and resolved_author: + authors = [resolved_author] - if not book: - return jsonify({"error": "Book not found in metadata provider"}), 404 + raw_publish_year = direct_book.get("year") + publish_year = None + if isinstance(raw_publish_year, int): + publish_year = raw_publish_year + elif isinstance(raw_publish_year, str): + normalized_year = raw_publish_year.strip() + if normalized_year.isdigit(): + publish_year = int(normalized_year) - # Override title from frontend if available (search results may have better data) - # Note: We intentionally DON'T override authors here - get_book() now returns - # filtered authors (primary authors only, excluding translators/narrators), - # which gives better release search results than the unfiltered search data - if title_param: - book.title = title_param + book = BookMetadata( + provider="direct_download", + provider_id=book_id, + provider_display_name="Direct Download", + title=resolved_title, + search_title=resolved_title, + search_author=resolved_author or None, + authors=authors, + cover_url=direct_book.get("preview"), + description=direct_book.get("description"), + publisher=direct_book.get("publisher"), + publish_year=publish_year, + language=direct_book.get("language"), + source_url=direct_book.get("source_url"), + ) + else: + if not is_provider_registered(provider): + return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400 + + # Get book metadata from provider + kwargs = get_provider_kwargs(provider) + prov = get_provider(provider, **kwargs) + book = prov.get_book(book_id) + + if not book: + return jsonify({"error": "Book not found in metadata provider"}), 404 + + # Override title from frontend if available (search results may have better data) + # Note: We intentionally DON'T override authors here - get_book() now returns + # filtered authors (primary authors only, excluding translators/narrators), + # which gives better release search results than the unfiltered search data + if title_param: + book.title = title_param # Determine which release sources to search if source_filter: sources_to_search = [source_filter] + elif provider == "direct_download": + # Direct mode has no metadata-provider fanout; keep release browsing focused + # on Direct Download results (same dataset as legacy direct search). + sources_to_search = ["direct_download"] else: # Search only enabled sources sources_to_search = [src["name"] for src in list_available_sources() if src["enabled"]] diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 15d7cd72..b2625cee 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1356,6 +1356,8 @@ function App() { currentStatus={statusForButtonState} defaultReleaseSource={config?.default_release_source} onSearchSeries={isBrowseFulfilMode ? undefined : handleSearchSeries} + defaultShowManualQuery={isBrowseFulfilMode} + isRequestMode={isBrowseFulfilMode} /> )} diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 2bc2d835..ea152fec 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -370,6 +370,8 @@ interface ReleaseModalProps { currentStatus: StatusData; defaultReleaseSource?: string; // Default tab to show (e.g., 'direct_download') onSearchSeries?: (seriesName: string) => void; // Callback to search for series + defaultShowManualQuery?: boolean; + isRequestMode?: boolean; } @@ -799,11 +801,14 @@ export const ReleaseModal = ({ currentStatus, defaultReleaseSource, onSearchSeries, + defaultShowManualQuery = false, + isRequestMode = false, }: ReleaseModalProps) => { // Use audiobook formats when in audiobook mode const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0 ? supportedAudiobookFormats : supportedFormats; + const isDirectProviderContext = (book?.provider || '').toLowerCase() === 'direct_download'; const [isClosing, setIsClosing] = useState(false); const [isRequestingBook, setIsRequestingBook] = useState(false); @@ -904,6 +909,7 @@ export const ReleaseModal = ({ useEffect(() => { setDescriptionExpanded(false); setDescriptionOverflows(false); + setShowHeaderThumb(false); setReleasesBySource({}); setLoadingBySource({}); setErrorBySource({}); @@ -912,8 +918,11 @@ export const ReleaseModal = ({ setLanguageFilter([LANGUAGE_OPTION_DEFAULT]); setIndexerFilter([]); indexerFilterInitializedRef.current = new Set(); - setManualQuery(''); - setShowManualQuery(false); + const baseTitle = book?.search_title || book?.title || ''; + const baseAuthor = book?.search_author || book?.author || ''; + const defaultQuery = `${baseTitle} ${baseAuthor}`.trim(); + setManualQuery(defaultShowManualQuery ? defaultQuery : ''); + setShowManualQuery(defaultShowManualQuery); setSearchStatus(null); lastStatusTimeRef.current = 0; pendingStatusRef.current = null; @@ -921,7 +930,7 @@ export const ReleaseModal = ({ clearTimeout(statusTimeoutRef.current); statusTimeoutRef.current = null; } - }, [book?.id]); + }, [book?.id, defaultShowManualQuery, book?.search_title, book?.title, book?.search_author, book?.author]); // Set up WebSocket listener for search status updates useEffect(() => { @@ -1032,14 +1041,26 @@ export const ReleaseModal = ({ try { setSourcesLoading(true); const sources = await getReleaseSources(); - setAvailableSources(sources); + const modalSources = isDirectProviderContext + ? sources.filter((source) => source.name === 'direct_download') + : sources; + setAvailableSources(modalSources); // Filter sources by content type support - const supportedSources = sources.filter(s => { + const supportedSources = modalSources.filter(s => { const types = s.supported_content_types || ['ebook', 'audiobook']; return types.includes(contentType); }); + if (isDirectProviderContext) { + if (supportedSources.some((source) => source.name === 'direct_download')) { + setActiveTab('direct_download'); + } else { + setActiveTab(''); + } + return; + } + // Set active tab: prefer defaultReleaseSource if enabled and supports content type if (supportedSources.length > 0) { const enabledSources = supportedSources.filter(s => s.enabled); @@ -1077,7 +1098,7 @@ export const ReleaseModal = ({ }; fetchSources(); - }, [book, defaultReleaseSource, contentType]); + }, [book, defaultReleaseSource, contentType, isDirectProviderContext]); // Fetch releases when active tab changes (with caching) // Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch @@ -1178,8 +1199,9 @@ export const ReleaseModal = ({ // Filter to only enabled sources that support this content type availableSources.forEach((src) => { - // Skip disabled sources entirely - they won't appear as tabs - if (!src.enabled) { + const allowDisabledDirectTab = isDirectProviderContext && src.name === 'direct_download'; + // Skip disabled sources entirely, except direct tab in direct-provider context. + if (!src.enabled && !allowDisabledDirectTab) { return; } @@ -1202,7 +1224,7 @@ export const ReleaseModal = ({ } return enabledTabs; - }, [availableSources, defaultReleaseSource, contentType]); + }, [availableSources, defaultReleaseSource, contentType, isDirectProviderContext]); // Update tab indicator position when active tab changes useEffect(() => { @@ -1593,140 +1615,142 @@ export const ReleaseModal = ({ {/* Scrollable content */}
+ {book.description} + {descriptionExpanded && descriptionOverflows && ( + <> + {' '} + + > + )} +
+ {!descriptionExpanded && descriptionOverflows && ( + + )} +- {book.description} - {descriptionExpanded && descriptionOverflows && ( - <> - {' '} - - > - )} -
- {!descriptionExpanded && descriptionOverflows && ( - - )} -