mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 13:40:21 +01:00
@@ -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,
|
||||
|
||||
+58
-14
@@ -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"]]
|
||||
|
||||
@@ -1356,6 +1356,8 @@ function App() {
|
||||
currentStatus={statusForButtonState}
|
||||
defaultReleaseSource={config?.default_release_source}
|
||||
onSearchSeries={isBrowseFulfilMode ? undefined : handleSearchSeries}
|
||||
defaultShowManualQuery={isBrowseFulfilMode}
|
||||
isRequestMode={isBrowseFulfilMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Book summary - scrolls with content */}
|
||||
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-[var(--border-muted)]">
|
||||
{book.preview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
alt="Book cover"
|
||||
className={`rounded-lg shadow-md object-cover object-top flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className={`rounded-lg border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[10px] text-gray-500 flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
|
||||
No cover
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{book.year && <span>{book.year}</span>}
|
||||
{displayFields?.starField && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StarRating rating={parseFloat(displayFields.starField.value || '0')} />
|
||||
<span>{displayFields.starField.value}</span>
|
||||
{displayFields.ratingsField && (
|
||||
<span className="text-gray-400 dark:text-gray-500">({displayFields.ratingsField.value})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{displayFields?.usersField && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
{displayFields.usersField.value} readers
|
||||
</span>
|
||||
)}
|
||||
{displayFields?.pagesField && (
|
||||
<span>{displayFields.pagesField.value} pages</span>
|
||||
)}
|
||||
</div>
|
||||
{!isRequestMode && (
|
||||
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-[var(--border-muted)]">
|
||||
{book.preview ? (
|
||||
<img
|
||||
src={book.preview}
|
||||
alt="Book cover"
|
||||
className={`rounded-lg shadow-md object-cover object-top flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className={`rounded-lg border border-dashed border-[var(--border-muted)] bg-[var(--bg)]/60 flex items-center justify-center text-[10px] text-gray-500 flex-shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
|
||||
No cover
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{book.year && <span>{book.year}</span>}
|
||||
{displayFields?.starField && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StarRating rating={parseFloat(displayFields.starField.value || '0')} />
|
||||
<span>{displayFields.starField.value}</span>
|
||||
{displayFields.ratingsField && (
|
||||
<span className="text-gray-400 dark:text-gray-500">({displayFields.ratingsField.value})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{displayFields?.usersField && (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="h-3.5 w-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
{displayFields.usersField.value} readers
|
||||
</span>
|
||||
)}
|
||||
{displayFields?.pagesField && (
|
||||
<span>{displayFields.pagesField.value} pages</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Series info */}
|
||||
{book.series_name && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
{book.series_position != null ? (
|
||||
<>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name}</>
|
||||
) : (
|
||||
<>Part of {book.series_name}</>
|
||||
{/* Series info */}
|
||||
{book.series_name && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
{book.series_position != null ? (
|
||||
<>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name}</>
|
||||
) : (
|
||||
<>Part of {book.series_name}</>
|
||||
)}
|
||||
</span>
|
||||
{onSearchSeries && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSearchSeries(book.series_name!);
|
||||
handleClose();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
View series
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{onSearchSeries && (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{book.description && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 relative">
|
||||
<p ref={descriptionRef} className={descriptionExpanded ? '' : 'line-clamp-3'}>
|
||||
{book.description}
|
||||
{descriptionExpanded && descriptionOverflows && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded(false)}
|
||||
className="text-emerald-600 dark:text-emerald-400 hover:underline font-medium inline"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{!descriptionExpanded && descriptionOverflows && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded(true)}
|
||||
className="absolute bottom-0 right-0 text-emerald-600 dark:text-emerald-400 hover:underline font-medium pl-8 bg-gradient-to-r from-transparent via-[var(--bg)] to-[var(--bg)] sm:via-[var(--bg-soft)] sm:to-[var(--bg-soft)]"
|
||||
>
|
||||
more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
{(book.isbn_13 || book.isbn_10) && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
ISBN: {book.isbn_13 || book.isbn_10}
|
||||
</span>
|
||||
)}
|
||||
{book.source_url && (
|
||||
<a
|
||||
href={book.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
View on {providerDisplay}
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{onRequestBook && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSearchSeries(book.series_name!);
|
||||
handleClose();
|
||||
void handleRequestBook();
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
|
||||
disabled={isRequestingBook}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
View series
|
||||
{isRequestingBook ? 'Adding...' : 'Add to requests'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{book.description && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 relative">
|
||||
<p ref={descriptionRef} className={descriptionExpanded ? '' : 'line-clamp-3'}>
|
||||
{book.description}
|
||||
{descriptionExpanded && descriptionOverflows && (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded(false)}
|
||||
className="text-emerald-600 dark:text-emerald-400 hover:underline font-medium inline"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{!descriptionExpanded && descriptionOverflows && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded(true)}
|
||||
className="absolute bottom-0 right-0 text-emerald-600 dark:text-emerald-400 hover:underline font-medium pl-8 bg-gradient-to-r from-transparent via-[var(--bg)] to-[var(--bg)] sm:via-[var(--bg-soft)] sm:to-[var(--bg-soft)]"
|
||||
>
|
||||
more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Links row */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
{(book.isbn_13 || book.isbn_10) && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
ISBN: {book.isbn_13 || book.isbn_10}
|
||||
</span>
|
||||
)}
|
||||
{book.source_url && (
|
||||
<a
|
||||
href={book.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
View on {providerDisplay}
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{onRequestBook && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleRequestBook();
|
||||
}}
|
||||
disabled={isRequestingBook}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 rounded-full hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{isRequestingBook ? 'Adding...' : 'Add to requests'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source tabs + filters - sticky within scroll container */}
|
||||
<div className="sticky top-0 z-10 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)]">
|
||||
|
||||
@@ -434,7 +434,7 @@ export const ActivityCard = ({
|
||||
|
||||
const provider = toOptionalText(bookData.provider)?.toLowerCase();
|
||||
const providerId = toOptionalText(bookData.provider_id);
|
||||
const canBrowseAlternatives = Boolean(provider && providerId && provider !== 'direct_download');
|
||||
const canBrowseAlternatives = Boolean(provider && providerId);
|
||||
|
||||
const handleReviewApprove = async () => {
|
||||
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
|
||||
|
||||
@@ -45,13 +45,14 @@ describe('requestPayload utilities', () => {
|
||||
assert.equal(payload.release_data?.source, 'direct_download');
|
||||
});
|
||||
|
||||
it('creates direct request payload at book level for request_book mode', () => {
|
||||
it('creates direct request payload with attached release for request_book mode', () => {
|
||||
const payload = buildDirectRequestPayload(baseBook, 'request_book');
|
||||
|
||||
assert.equal(payload.context.request_level, 'book');
|
||||
assert.equal(payload.context.request_level, 'release');
|
||||
assert.equal(payload.context.source, 'direct_download');
|
||||
assert.equal(payload.context.content_type, 'ebook');
|
||||
assert.equal(payload.release_data, null);
|
||||
assert.ok(payload.release_data);
|
||||
assert.equal(payload.release_data?.source, 'direct_download');
|
||||
});
|
||||
|
||||
it('builds metadata book + release payload fragments', () => {
|
||||
|
||||
@@ -90,14 +90,18 @@ export const buildDirectRequestPayload = (
|
||||
mode: Extract<RequestPolicyMode, 'request_release' | 'request_book'>
|
||||
): CreateRequestPayload => {
|
||||
const bookData = buildDirectBookRequestData(book);
|
||||
|
||||
// In direct mode, every result already represents a concrete downloadable release.
|
||||
// Even when policy defaults resolve to request_book, attach the selected release so
|
||||
// admins can approve immediately or browse alternatives from the same record.
|
||||
if (mode === 'request_book') {
|
||||
return {
|
||||
book_data: bookData,
|
||||
release_data: null,
|
||||
release_data: buildReleaseDataFromDirectBook(book),
|
||||
context: {
|
||||
source: 'direct_download',
|
||||
content_type: 'ebook',
|
||||
request_level: 'book',
|
||||
request_level: 'release',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for /api/releases with direct_download provider context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources import (
|
||||
ColumnAlign,
|
||||
ColumnRenderType,
|
||||
ColumnSchema,
|
||||
Release,
|
||||
ReleaseColumnConfig,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
"""Import `shelfmark.main` with background startup disabled."""
|
||||
with patch("shelfmark.download.orchestrator.start"):
|
||||
import shelfmark.main as main
|
||||
|
||||
importlib.reload(main)
|
||||
return main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(main_module):
|
||||
return main_module.app.test_client()
|
||||
|
||||
|
||||
class _FakeDirectSource:
|
||||
last_search_type = "title_author"
|
||||
|
||||
def search(self, book, plan, expand_search=False, content_type="ebook"): # noqa: ANN001
|
||||
assert book.provider == "direct_download"
|
||||
assert book.provider_id == "md5-abc"
|
||||
assert book.title == "The Gun Seller"
|
||||
assert plan.primary_query
|
||||
return [
|
||||
Release(
|
||||
source="direct_download",
|
||||
source_id="md5-rel-1",
|
||||
title="The Gun Seller",
|
||||
format="epub",
|
||||
size="2 MB",
|
||||
)
|
||||
]
|
||||
|
||||
def get_column_config(self):
|
||||
return ReleaseColumnConfig(
|
||||
columns=[
|
||||
ColumnSchema(
|
||||
key="format",
|
||||
label="Format",
|
||||
render_type=ColumnRenderType.BADGE,
|
||||
align=ColumnAlign.CENTER,
|
||||
width="80px",
|
||||
),
|
||||
],
|
||||
grid_template="minmax(0,2fr) 80px",
|
||||
)
|
||||
|
||||
|
||||
def test_releases_accepts_direct_download_provider(main_module, client):
|
||||
fake_direct_source = _FakeDirectSource()
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(
|
||||
main_module.backend,
|
||||
"get_book_info",
|
||||
return_value={
|
||||
"id": "md5-abc",
|
||||
"title": "The Gun Seller",
|
||||
"author": "Iain Banks",
|
||||
"preview": "https://example.com/cover.jpg",
|
||||
},
|
||||
) as mock_get_book_info:
|
||||
with patch("shelfmark.release_sources.get_source", return_value=fake_direct_source) as mock_get_source:
|
||||
with patch(
|
||||
"shelfmark.release_sources.list_available_sources",
|
||||
side_effect=AssertionError("list_available_sources should not be called"),
|
||||
):
|
||||
resp = client.get(
|
||||
"/api/releases",
|
||||
query_string={
|
||||
"provider": "direct_download",
|
||||
"book_id": "md5-abc",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["sources_searched"] == ["direct_download"]
|
||||
assert body["book"]["provider"] == "direct_download"
|
||||
assert body["book"]["provider_id"] == "md5-abc"
|
||||
assert body["book"]["title"] == "The Gun Seller"
|
||||
assert body["releases"][0]["source"] == "direct_download"
|
||||
assert body["releases"][0]["source_id"] == "md5-rel-1"
|
||||
assert body["search_info"]["direct_download"]["search_type"] == "title_author"
|
||||
mock_get_book_info.assert_called_once_with("md5-abc")
|
||||
mock_get_source.assert_called_once_with("direct_download")
|
||||
|
||||
|
||||
def test_releases_direct_provider_returns_404_when_book_missing(main_module, client):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
with patch.object(main_module.backend, "get_book_info", return_value=None):
|
||||
with patch("shelfmark.release_sources.get_source") as mock_get_source:
|
||||
resp = client.get(
|
||||
"/api/releases",
|
||||
query_string={
|
||||
"provider": "direct_download",
|
||||
"book_id": "missing-md5",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json() == {"error": "Book not found in direct source"}
|
||||
mock_get_source.assert_not_called()
|
||||
@@ -541,6 +541,44 @@ class TestRequestRoutes:
|
||||
assert resp.json["code"] == "policy_requires_request"
|
||||
assert resp.json["required_mode"] == "request_book"
|
||||
|
||||
def test_request_book_policy_allows_direct_release_level_request(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
policy = _policy(default_ebook="request_book")
|
||||
|
||||
payload = {
|
||||
"book_data": {
|
||||
"title": "Direct Result",
|
||||
"author": "Direct Author",
|
||||
"content_type": "ebook",
|
||||
"provider": "direct_download",
|
||||
"provider_id": "dd-1",
|
||||
},
|
||||
"context": {
|
||||
"source": "direct_download",
|
||||
"content_type": "ebook",
|
||||
"request_level": "release",
|
||||
},
|
||||
"release_data": {
|
||||
"source": "direct_download",
|
||||
"source_id": "dd-1",
|
||||
"title": "Direct Result.epub",
|
||||
"format": "epub",
|
||||
"size": "2 MB",
|
||||
},
|
||||
}
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module, "_load_users_request_policy_settings", return_value=policy):
|
||||
with patch("shelfmark.core.request_routes._load_users_request_policy_settings", return_value=policy):
|
||||
resp = client.post("/api/requests", json=payload)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json["request_level"] == "release"
|
||||
assert resp.json["policy_mode"] == "request_book"
|
||||
assert resp.json["release_data"]["source"] == "direct_download"
|
||||
assert resp.json["release_data"]["source_id"] == "dd-1"
|
||||
|
||||
def test_non_admin_cannot_access_admin_request_routes(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
|
||||
Reference in New Issue
Block a user