fix(opds): advertise search inline for clients that skip OpenSearch

Moon+ Reader and CrossPoint never fetch the OpenSearch description document, so they
detected no search capability and hid the search affordance entirely. Both look only for a
rel="search" link whose href already carries {searchTerms}, and they match the link type by
equality rather than by substring.

Emit that templated link alongside the existing OpenSearch pointer, keeping the pointer
first so KOReader's has_opensearch guard still wins and its behaviour is unchanged. The
type stays bare application/atom+xml and the href stays relative like every other href in
the feed, so nothing has to reconstruct an external base URL behind a reverse proxy.
Calibre-Web ships the same two-link pairing.

Add the pair to the libraries, collections, smart-scopes, authors and series feeds too.
Those carried only a self link, so search disappeared once a client descended into a
sub-folder even when it did support OpenSearch.

Closes #860
This commit is contained in:
neon
2026-08-11 00:58:47 +00:00
parent 2d09c37806
commit 208d04d218
4 changed files with 107 additions and 13 deletions
@@ -71,6 +71,53 @@ describe('OpdsService', () => {
});
});
// Moon+ Reader and CrossPoint never fetch the OpenSearch description; they detect search
// only from a rel="search" href that already carries {searchTerms} (issue #860).
describe('inline templated search link', () => {
const feeds: [string, () => string][] = [
['root', () => makeService().generateRootNavigation()],
['libraries', () => makeService().generateLibrariesNavigation([{ id: 1, name: 'Main', bookCount: 2 }])],
['collections', () => makeService().generateCollectionsNavigation([{ id: 1, name: 'Favourites', bookCount: 2 }])],
['smart scopes', () => makeService().generateSmartScopesNavigation([{ id: 1, name: 'Unread', icon: null }])],
['authors', () => makeService().generateAuthorsNavigation([{ name: 'Brandon Sanderson', bookCount: 2 }])],
['series', () => makeService().generateSeriesNavigation([{ id: 1, name: 'Mistborn', bookCount: 2 }])],
['acquisition', () => makeService().generateAcquisitionFeed('Catalog', 'urn:bookorbit:catalog', [], 0, 1, 50, `${BASE}/catalog`, 'tok')],
];
it.each(feeds)('%s feed advertises both search links', (_name, generate) => {
const xml = generate();
expect(xml).toContain(`<link rel="search" href="${BASE}/search.opds" type="application/opensearchdescription+xml"/>`);
expect(xml).toContain(`<link rel="search" href="${BASE}/catalog?q={searchTerms}" type="application/atom+xml" title="Search"/>`);
});
it('leaves the searchTerms placeholder unescaped so clients can substitute it', () => {
const xml = makeService().generateRootNavigation();
expect(xml).toContain('{searchTerms}');
expect(xml).not.toContain('%7BsearchTerms%7D');
});
it('keeps the templated link type free of profile and kind parameters', () => {
const xml = makeService().generateRootNavigation();
const templated = xml.split('\n').find((line) => line.includes('{searchTerms}'))!;
expect(templated).toContain('type="application/atom+xml"');
expect(templated).not.toContain('profile=opds-catalog');
});
it('matches the template advertised by the OpenSearch description', () => {
const service = makeService();
const templated = service
.generateRootNavigation()
.split('\n')
.find((line) => line.includes('{searchTerms}'))!;
expect(service.generateOpenSearchDescription()).toContain(`template="${BASE}/catalog?q={searchTerms}"`);
expect(templated).toContain(`href="${BASE}/catalog?q={searchTerms}"`);
});
});
describe('generateLibrariesNavigation', () => {
it('generates entries for each library', () => {
const service = makeService();
@@ -55,3 +55,7 @@ export function fileMimeType(format: string): string {
export const OPDS_MIME_NAV = 'application/atom+xml;profile=opds-catalog;kind=navigation';
export const OPDS_MIME_ACQ = 'application/atom+xml;profile=opds-catalog;kind=acquisition';
export const OPDS_MIME_SEARCH = 'application/opensearchdescription+xml';
// Bare atom type, used only on the inline templated search link. Clients that skip
// OpenSearch discovery match this type by equality, so the profile/kind parameters
// carried by OPDS_MIME_ACQ must not be attached here.
export const OPDS_MIME_ATOM = 'application/atom+xml';
+41 -13
View File
@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common';
import { esc, fileMimeType, OPDS_MIME_ACQ, OPDS_MIME_NAV, OPDS_MIME_SEARCH, xmlEl, xmlLink } from './opds-xml.helpers';
import { esc, fileMimeType, OPDS_MIME_ACQ, OPDS_MIME_ATOM, OPDS_MIME_NAV, OPDS_MIME_SEARCH, xmlEl, xmlLink } from './opds-xml.helpers';
import type { OpdsBookEntry } from './opds-book.service';
const BASE = '/api/v1/opds';
const SEARCH_TEMPLATE = `${BASE}/catalog?q={searchTerms}`;
@Injectable()
export class OpdsService {
@@ -13,7 +14,7 @@ export class OpdsService {
'bookorbit OPDS Catalog',
'urn:bookorbit:root',
now,
[xmlLink('self', BASE, OPDS_MIME_NAV), xmlLink('start', BASE, OPDS_MIME_NAV), xmlLink('search', `${BASE}/search.opds`, OPDS_MIME_SEARCH)],
[xmlLink('self', BASE, OPDS_MIME_NAV), xmlLink('start', BASE, OPDS_MIME_NAV), ...this.searchLinks()],
[
this.navEntry('urn:bookorbit:all', 'All Books', 'Browse the full catalog', `${BASE}/catalog`, now),
this.navEntry('urn:bookorbit:recent', 'Recent Books', 'Recently added books', `${BASE}/recent`, now),
@@ -32,7 +33,13 @@ export class OpdsService {
const entries = libs.map((lib) =>
this.navEntry(`urn:bookorbit:library:${lib.id}`, lib.name, `${lib.bookCount} books`, `${BASE}/catalog?libraryId=${lib.id}`, now),
);
return this.wrapFeed('Libraries', 'urn:bookorbit:libraries', now, [xmlLink('self', `${BASE}/libraries`, OPDS_MIME_NAV)], entries);
return this.wrapFeed(
'Libraries',
'urn:bookorbit:libraries',
now,
[xmlLink('self', `${BASE}/libraries`, OPDS_MIME_NAV), ...this.searchLinks()],
entries,
);
}
generateCollectionsNavigation(cols: { id: number; name: string; bookCount: number }[]): string {
@@ -40,7 +47,13 @@ export class OpdsService {
const entries = cols.map((col) =>
this.navEntry(`urn:bookorbit:collection:${col.id}`, col.name, `${col.bookCount} books`, `${BASE}/catalog?collectionId=${col.id}`, now),
);
return this.wrapFeed('Collections', 'urn:bookorbit:collections', now, [xmlLink('self', `${BASE}/collections`, OPDS_MIME_NAV)], entries);
return this.wrapFeed(
'Collections',
'urn:bookorbit:collections',
now,
[xmlLink('self', `${BASE}/collections`, OPDS_MIME_NAV), ...this.searchLinks()],
entries,
);
}
generateSmartScopesNavigation(items: { id: number; name: string; icon: string | null }[]): string {
@@ -54,7 +67,13 @@ export class OpdsService {
now,
),
);
return this.wrapFeed('SmartScopes', 'urn:bookorbit:smartScopes', now, [xmlLink('self', `${BASE}/smart-scopes`, OPDS_MIME_NAV)], entries);
return this.wrapFeed(
'SmartScopes',
'urn:bookorbit:smartScopes',
now,
[xmlLink('self', `${BASE}/smart-scopes`, OPDS_MIME_NAV), ...this.searchLinks()],
entries,
);
}
generateAuthorsNavigation(items: { name: string; bookCount: number }[]): string {
@@ -68,7 +87,13 @@ export class OpdsService {
now,
),
);
return this.wrapFeed('Authors', 'urn:bookorbit:authors', now, [xmlLink('self', `${BASE}/authors`, OPDS_MIME_NAV)], entries);
return this.wrapFeed(
'Authors',
'urn:bookorbit:authors',
now,
[xmlLink('self', `${BASE}/authors`, OPDS_MIME_NAV), ...this.searchLinks()],
entries,
);
}
generateSeriesNavigation(items: { id?: number; name: string; bookCount: number }[]): string {
@@ -82,7 +107,7 @@ export class OpdsService {
now,
),
);
return this.wrapFeed('Series', 'urn:bookorbit:series', now, [xmlLink('self', `${BASE}/series`, OPDS_MIME_NAV)], entries);
return this.wrapFeed('Series', 'urn:bookorbit:series', now, [xmlLink('self', `${BASE}/series`, OPDS_MIME_NAV), ...this.searchLinks()], entries);
}
generateAcquisitionFeed(
@@ -97,11 +122,7 @@ export class OpdsService {
): string {
const now = new Date().toISOString();
const totalPages = Math.max(1, Math.ceil(total / size));
const links = [
xmlLink('self', selfPath, OPDS_MIME_ACQ),
xmlLink('start', BASE, OPDS_MIME_NAV),
xmlLink('search', `${BASE}/search.opds`, OPDS_MIME_SEARCH),
];
const links = [xmlLink('self', selfPath, OPDS_MIME_ACQ), xmlLink('start', BASE, OPDS_MIME_NAV), ...this.searchLinks()];
const url = new URL(selfPath, 'http://localhost');
const pageUrl = (p: number) => {
@@ -129,7 +150,7 @@ export class OpdsService {
'<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">',
` ${xmlEl('ShortName', 'bookorbit OPDS')}`,
` ${xmlEl('Description', 'Search the bookorbit book catalog')}`,
` <Url type="${esc(OPDS_MIME_ACQ)}" template="${esc(BASE)}/catalog?q={searchTerms}"/>`,
` <Url type="${esc(OPDS_MIME_ACQ)}" template="${esc(SEARCH_TEMPLATE)}"/>`,
' <InputEncoding>UTF-8</InputEncoding>',
' <OutputEncoding>UTF-8</OutputEncoding>',
'</OpenSearchDescription>',
@@ -189,6 +210,13 @@ export class OpdsService {
return lines.join('\n');
}
// Both links are needed. Compliant clients follow the OpenSearch description; Moon+ Reader
// and CrossPoint never fetch it and only detect search from a rel="search" href that already
// contains {searchTerms}, so without the second link they hide search entirely (issue #860).
private searchLinks(): string[] {
return [xmlLink('search', `${BASE}/search.opds`, OPDS_MIME_SEARCH), xmlLink('search', SEARCH_TEMPLATE, OPDS_MIME_ATOM, 'Search')];
}
private navEntry(id: string, title: string, content: string, href: string, updated: string): string {
return [
'<entry>',
+15
View File
@@ -446,6 +446,21 @@ describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
expect(searchResponse.body).toContain('/api/v1/opds/catalog?q={searchTerms}');
});
// Clients that skip OpenSearch discovery need the templated link inline, on every
// feed they can land on, or they show no search affordance at all (issue #860).
it('advertises the inline templated search link on every navigable feed', async () => {
const paths = ['/api/v1/opds', '/api/v1/opds/libraries', '/api/v1/opds/collections', '/api/v1/opds/smart-scopes'];
const acquisitionPaths = ['/api/v1/opds/authors', '/api/v1/opds/series', '/api/v1/opds/catalog', '/api/v1/opds/recent'];
for (const path of [...paths, ...acquisitionPaths]) {
const response = await opdsGet(path, ownerCredentials);
expect(response.statusCode).toBe(200);
expect(response.body).toContain(
'<link rel="search" href="/api/v1/opds/catalog?q={searchTerms}" type="application/atom+xml" title="Search"/>',
);
}
});
it('scopes libraries, collections, smartScopes, authors, and series to the authenticated parent user', async () => {
const librariesResponse = await opdsGet('/api/v1/opds/libraries', ownerCredentials);
expect(librariesResponse.statusCode).toBe(200);