mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Add browsing functionality by alphabet letter in Calibre OPDS client
This commit is contained in:
@@ -743,25 +743,102 @@ class CalibreOPDSClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_navigation_link(entry: OPDSEntry) -> bool:
|
def _resolve_link(links: Optional[Mapping[str, OPDSLink]], rel: str) -> Optional[OPDSLink]:
|
||||||
for link in entry.links:
|
if not links:
|
||||||
href = (link.href or "").strip()
|
return None
|
||||||
if not href:
|
if rel in links:
|
||||||
continue
|
return links[rel]
|
||||||
rel = (link.rel or "").strip().lower()
|
rel_lower = rel.lower()
|
||||||
link_type = (link.type or "").strip().lower()
|
for key, link in links.items():
|
||||||
if "acquisition" in rel:
|
key_lower = (key or "").strip().lower()
|
||||||
continue
|
if key_lower == rel_lower or key_lower.endswith(rel_lower):
|
||||||
if rel == "self":
|
return link
|
||||||
continue
|
return None
|
||||||
if "opds-catalog" in link_type:
|
|
||||||
return True
|
@staticmethod
|
||||||
if rel.endswith("navigation") or rel.endswith("collection"):
|
def _is_navigation_link(link: OPDSLink) -> bool:
|
||||||
return True
|
href = (link.href or "").strip()
|
||||||
if rel.startswith("http://opds-spec.org/sort") or rel.startswith("http://opds-spec.org/group"):
|
if not href:
|
||||||
return True
|
return False
|
||||||
|
rel = (link.rel or "").strip().lower()
|
||||||
|
link_type = (link.type or "").strip().lower()
|
||||||
|
if "acquisition" in rel:
|
||||||
|
return False
|
||||||
|
if rel == "self":
|
||||||
|
return False
|
||||||
|
if "opds-catalog" in link_type:
|
||||||
|
return True
|
||||||
|
if rel.endswith("navigation") or rel.endswith("collection"):
|
||||||
|
return True
|
||||||
|
if rel.startswith("http://opds-spec.org/sort") or rel.startswith("http://opds-spec.org/group"):
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_navigation_link(entry: OPDSEntry) -> bool:
|
||||||
|
return any(CalibreOPDSClient._is_navigation_link(link) for link in entry.links)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _browse_mode_for_title(title: Optional[str]) -> str:
|
||||||
|
if not title:
|
||||||
|
return "generic"
|
||||||
|
lowered = title.lower()
|
||||||
|
if "author" in lowered:
|
||||||
|
return "author"
|
||||||
|
if "series" in lowered:
|
||||||
|
return "series"
|
||||||
|
if "title" in lowered or "book" in lowered:
|
||||||
|
return "title"
|
||||||
|
return "generic"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_leading_article(text: str) -> str:
|
||||||
|
working = text.strip()
|
||||||
|
lowered = working.lower()
|
||||||
|
for article in ("the ", "a ", "an "):
|
||||||
|
if lowered.startswith(article):
|
||||||
|
return working[len(article):].strip()
|
||||||
|
return working
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _alphabet_source(entry: OPDSEntry, mode: str) -> str:
|
||||||
|
if mode == "author" and entry.authors:
|
||||||
|
candidate = entry.authors[0] or ""
|
||||||
|
if "," in candidate:
|
||||||
|
return candidate.split(",", 1)[0].strip()
|
||||||
|
parts = candidate.split()
|
||||||
|
if len(parts) > 1:
|
||||||
|
return parts[-1].strip()
|
||||||
|
return candidate.strip()
|
||||||
|
if mode == "series" and entry.series:
|
||||||
|
return entry.series.strip()
|
||||||
|
if entry.title:
|
||||||
|
return entry.title.strip()
|
||||||
|
if entry.series:
|
||||||
|
return entry.series.strip()
|
||||||
|
for link in entry.links:
|
||||||
|
if link.title:
|
||||||
|
return link.title.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _alphabet_letter_for_entry(entry: OPDSEntry, mode: str) -> Optional[str]:
|
||||||
|
source = CalibreOPDSClient._alphabet_source(entry, mode)
|
||||||
|
if not source:
|
||||||
|
return None
|
||||||
|
if mode == "title":
|
||||||
|
source = CalibreOPDSClient._strip_leading_article(source)
|
||||||
|
source = re.sub(r"^[^0-9A-Za-z]+", "", source)
|
||||||
|
if not source:
|
||||||
|
return "#"
|
||||||
|
initial = source[0]
|
||||||
|
if initial.isalpha():
|
||||||
|
return initial.upper()
|
||||||
|
if initial.isdigit():
|
||||||
|
return "#"
|
||||||
|
normalized = initial.upper()
|
||||||
|
return normalized if normalized.isalpha() else "#"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _entry_matches_query(entry: OPDSEntry, tokens: List[str]) -> bool:
|
def _entry_matches_query(entry: OPDSEntry, tokens: List[str]) -> bool:
|
||||||
if not tokens:
|
if not tokens:
|
||||||
@@ -794,6 +871,70 @@ class CalibreOPDSClient:
|
|||||||
filtered = [entry for entry in feed.entries if self._entry_matches_query(entry, tokens)]
|
filtered = [entry for entry in feed.entries if self._entry_matches_query(entry, tokens)]
|
||||||
return dataclasses.replace(feed, entries=filtered)
|
return dataclasses.replace(feed, entries=filtered)
|
||||||
|
|
||||||
|
def browse_letter(
|
||||||
|
self,
|
||||||
|
letter: str,
|
||||||
|
*,
|
||||||
|
start_href: Optional[str] = None,
|
||||||
|
max_pages: int = 40,
|
||||||
|
) -> OPDSFeed:
|
||||||
|
normalized = (letter or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return self.fetch_feed(start_href)
|
||||||
|
key = normalized.upper()
|
||||||
|
if key in {"ALL", "*"}:
|
||||||
|
return self.fetch_feed(start_href)
|
||||||
|
if key in {"0-9", "NUMERIC"}:
|
||||||
|
key = "#"
|
||||||
|
if len(key) > 1 and key != "#":
|
||||||
|
key = key[0]
|
||||||
|
base_feed = self.fetch_feed(start_href)
|
||||||
|
mode = self._browse_mode_for_title(base_feed.title)
|
||||||
|
|
||||||
|
def matches_letter(entry: OPDSEntry) -> bool:
|
||||||
|
letter_value = self._alphabet_letter_for_entry(entry, mode)
|
||||||
|
if not letter_value:
|
||||||
|
return False
|
||||||
|
if key == "#":
|
||||||
|
return letter_value == "#"
|
||||||
|
return letter_value == key
|
||||||
|
|
||||||
|
def find_navigation(feed: OPDSFeed) -> Optional[OPDSLink]:
|
||||||
|
for entry in feed.entries:
|
||||||
|
for link in entry.links:
|
||||||
|
if not self._is_navigation_link(link):
|
||||||
|
continue
|
||||||
|
if matches_letter(entry):
|
||||||
|
return link
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidate = find_navigation(base_feed)
|
||||||
|
if candidate and candidate.href:
|
||||||
|
return self.fetch_feed(candidate.href)
|
||||||
|
|
||||||
|
visited: Set[str] = set()
|
||||||
|
pages_examined = 0
|
||||||
|
next_link = self._resolve_link(base_feed.links, "next")
|
||||||
|
while next_link and pages_examined < max_pages:
|
||||||
|
href = (next_link.href or "").strip()
|
||||||
|
if not href or href in visited:
|
||||||
|
break
|
||||||
|
visited.add(href)
|
||||||
|
pages_examined += 1
|
||||||
|
try:
|
||||||
|
feed = self.fetch_feed(href)
|
||||||
|
except CalibreOPDSError:
|
||||||
|
break
|
||||||
|
candidate = find_navigation(feed)
|
||||||
|
if candidate and candidate.href:
|
||||||
|
return self.fetch_feed(candidate.href)
|
||||||
|
next_link = self._resolve_link(feed.links, "next")
|
||||||
|
|
||||||
|
filtered_entries = [entry for entry in base_feed.entries if matches_letter(entry)]
|
||||||
|
if filtered_entries:
|
||||||
|
return dataclasses.replace(base_feed, entries=filtered_entries)
|
||||||
|
return dataclasses.replace(base_feed, entries=[])
|
||||||
|
|
||||||
|
|
||||||
def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]:
|
def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]:
|
||||||
"""Helper used by APIs to convert a feed into JSON-serialisable payloads."""
|
"""Helper used by APIs to convert a feed into JSON-serialisable payloads."""
|
||||||
|
|||||||
@@ -2909,8 +2909,11 @@ def calibre_opds_feed() -> ResponseReturnValue:
|
|||||||
|
|
||||||
href = request.args.get("href", type=str)
|
href = request.args.get("href", type=str)
|
||||||
query = request.args.get("q", type=str)
|
query = request.args.get("q", type=str)
|
||||||
|
letter = request.args.get("letter", type=str)
|
||||||
try:
|
try:
|
||||||
if query:
|
if letter:
|
||||||
|
feed = client.browse_letter(letter, start_href=href)
|
||||||
|
elif query:
|
||||||
feed = client.search(query)
|
feed = client.search(query)
|
||||||
else:
|
else:
|
||||||
feed = client.fetch_feed(href)
|
feed = client.fetch_feed(href)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ if (modal && browser) {
|
|||||||
status: { message: '', level: null },
|
status: { message: '', level: null },
|
||||||
baseStatus: null,
|
baseStatus: null,
|
||||||
lastContextKey: '',
|
lastContextKey: '',
|
||||||
|
currentLinks: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
@@ -314,33 +315,45 @@ if (modal && browser) {
|
|||||||
button.textContent = letter === LETTER_ALL ? 'All' : letter === LETTER_NUMERIC ? '# / 0-9' : letter;
|
button.textContent = letter === LETTER_ALL ? 'All' : letter === LETTER_NUMERIC ? '# / 0-9' : letter;
|
||||||
const enabledCount = letter === LETTER_ALL ? list.length : counts.get(letter) || 0;
|
const enabledCount = letter === LETTER_ALL ? list.length : counts.get(letter) || 0;
|
||||||
if (letter !== LETTER_ALL && enabledCount === 0) {
|
if (letter !== LETTER_ALL && enabledCount === 0) {
|
||||||
button.disabled = true;
|
button.dataset.empty = 'true';
|
||||||
button.setAttribute('aria-disabled', 'true');
|
|
||||||
} else {
|
|
||||||
button.addEventListener('click', () => handleAlphabetSelect(letter));
|
|
||||||
}
|
}
|
||||||
|
button.title = `Show entries for ${describeAlphabetLetter(letter)} (${enabledCount} in view)`;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
handleAlphabetSelect(letter).catch((error) => {
|
||||||
|
console.error('Alphabet picker failed', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
alphaPickerEl.appendChild(button);
|
alphaPickerEl.appendChild(button);
|
||||||
});
|
});
|
||||||
refreshAlphabetActiveState();
|
refreshAlphabetActiveState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAlphabetSelect = (letter) => {
|
const handleAlphabetSelect = async (letter) => {
|
||||||
const normalized = letter || LETTER_ALL;
|
const normalized = letter || LETTER_ALL;
|
||||||
if (!Array.isArray(state.lastEntries) || !state.lastEntries.length) {
|
if (normalized === LETTER_ALL) {
|
||||||
|
state.activeLetter = LETTER_ALL;
|
||||||
|
refreshAlphabetActiveState();
|
||||||
|
const upLink = resolveRelLink(state.currentLinks, 'up') || resolveRelLink(state.currentLinks, '/up') || resolveRelLink(state.currentLinks, 'start');
|
||||||
|
const targetHref = upLink?.href || '';
|
||||||
|
await loadFeed({ href: targetHref, query: '', letter: '', activeTab: targetHref ? TabIds.CUSTOM : TabIds.ROOT, updateTabs: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (normalized === state.activeLetter) {
|
|
||||||
return;
|
if (!Array.isArray(state.lastEntries)) {
|
||||||
|
state.lastEntries = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
state.activeLetter = normalized;
|
state.activeLetter = normalized;
|
||||||
const filtered = applyAlphabetFilter(state.lastEntries);
|
const filtered = applyAlphabetFilter(state.lastEntries);
|
||||||
state.filteredStats = renderEntries(filtered);
|
if (filtered.length) {
|
||||||
refreshAlphabetActiveState();
|
state.filteredStats = renderEntries(filtered);
|
||||||
if (!filtered.length) {
|
refreshAlphabetActiveState();
|
||||||
setStatus(`No entries found for ${describeAlphabetLetter(normalized)}.`, 'info');
|
setStatus(`Showing ${filtered.length} entr${filtered.length === 1 ? 'y' : 'ies'} for ${describeAlphabetLetter(normalized)}.`, 'info');
|
||||||
} else if (normalized === LETTER_ALL && state.baseStatus) {
|
return;
|
||||||
restoreBaseStatus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshAlphabetActiveState();
|
||||||
|
await loadFeed({ href: state.currentHref || '', query: '', letter: normalized, activeTab: TabIds.CUSTOM, updateTabs: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyAlphabetFilter = (entries) => {
|
const applyAlphabetFilter = (entries) => {
|
||||||
@@ -359,13 +372,15 @@ if (modal && browser) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const setEntries = (entries, { resetAlphabet = false } = {}) => {
|
const setEntries = (entries, { resetAlphabet = false, activeLetter = null } = {}) => {
|
||||||
const list = Array.isArray(entries) ? entries.slice() : [];
|
const list = Array.isArray(entries) ? entries.slice() : [];
|
||||||
state.lastEntries = list;
|
state.lastEntries = list;
|
||||||
const totalStats = computeEntryStats(list);
|
const totalStats = computeEntryStats(list);
|
||||||
state.totalStats = totalStats;
|
state.totalStats = totalStats;
|
||||||
if (resetAlphabet) {
|
if (resetAlphabet) {
|
||||||
state.activeLetter = LETTER_ALL;
|
state.activeLetter = LETTER_ALL;
|
||||||
|
} else if (activeLetter && activeLetter !== LETTER_ALL) {
|
||||||
|
state.activeLetter = activeLetter;
|
||||||
}
|
}
|
||||||
const filtered = applyAlphabetFilter(list);
|
const filtered = applyAlphabetFilter(list);
|
||||||
const filteredStats = renderEntries(filtered);
|
const filteredStats = renderEntries(filtered);
|
||||||
@@ -815,13 +830,28 @@ if (modal && browser) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadFeed = async ({ href = '', query = '', activeTab = null, updateTabs = false } = {}) => {
|
const loadFeed = async ({ href = '', query = '', letter = '', activeTab = null, updateTabs = false } = {}) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (href) {
|
const normalizedHref = href || '';
|
||||||
params.set('href', href);
|
const normalizedQuery = (query || '').trim();
|
||||||
|
let normalizedLetter = (letter || '').trim();
|
||||||
|
if (normalizedLetter === LETTER_ALL) {
|
||||||
|
normalizedLetter = '';
|
||||||
}
|
}
|
||||||
if (query) {
|
if (normalizedQuery) {
|
||||||
params.set('q', query);
|
normalizedLetter = '';
|
||||||
|
}
|
||||||
|
if (normalizedLetter && normalizedLetter !== LETTER_NUMERIC) {
|
||||||
|
normalizedLetter = normalizedLetter.toUpperCase();
|
||||||
|
}
|
||||||
|
if (normalizedHref) {
|
||||||
|
params.set('href', normalizedHref);
|
||||||
|
}
|
||||||
|
if (normalizedQuery) {
|
||||||
|
params.set('q', normalizedQuery);
|
||||||
|
}
|
||||||
|
if (!normalizedQuery && normalizedLetter) {
|
||||||
|
params.set('letter', normalizedLetter);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = ++state.requestToken;
|
const requestId = ++state.requestToken;
|
||||||
@@ -839,20 +869,27 @@ if (modal && browser) {
|
|||||||
}
|
}
|
||||||
const feed = payload.feed || {};
|
const feed = payload.feed || {};
|
||||||
state.feedTitle = feed.title || '';
|
state.feedTitle = feed.title || '';
|
||||||
state.currentHref = href;
|
state.currentHref = normalizedHref;
|
||||||
state.query = query;
|
state.currentLinks = feed.links || {};
|
||||||
|
const selfLink = resolveRelLink(state.currentLinks, 'self');
|
||||||
|
if (selfLink?.href) {
|
||||||
|
state.currentHref = selfLink.href;
|
||||||
|
}
|
||||||
|
state.query = normalizedLetter ? '' : normalizedQuery;
|
||||||
if (typeof activeTab === 'string') {
|
if (typeof activeTab === 'string') {
|
||||||
state.activeTab = activeTab;
|
state.activeTab = activeTab;
|
||||||
} else if (query) {
|
} else if (normalizedQuery) {
|
||||||
state.activeTab = TabIds.SEARCH;
|
state.activeTab = TabIds.SEARCH;
|
||||||
} else if (href) {
|
} else if (normalizedLetter) {
|
||||||
state.activeTab = resolveTabIdForHref(href) || TabIds.CUSTOM;
|
state.activeTab = TabIds.CUSTOM;
|
||||||
|
} else if (normalizedHref) {
|
||||||
|
state.activeTab = resolveTabIdForHref(normalizedHref) || TabIds.CUSTOM;
|
||||||
} else {
|
} else {
|
||||||
state.activeTab = TabIds.ROOT;
|
state.activeTab = TabIds.ROOT;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.value = query || '';
|
searchInput.value = state.query || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updateTabs || !state.tabsReady) {
|
if (updateTabs || !state.tabsReady) {
|
||||||
@@ -862,17 +899,42 @@ if (modal && browser) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderNav(feed.links);
|
renderNav(feed.links);
|
||||||
const { stats } = setEntries(feed.entries || [], { resetAlphabet: true });
|
const { stats } = setEntries(feed.entries || [], {
|
||||||
|
resetAlphabet: !normalizedLetter,
|
||||||
|
activeLetter: normalizedLetter || null,
|
||||||
|
});
|
||||||
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 (normalizedLetter) {
|
||||||
if (books) {
|
const letterDescription = describeAlphabetLetter(normalizedLetter);
|
||||||
setStatus(`Found ${books} book${books === 1 ? '' : 's'} for "${query}".`, 'success', { persist: true });
|
if (books && views) {
|
||||||
|
setStatus(
|
||||||
|
`Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'} for ${letterDescription}.`,
|
||||||
|
'success',
|
||||||
|
{ persist: true },
|
||||||
|
);
|
||||||
|
} else if (books) {
|
||||||
|
setStatus(`Found ${books} book${books === 1 ? '' : 's'} for ${letterDescription}.`, 'success', { persist: true });
|
||||||
} else if (views) {
|
} else if (views) {
|
||||||
setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${query}".`, 'info', { persist: true });
|
setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} for ${letterDescription}.`, 'info', { persist: true });
|
||||||
} else {
|
} else {
|
||||||
setStatus(`No results for "${query}".`, 'error', { persist: true });
|
setStatus(`No catalog entries found for ${letterDescription}.`, 'info', { persist: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedQuery) {
|
||||||
|
if (books) {
|
||||||
|
setStatus(`Found ${books} book${books === 1 ? '' : 's'} for "${normalizedQuery}".`, 'success', { persist: true });
|
||||||
|
} else if (views) {
|
||||||
|
setStatus(
|
||||||
|
`Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${normalizedQuery}".`,
|
||||||
|
'info',
|
||||||
|
{ persist: true },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setStatus(`No results for "${normalizedQuery}".`, 'error', { persist: true });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -895,6 +957,7 @@ if (modal && browser) {
|
|||||||
if (navEl) {
|
if (navEl) {
|
||||||
navEl.innerHTML = '';
|
navEl.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
state.currentLinks = {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -688,6 +688,10 @@ body {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.opds-alpha-picker__button[data-empty="true"] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
.opds-modal__results-wrap {
|
.opds-modal__results-wrap {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -296,3 +296,84 @@ def test_calibre_opds_search_falls_back_to_local_search(monkeypatch) -> None:
|
|||||||
|
|
||||||
result = client.search("journey")
|
result = client.search("journey")
|
||||||
assert [entry.id for entry in result.entries] == ["2"]
|
assert [entry.id for entry in result.entries] == ["2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibre_opds_browse_letter_traverses_next(monkeypatch) -> None:
|
||||||
|
client = CalibreOPDSClient("http://example.com/catalog")
|
||||||
|
root_feed = OPDSFeed(
|
||||||
|
id="catalog",
|
||||||
|
title="Browse Authors",
|
||||||
|
entries=[
|
||||||
|
OPDSEntry(
|
||||||
|
id="nav-a",
|
||||||
|
title="A",
|
||||||
|
links=[
|
||||||
|
OPDSLink(
|
||||||
|
href="http://example.com/catalog/authors/a",
|
||||||
|
rel="http://opds-spec.org/navigation",
|
||||||
|
type="application/atom+xml;profile=opds-catalog",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
|
||||||
|
)
|
||||||
|
page_two = OPDSFeed(
|
||||||
|
id="catalog",
|
||||||
|
title="Browse Authors",
|
||||||
|
entries=[
|
||||||
|
OPDSEntry(
|
||||||
|
id="nav-c",
|
||||||
|
title="C",
|
||||||
|
links=[
|
||||||
|
OPDSLink(
|
||||||
|
href="http://example.com/catalog/authors/c",
|
||||||
|
rel="http://opds-spec.org/navigation",
|
||||||
|
type="application/atom+xml;profile=opds-catalog",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
links={},
|
||||||
|
)
|
||||||
|
letter_feed = OPDSFeed(
|
||||||
|
id="authors-c",
|
||||||
|
title="Authors starting with C",
|
||||||
|
entries=[OPDSEntry(id="author-1", title="Clarke, Arthur C.")],
|
||||||
|
links={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_fetch(href=None, params=None):
|
||||||
|
if not href:
|
||||||
|
return root_feed
|
||||||
|
if href == "http://example.com/catalog?page=2":
|
||||||
|
return page_two
|
||||||
|
if href == "http://example.com/catalog/authors/c":
|
||||||
|
return letter_feed
|
||||||
|
return root_feed
|
||||||
|
|
||||||
|
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
|
||||||
|
|
||||||
|
result = client.browse_letter("C")
|
||||||
|
assert [entry.id for entry in result.entries] == ["author-1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibre_opds_browse_letter_filters_when_missing_navigation(monkeypatch) -> None:
|
||||||
|
client = CalibreOPDSClient("http://example.com/catalog")
|
||||||
|
titles_feed = OPDSFeed(
|
||||||
|
id="catalog",
|
||||||
|
title="Browse Titles",
|
||||||
|
entries=[
|
||||||
|
OPDSEntry(id="book-1", title="The Moon is a Harsh Mistress"),
|
||||||
|
OPDSEntry(id="book-2", title="Another Story"),
|
||||||
|
],
|
||||||
|
links={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_fetch(href=None, params=None):
|
||||||
|
return titles_feed
|
||||||
|
|
||||||
|
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
|
||||||
|
|
||||||
|
result = client.browse_letter("M")
|
||||||
|
assert [entry.id for entry in result.entries] == ["book-1"]
|
||||||
|
|||||||
Reference in New Issue
Block a user