Hardcover list separation + Browser download fix (#745)

- Added full Hardcover reading status types into the list selector
- Split reading status entries from dedicated lists
- Added option to disable the automatic removal of books when downloaded
from a Hardcover list
- Fixed browser download not firing when the completed state was
triggered in specific cases
This commit is contained in:
Alex
2026-03-13 14:33:10 +00:00
committed by GitHub
parent 3d72f9e258
commit 685c35d552
10 changed files with 221 additions and 127 deletions
+7 -2
View File
@@ -1016,6 +1016,7 @@ def api_config() -> Union[Response, Tuple[Response, int]]:
"show_release_source_links": app_config.get("SHOW_RELEASE_SOURCE_LINKS", True),
"books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"),
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
"hardcover_auto_remove_on_download": app_config.get("HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD", True),
"download_to_browser_content_types": app_config.get(
"DOWNLOAD_TO_BROWSER_CONTENT_TYPES",
[],
@@ -2375,11 +2376,15 @@ def api_metadata_book_targets_update(provider: str, book_id: str) -> Union[Respo
return jsonify({"error": "selected must be a boolean"}), 400
result = prov.set_book_target_state(book_id, target, selected)
return jsonify({
response: dict = {
"success": True,
"changed": bool(result.get("changed", True)),
"selected": selected,
})
}
deselected = result.get("deselected_target")
if isinstance(deselected, str) and deselected:
response["deselected_target"] = deselected
return jsonify(response)
@app.route('/api/releases', methods=['GET'])
+59 -30
View File
@@ -104,7 +104,22 @@ query GetUserLists {
me {
id
username
want_to_read_books: user_books_aggregate(where: {status_id: {_eq: 1}}) {
want_to_read_count: user_books_aggregate(where: {status_id: {_eq: 1}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
currently_reading_count: user_books_aggregate(where: {status_id: {_eq: 2}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
read_count: user_books_aggregate(where: {status_id: {_eq: 3}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
}
did_not_finish_count: user_books_aggregate(where: {status_id: {_eq: 5}}) {
aggregate {
count(columns: [book_id], distinct: true)
}
@@ -371,16 +386,17 @@ query GetSeriesBooks($seriesId: Int!) {
}
"""
HARDCOVER_WANT_TO_READ_STATUS_ID = 1
HARDCOVER_STATUS_PREFIX = "status:"
HARDCOVER_STATUS_URL_SLUGS: dict[int, str] = {
1: "want-to-read",
2: "currently-reading",
3: "read",
5: "did-not-finish",
}
HARDCOVER_STATUSES: list[dict] = [
{"id": 1, "label": "Want to Read", "slug": "want-to-read", "query_key": "want_to_read_count"},
{"id": 2, "label": "Currently Reading", "slug": "currently-reading", "query_key": "currently_reading_count"},
{"id": 3, "label": "Read", "slug": "read", "query_key": "read_count"},
{"id": 5, "label": "Did Not Finish", "slug": "did-not-finish", "query_key": "did_not_finish_count"},
]
HARDCOVER_STATUS_URL_SLUGS: dict[int, str] = {s["id"]: s["slug"] for s in HARDCOVER_STATUSES}
HARDCOVER_STATUS_GROUP = "Reading Status"
HARDCOVER_LIST_ID_PREFIX = "id:"
HARDCOVER_WRITABLE_TARGET_GROUPS = {"My Books", "My Lists"}
HARDCOVER_WRITABLE_TARGET_GROUPS = {HARDCOVER_STATUS_GROUP, "My Lists"}
@dataclass(frozen=True)
@@ -1539,26 +1555,27 @@ class HardcoverProvider(MetadataProvider):
except (TypeError, ValueError):
return name
want_to_read_count_data = me_data.get("want_to_read_books", {})
want_to_read_aggregate = (
want_to_read_count_data.get("aggregate", {})
if isinstance(want_to_read_count_data, dict)
else {}
)
want_to_read_count = (
want_to_read_aggregate.get("count")
if isinstance(want_to_read_aggregate, dict)
else None
)
want_to_read_value = f"{HARDCOVER_STATUS_PREFIX}{HARDCOVER_WANT_TO_READ_STATUS_ID}"
seen_values.add(want_to_read_value)
options.append(
{
"value": want_to_read_value,
"label": _format_label("Want to Read", want_to_read_count),
"group": "My Books",
}
)
for status in HARDCOVER_STATUSES:
count_data = me_data.get(status["query_key"], {})
aggregate = (
count_data.get("aggregate", {})
if isinstance(count_data, dict)
else {}
)
count = (
aggregate.get("count")
if isinstance(aggregate, dict)
else None
)
value = f"{HARDCOVER_STATUS_PREFIX}{status['id']}"
seen_values.add(value)
options.append(
{
"value": value,
"label": _format_label(status["label"], count),
"group": HARDCOVER_STATUS_GROUP,
}
)
for list_item in me_data.get("lists", []):
if not isinstance(list_item, dict):
@@ -1652,6 +1669,7 @@ class HardcoverProvider(MetadataProvider):
state = self._fetch_book_target_state(book_id_int)
status_ids_to_invalidate: set[int] = set()
list_ids_to_invalidate: set[int] = set()
deselected_target: Optional[str] = None
if selected_target.startswith(HARDCOVER_STATUS_PREFIX):
status_id = self._parse_prefixed_int(selected_target, "status target")
@@ -1660,6 +1678,8 @@ class HardcoverProvider(MetadataProvider):
if changed:
if previous_status_id is not None:
status_ids_to_invalidate.add(previous_status_id)
if selected and previous_status_id != status_id:
deselected_target = f"{HARDCOVER_STATUS_PREFIX}{previous_status_id}"
status_ids_to_invalidate.add(status_id)
elif selected_target.startswith(HARDCOVER_LIST_ID_PREFIX):
list_id = self._parse_prefixed_int(selected_target, "list target")
@@ -1676,7 +1696,10 @@ class HardcoverProvider(MetadataProvider):
list_ids=list_ids_to_invalidate,
)
return {"changed": changed}
result_data: Dict[str, Any] = {"changed": changed}
if deselected_target:
result_data["deselected_target"] = deselected_target
return result_data
@staticmethod
def _unwrap_me_data(result: Optional[Dict]) -> Dict:
@@ -2716,4 +2739,10 @@ def hardcover_settings():
description="Filter out books with a release year in the future",
default=False,
),
CheckboxField(
key="HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD",
label="Auto-Remove from List on Download",
description="Automatically remove a book from the active Hardcover list when you download it",
default=True,
),
]
+9 -9
View File
@@ -591,13 +591,11 @@ function App() {
});
// Check for completed items
const prevDownloadingIds = new Set(Object.keys(prevDownloading));
const prevResolvingIds = new Set(Object.keys(prev.resolving || {}));
const prevQueuedIds = new Set(Object.keys(prevQueued));
const prevComplete = prev.complete || {};
const currComplete = curr.complete || {};
Object.keys(currComplete).forEach(bookId => {
if (prevDownloadingIds.has(bookId) || prevQueuedIds.has(bookId)) {
if (!prevComplete[bookId]) {
const book = currComplete[bookId];
showToast(`${book.title || 'Book'} completed`, 'success');
@@ -621,9 +619,10 @@ function App() {
});
// Check for failed items
const prevError = prev.error || {};
const currError = curr.error || {};
Object.keys(currError).forEach(bookId => {
if (prevDownloadingIds.has(bookId) || prevResolvingIds.has(bookId) || prevQueuedIds.has(bookId)) {
if (!prevError[bookId]) {
const book = currError[bookId];
const errorMsg = book.status_message || 'Download failed';
showToast(`${book.title || 'Book'}: ${errorMsg}`, 'error');
@@ -1115,18 +1114,19 @@ function App() {
metadataConfigRef.current = activeMetadataConfig;
const removeBookFromActiveList = useCallback((book: Book) => {
if (config?.hardcover_auto_remove_on_download === false) return;
if (!bookSupportsTargets(book)) return;
const activeList = searchFieldValuesRef.current.hardcover_list;
if (!activeList) return;
const target = String(activeList);
// Only auto-remove from lists the user owns (My Books / My Lists)
// Only auto-remove from lists the user owns (Reading Status / My Lists)
const listField = metadataConfigRef.current?.search_fields.find(
(f) => f.key === 'hardcover_list' && f.type === 'DynamicSelectSearchField',
);
if (listField && listField.type === 'DynamicSelectSearchField') {
const group = getDynamicOptionGroup(listField.options_endpoint, target);
if (group && group !== 'My Books' && group !== 'My Lists') return;
if (group && group !== 'Reading Status' && group !== 'My Lists') return;
}
void setBookTargetState(book.provider!, book.provider_id!, target, false).then((result) => {
@@ -1141,7 +1141,7 @@ function App() {
showToast(`Removed from ${listName || 'list'}`, 'info');
}
}).catch(() => {});
}, [showToast]);
}, [config?.hardcover_auto_remove_on_download, showToast]);
const executeBookDownload = useCallback(
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
@@ -2125,7 +2125,7 @@ function App() {
bottom: 0,
left: 0,
right: '25rem',
zIndex: 40,
zIndex: 20,
}
: { paddingTop: `${headerHeight}px` }
}
@@ -51,14 +51,23 @@ const renderSummary = (selectedOptions: DropdownListOption[]) => {
);
};
const STATUS_PREFIX = 'status:';
const isStatusTarget = (value: string): boolean => value.startsWith(STATUS_PREFIX);
const updateOptionChecked = (
prev: BookTargetOption[],
target: string,
checked: boolean,
): BookTargetOption[] =>
prev.map((option) =>
option.value === target ? { ...option, checked } : option,
);
prev.map((option) => {
if (option.value === target) return { ...option, checked };
// Statuses are mutually exclusive — uncheck other statuses when one is selected
if (checked && isStatusTarget(target) && isStatusTarget(option.value)) {
return { ...option, checked: false };
}
return option;
});
export const BookTargetDropdown = ({
provider,
@@ -136,6 +145,7 @@ export const BookTargetDropdown = ({
value: option.value,
label: option.label,
description: option.description,
group: option.group,
disabled: !option.writable || pendingTargets.has(option.value),
}));
}, [isLoading, loadError, options, pendingTargets]);
@@ -176,6 +186,16 @@ export const BookTargetDropdown = ({
target: toggledTarget,
selected: result.selected,
});
// When a status was implicitly deselected, sync other instances
if (result.deselectedTarget) {
setOptions((prev) => updateOptionChecked(prev, result.deselectedTarget!, false));
emitBookTargetChange({
provider,
bookId,
target: result.deselectedTarget,
selected: false,
});
}
const label = stripCountSuffix(toggledOption.label);
onShowToast?.(
`${result.selected ? 'Added to' : 'Removed from'} ${label}`,
@@ -232,7 +252,7 @@ export const BookTargetDropdown = ({
options={dropdownOptions}
value={selectedValues}
onChange={handleChange}
placeholder={isLoading ? 'Loading…' : 'Lists & Want to Read'}
placeholder={isLoading ? 'Loading…' : 'Hardcover'}
widthClassName={variant !== 'default' ? 'w-auto' : widthClassName}
buttonClassName={variant !== 'default' ? '' : 'py-1.5 leading-none'}
panelClassName={variant !== 'default' ? 'w-56' : undefined}
+45 -31
View File
@@ -7,6 +7,7 @@ export interface DropdownListOption {
description?: string;
disabled?: boolean;
icon?: ReactNode;
group?: string;
}
interface DropdownListProps {
@@ -115,37 +116,50 @@ export const DropdownList = ({
renderTrigger={renderTrigger}
onOpenChange={onOpenChange}
>
{({ close }) => (
<div role="listbox" aria-multiselectable={multiple}>
{options.map(option => (
<button
type="button"
key={option.value}
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => handleOptionClick(option, close)}
disabled={option.disabled}
>
{checkboxEnabled && (
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
/>
)}
{option.icon}
<div className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-xs opacity-70">{option.description}</span>
)}
</div>
</button>
))}
</div>
)}
{({ close }) => {
let lastGroup: string | undefined;
return (
<div role="listbox" aria-multiselectable={multiple}>
{options.map(option => {
const showGroupHeader = option.group != null && option.group !== lastGroup;
if (option.group != null) lastGroup = option.group;
return (
<div key={option.value}>
{showGroupHeader && (
<div className="px-3 pt-2 pb-1 text-xs font-medium uppercase tracking-wide opacity-60 select-none">
{option.group}
</div>
)}
<button
type="button"
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => handleOptionClick(option, close)}
disabled={option.disabled}
>
{checkboxEnabled && (
<input
type="checkbox"
checked={selectedValues.includes(option.value)}
readOnly
className="h-4 w-4 rounded-sm border-gray-300 text-sky-600 focus:ring-sky-500 pointer-events-none"
/>
)}
{option.icon}
<div className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-xs opacity-70">{option.description}</span>
)}
</div>
</button>
</div>
);
})}
</div>
);
}}
</Dropdown>
);
};
+32 -25
View File
@@ -274,7 +274,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
fetchFieldOptions(dynamicEndpoint).then((loaded) => {
if (cancelled) return;
setDynamicOptions(loaded.map((o) => ({ value: o.value, label: o.label })));
setDynamicOptions(loaded.map((o) => ({ value: o.value, label: o.label, group: o.group })));
setIsDynamicLoading(false);
}).catch(() => {
if (cancelled) return;
@@ -832,34 +832,41 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
aria-label={effectiveInputAriaLabel}
>
<div className="max-h-64 overflow-y-auto py-1.5">
{selectOptions.map((option) => {
{selectOptions.map((option, index) => {
const currentValue = typeof value === 'string' ? value : String(value ?? '');
const isSelected = option.value === currentValue;
const showGroupHeader = option.group != null && (index === 0 || option.group !== selectOptions[index - 1]?.group);
return (
<button
type="button"
key={option.value}
role="option"
aria-selected={isSelected}
onClick={() => {
onChange(option.value, option.label);
setIsSelectOpen(false);
setTimeout(() => onSubmitRef.current(), 0);
}}
className={`w-full px-5 py-2.5 text-left text-sm flex items-center gap-3 transition-colors ${
isSelected ? '' : 'hover-surface'
}`}
style={{ color: 'var(--text)' }}
>
<span className={`flex-1 truncate ${isSelected ? 'font-medium' : ''}`}>
{option.label}
</span>
{isSelected && (
<svg className="w-4 h-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
<div key={option.value}>
{showGroupHeader && (
<div className="px-5 pt-2 pb-1 text-xs font-medium uppercase tracking-wide opacity-60 select-none">
{option.group}
</div>
)}
</button>
<button
type="button"
role="option"
aria-selected={isSelected}
onClick={() => {
onChange(option.value, option.label);
setIsSelectOpen(false);
setTimeout(() => onSubmitRef.current(), 0);
}}
className={`w-full px-5 py-2.5 text-left text-sm flex items-center gap-3 transition-colors ${
isSelected ? '' : 'hover-surface'
}`}
style={{ color: 'var(--text)' }}
>
<span className={`flex-1 truncate ${isSelected ? 'font-medium' : ''}`}>
{option.label}
</span>
{isSelected && (
<svg className="w-4 h-4 text-emerald-500 shrink-0" fill="none" viewBox="0 0 24 24" strokeWidth="2.5" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</button>
</div>
);
})}
</div>
@@ -22,6 +22,7 @@ const buildOptions = (
value: option.value,
label: option.label,
description: option.description,
group: option.group,
}));
};
+3 -1
View File
@@ -257,6 +257,7 @@ export interface BookTargetOption {
export interface BookTargetStateResult {
changed: boolean;
selected: boolean;
deselectedTarget?: string;
}
// Search metadata providers and normalize to Book format
@@ -423,7 +424,7 @@ export const setBookTargetState = async (
target: string,
selected: boolean,
): Promise<BookTargetStateResult> => {
const response = await fetchJSON<{ changed?: unknown; selected?: unknown }>(
const response = await fetchJSON<{ changed?: unknown; selected?: unknown; deselected_target?: unknown }>(
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/${encodeURIComponent(bookId)}/targets`,
{
method: 'PUT',
@@ -434,6 +435,7 @@ export const setBookTargetState = async (
return {
changed: response.changed === true,
selected: response.selected === true,
deselectedTarget: typeof response.deselected_target === 'string' ? response.deselected_target : undefined,
};
};
+2
View File
@@ -115,6 +115,7 @@ export interface Toast {
export interface SortOption {
value: string;
label: string;
group?: string;
}
// Search field types (mirror backend search field types)
@@ -264,6 +265,7 @@ export interface AppConfig {
show_release_source_links: boolean;
books_output_mode: BooksOutputMode;
auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued
hardcover_auto_remove_on_download: boolean; // Auto-remove from active Hardcover list on download
download_to_browser_content_types: string[]; // Auto-download completed files to browser for selected content types
settings_enabled: boolean; // Whether config directory is mounted and writable
onboarding_complete: boolean; // Whether the user has completed initial setup
+39 -25
View File
@@ -1,7 +1,7 @@
from shelfmark.core.cache import cache_key
from shelfmark.metadata_providers import MetadataSearchOptions, SearchResult
from shelfmark.metadata_providers.hardcover import (
HARDCOVER_WANT_TO_READ_STATUS_ID,
HARDCOVER_STATUS_GROUP,
HardcoverProvider,
)
@@ -23,7 +23,7 @@ class CacheStub:
class TestHardcoverLists:
def test_fetch_user_lists_includes_want_to_read_shelf(self, monkeypatch):
def test_fetch_user_lists_includes_all_status_shelves(self, monkeypatch):
provider = HardcoverProvider(api_key="test-token")
monkeypatch.setattr(
@@ -32,11 +32,10 @@ class TestHardcoverLists:
lambda query, variables: {
"me": {
"username": "alex",
"want_to_read_books": {
"aggregate": {
"count": 7,
}
},
"want_to_read_count": {"aggregate": {"count": 7}},
"currently_reading_count": {"aggregate": {"count": 3}},
"read_count": {"aggregate": {"count": 20}},
"did_not_finish_count": {"aggregate": {"count": 1}},
"lists": [
{
"id": 42,
@@ -53,11 +52,26 @@ class TestHardcoverLists:
options = provider._fetch_user_lists()
assert options[0] == {
"value": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
"value": "status:1",
"label": "Want to Read (7)",
"group": "My Books",
"group": HARDCOVER_STATUS_GROUP,
}
assert options[1] == {
"value": "status:2",
"label": "Currently Reading (3)",
"group": HARDCOVER_STATUS_GROUP,
}
assert options[2] == {
"value": "status:3",
"label": "Read (20)",
"group": HARDCOVER_STATUS_GROUP,
}
assert options[3] == {
"value": "status:5",
"label": "Did Not Finish (1)",
"group": HARDCOVER_STATUS_GROUP,
}
assert options[4] == {
"value": "id:42",
"label": "Sci-Fi Favourites (12)",
"group": "My Lists",
@@ -81,13 +95,13 @@ class TestHardcoverLists:
query="",
page=2,
limit=20,
fields={"hardcover_list": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}"},
fields={"hardcover_list": "status:1"},
)
)
assert result == expected
assert captured == {
"status_id": HARDCOVER_WANT_TO_READ_STATUS_ID,
"status_id": 1,
"page": 2,
"limit": 20,
}
@@ -134,13 +148,13 @@ class TestHardcoverLists:
monkeypatch.setattr(provider, "_execute_query", fake_execute)
result = provider._fetch_current_user_books_by_status(
HARDCOVER_WANT_TO_READ_STATUS_ID,
1,
page=1,
limit=10,
)
assert captured["variables"] == {
"statusId": HARDCOVER_WANT_TO_READ_STATUS_ID,
"statusId": 1,
"limit": 10,
"offset": 0,
}
@@ -159,9 +173,9 @@ class TestHardcoverLists:
"get_user_lists",
lambda: [
{
"value": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
"value": "status:1",
"label": "Want to Read (7)",
"group": "My Books",
"group": HARDCOVER_STATUS_GROUP,
},
{
"value": "id:42",
@@ -181,7 +195,7 @@ class TestHardcoverLists:
"_execute_query",
lambda query, variables, raise_on_error=False: {
"me": {
"user_books": [{"id": 55, "status_id": HARDCOVER_WANT_TO_READ_STATUS_ID}],
"user_books": [{"id": 55, "status_id": 1}],
"lists": [
{"id": 42, "list_books": [{"id": 500}]},
{"id": 99, "list_books": [{"id": 900}]},
@@ -194,9 +208,9 @@ class TestHardcoverLists:
assert options == [
{
"value": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
"value": "status:1",
"label": "Want to Read (7)",
"group": "My Books",
"group": HARDCOVER_STATUS_GROUP,
"checked": True,
"writable": True,
},
@@ -219,9 +233,9 @@ class TestHardcoverLists:
"get_user_lists",
lambda: [
{
"value": f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
"value": "status:1",
"label": "Want to Read (7)",
"group": "My Books",
"group": HARDCOVER_STATUS_GROUP,
}
],
)
@@ -242,7 +256,7 @@ class TestHardcoverLists:
"update_user_book": {
"id": 77,
"error": None,
"user_book": {"id": 77, "book_id": 123, "status_id": HARDCOVER_WANT_TO_READ_STATUS_ID},
"user_book": {"id": 77, "book_id": 123, "status_id": 1},
}
}
raise AssertionError(f"Unexpected query: {query}")
@@ -251,21 +265,21 @@ class TestHardcoverLists:
result = provider.set_book_target_state(
"123",
f"status:{HARDCOVER_WANT_TO_READ_STATUS_ID}",
"status:1",
True,
)
assert result == {"changed": True}
assert result == {"changed": True, "deselected_target": "status:2"}
assert captured["variables"] == {
"userBookId": 77,
"statusId": HARDCOVER_WANT_TO_READ_STATUS_ID,
"statusId": 1,
}
assert cache_stub.invalidated == [
cache_key("hardcover:user_lists", "user-123"),
]
assert set(cache_stub.invalidated_prefixes) == {
cache_key("hardcover:user_books:status", "user-123", 2),
cache_key("hardcover:user_books:status", "user-123", HARDCOVER_WANT_TO_READ_STATUS_ID),
cache_key("hardcover:user_books:status", "user-123", 1),
}
def test_set_book_target_state_removes_list_membership(self, monkeypatch):