diff --git a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts
index 8c1a7929..a532408f 100644
--- a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts
+++ b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts
@@ -49,6 +49,7 @@ import {
isSearchLink,
resolveURL,
getFileExtFromPath,
+ looksLikeXMLContent,
MIME,
validateOPDSURL,
} from '@/app/opds/utils/opdsUtils';
@@ -354,6 +355,46 @@ describe('opdsUtils', () => {
});
});
+ describe('looksLikeXMLContent', () => {
+ it('detects XML that starts with the root element directly', () => {
+ expect(looksLikeXMLContent('')).toBe(true);
+ });
+
+ it('detects XML with an declaration', () => {
+ expect(looksLikeXMLContent('')).toBe(true);
+ });
+
+ // Regression for https://github.com/readest/readest/issues/4181
+ // The Hungarian MEK catalog returns XML feeds with leading whitespace and
+ // newlines before (and no declaration). A naive
+ // text.startsWith('<') check misfired, sending the body to JSON.parse.
+ it('detects XML with leading whitespace and newlines', () => {
+ expect(looksLikeXMLContent(' \n ')).toBe(
+ true,
+ );
+ });
+
+ it('detects XML with a leading UTF-8 BOM', () => {
+ expect(looksLikeXMLContent('')).toBe(true);
+ });
+
+ it('returns false for JSON content', () => {
+ expect(looksLikeXMLContent('{"metadata":{}}')).toBe(false);
+ });
+
+ it('returns false for JSON with leading whitespace', () => {
+ expect(looksLikeXMLContent(' \n {"metadata":{}}')).toBe(false);
+ });
+
+ it('returns false for plain text', () => {
+ expect(looksLikeXMLContent('Just some plain text')).toBe(false);
+ });
+
+ it('returns false for an empty string', () => {
+ expect(looksLikeXMLContent('')).toBe(false);
+ });
+ });
+
describe('validateOPDSURL', () => {
beforeEach(() => {
mockFetchWithAuth.mockReset();
@@ -522,6 +563,22 @@ describe('opdsUtils', () => {
expect(result.isValid).toBe(true);
});
+ // Regression for https://github.com/readest/readest/issues/4181
+ it('should accept an XML feed with leading whitespace and a text/html Content-Type', async () => {
+ const xmlFeed = ` \n\n MEK\n`;
+
+ mockFetchWithAuth.mockResolvedValue({
+ ok: true,
+ url: 'https://bookserver.mek.oszk.hu/all/epub/0',
+ text: () => Promise.resolve(xmlFeed),
+ headers: new Headers({ 'Content-Type': 'text/html' }),
+ } as Response);
+
+ const result = await validateOPDSURL('https://bookserver.mek.oszk.hu/all/epub/0');
+ expect(result.isValid).toBe(true);
+ expect(result.data?.type).toBe('feed');
+ });
+
it('should return invalid for XML that is not a recognized OPDS document type', async () => {
const xmlDoc = `
diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx
index 3013a62f..ce665f60 100644
--- a/apps/readest-app/src/app/opds/page.tsx
+++ b/apps/readest-app/src/app/opds/page.tsx
@@ -26,6 +26,7 @@ import { OPDSFeed, OPDSPublication, OPDSSearch } from '@/types/opds';
import {
getFileExtFromPath,
isSearchLink,
+ looksLikeXMLContent,
MIME,
parseMediaType,
resolveURL,
@@ -216,7 +217,7 @@ export default function BrowserPage() {
const responseURL = res.url;
const text = await res.text();
- if (text.startsWith('<')) {
+ if (looksLikeXMLContent(text)) {
const doc = new DOMParser().parseFromString(text, MIME.XML as DOMParserSupportedType);
const {
documentElement: { localName },
diff --git a/apps/readest-app/src/app/opds/utils/opdsUtils.ts b/apps/readest-app/src/app/opds/utils/opdsUtils.ts
index 4717fe28..40d2e907 100644
--- a/apps/readest-app/src/app/opds/utils/opdsUtils.ts
+++ b/apps/readest-app/src/app/opds/utils/opdsUtils.ts
@@ -68,6 +68,20 @@ export const parseMediaType = (str?: string) => {
};
};
+/**
+ * Detect whether an OPDS response body is XML rather than JSON.
+ *
+ * Some OPDS servers (e.g. the Hungarian MEK catalog, issue #4181) return XML
+ * feeds with leading whitespace/newlines before the root element — sometimes
+ * without an `` declaration — and a wrong `text/html` Content-Type. A
+ * naive `text.startsWith('<')` check then misfires and the body is handed to
+ * JSON.parse, producing "Unexpected token '<' ... is not valid JSON".
+ *
+ * Trimming leading whitespace (which also strips a UTF-8 BOM) before the
+ * check makes detection robust regardless of Content-Type.
+ */
+export const looksLikeXMLContent = (text: string): boolean => text.trimStart().startsWith('<');
+
export const isSearchLink = (link: OPDSBaseLink): boolean => {
const rels = Array.isArray(link.rel) ? link.rel : [link.rel || ''];
return rels.includes('search') && (link.type === MIME.OPENSEARCH || link.type === MIME.ATOM);
@@ -131,7 +145,7 @@ export const validateOPDSURL = async (
const text = await res.text();
// Check if it's XML-based OPDS
- if (text.startsWith('<')) {
+ if (looksLikeXMLContent(text)) {
const doc = new DOMParser().parseFromString(text, MIME.XML as DOMParserSupportedType);
const {
documentElement: { localName },