diff --git a/server/src/modules/opds/__tests__/opds.service.test.ts b/server/src/modules/opds/__tests__/opds.service.test.ts
index 20963903a..57dcbc161 100644
--- a/server/src/modules/opds/__tests__/opds.service.test.ts
+++ b/server/src/modules/opds/__tests__/opds.service.test.ts
@@ -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(``);
+ expect(xml).toContain(``);
+ });
+
+ 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();
diff --git a/server/src/modules/opds/opds-xml.helpers.ts b/server/src/modules/opds/opds-xml.helpers.ts
index 039110878..81cf979e7 100644
--- a/server/src/modules/opds/opds-xml.helpers.ts
+++ b/server/src/modules/opds/opds-xml.helpers.ts
@@ -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';
diff --git a/server/src/modules/opds/opds.service.ts b/server/src/modules/opds/opds.service.ts
index 6d208d3b5..891d7168a 100644
--- a/server/src/modules/opds/opds.service.ts
+++ b/server/src/modules/opds/opds.service.ts
@@ -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 {
'',
` ${xmlEl('ShortName', 'bookorbit OPDS')}`,
` ${xmlEl('Description', 'Search the bookorbit book catalog')}`,
- ` `,
+ ` `,
' UTF-8',
' UTF-8',
'',
@@ -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 [
'',
diff --git a/server/test/opds-auth-catalog.e2e-spec.ts b/server/test/opds-auth-catalog.e2e-spec.ts
index 753cb574b..1872ae99b 100644
--- a/server/test/opds-auth-catalog.e2e-spec.ts
+++ b/server/test/opds-auth-catalog.e2e-spec.ts
@@ -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(
+ '',
+ );
+ }
+ });
+
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);