fix(opds): handle entries with no downloadable format (#4599) (#4611)

An OPDS entry with full metadata and a cover image but no acquisition
link — e.g. a Calibre book whose file was removed but kept for tracking
borrowed/loaned titles — was classified by foliate-js as a navigation
item whose href fell back to the cover image link. Tapping it loaded the
image, which is neither XML nor JSON, so the OPDS browser crashed with a
JSON parse error.

- Bump foliate-js to include the getFeed fix that classifies such
  metadata-only entries as publications instead of navigation.
- PublicationView: show "No downloadable format available" when an entry
  has no acquisition or stream links.
- loadOPDS: defense-in-depth — surface a clear message instead of a raw
  JSON.parse SyntaxError when a response is neither XML nor JSON.
- Add tests covering the Calibre no-format entry and a regression guard
  that a true navigation entry still classifies as navigation; add the
  two new UI strings across all locales.

Closes #4599

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-16 22:45:43 +08:00
committed by GitHub
parent f950685f22
commit a30a310a17
37 changed files with 184 additions and 35 deletions
@@ -1883,5 +1883,7 @@
"Update": "تحديث",
"Nightly Builds": "إصدارات ليلية",
"Early daily builds": "إصدارات يومية مبكرة",
"Privacy": "الخصوصية"
"Privacy": "الخصوصية",
"No downloadable format available": "لا يتوفر تنسيق قابل للتنزيل",
"Content is neither valid XML nor JSON": "المحتوى ليس بصيغة XML ولا JSON صالحة"
}
@@ -1751,5 +1751,7 @@
"Update": "আপডেট",
"Nightly Builds": "নাইটলি বিল্ড",
"Early daily builds": "প্রাথমিক দৈনিক বিল্ড",
"Privacy": "গোপনীয়তা"
"Privacy": "গোপনীয়তা",
"No downloadable format available": "ডাউনলোডযোগ্য কোনো ফরম্যাট নেই",
"Content is neither valid XML nor JSON": "বিষয়বস্তু বৈধ XML বা JSON কোনোটিই নয়"
}
@@ -1718,5 +1718,7 @@
"Update": "གསར་སྒྱུར།",
"Nightly Builds": "མཚན་མོའི་བཟོ་སྐྲུན།",
"Early daily builds": "སྔ་མོའི་ཉིན་རེའི་བཟོ་སྐྲུན།",
"Privacy": "གསང་དོན།"
"Privacy": "གསང་དོན།",
"No downloadable format available": "ཕབ་ལེན་བྱེད་ཆོག་པའི་རྣམ་པ་མི་འདུག",
"Content is neither valid XML nor JSON": "ནང་དོན་ནི་ XML དང་ JSON གང་ཡང་ཚད་ལྡན་མིན།"
}
@@ -1751,5 +1751,7 @@
"Update": "Aktualisierung",
"Nightly Builds": "Nightly-Builds",
"Early daily builds": "Frühe tägliche Builds",
"Privacy": "Datenschutz"
"Privacy": "Datenschutz",
"No downloadable format available": "Kein herunterladbares Format verfügbar",
"Content is neither valid XML nor JSON": "Inhalt ist weder gültiges XML noch JSON"
}
@@ -1751,5 +1751,7 @@
"Update": "Ενημέρωση",
"Nightly Builds": "Νυχτερινές εκδόσεις",
"Early daily builds": "Πρώιμες καθημερινές εκδόσεις",
"Privacy": "Απόρρητο"
"Privacy": "Απόρρητο",
"No downloadable format available": "Δεν υπάρχει διαθέσιμη μορφή για λήψη",
"Content is neither valid XML nor JSON": "Το περιεχόμενο δεν είναι ούτε έγκυρο XML ούτε JSON"
}
@@ -1784,5 +1784,7 @@
"Update": "Actualización",
"Nightly Builds": "Compilaciones nocturnas",
"Early daily builds": "Compilaciones diarias anticipadas",
"Privacy": "Privacidad"
"Privacy": "Privacidad",
"No downloadable format available": "No hay ningún formato descargable disponible",
"Content is neither valid XML nor JSON": "El contenido no es XML ni JSON válido"
}
@@ -1751,5 +1751,7 @@
"Update": "به‌روزرسانی",
"Nightly Builds": "نسخه‌های شبانه",
"Early daily builds": "نسخه‌های روزانهٔ آزمایشی",
"Privacy": "حریم خصوصی"
"Privacy": "حریم خصوصی",
"No downloadable format available": "هیچ قالب قابل دانلودی موجود نیست",
"Content is neither valid XML nor JSON": "محتوا نه XML معتبر است و نه JSON"
}
@@ -1784,5 +1784,7 @@
"Update": "Mise à jour",
"Nightly Builds": "Versions nocturnes",
"Early daily builds": "Versions quotidiennes anticipées",
"Privacy": "Confidentialité"
"Privacy": "Confidentialité",
"No downloadable format available": "Aucun format téléchargeable disponible",
"Content is neither valid XML nor JSON": "Le contenu n'est ni du XML ni du JSON valide"
}
@@ -1784,5 +1784,7 @@
"Update": "עדכון",
"Nightly Builds": "גרסאות לילה",
"Early daily builds": "גרסאות יומיות מוקדמות",
"Privacy": "פרטיות"
"Privacy": "פרטיות",
"No downloadable format available": "אין פורמט להורדה זמין",
"Content is neither valid XML nor JSON": "התוכן אינו XML או JSON תקין"
}
@@ -1751,5 +1751,7 @@
"Update": "अपडेट",
"Nightly Builds": "नाइटली बिल्ड",
"Early daily builds": "प्रारंभिक दैनिक बिल्ड",
"Privacy": "गोपनीयता"
"Privacy": "गोपनीयता",
"No downloadable format available": "कोई डाउनलोड करने योग्य प्रारूप उपलब्ध नहीं है",
"Content is neither valid XML nor JSON": "सामग्री न तो मान्य XML है और न ही JSON"
}
@@ -1751,5 +1751,7 @@
"Update": "Frissítés",
"Nightly Builds": "Éjszakai buildek",
"Early daily builds": "Korai napi buildek",
"Privacy": "Adatvédelem"
"Privacy": "Adatvédelem",
"No downloadable format available": "Nincs letölthető formátum",
"Content is neither valid XML nor JSON": "A tartalom sem érvényes XML, sem JSON"
}
@@ -1718,5 +1718,7 @@
"Update": "Pembaruan",
"Nightly Builds": "Build Nightly",
"Early daily builds": "Build harian awal",
"Privacy": "Privasi"
"Privacy": "Privasi",
"No downloadable format available": "Tidak ada format yang dapat diunduh",
"Content is neither valid XML nor JSON": "Konten bukan XML maupun JSON yang valid"
}
@@ -1784,5 +1784,7 @@
"Update": "Aggiornamento",
"Nightly Builds": "Build notturne",
"Early daily builds": "Build giornaliere anticipate",
"Privacy": "Privacy"
"Privacy": "Privacy",
"No downloadable format available": "Nessun formato scaricabile disponibile",
"Content is neither valid XML nor JSON": "Il contenuto non è né XML né JSON valido"
}
@@ -1718,5 +1718,7 @@
"Update": "アップデート",
"Nightly Builds": "ナイトリービルド",
"Early daily builds": "毎日の先行ビルド",
"Privacy": "プライバシー"
"Privacy": "プライバシー",
"No downloadable format available": "ダウンロード可能な形式がありません",
"Content is neither valid XML nor JSON": "コンテンツが有効な XML でも JSON でもありません"
}
@@ -1718,5 +1718,7 @@
"Update": "업데이트",
"Nightly Builds": "야간 빌드",
"Early daily builds": "매일 제공되는 미리 보기 빌드",
"Privacy": "개인정보 보호"
"Privacy": "개인정보 보호",
"No downloadable format available": "다운로드 가능한 형식이 없습니다",
"Content is neither valid XML nor JSON": "콘텐츠가 유효한 XML 또는 JSON이 아닙니다"
}
@@ -1718,5 +1718,7 @@
"Update": "Kemas kini",
"Nightly Builds": "Binaan Nightly",
"Early daily builds": "Binaan harian awal",
"Privacy": "Privasi"
"Privacy": "Privasi",
"No downloadable format available": "Tiada format yang boleh dimuat turun",
"Content is neither valid XML nor JSON": "Kandungan bukan XML mahupun JSON yang sah"
}
@@ -1751,5 +1751,7 @@
"Update": "Update",
"Nightly Builds": "Nightly-builds",
"Early daily builds": "Vroege dagelijkse builds",
"Privacy": "Privacy"
"Privacy": "Privacy",
"No downloadable format available": "Geen downloadbaar formaat beschikbaar",
"Content is neither valid XML nor JSON": "Inhoud is geen geldige XML of JSON"
}
@@ -1817,5 +1817,7 @@
"Update": "Aktualizacja",
"Nightly Builds": "Kompilacje nocne",
"Early daily builds": "Wczesne kompilacje dzienne",
"Privacy": "Prywatność"
"Privacy": "Prywatność",
"No downloadable format available": "Brak formatu do pobrania",
"Content is neither valid XML nor JSON": "Treść nie jest prawidłowym XML ani JSON"
}
@@ -1784,5 +1784,7 @@
"Update": "Atualização",
"Nightly Builds": "Compilações noturnas",
"Early daily builds": "Compilações diárias antecipadas",
"Privacy": "Privacidade"
"Privacy": "Privacidade",
"No downloadable format available": "Nenhum formato disponível para download",
"Content is neither valid XML nor JSON": "O conteúdo não é XML nem JSON válido"
}
@@ -1784,5 +1784,7 @@
"Update": "Atualização",
"Nightly Builds": "Compilações noturnas",
"Early daily builds": "Compilações diárias antecipadas",
"Privacy": "Privacidade"
"Privacy": "Privacidade",
"No downloadable format available": "Nenhum formato disponível para download",
"Content is neither valid XML nor JSON": "O conteúdo não é XML nem JSON válido"
}
@@ -1784,5 +1784,7 @@
"Update": "Actualizare",
"Nightly Builds": "Versiuni nightly",
"Early daily builds": "Versiuni zilnice timpurii",
"Privacy": "Confidențialitate"
"Privacy": "Confidențialitate",
"No downloadable format available": "Niciun format descărcabil disponibil",
"Content is neither valid XML nor JSON": "Conținutul nu este nici XML, nici JSON valid"
}
@@ -1817,5 +1817,7 @@
"Update": "Обновление",
"Nightly Builds": "Ночные сборки",
"Early daily builds": "Ранние ежедневные сборки",
"Privacy": "Конфиденциальность"
"Privacy": "Конфиденциальность",
"No downloadable format available": "Нет доступного формата для загрузки",
"Content is neither valid XML nor JSON": "Содержимое не является ни корректным XML, ни JSON"
}
@@ -1751,5 +1751,7 @@
"Update": "යාවත්කාලීන කිරීම",
"Nightly Builds": "රාත්‍රී නිකුතු",
"Early daily builds": "මුල් දෛනික නිකුතු",
"Privacy": "පෞද්ගලිකත්වය"
"Privacy": "පෞද්ගලිකත්වය",
"No downloadable format available": "බාගත කළ හැකි ආකෘතියක් නොමැත",
"Content is neither valid XML nor JSON": "අන්තර්ගතය වලංගු XML හෝ JSON නොවේ"
}
@@ -1817,5 +1817,7 @@
"Update": "Posodobitev",
"Nightly Builds": "Nočne gradnje",
"Early daily builds": "Zgodnje dnevne gradnje",
"Privacy": "Zasebnost"
"Privacy": "Zasebnost",
"No downloadable format available": "Na voljo ni nobene oblike za prenos",
"Content is neither valid XML nor JSON": "Vsebina ni veljaven XML niti JSON"
}
@@ -1751,5 +1751,7 @@
"Update": "Uppdatering",
"Nightly Builds": "Nattliga versioner",
"Early daily builds": "Tidiga dagliga versioner",
"Privacy": "Integritet"
"Privacy": "Integritet",
"No downloadable format available": "Inget nedladdningsbart format tillgängligt",
"Content is neither valid XML nor JSON": "Innehållet är varken giltig XML eller JSON"
}
@@ -1751,5 +1751,7 @@
"Update": "புதுப்பிப்பு",
"Nightly Builds": "இரவு உருவாக்கங்கள்",
"Early daily builds": "ஆரம்ப தினசரி உருவாக்கங்கள்",
"Privacy": "தனியுரிமை"
"Privacy": "தனியுரிமை",
"No downloadable format available": "பதிவிறக்கம் செய்யக்கூடிய வடிவம் எதுவும் இல்லை",
"Content is neither valid XML nor JSON": "உள்ளடக்கம் சரியான XML அல்லது JSON அல்ல"
}
@@ -1718,5 +1718,7 @@
"Update": "อัปเดต",
"Nightly Builds": "บิลด์รายคืน",
"Early daily builds": "บิลด์รายวันรุ่นแรก",
"Privacy": "ความเป็นส่วนตัว"
"Privacy": "ความเป็นส่วนตัว",
"No downloadable format available": "ไม่มีรูปแบบที่ดาวน์โหลดได้",
"Content is neither valid XML nor JSON": "เนื้อหาไม่ใช่ทั้ง XML และ JSON ที่ถูกต้อง"
}
@@ -1751,5 +1751,7 @@
"Update": "Güncelleme",
"Nightly Builds": "Gecelik sürümler",
"Early daily builds": "Erken günlük sürümler",
"Privacy": "Gizlilik"
"Privacy": "Gizlilik",
"No downloadable format available": "İndirilebilir biçim yok",
"Content is neither valid XML nor JSON": "İçerik geçerli XML veya JSON değil"
}
@@ -1817,5 +1817,7 @@
"Update": "Оновлення",
"Nightly Builds": "Нічні збірки",
"Early daily builds": "Ранні щоденні збірки",
"Privacy": "Конфіденційність"
"Privacy": "Конфіденційність",
"No downloadable format available": "Немає доступного формату для завантаження",
"Content is neither valid XML nor JSON": "Вміст не є дійсним XML чи JSON"
}
@@ -1751,5 +1751,7 @@
"Update": "Yangilanish",
"Nightly Builds": "Tungi buildlar",
"Early daily builds": "Erta kunlik buildlar",
"Privacy": "Maxfiylik"
"Privacy": "Maxfiylik",
"No downloadable format available": "Yuklab olinadigan format mavjud emas",
"Content is neither valid XML nor JSON": "Kontent yaroqli XML ham, JSON ham emas"
}
@@ -1718,5 +1718,7 @@
"Update": "Cập nhật",
"Nightly Builds": "Bản dựng hằng đêm",
"Early daily builds": "Bản dựng hằng ngày sớm",
"Privacy": "Quyền riêng tư"
"Privacy": "Quyền riêng tư",
"No downloadable format available": "Không có định dạng nào để tải xuống",
"Content is neither valid XML nor JSON": "Nội dung không phải XML hay JSON hợp lệ"
}
@@ -1718,5 +1718,7 @@
"Update": "更新",
"Nightly Builds": "每夜构建版",
"Early daily builds": "每日抢先构建版",
"Privacy": "隐私"
"Privacy": "隐私",
"No downloadable format available": "没有可下载的格式",
"Content is neither valid XML nor JSON": "内容既不是有效的 XML 也不是 JSON"
}
@@ -1718,5 +1718,7 @@
"Update": "更新",
"Nightly Builds": "每夜建置版",
"Early daily builds": "每日搶先建置版",
"Privacy": "隱私"
"Privacy": "隱私",
"No downloadable format available": "沒有可下載的格式",
"Content is neither valid XML nor JSON": "內容既不是有效的 XML 也不是 JSON"
}
@@ -167,6 +167,76 @@ describe('OPDS feed parsing', () => {
});
});
describe('metadata-only entries without an acquisition link (Calibre, #4599)', () => {
// Reproduces https://github.com/readest/readest/issues/4599
// A Calibre book whose file was removed (e.g. a borrowed/loaned title kept
// for tracking) still emits a full metadata entry with cover/thumbnail
// links but no acquisition link. Such an entry was classified as a
// navigation item whose href fell back to links[0] — the cover image — so
// tapping it tried to load the image as a feed and crashed with a JSON
// parse error. It should be treated as a publication (so its metadata is
// shown) with no downloadable format.
const calibreNoFormatFeed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opds="http://opds-spec.org/2010/catalog">
<title>Calibre Library</title>
<entry>
<title>A Borrowed Book</title>
<id>urn:uuid:1234</id>
<author><name>Jane Author</name></author>
<updated>2026-01-15T10:30:00Z</updated>
<dc:language>eng</dc:language>
<summary>A book I borrowed; the file is no longer available.</summary>
<link type="image/jpeg" href="/get/cover/123/lib" rel="http://opds-spec.org/image"/>
<link type="image/png" href="/get/thumb/123/lib" rel="http://opds-spec.org/image/thumbnail"/>
</entry>
</feed>`;
it('classifies a metadata+cover entry with no acquisition link as a publication', () => {
const doc = parseXML(calibreNoFormatFeed);
const feed = getFeed(doc) as OPDSFeed;
expect(feed.publications).toBeDefined();
expect(feed.publications!).toHaveLength(1);
expect(feed.publications![0]!.metadata.title).toBe('A Borrowed Book');
// The crash path: the entry must NOT become a navigation item pointing at
// the cover image.
expect(feed.navigation).toBeUndefined();
});
it('exposes no acquisition link for a format-less publication', () => {
const doc = parseXML(calibreNoFormatFeed);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
const hasAcquisition = pub.links.some((link) => {
const rels = Array.isArray(link.rel) ? link.rel : [link.rel ?? ''];
return rels.some((rel) => rel.startsWith('http://opds-spec.org/acquisition'));
});
expect(hasAcquisition).toBe(false);
});
it('still classifies a true navigation entry (sub-catalog link, no image) as navigation', () => {
const navFeed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Root</title>
<entry>
<title>By Author</title>
<link rel="subsection" href="author/?opds"
type="application/atom+xml;profile=opds-catalog"/>
<updated>2026-03-28T05:37:03Z</updated>
</entry>
</feed>`;
const doc = parseXML(navFeed);
const feed = getFeed(doc) as OPDSFeed;
expect(feed.publications).toBeUndefined();
expect(feed.navigation).toBeDefined();
expect(feed.navigation!).toHaveLength(1);
expect(feed.navigation![0]!.title).toBe('By Author');
});
});
describe('entry id and updated parsing', () => {
const shelfFeed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
@@ -223,6 +223,10 @@ export function PublicationView({
)}
</div>
{!downloadedBook && acquisitionLinks.length === 0 && streamLinks.length === 0 && (
<p className='text-base-content/60 text-sm'>{_('No downloadable format available')}</p>
)}
{(acquisitionLinks.length > 0 || streamLinks.length > 0) && (
<div className='flex flex-wrap items-center gap-2'>
{acquisitionLinks.map(({ rel, links }) => {
+10 -1
View File
@@ -329,7 +329,16 @@ export default function BrowserPage() {
}
}
} else {
const feed = JSON.parse(text);
// Defense-in-depth: a non-feed response (e.g. an image returned for a
// link that isn't actually a feed) is neither XML nor JSON. Surface a
// clear message instead of letting a raw JSON.parse SyntaxError leak
// into the error view (readest issue #4599).
let feed;
try {
feed = JSON.parse(text);
} catch {
throw new Error(_('Content is neither valid XML nor JSON'));
}
const newState = {
feed,
baseURL: responseURL,