feat: Implement local search functionality with alphabet filtering in OPDS feed

This commit is contained in:
JB
2025-11-11 18:09:58 -08:00
parent aba72524ce
commit 166162931c
5 changed files with 570 additions and 35 deletions
+123 -5
View File
@@ -3,9 +3,10 @@ from __future__ import annotations
import dataclasses import dataclasses
import html import html
import re import re
from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any, Dict, Iterable, List, Mapping, Optional from typing import Any, Deque, Dict, Iterable, List, Mapping, Optional, Set
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
@@ -207,17 +208,134 @@ class CalibreOPDSClient:
(None, {"search": cleaned}), (None, {"search": cleaned}),
] ]
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
base_feed: Optional[OPDSFeed] = None
for path, params in candidates: for path, params in candidates:
try: try:
feed = self.fetch_feed(path, params=params) feed = self.fetch_feed(path, params=params)
return self._filter_feed_entries(feed, cleaned)
except CalibreOPDSError as exc: except CalibreOPDSError as exc:
last_error = exc last_error = exc
continue continue
if last_error is not None: filtered = self._filter_feed_entries(feed, cleaned)
if filtered.entries:
return filtered
fallback = self._local_search(cleaned, seed_feed=feed)
if fallback.entries:
return fallback
if base_feed is None:
try:
base_feed = self.fetch_feed()
except CalibreOPDSError:
base_feed = None
if base_feed is not None:
filtered_root = self._filter_feed_entries(base_feed, cleaned)
if filtered_root.entries:
return filtered_root
fallback_root = self._local_search(cleaned, seed_feed=base_feed)
if fallback_root.entries:
return fallback_root
if last_error is not None and base_feed is None:
raise last_error raise last_error
feed = self.fetch_feed() if base_feed is None:
return self._filter_feed_entries(feed, cleaned) base_feed = self.fetch_feed()
filtered = self._filter_feed_entries(base_feed, cleaned)
if filtered.entries:
return filtered
return self._local_search(cleaned, seed_feed=base_feed)
def _local_search(
self,
query: str,
*,
seed_feed: Optional[OPDSFeed] = None,
max_pages: int = 40,
) -> OPDSFeed:
normalized = (query or "").strip()
if not normalized:
return seed_feed or self.fetch_feed()
tokens = [token for token in re.split(r"\s+", normalized.lower()) if token]
if not tokens:
return seed_feed or self.fetch_feed()
start_feed = seed_feed or self.fetch_feed()
initial_filtered = self._filter_feed_entries(start_feed, normalized)
if initial_filtered.entries:
return initial_filtered
queue: Deque[str] = deque()
queued: Set[str] = set()
visited: Set[str] = set()
def is_navigation_link(rel_hint: Optional[str], link: OPDSLink) -> bool:
rel_candidates: List[str] = []
if rel_hint:
rel_candidates.append(rel_hint)
if link.rel and link.rel not in rel_candidates:
rel_candidates.append(link.rel)
rel_candidates = [(rel or "").strip().lower() for rel in rel_candidates if rel]
link_type = (link.type or "").strip().lower()
if link_type and "opds-catalog" in link_type:
return True
for rel_value in rel_candidates:
if not rel_value:
continue
if "acquisition" in rel_value:
return False
if rel_value == "self":
continue
if rel_value == "next":
return True
if rel_value in {"start", "up", "down"}:
return True
if rel_value.endswith("navigation") or rel_value.endswith("collection"):
return True
if rel_value.startswith("http://opds-spec.org/"):
if rel_value.startswith("http://opds-spec.org/group") or rel_value.startswith(
"http://opds-spec.org/sort"
):
return True
if rel_value.endswith("navigation") or rel_value.endswith("collection"):
return True
return False
def enqueue_link(link: OPDSLink, rel_hint: Optional[str] = None) -> None:
if not is_navigation_link(rel_hint, link):
return
href = (link.href or "").strip()
if not href:
return
absolute = self._make_url(href)
if absolute in queued or absolute in visited:
return
queued.add(absolute)
queue.append(absolute)
for rel_key, link in (start_feed.links or {}).items():
enqueue_link(link, rel_key)
for entry in start_feed.entries:
for link in entry.links:
enqueue_link(link, link.rel)
pages_examined = 0
while queue and pages_examined < max_pages:
href = queue.popleft()
if href in visited:
continue
visited.add(href)
pages_examined += 1
try:
feed = self.fetch_feed(href)
except CalibreOPDSError:
continue
filtered = self._filter_feed_entries(feed, normalized)
if filtered.entries:
return filtered
for rel_key, link in (feed.links or {}).items():
enqueue_link(link, rel_key)
for entry in feed.entries:
for link in entry.links:
enqueue_link(link, link.rel)
return dataclasses.replace(start_feed, entries=[])
def download(self, href: str) -> DownloadedResource: def download(self, href: str) -> DownloadedResource:
if not href: if not href:
+307 -18
View File
@@ -5,6 +5,7 @@ if (modal && browser) {
const statusEl = browser.querySelector('[data-role="opds-status"]'); const statusEl = browser.querySelector('[data-role="opds-status"]');
const resultsEl = browser.querySelector('[data-role="opds-results"]'); const resultsEl = browser.querySelector('[data-role="opds-results"]');
const navEl = browser.querySelector('[data-role="opds-nav"]'); const navEl = browser.querySelector('[data-role="opds-nav"]');
const alphaPickerEl = browser.querySelector('[data-role="opds-alpha-picker"]');
const tabsEl = modal.querySelector('[data-role="opds-tabs"]'); const tabsEl = modal.querySelector('[data-role="opds-tabs"]');
const searchForm = modal.querySelector('[data-role="opds-search"]'); const searchForm = modal.querySelector('[data-role="opds-search"]');
const searchInput = searchForm?.querySelector('input[name="q"]'); const searchInput = searchForm?.querySelector('input[name="q"]');
@@ -24,6 +25,10 @@ if (modal && browser) {
OTHER: 'other', OTHER: 'other',
}; };
const LETTER_ALL = 'ALL';
const LETTER_NUMERIC = '#';
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const state = { const state = {
query: '', query: '',
currentHref: '', currentHref: '',
@@ -31,6 +36,15 @@ if (modal && browser) {
tabs: [], tabs: [],
tabsReady: false, tabsReady: false,
requestToken: 0, requestToken: 0,
feedTitle: '',
lastEntries: [],
activeLetter: LETTER_ALL,
availableLetters: new Set(),
totalStats: null,
filteredStats: null,
status: { message: '', level: null },
baseStatus: null,
lastContextKey: '',
}; };
let isOpen = false; let isOpen = false;
@@ -102,7 +116,265 @@ if (modal && browser) {
return ''; return '';
}; };
const setStatus = (message, level) => { const deriveBrowseMode = () => {
const title = (state.feedTitle || '').toLowerCase();
if (!title) {
return 'generic';
}
if (title.includes('author')) {
return 'author';
}
if (title.includes('series')) {
return 'series';
}
if (title.includes('title')) {
return 'title';
}
if (title.includes('books')) {
return 'title';
}
return 'generic';
};
const stripLeadingArticle = (text) => text.replace(/^(?:the|a|an)\s+/i, '').trim();
const extractAlphabetSource = (entry) => {
if (!entry) {
return '';
}
const mode = deriveBrowseMode();
if (mode === 'author') {
if (Array.isArray(entry.authors) && entry.authors.length) {
return entry.authors[0] || '';
}
}
if (mode === 'series' && entry.series) {
return entry.series;
}
if (entry.title) {
return entry.title;
}
if (entry.series) {
return entry.series;
}
const navLink = findNavigationLink(entry);
if (navLink?.title) {
return navLink.title;
}
return '';
};
const deriveAlphabetKey = (entry) => {
const mode = deriveBrowseMode();
let source = (extractAlphabetSource(entry) || '').trim();
if (!source) {
return '';
}
if (mode === 'author') {
if (source.includes(',')) {
source = source.split(',')[0];
} else {
const parts = source.split(/\s+/);
if (parts.length > 1) {
source = parts[parts.length - 1];
}
}
} else if (mode === 'title') {
source = stripLeadingArticle(source);
}
source = source.replace(/^[^\p{L}\p{N}]+/u, '');
return source;
};
const deriveAlphabetLetter = (entry) => {
const key = deriveAlphabetKey(entry);
if (!key) {
return LETTER_NUMERIC;
}
const initial = key.charAt(0).toUpperCase();
if (initial >= 'A' && initial <= 'Z') {
return initial;
}
return LETTER_NUMERIC;
};
const collectAlphabetCounts = (entries) => {
const counts = new Map();
if (!Array.isArray(entries)) {
return counts;
}
entries.forEach((entry) => {
const letter = deriveAlphabetLetter(entry);
counts.set(letter, (counts.get(letter) || 0) + 1);
});
return counts;
};
const detectEntryType = (entry, navigationLink) => {
if (!entry) {
return EntryTypes.OTHER;
}
const downloadLink = entry.download && entry.download.href ? entry.download.href : null;
if (downloadLink) {
return EntryTypes.BOOK;
}
const navLink = navigationLink === undefined ? findNavigationLink(entry) : navigationLink;
if (navLink && navLink.href) {
return EntryTypes.NAVIGATION;
}
return EntryTypes.OTHER;
};
const computeEntryStats = (entries) => {
const stats = {
[EntryTypes.BOOK]: 0,
[EntryTypes.NAVIGATION]: 0,
[EntryTypes.OTHER]: 0,
};
if (!Array.isArray(entries)) {
return stats;
}
entries.forEach((entry) => {
const type = detectEntryType(entry);
stats[type] += 1;
});
return stats;
};
const shouldShowAlphabetPicker = (entries, stats) => {
if (!alphaPickerEl || state.query) {
return false;
}
if (!Array.isArray(entries) || entries.length < 6) {
return false;
}
const mode = deriveBrowseMode();
const counts = stats || computeEntryStats(entries);
const hasNavigation = counts[EntryTypes.NAVIGATION] > 0;
if (mode === 'generic' && !hasNavigation) {
return false;
}
const alphabetCounts = collectAlphabetCounts(entries);
return alphabetCounts.size > 1;
};
const refreshAlphabetActiveState = () => {
if (!alphaPickerEl) {
return;
}
const buttons = alphaPickerEl.querySelectorAll('button[data-letter]');
buttons.forEach((button) => {
const value = button.dataset.letter || LETTER_ALL;
const isActive = value === state.activeLetter;
button.classList.toggle('is-active', isActive);
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
};
const describeAlphabetLetter = (letter) => {
if (letter === LETTER_ALL) {
return 'all entries';
}
if (letter === LETTER_NUMERIC) {
return 'numbers or symbols';
}
return `letter ${letter}`;
};
const updateAlphabetPicker = (entries, { reset = false, stats = null } = {}) => {
if (!alphaPickerEl) {
return;
}
const list = Array.isArray(entries) ? entries : [];
if (reset) {
state.activeLetter = LETTER_ALL;
}
const counts = collectAlphabetCounts(list);
state.availableLetters = new Set(counts.keys());
const shouldShow = shouldShowAlphabetPicker(list, stats);
if (!shouldShow) {
alphaPickerEl.innerHTML = '';
alphaPickerEl.hidden = true;
state.activeLetter = LETTER_ALL;
return;
}
if (state.activeLetter !== LETTER_ALL && !state.availableLetters.has(state.activeLetter)) {
state.activeLetter = LETTER_ALL;
}
alphaPickerEl.hidden = false;
alphaPickerEl.innerHTML = '';
const letters = [LETTER_ALL, ...ALPHABET, LETTER_NUMERIC];
letters.forEach((letter) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'opds-alpha-picker__button';
button.dataset.letter = letter;
button.textContent = letter === LETTER_ALL ? 'All' : letter === LETTER_NUMERIC ? '# / 0-9' : letter;
const enabledCount = letter === LETTER_ALL ? list.length : counts.get(letter) || 0;
if (letter !== LETTER_ALL && enabledCount === 0) {
button.disabled = true;
button.setAttribute('aria-disabled', 'true');
} else {
button.addEventListener('click', () => handleAlphabetSelect(letter));
}
alphaPickerEl.appendChild(button);
});
refreshAlphabetActiveState();
};
const handleAlphabetSelect = (letter) => {
const normalized = letter || LETTER_ALL;
if (!Array.isArray(state.lastEntries) || !state.lastEntries.length) {
return;
}
if (normalized === state.activeLetter) {
return;
}
state.activeLetter = normalized;
const filtered = applyAlphabetFilter(state.lastEntries);
state.filteredStats = renderEntries(filtered);
refreshAlphabetActiveState();
if (!filtered.length) {
setStatus(`No entries found for ${describeAlphabetLetter(normalized)}.`, 'info');
} else if (normalized === LETTER_ALL && state.baseStatus) {
restoreBaseStatus();
}
};
const applyAlphabetFilter = (entries) => {
if (!Array.isArray(entries) || !entries.length) {
return [];
}
if (state.activeLetter === LETTER_ALL) {
return entries.slice();
}
return entries.filter((entry) => {
const letter = deriveAlphabetLetter(entry);
if (state.activeLetter === LETTER_NUMERIC) {
return letter === LETTER_NUMERIC;
}
return letter === state.activeLetter;
});
};
const setEntries = (entries, { resetAlphabet = false } = {}) => {
const list = Array.isArray(entries) ? entries.slice() : [];
state.lastEntries = list;
const totalStats = computeEntryStats(list);
state.totalStats = totalStats;
if (resetAlphabet) {
state.activeLetter = LETTER_ALL;
}
const filtered = applyAlphabetFilter(list);
const filteredStats = renderEntries(filtered);
state.filteredStats = filteredStats;
updateAlphabetPicker(list, { reset: resetAlphabet, stats: totalStats });
return { stats: totalStats, filteredStats };
};
const setStatus = (message, level, { persist = false } = {}) => {
if (!statusEl) { if (!statusEl) {
return; return;
} }
@@ -112,10 +384,22 @@ if (modal && browser) {
} else { } else {
delete statusEl.dataset.state; delete statusEl.dataset.state;
} }
state.status = { message: message || '', level: level || null };
if (persist) {
state.baseStatus = { ...state.status };
}
}; };
const clearStatus = () => setStatus('', null); const clearStatus = () => setStatus('', null);
const restoreBaseStatus = () => {
if (state.baseStatus) {
setStatus(state.baseStatus.message, state.baseStatus.level);
} else {
clearStatus();
}
};
const focusSearch = () => { const focusSearch = () => {
if (!searchInput) { if (!searchInput) {
return; return;
@@ -345,12 +629,9 @@ if (modal && browser) {
const downloadLink = entry.download && entry.download.href ? entry.download.href : null; const downloadLink = entry.download && entry.download.href ? entry.download.href : null;
const alternateLink = entry.alternate && entry.alternate.href ? entry.alternate.href : null; const alternateLink = entry.alternate && entry.alternate.href ? entry.alternate.href : null;
const navigationLink = findNavigationLink(entry); const navigationLink = findNavigationLink(entry);
const entryType = detectEntryType(entry, navigationLink);
let entryType = EntryTypes.OTHER; if (entryType === EntryTypes.NAVIGATION && navigationLink) {
if (downloadLink) {
entryType = EntryTypes.BOOK;
} else if (navigationLink && navigationLink.href) {
entryType = EntryTypes.NAVIGATION;
item.classList.add('opds-browser__entry--navigation'); item.classList.add('opds-browser__entry--navigation');
} }
@@ -400,10 +681,17 @@ if (modal && browser) {
return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 }; return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 };
} }
resultsEl.innerHTML = ''; resultsEl.innerHTML = '';
if (!Array.isArray(entries) || !entries.length) { const list = Array.isArray(entries) ? entries : [];
if (!list.length) {
const empty = document.createElement('li'); const empty = document.createElement('li');
empty.className = 'opds-browser__empty'; empty.className = 'opds-browser__empty';
if (state.activeLetter !== LETTER_ALL) {
empty.textContent = `No entries start with ${describeAlphabetLetter(state.activeLetter)}.`;
} else if (state.query) {
empty.textContent = 'No results returned for this view yet.';
} else {
empty.textContent = 'No catalog entries found here yet.'; empty.textContent = 'No catalog entries found here yet.';
}
resultsEl.appendChild(empty); resultsEl.appendChild(empty);
return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 }; return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 };
} }
@@ -413,7 +701,7 @@ if (modal && browser) {
[EntryTypes.NAVIGATION]: 0, [EntryTypes.NAVIGATION]: 0,
[EntryTypes.OTHER]: 0, [EntryTypes.OTHER]: 0,
}; };
entries.forEach((entry) => { list.forEach((entry) => {
const { element, type } = createEntry(entry); const { element, type } = createEntry(entry);
stats[type] += 1; stats[type] += 1;
fragment.appendChild(element); fragment.appendChild(element);
@@ -550,6 +838,7 @@ if (modal && browser) {
throw new Error(payload.error || 'Unable to load the Calibre catalog.'); throw new Error(payload.error || 'Unable to load the Calibre catalog.');
} }
const feed = payload.feed || {}; const feed = payload.feed || {};
state.feedTitle = feed.title || '';
state.currentHref = href; state.currentHref = href;
state.query = query; state.query = query;
if (typeof activeTab === 'string') { if (typeof activeTab === 'string') {
@@ -573,36 +862,36 @@ if (modal && browser) {
} }
renderNav(feed.links); renderNav(feed.links);
const stats = renderEntries(feed.entries || []); const { stats } = setEntries(feed.entries || [], { resetAlphabet: true });
const books = stats?.[EntryTypes.BOOK] || 0; const books = stats?.[EntryTypes.BOOK] || 0;
const views = stats?.[EntryTypes.NAVIGATION] || 0; const views = stats?.[EntryTypes.NAVIGATION] || 0;
if (query) { if (query) {
if (books) { if (books) {
setStatus(`Found ${books} book${books === 1 ? '' : 's'} for "${query}".`, 'success'); setStatus(`Found ${books} book${books === 1 ? '' : 's'} for "${query}".`, 'success', { persist: true });
} else if (views) { } else if (views) {
setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${query}".`, 'info'); setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${query}".`, 'info', { persist: true });
} else { } else {
setStatus(`No results for "${query}".`, 'error'); setStatus(`No results for "${query}".`, 'error', { persist: true });
} }
return; return;
} }
if (books && views) { if (books && views) {
setStatus(`Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'}.`, 'success'); setStatus(`Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'}.`, 'success', { persist: true });
} else if (books) { } else if (books) {
setStatus(`Found ${books} book${books === 1 ? '' : 's'} in this view.`, 'success'); setStatus(`Found ${books} book${books === 1 ? '' : 's'} in this view.`, 'success', { persist: true });
} else if (views) { } else if (views) {
setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} to drill deeper.`, 'info'); setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} to drill deeper.`, 'info', { persist: true });
} else { } else {
setStatus('No catalog entries found here yet.', 'info'); setStatus('No catalog entries found here yet.', 'info', { persist: true });
} }
} catch (error) { } catch (error) {
if (requestId !== state.requestToken) { if (requestId !== state.requestToken) {
return; return;
} }
setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error'); setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error', { persist: true });
renderEntries([]); setEntries([], { resetAlphabet: true });
if (navEl) { if (navEl) {
navEl.innerHTML = ''; navEl.innerHTML = '';
} }
+38
View File
@@ -650,6 +650,44 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
.opds-modal__alpha {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.35rem 0 0.65rem;
}
.opds-alpha-picker__button {
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 999px;
background: rgba(15, 23, 42, 0.35);
color: rgba(226, 232, 240, 0.88);
padding: 0.3rem 0.65rem;
font-size: 0.82rem;
letter-spacing: 0.02em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.opds-alpha-picker__button:hover {
border-color: rgba(148, 163, 184, 0.55);
color: #f8fafc;
}
.opds-alpha-picker__button.is-active {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.65);
color: #f0f9ff;
box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.25);
}
.opds-alpha-picker__button:disabled,
.opds-alpha-picker__button[aria-disabled="true"] {
opacity: 0.45;
cursor: default;
}
.opds-modal__results-wrap { .opds-modal__results-wrap {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
+1
View File
@@ -72,6 +72,7 @@
<div class="opds-browser" data-role="opds-browser"> <div class="opds-browser" data-role="opds-browser">
<div class="opds-modal__status" data-role="opds-status"></div> <div class="opds-modal__status" data-role="opds-status"></div>
<div class="opds-modal__nav" data-role="opds-nav"></div> <div class="opds-modal__nav" data-role="opds-nav"></div>
<div class="opds-modal__alpha" data-role="opds-alpha-picker"></div>
<div class="opds-modal__results-wrap"> <div class="opds-modal__results-wrap">
<ul class="opds-modal__results" data-role="opds-results"></ul> <ul class="opds-modal__results" data-role="opds-results"></ul>
</div> </div>
+95 -6
View File
@@ -1,9 +1,4 @@
from abogen.integrations.calibre_opds import ( from abogen.integrations.calibre_opds import CalibreOPDSClient, OPDSEntry, OPDSFeed, OPDSLink, feed_to_dict
CalibreOPDSClient,
OPDSEntry,
OPDSFeed,
feed_to_dict,
)
def test_calibre_opds_feed_exposes_series_metadata() -> None: def test_calibre_opds_feed_exposes_series_metadata() -> None:
@@ -207,3 +202,97 @@ def test_calibre_opds_search_filters_by_title_and_author() -> None:
filtered = client._filter_feed_entries(feed, "missing") filtered = client._filter_feed_entries(feed, "missing")
assert filtered.entries == [] assert filtered.entries == []
def test_calibre_opds_local_search_follows_next(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
page_one = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
page_two = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="2", title="The Journey Continues", authors=["Bob Johnson"])],
links={},
)
def fake_fetch(href=None, params=None):
if href == "http://example.com/catalog?page=2":
return page_two
return page_one
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client._local_search("journey", seed_feed=page_one)
assert [entry.id for entry in result.entries] == ["2"]
def test_calibre_opds_local_search_traverses_navigation(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
root_feed = OPDSFeed(
id="catalog",
title="Catalog",
entries=[
OPDSEntry(
id="nav-authors",
title="Browse Authors",
links=[
OPDSLink(
href="http://example.com/catalog/authors",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
],
)
],
links={},
)
authors_feed = OPDSFeed(
id="authors",
title="Authors",
entries=[
OPDSEntry(id="book-42", title="The Count of Monte Cristo", authors=["Alexandre Dumas"])
],
links={},
)
def fake_fetch(href=None, params=None):
if href == "http://example.com/catalog/authors":
return authors_feed
return root_feed
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client._local_search("monte cristo", seed_feed=root_feed)
assert [entry.id for entry in result.entries] == ["book-42"]
def test_calibre_opds_search_falls_back_to_local_search(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
search_page = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
next_page = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="2", title="Journey in Space", authors=["Cara Nguyen"])],
links={},
)
def fake_fetch(path=None, params=None):
if path == "search":
return search_page
if path == "http://example.com/catalog?page=2":
return next_page
return search_page
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client.search("journey")
assert [entry.id for entry in result.entries] == ["2"]