feat(opds): support auto-download books from OPDS feeds (#3844)

* feat: auto-download new items from subscribed OPDS catalogs

Add opt-in auto-download per OPDS catalog. When enabled, new publications
are downloaded on app startup and pull-to-refresh. Dedup via Atom <id>.

- Extend foliate-js parser to surface entry id/updated fields
- Add subscription state to OPDSCatalog type
- New headless opdsSyncService with navigation feed crawling
- Auto-download toggle in CatalogManager UI
- Wire sync into startup and pull-to-refresh hooks
- 16 new test cases

Built with an AI code agent.

Closes #3836

* chore: point foliate-js submodule to fork with OPDS id/updated fields

CI needs to fetch the foliate-js commit that adds id and updated field
extraction from OPDS feeds. Point submodule URL to our fork where the
commit is pushed.

* feat(opds): rearchitect auto-download with two-phase OPDS sync

Replaces the monolithic opdsSyncService from #3837 with a separated
discovery + acquisition pipeline. Subscription state lives in
per-catalog JSON files under Data/opds-subscriptions/, decoupled from
catalog config.

Discovery (services/opds/feedChecker.ts) resolves a catalog URL down
to its "by newest" feed via three detection tiers — rel="http://opds-
spec.org/sort/new" (Calibre / Calibre-Web), title heuristics
("Newest", "Recently Added", "Latest"), and href patterns
(?sort_order=release_date for Project Gutenberg, /new-releases for
Standard Ebooks). It then walks only that feed plus rel=next
pagination, never the full navigation tree. When no by-newest feed is
found, the catalog is skipped with a warning instead of silently
mirroring everything.

Acquisition link selection filters to safe rels
(acquisition / acquisition/open-access — drops buy / borrow /
subscribe / sample / indirect), prefers open-access when both rels
are present, then ranks by format tier:
  0  Advanced EPUB / EPUB 3 (link title, .epub3 href, version=3.x)
  1  plain EPUB
  2  MOBI / AZW / AZW3
  3  PDF / CBZ
  4  anything else
Within a tier, ties resolve by feed order. When the upstream omits or
mis-declares (octet-stream) the link's media type, format is inferred
from href extension and link title. Entries that appear in both
feed.publications and a group are deduped within the same batch.

Orchestration (services/opds/autoDownload.ts) runs catalogs
sequentially — per-catalog concurrency already caps parallel downloads
at 3, so a parallel fan-out across catalogs would be N×3 simultaneous
requests on cellular. Pending and retry items are deduped by entryId,
and entries still inside their exponential-backoff window are skipped
instead of re-attempted (avoids appending duplicate failedEntries
records). Filenames come from the last path segment, capped at 200
chars, with a try/catch around decodeURIComponent for malformed
%-sequences.

Hook (hooks/useOPDSSubscriptions.ts): startup, 5-minute interval, and
'check-opds-subscriptions' event triggers. Toggling auto-download on
fires the event so the sync runs immediately. Newly imported books
are queued for cloud upload via transferManager when the user is
logged in and settings.autoUpload is on, mirroring the manual OPDS
download path. 'opds-sync-complete' is dispatched after every sync so
listening UI can refresh without polling. loadSubscriptionState
self-heals duplicate failedEntries from older state files.

The submodule URL is reverted to readest/foliate-js (the changes
needed for OPDS id/updated extraction are already in main), and
OPDSCatalog is trimmed to only the autoDownload field.

Verbose [OPDS]-prefixed debug logs cover the whole pipeline: feed
fetch → resolve → discovery → per-item download (with magic-byte
dump of the saved file) → import → cloud upload queueing.

34 new tests across opds-feed-checker, opds-auto-download,
opds-subscription-state, opds-types.

Closes #3836.
Supersedes #3837.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Johannes Mauerer <johannes@mauerer.info>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-04-27 12:04:32 +08:00
committed by GitHub
parent 8a8fcb2611
commit 38d7ba80fe
47 changed files with 3304 additions and 81 deletions
@@ -1244,5 +1244,38 @@
"Edit OPDS Catalog": "تعديل كتالوج OPDS",
"Save Changes": "حفظ التغييرات",
"Use Book Layout": "استخدام تخطيط الكتاب",
"End of this section. Continue to the next.": "نهاية هذا القسم. تابع إلى القسم التالي."
"End of this section. Continue to the next.": "نهاية هذا القسم. تابع إلى القسم التالي.",
"Auto-download": "تنزيل تلقائي",
"Auto-download new items": "تنزيل العناصر الجديدة تلقائياً",
"Automatically download new publications when the app syncs": "تنزيل المنشورات الجديدة تلقائياً عند مزامنة التطبيق",
"{{count}} new item(s) downloaded from OPDS_zero": "لم يتم تنزيل أي عناصر جديدة من OPDS",
"{{count}} new item(s) downloaded from OPDS_one": "تم تنزيل عنصر جديد من OPDS",
"{{count}} new item(s) downloaded from OPDS_two": "تم تنزيل عنصرين جديدين من OPDS",
"{{count}} new item(s) downloaded from OPDS_few": "تم تنزيل {{count}} عناصر جديدة من OPDS",
"{{count}} new item(s) downloaded from OPDS_many": "تم تنزيل {{count}} عنصراً جديداً من OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "تم تنزيل {{count}} عنصر جديد من OPDS",
"Failed to sync {{count}} OPDS catalog(s)_zero": "فشلت مزامنة كتالوجات OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "فشلت مزامنة كتالوج OPDS واحد",
"Failed to sync {{count}} OPDS catalog(s)_two": "فشلت مزامنة كتالوجي OPDS",
"Failed to sync {{count}} OPDS catalog(s)_few": "فشلت مزامنة {{count}} كتالوجات OPDS",
"Failed to sync {{count}} OPDS catalog(s)_many": "فشلت مزامنة {{count}} كتالوجاً من OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "فشلت مزامنة {{count}} كتالوج OPDS",
"Last synced {{when}}": "آخر مزامنة {{when}}",
"Failed downloads": "التنزيلات الفاشلة",
"No failed downloads": "لا توجد تنزيلات فاشلة",
"Attempts: {{count}}_zero": "المحاولات: {{count}}",
"Attempts: {{count}}_one": "المحاولات: {{count}}",
"Attempts: {{count}}_two": "المحاولات: {{count}}",
"Attempts: {{count}}_few": "المحاولات: {{count}}",
"Attempts: {{count}}_many": "المحاولات: {{count}}",
"Attempts: {{count}}_other": "المحاولات: {{count}}",
"Skip": "تخطي",
"Skip all": "تخطي الكل",
"Retry all": "إعادة المحاولة للكل",
"{{count}} failed_zero": "{{count}} فشل",
"{{count}} failed_one": "{{count}} فشل",
"{{count}} failed_two": "{{count}} فشل",
"{{count}} failed_few": "{{count}} فشل",
"{{count}} failed_many": "{{count}} فشل",
"{{count}} failed_other": "{{count}} فشل"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "OPDS ক্যাটালগ সম্পাদনা",
"Save Changes": "পরিবর্তন সংরক্ষণ",
"Use Book Layout": "বইয়ের লেআউট ব্যবহার করুন",
"End of this section. Continue to the next.": "এই বিভাগের শেষ। পরবর্তী বিভাগে চালিয়ে যান।"
"End of this section. Continue to the next.": "এই বিভাগের শেষ। পরবর্তী বিভাগে চালিয়ে যান।",
"Auto-download": "স্বয়ংক্রিয় ডাউনলোড",
"Auto-download new items": "নতুন আইটেম স্বয়ংক্রিয়ভাবে ডাউনলোড করুন",
"Automatically download new publications when the app syncs": "অ্যাপ সিঙ্ক হওয়ার সময় নতুন প্রকাশনা স্বয়ংক্রিয়ভাবে ডাউনলোড করুন",
"{{count}} new item(s) downloaded from OPDS_one": "OPDS থেকে {{count}}টি নতুন আইটেম ডাউনলোড হয়েছে",
"{{count}} new item(s) downloaded from OPDS_other": "OPDS থেকে {{count}}টি নতুন আইটেম ডাউনলোড হয়েছে",
"Failed to sync {{count}} OPDS catalog(s)_one": "{{count}}টি OPDS ক্যাটালগ সিঙ্ক করতে ব্যর্থ",
"Failed to sync {{count}} OPDS catalog(s)_other": "{{count}}টি OPDS ক্যাটালগ সিঙ্ক করতে ব্যর্থ",
"Last synced {{when}}": "শেষ সিঙ্ক হয়েছে {{when}}",
"Failed downloads": "ব্যর্থ ডাউনলোড",
"No failed downloads": "কোনো ব্যর্থ ডাউনলোড নেই",
"Attempts: {{count}}_one": "প্রচেষ্টা: {{count}}",
"Attempts: {{count}}_other": "প্রচেষ্টা: {{count}}",
"Skip": "এড়িয়ে যান",
"Skip all": "সব এড়িয়ে যান",
"Retry all": "সব পুনরায় চেষ্টা করুন",
"{{count}} failed_one": "{{count}}টি ব্যর্থ",
"{{count}} failed_other": "{{count}}টি ব্যর্থ"
}
@@ -1179,5 +1179,18 @@
"Edit OPDS Catalog": "OPDS དཀར་ཆག་རྩོམ་སྒྲིག",
"Save Changes": "བསྒྱུར་བཅོས་ཉར་ཚགས།",
"Use Book Layout": "དཔེ་ཆའི་བཀོད་སྒྲིག་སྤྱོད་པ",
"End of this section. Continue to the next.": "སྡེ་ཚན་འདིའི་མཇུག་རེད། རྗེས་མའི་སྡེ་ཚན་དུ་མུ་མཐུད་རོགས།"
"End of this section. Continue to the next.": "སྡེ་ཚན་འདིའི་མཇུག་རེད། རྗེས་མའི་སྡེ་ཚན་དུ་མུ་མཐུད་རོགས།",
"Auto-download": "རང་འགུལ་ཕབ་ལེན།",
"Auto-download new items": "གསར་འདོན་གསར་པ་རང་འགུལ་ཕབ་ལེན།",
"Automatically download new publications when the app syncs": "མཉེན་ཆས་མཉམ་སྦྱོར་སྐབས་གསར་འདོན་གསར་པ་རང་འགུལ་ཕབ་ལེན་བྱེད།",
"{{count}} new item(s) downloaded from OPDS_other": "OPDS ནས་གསར་འདོན་གསར་པ་ {{count}} ཕབ་ལེན་བྱས།",
"Failed to sync {{count}} OPDS catalog(s)_other": "OPDS དཀར་ཆག་ {{count}} མཉམ་སྦྱོར་མ་ཐུབ།",
"Last synced {{when}}": "མཐའ་མའི་མཐུན་སྒྲིག་ {{when}}",
"Failed downloads": "ཕབ་ལེན་ཕམ་ཁ།",
"No failed downloads": "ཕབ་ལེན་ཕམ་ཁ་མེད།",
"Attempts: {{count}}_other": "ཐེངས་ {{count}}",
"Skip": "གཡོལ།",
"Skip all": "ཡོད་ཚད་གཡོལ།",
"Retry all": "ཡོད་ཚད་ཡང་སྐྱར།",
"{{count}} failed_other": "{{count}} ཕམ་ཁ།"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "OPDS-Katalog bearbeiten",
"Save Changes": "Änderungen speichern",
"Use Book Layout": "Buchlayout verwenden",
"End of this section. Continue to the next.": "Ende dieses Abschnitts. Weiter zum nächsten."
"End of this section. Continue to the next.": "Ende dieses Abschnitts. Weiter zum nächsten.",
"Auto-download": "Automatisch herunterladen",
"Auto-download new items": "Neue Einträge automatisch herunterladen",
"Automatically download new publications when the app syncs": "Neue Veröffentlichungen automatisch herunterladen, wenn die App synchronisiert",
"{{count}} new item(s) downloaded from OPDS_one": "{{count}} neuer Eintrag von OPDS heruntergeladen",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} neue Einträge von OPDS heruntergeladen",
"Failed to sync {{count}} OPDS catalog(s)_one": "Synchronisation von {{count}} OPDS-Katalog fehlgeschlagen",
"Failed to sync {{count}} OPDS catalog(s)_other": "Synchronisation von {{count}} OPDS-Katalogen fehlgeschlagen",
"Last synced {{when}}": "Zuletzt synchronisiert {{when}}",
"Failed downloads": "Fehlgeschlagene Downloads",
"No failed downloads": "Keine fehlgeschlagenen Downloads",
"Attempts: {{count}}_one": "Versuche: {{count}}",
"Attempts: {{count}}_other": "Versuche: {{count}}",
"Skip": "Überspringen",
"Skip all": "Alle überspringen",
"Retry all": "Alle erneut versuchen",
"{{count}} failed_one": "{{count}} fehlgeschlagen",
"{{count}} failed_other": "{{count}} fehlgeschlagen"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "Επεξεργασία καταλόγου OPDS",
"Save Changes": "Αποθήκευση αλλαγών",
"Use Book Layout": "Χρήση διάταξης βιβλίου",
"End of this section. Continue to the next.": "Τέλος αυτής της ενότητας. Συνεχίστε στην επόμενη."
"End of this section. Continue to the next.": "Τέλος αυτής της ενότητας. Συνεχίστε στην επόμενη.",
"Auto-download": "Αυτόματη λήψη",
"Auto-download new items": "Αυτόματη λήψη νέων στοιχείων",
"Automatically download new publications when the app syncs": "Αυτόματη λήψη νέων δημοσιεύσεων κατά τον συγχρονισμό της εφαρμογής",
"{{count}} new item(s) downloaded from OPDS_one": "Λήφθηκε {{count}} νέο στοιχείο από OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "Λήφθηκαν {{count}} νέα στοιχεία από OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Αποτυχία συγχρονισμού {{count}} καταλόγου OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "Αποτυχία συγχρονισμού {{count}} καταλόγων OPDS",
"Last synced {{when}}": "Τελευταίος συγχρονισμός {{when}}",
"Failed downloads": "Αποτυχημένες λήψεις",
"No failed downloads": "Δεν υπάρχουν αποτυχημένες λήψεις",
"Attempts: {{count}}_one": "Προσπάθειες: {{count}}",
"Attempts: {{count}}_other": "Προσπάθειες: {{count}}",
"Skip": "Παράλειψη",
"Skip all": "Παράλειψη όλων",
"Retry all": "Επανάληψη όλων",
"{{count}} failed_one": "{{count}} απέτυχε",
"{{count}} failed_other": "{{count}} απέτυχαν"
}
@@ -1205,5 +1205,26 @@
"Edit OPDS Catalog": "Editar catálogo OPDS",
"Save Changes": "Guardar cambios",
"Use Book Layout": "Usar el diseño del libro",
"End of this section. Continue to the next.": "Fin de esta sección. Continuar a la siguiente."
"End of this section. Continue to the next.": "Fin de esta sección. Continuar a la siguiente.",
"Auto-download": "Descarga automática",
"Auto-download new items": "Descargar nuevos elementos automáticamente",
"Automatically download new publications when the app syncs": "Descargar nuevas publicaciones automáticamente cuando la aplicación se sincroniza",
"{{count}} new item(s) downloaded from OPDS_one": "Se descargó {{count}} nuevo elemento desde OPDS",
"{{count}} new item(s) downloaded from OPDS_many": "Se descargaron {{count}} nuevos elementos desde OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "Se descargaron {{count}} nuevos elementos desde OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "No se pudo sincronizar {{count}} catálogo OPDS",
"Failed to sync {{count}} OPDS catalog(s)_many": "No se pudieron sincronizar {{count}} catálogos OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "No se pudieron sincronizar {{count}} catálogos OPDS",
"Last synced {{when}}": "Última sincronización {{when}}",
"Failed downloads": "Descargas fallidas",
"No failed downloads": "No hay descargas fallidas",
"Attempts: {{count}}_one": "Intentos: {{count}}",
"Attempts: {{count}}_many": "Intentos: {{count}}",
"Attempts: {{count}}_other": "Intentos: {{count}}",
"Skip": "Omitir",
"Skip all": "Omitir todos",
"Retry all": "Reintentar todos",
"{{count}} failed_one": "{{count}} fallida",
"{{count}} failed_many": "{{count}} fallidas",
"{{count}} failed_other": "{{count}} fallidas"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "ویرایش کاتالوگ OPDS",
"Save Changes": "ذخیره تغییرات",
"Use Book Layout": "استفاده از چیدمان کتاب",
"End of this section. Continue to the next.": "پایان این بخش. به بخش بعدی ادامه دهید."
"End of this section. Continue to the next.": "پایان این بخش. به بخش بعدی ادامه دهید.",
"Auto-download": "دانلود خودکار",
"Auto-download new items": "دانلود خودکار موارد جدید",
"Automatically download new publications when the app syncs": "دانلود خودکار انتشارات جدید هنگام همگام‌سازی برنامه",
"{{count}} new item(s) downloaded from OPDS_one": "{{count}} مورد جدید از OPDS دانلود شد",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} مورد جدید از OPDS دانلود شدند",
"Failed to sync {{count}} OPDS catalog(s)_one": "همگام‌سازی {{count}} کاتالوگ OPDS ناموفق بود",
"Failed to sync {{count}} OPDS catalog(s)_other": "همگام‌سازی {{count}} کاتالوگ OPDS ناموفق بود",
"Last synced {{when}}": "آخرین همگام‌سازی {{when}}",
"Failed downloads": "دانلودهای ناموفق",
"No failed downloads": "هیچ دانلود ناموفقی وجود ندارد",
"Attempts: {{count}}_one": "تلاش‌ها: {{count}}",
"Attempts: {{count}}_other": "تلاش‌ها: {{count}}",
"Skip": "رد کردن",
"Skip all": "رد کردن همه",
"Retry all": "تلاش مجدد همه",
"{{count}} failed_one": "{{count}} ناموفق",
"{{count}} failed_other": "{{count}} ناموفق"
}
@@ -1205,5 +1205,26 @@
"Edit OPDS Catalog": "Modifier le catalogue OPDS",
"Save Changes": "Enregistrer les modifications",
"Use Book Layout": "Utiliser la mise en page du livre",
"End of this section. Continue to the next.": "Fin de cette section. Continuer à la suivante."
"End of this section. Continue to the next.": "Fin de cette section. Continuer à la suivante.",
"Auto-download": "Téléchargement automatique",
"Auto-download new items": "Télécharger automatiquement les nouveaux éléments",
"Automatically download new publications when the app syncs": "Télécharger automatiquement les nouvelles publications lors de la synchronisation de l'application",
"{{count}} new item(s) downloaded from OPDS_one": "{{count}} nouvel élément téléchargé depuis OPDS",
"{{count}} new item(s) downloaded from OPDS_many": "{{count}} nouveaux éléments téléchargés depuis OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} nouveaux éléments téléchargés depuis OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Échec de la synchronisation de {{count}} catalogue OPDS",
"Failed to sync {{count}} OPDS catalog(s)_many": "Échec de la synchronisation de {{count}} catalogues OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "Échec de la synchronisation de {{count}} catalogues OPDS",
"Last synced {{when}}": "Dernière synchronisation {{when}}",
"Failed downloads": "Téléchargements échoués",
"No failed downloads": "Aucun téléchargement échoué",
"Attempts: {{count}}_one": "Tentatives : {{count}}",
"Attempts: {{count}}_many": "Tentatives : {{count}}",
"Attempts: {{count}}_other": "Tentatives : {{count}}",
"Skip": "Ignorer",
"Skip all": "Tout ignorer",
"Retry all": "Tout réessayer",
"{{count}} failed_one": "{{count}} échec",
"{{count}} failed_many": "{{count}} échecs",
"{{count}} failed_other": "{{count}} échecs"
}
@@ -1205,5 +1205,26 @@
"Edit OPDS Catalog": "עריכת קטלוג OPDS",
"Save Changes": "שמור שינויים",
"Use Book Layout": "השתמש בפריסת הספר",
"End of this section. Continue to the next.": "סוף הסעיף הזה. המשך לסעיף הבא."
"End of this section. Continue to the next.": "סוף הסעיף הזה. המשך לסעיף הבא.",
"Auto-download": "הורדה אוטומטית",
"Auto-download new items": "הורד פריטים חדשים באופן אוטומטי",
"Automatically download new publications when the app syncs": "הורדה אוטומטית של פרסומים חדשים כאשר האפליקציה מסתנכרנת",
"{{count}} new item(s) downloaded from OPDS_one": "פריט חדש אחד הורד מ-OPDS",
"{{count}} new item(s) downloaded from OPDS_two": "{{count}} פריטים חדשים הורדו מ-OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} פריטים חדשים הורדו מ-OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "סנכרון קטלוג OPDS אחד נכשל",
"Failed to sync {{count}} OPDS catalog(s)_two": "סנכרון {{count}} קטלוגי OPDS נכשל",
"Failed to sync {{count}} OPDS catalog(s)_other": "סנכרון {{count}} קטלוגי OPDS נכשל",
"Last synced {{when}}": "סנכרון אחרון {{when}}",
"Failed downloads": "הורדות שנכשלו",
"No failed downloads": "אין הורדות שנכשלו",
"Attempts: {{count}}_one": "ניסיונות: {{count}}",
"Attempts: {{count}}_two": "ניסיונות: {{count}}",
"Attempts: {{count}}_other": "ניסיונות: {{count}}",
"Skip": "דלג",
"Skip all": "דלג על הכל",
"Retry all": "נסה שוב את הכל",
"{{count}} failed_one": "{{count}} נכשל",
"{{count}} failed_two": "{{count}} נכשלו",
"{{count}} failed_other": "{{count}} נכשלו"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "OPDS कैटलॉग संपादित करें",
"Save Changes": "परिवर्तन सहेजें",
"Use Book Layout": "पुस्तक का लेआउट उपयोग करें",
"End of this section. Continue to the next.": "इस अनुभाग का अंत। अगले पर जारी रखें।"
"End of this section. Continue to the next.": "इस अनुभाग का अंत। अगले पर जारी रखें।",
"Auto-download": "स्वचालित डाउनलोड",
"Auto-download new items": "नई आइटम स्वचालित रूप से डाउनलोड करें",
"Automatically download new publications when the app syncs": "ऐप सिंक होने पर नए प्रकाशन स्वचालित रूप से डाउनलोड करें",
"{{count}} new item(s) downloaded from OPDS_one": "OPDS से {{count}} नया आइटम डाउनलोड किया गया",
"{{count}} new item(s) downloaded from OPDS_other": "OPDS से {{count}} नई आइटम डाउनलोड की गईं",
"Failed to sync {{count}} OPDS catalog(s)_one": "{{count}} OPDS कैटलॉग सिंक करने में विफल",
"Failed to sync {{count}} OPDS catalog(s)_other": "{{count}} OPDS कैटलॉग सिंक करने में विफल",
"Last synced {{when}}": "अंतिम सिंक्रनाइज़ेशन {{when}}",
"Failed downloads": "असफल डाउनलोड",
"No failed downloads": "कोई असफल डाउनलोड नहीं",
"Attempts: {{count}}_one": "प्रयास: {{count}}",
"Attempts: {{count}}_other": "प्रयास: {{count}}",
"Skip": "छोड़ें",
"Skip all": "सब छोड़ें",
"Retry all": "सब पुनः प्रयास करें",
"{{count}} failed_one": "{{count}} असफल",
"{{count}} failed_other": "{{count}} असफल"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "OPDS katalógus szerkesztése",
"Save Changes": "Módosítások mentése",
"Use Book Layout": "Könyv elrendezésének használata",
"End of this section. Continue to the next.": "Ennek a szakasznak a vége. Folytatás a következővel."
"End of this section. Continue to the next.": "Ennek a szakasznak a vége. Folytatás a következővel.",
"Auto-download": "Automatikus letöltés",
"Auto-download new items": "Új elemek automatikus letöltése",
"Automatically download new publications when the app syncs": "Új kiadványok automatikus letöltése, amikor az alkalmazás szinkronizál",
"{{count}} new item(s) downloaded from OPDS_one": "{{count}} új elem letöltve az OPDS-ből",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} új elem letöltve az OPDS-ből",
"Failed to sync {{count}} OPDS catalog(s)_one": "{{count}} OPDS-katalógus szinkronizálása sikertelen",
"Failed to sync {{count}} OPDS catalog(s)_other": "{{count}} OPDS-katalógus szinkronizálása sikertelen",
"Last synced {{when}}": "Utoljára szinkronizálva {{when}}",
"Failed downloads": "Sikertelen letöltések",
"No failed downloads": "Nincsenek sikertelen letöltések",
"Attempts: {{count}}_one": "Próbálkozások: {{count}}",
"Attempts: {{count}}_other": "Próbálkozások: {{count}}",
"Skip": "Kihagyás",
"Skip all": "Összes kihagyása",
"Retry all": "Összes újrapróbálása",
"{{count}} failed_one": "{{count}} sikertelen",
"{{count}} failed_other": "{{count}} sikertelen"
}
@@ -1179,5 +1179,18 @@
"Edit OPDS Catalog": "Edit Katalog OPDS",
"Save Changes": "Simpan Perubahan",
"Use Book Layout": "Gunakan tata letak buku",
"End of this section. Continue to the next.": "Akhir bagian ini. Lanjutkan ke bagian berikutnya."
"End of this section. Continue to the next.": "Akhir bagian ini. Lanjutkan ke bagian berikutnya.",
"Auto-download": "Unduh otomatis",
"Auto-download new items": "Unduh item baru otomatis",
"Automatically download new publications when the app syncs": "Otomatis unduh publikasi baru saat aplikasi disinkronkan",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} item baru telah diunduh dari OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "Gagal menyinkronkan {{count}} katalog OPDS",
"Last synced {{when}}": "Sinkronisasi terakhir {{when}}",
"Failed downloads": "Unduhan gagal",
"No failed downloads": "Tidak ada unduhan gagal",
"Attempts: {{count}}_other": "Percobaan: {{count}}",
"Skip": "Lewati",
"Skip all": "Lewati semua",
"Retry all": "Coba lagi semua",
"{{count}} failed_other": "{{count}} gagal"
}
@@ -1205,5 +1205,26 @@
"Edit OPDS Catalog": "Modifica catalogo OPDS",
"Save Changes": "Salva modifiche",
"Use Book Layout": "Usa layout del libro",
"End of this section. Continue to the next.": "Fine di questa sezione. Continua alla successiva."
"End of this section. Continue to the next.": "Fine di questa sezione. Continua alla successiva.",
"Auto-download": "Scaricamento automatico",
"Auto-download new items": "Scarica automaticamente i nuovi elementi",
"Automatically download new publications when the app syncs": "Scarica automaticamente le nuove pubblicazioni quando l'app si sincronizza",
"{{count}} new item(s) downloaded from OPDS_one": "{{count}} nuovo elemento scaricato da OPDS",
"{{count}} new item(s) downloaded from OPDS_many": "{{count}} nuovi elementi scaricati da OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} nuovi elementi scaricati da OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Impossibile sincronizzare {{count}} catalogo OPDS",
"Failed to sync {{count}} OPDS catalog(s)_many": "Impossibile sincronizzare {{count}} cataloghi OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "Impossibile sincronizzare {{count}} cataloghi OPDS",
"Last synced {{when}}": "Ultima sincronizzazione {{when}}",
"Failed downloads": "Download non riusciti",
"No failed downloads": "Nessun download non riuscito",
"Attempts: {{count}}_one": "Tentativi: {{count}}",
"Attempts: {{count}}_many": "Tentativi: {{count}}",
"Attempts: {{count}}_other": "Tentativi: {{count}}",
"Skip": "Ignora",
"Skip all": "Ignora tutti",
"Retry all": "Riprova tutti",
"{{count}} failed_one": "{{count}} non riuscito",
"{{count}} failed_many": "{{count}} non riusciti",
"{{count}} failed_other": "{{count}} non riusciti"
}
@@ -1179,5 +1179,18 @@
"Edit OPDS Catalog": "OPDSカタログを編集",
"Save Changes": "変更を保存",
"Use Book Layout": "書籍のレイアウトを使用",
"End of this section. Continue to the next.": "このセクションの終わりです。次のセクションへ進みます。"
"End of this section. Continue to the next.": "このセクションの終わりです。次のセクションへ進みます。",
"Auto-download": "自動ダウンロード",
"Auto-download new items": "新着アイテムを自動ダウンロード",
"Automatically download new publications when the app syncs": "アプリの同期時に新しい出版物を自動的にダウンロードします",
"{{count}} new item(s) downloaded from OPDS_other": "OPDSから{{count}}件の新着アイテムをダウンロードしました",
"Failed to sync {{count}} OPDS catalog(s)_other": "OPDSカタログ{{count}}件の同期に失敗しました",
"Last synced {{when}}": "最終同期 {{when}}",
"Failed downloads": "失敗したダウンロード",
"No failed downloads": "失敗したダウンロードはありません",
"Attempts: {{count}}_other": "試行回数: {{count}}",
"Skip": "スキップ",
"Skip all": "すべてスキップ",
"Retry all": "すべて再試行",
"{{count}} failed_other": "{{count}}件失敗"
}
@@ -1179,5 +1179,18 @@
"Edit OPDS Catalog": "OPDS 카탈로그 편집",
"Save Changes": "변경 사항 저장",
"Use Book Layout": "책 레이아웃 사용",
"End of this section. Continue to the next.": "이 섹션의 끝입니다. 다음으로 계속하세요."
"End of this section. Continue to the next.": "이 섹션의 끝입니다. 다음으로 계속하세요.",
"Auto-download": "자동 다운로드",
"Auto-download new items": "새 항목 자동 다운로드",
"Automatically download new publications when the app syncs": "앱 동기화 시 새 출판물 자동 다운로드",
"{{count}} new item(s) downloaded from OPDS_other": "OPDS에서 {{count}}개의 새 항목이 다운로드됨",
"Failed to sync {{count}} OPDS catalog(s)_other": "OPDS 카탈로그 {{count}}개 동기화 실패",
"Last synced {{when}}": "마지막 동기화 {{when}}",
"Failed downloads": "실패한 다운로드",
"No failed downloads": "실패한 다운로드 없음",
"Attempts: {{count}}_other": "시도: {{count}}",
"Skip": "건너뛰기",
"Skip all": "모두 건너뛰기",
"Retry all": "모두 재시도",
"{{count}} failed_other": "{{count}}개 실패"
}
@@ -1179,5 +1179,18 @@
"Edit OPDS Catalog": "Edit Katalog OPDS",
"Save Changes": "Simpan Perubahan",
"Use Book Layout": "Guna susun atur buku",
"End of this section. Continue to the next.": "Akhir bahagian ini. Teruskan ke bahagian seterusnya."
"End of this section. Continue to the next.": "Akhir bahagian ini. Teruskan ke bahagian seterusnya.",
"Auto-download": "Muat turun automatik",
"Auto-download new items": "Muat turun item baharu secara automatik",
"Automatically download new publications when the app syncs": "Muat turun penerbitan baharu secara automatik apabila apl disegerakkan",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} item baharu dimuat turun dari OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "Gagal menyegerak {{count}} katalog OPDS",
"Last synced {{when}}": "Selaras kali terakhir {{when}}",
"Failed downloads": "Muat turun gagal",
"No failed downloads": "Tiada muat turun gagal",
"Attempts: {{count}}_other": "Percubaan: {{count}}",
"Skip": "Langkau",
"Skip all": "Langkau semua",
"Retry all": "Cuba lagi semua",
"{{count}} failed_other": "{{count}} gagal"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "OPDS-catalogus bewerken",
"Save Changes": "Wijzigingen opslaan",
"Use Book Layout": "Boekindeling gebruiken",
"End of this section. Continue to the next.": "Einde van deze sectie. Ga verder naar de volgende."
"End of this section. Continue to the next.": "Einde van deze sectie. Ga verder naar de volgende.",
"Auto-download": "Automatisch downloaden",
"Auto-download new items": "Nieuwe items automatisch downloaden",
"Automatically download new publications when the app syncs": "Download automatisch nieuwe publicaties wanneer de app synchroniseert",
"{{count}} new item(s) downloaded from OPDS_one": "{{count}} nieuw item gedownload van OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} nieuwe items gedownload van OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Synchronisatie van {{count}} OPDS-catalogus mislukt",
"Failed to sync {{count}} OPDS catalog(s)_other": "Synchronisatie van {{count}} OPDS-catalogi mislukt",
"Last synced {{when}}": "Laatst gesynchroniseerd {{when}}",
"Failed downloads": "Mislukte downloads",
"No failed downloads": "Geen mislukte downloads",
"Attempts: {{count}}_one": "Pogingen: {{count}}",
"Attempts: {{count}}_other": "Pogingen: {{count}}",
"Skip": "Overslaan",
"Skip all": "Alles overslaan",
"Retry all": "Alles opnieuw proberen",
"{{count}} failed_one": "{{count}} mislukt",
"{{count}} failed_other": "{{count}} mislukt"
}
@@ -1218,5 +1218,30 @@
"Edit OPDS Catalog": "Edytuj katalog OPDS",
"Save Changes": "Zapisz zmiany",
"Use Book Layout": "Użyj układu książki",
"End of this section. Continue to the next.": "Koniec tej sekcji. Przejdź do następnej."
"End of this section. Continue to the next.": "Koniec tej sekcji. Przejdź do następnej.",
"Auto-download": "Automatyczne pobieranie",
"Auto-download new items": "Automatycznie pobieraj nowe pozycje",
"Automatically download new publications when the app syncs": "Automatycznie pobieraj nowe publikacje podczas synchronizacji aplikacji",
"{{count}} new item(s) downloaded from OPDS_one": "Pobrano {{count}} nową pozycję z OPDS",
"{{count}} new item(s) downloaded from OPDS_few": "Pobrano {{count}} nowe pozycje z OPDS",
"{{count}} new item(s) downloaded from OPDS_many": "Pobrano {{count}} nowych pozycji z OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "Pobrano {{count}} nowych pozycji z OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Nie udało się zsynchronizować {{count}} katalogu OPDS",
"Failed to sync {{count}} OPDS catalog(s)_few": "Nie udało się zsynchronizować {{count}} katalogów OPDS",
"Failed to sync {{count}} OPDS catalog(s)_many": "Nie udało się zsynchronizować {{count}} katalogów OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "Nie udało się zsynchronizować {{count}} katalogów OPDS",
"Last synced {{when}}": "Ostatnia synchronizacja {{when}}",
"Failed downloads": "Nieudane pobrania",
"No failed downloads": "Brak nieudanych pobrań",
"Attempts: {{count}}_one": "Próby: {{count}}",
"Attempts: {{count}}_few": "Próby: {{count}}",
"Attempts: {{count}}_many": "Próby: {{count}}",
"Attempts: {{count}}_other": "Próby: {{count}}",
"Skip": "Pomiń",
"Skip all": "Pomiń wszystko",
"Retry all": "Ponów wszystkie",
"{{count}} failed_one": "{{count}} nieudane",
"{{count}} failed_few": "{{count}} nieudane",
"{{count}} failed_many": "{{count}} nieudanych",
"{{count}} failed_other": "{{count}} nieudanych"
}
@@ -1205,5 +1205,26 @@
"Edit OPDS Catalog": "Editar catálogo OPDS",
"Save Changes": "Salvar alterações",
"Use Book Layout": "Usar layout do livro",
"End of this section. Continue to the next.": "Fim desta secção. Continuar para a próxima."
"End of this section. Continue to the next.": "Fim desta secção. Continuar para a próxima.",
"Auto-download": "Download automático",
"Auto-download new items": "Baixar novos itens automaticamente",
"Automatically download new publications when the app syncs": "Baixe automaticamente novas publicações quando o aplicativo sincroniza",
"{{count}} new item(s) downloaded from OPDS_one": "{{count}} novo item baixado do OPDS",
"{{count}} new item(s) downloaded from OPDS_many": "{{count}} novos itens baixados do OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} novos itens baixados do OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Falha ao sincronizar {{count}} catálogo OPDS",
"Failed to sync {{count}} OPDS catalog(s)_many": "Falha ao sincronizar {{count}} catálogos OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "Falha ao sincronizar {{count}} catálogos OPDS",
"Last synced {{when}}": "Última sincronização {{when}}",
"Failed downloads": "Downloads falhados",
"No failed downloads": "Nenhum download falhado",
"Attempts: {{count}}_one": "Tentativas: {{count}}",
"Attempts: {{count}}_many": "Tentativas: {{count}}",
"Attempts: {{count}}_other": "Tentativas: {{count}}",
"Skip": "Ignorar",
"Skip all": "Ignorar tudo",
"Retry all": "Tentar tudo novamente",
"{{count}} failed_one": "{{count}} falhada",
"{{count}} failed_many": "{{count}} falhadas",
"{{count}} failed_other": "{{count}} falhadas"
}
@@ -1205,5 +1205,26 @@
"Edit OPDS Catalog": "Editare catalog OPDS",
"Save Changes": "Salvare modificări",
"Use Book Layout": "Folosește aspectul cărții",
"End of this section. Continue to the next.": "Sfârșitul acestei secțiuni. Continuați la următoarea."
"End of this section. Continue to the next.": "Sfârșitul acestei secțiuni. Continuați la următoarea.",
"Auto-download": "Descărcare automată",
"Auto-download new items": "Descarcă automat elementele noi",
"Automatically download new publications when the app syncs": "Descarcă automat publicații noi atunci când aplicația sincronizează",
"{{count}} new item(s) downloaded from OPDS_one": "A fost descărcat {{count}} element nou din OPDS",
"{{count}} new item(s) downloaded from OPDS_few": "Au fost descărcate {{count}} elemente noi din OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "Au fost descărcate {{count}} de elemente noi din OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Sincronizarea a {{count}} catalog OPDS a eșuat",
"Failed to sync {{count}} OPDS catalog(s)_few": "Sincronizarea a {{count}} cataloage OPDS a eșuat",
"Failed to sync {{count}} OPDS catalog(s)_other": "Sincronizarea a {{count}} de cataloage OPDS a eșuat",
"Last synced {{when}}": "Ultima sincronizare {{when}}",
"Failed downloads": "Descărcări eșuate",
"No failed downloads": "Nu există descărcări eșuate",
"Attempts: {{count}}_one": "Încercări: {{count}}",
"Attempts: {{count}}_few": "Încercări: {{count}}",
"Attempts: {{count}}_other": "Încercări: {{count}}",
"Skip": "Omite",
"Skip all": "Omite toate",
"Retry all": "Reîncearcă toate",
"{{count}} failed_one": "{{count}} eșuat",
"{{count}} failed_few": "{{count}} eșuate",
"{{count}} failed_other": "{{count}} eșuate"
}
@@ -1218,5 +1218,30 @@
"Edit OPDS Catalog": "Редактировать каталог OPDS",
"Save Changes": "Сохранить изменения",
"Use Book Layout": "Использовать макет книги",
"End of this section. Continue to the next.": "Конец этого раздела. Перейти к следующему."
"End of this section. Continue to the next.": "Конец этого раздела. Перейти к следующему.",
"Auto-download": "Автозагрузка",
"Auto-download new items": "Автоматически загружать новые элементы",
"Automatically download new publications when the app syncs": "Автоматически загружать новые публикации при синхронизации приложения",
"{{count}} new item(s) downloaded from OPDS_one": "Загружен {{count}} новый элемент из OPDS",
"{{count}} new item(s) downloaded from OPDS_few": "Загружено {{count}} новых элемента из OPDS",
"{{count}} new item(s) downloaded from OPDS_many": "Загружено {{count}} новых элементов из OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "Загружено {{count}} новых элементов из OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Не удалось синхронизировать {{count}} каталог OPDS",
"Failed to sync {{count}} OPDS catalog(s)_few": "Не удалось синхронизировать {{count}} каталога OPDS",
"Failed to sync {{count}} OPDS catalog(s)_many": "Не удалось синхронизировать {{count}} каталогов OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "Не удалось синхронизировать {{count}} каталогов OPDS",
"Last synced {{when}}": "Последняя синхронизация {{when}}",
"Failed downloads": "Неудачные загрузки",
"No failed downloads": "Нет неудачных загрузок",
"Attempts: {{count}}_one": "Попытки: {{count}}",
"Attempts: {{count}}_few": "Попытки: {{count}}",
"Attempts: {{count}}_many": "Попытки: {{count}}",
"Attempts: {{count}}_other": "Попытки: {{count}}",
"Skip": "Пропустить",
"Skip all": "Пропустить все",
"Retry all": "Повторить все",
"{{count}} failed_one": "{{count}} ошибка",
"{{count}} failed_few": "{{count}} ошибки",
"{{count}} failed_many": "{{count}} ошибок",
"{{count}} failed_other": "{{count}} ошибок"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "OPDS නාමාවලිය සංස්කරණය",
"Save Changes": "වෙනස්කම් සුරකින්න",
"Use Book Layout": "පොතේ සැකැස්ම භාවිත කරන්න",
"End of this section. Continue to the next.": "මෙම කොටසේ අවසානයයි. ඊළඟ කොටසට ඉදිරියට යන්න."
"End of this section. Continue to the next.": "මෙම කොටසේ අවසානයයි. ඊළඟ කොටසට ඉදිරියට යන්න.",
"Auto-download": "ස්වයංක්‍රීය බාගත කිරීම",
"Auto-download new items": "නව අයිතම ස්වයංක්‍රීයව බාගත කරන්න",
"Automatically download new publications when the app syncs": "යෙදුම සමමුහුර්ත වන විට නව ප්‍රකාශන ස්වයංක්‍රීයව බාගත කරන්න",
"{{count}} new item(s) downloaded from OPDS_one": "OPDS වෙතින් නව අයිතමයක් බාගත කරන ලදී",
"{{count}} new item(s) downloaded from OPDS_other": "OPDS වෙතින් නව අයිතම {{count}}ක් බාගත කරන ලදී",
"Failed to sync {{count}} OPDS catalog(s)_one": "OPDS නාමාවලියක් සමමුහුර්ත කිරීමට අසමත් විය",
"Failed to sync {{count}} OPDS catalog(s)_other": "OPDS නාමාවලි {{count}}ක් සමමුහුර්ත කිරීමට අසමත් විය",
"Last synced {{when}}": "අවසන් වරට සමමුහුර්ත කළේ {{when}}",
"Failed downloads": "අසාර්ථක බාගත කිරීම්",
"No failed downloads": "අසාර්ථක බාගත කිරීම් නැත",
"Attempts: {{count}}_one": "උත්සාහයන්: {{count}}",
"Attempts: {{count}}_other": "උත්සාහයන්: {{count}}",
"Skip": "මඟ හරින්න",
"Skip all": "සියල්ල මඟ හරින්න",
"Retry all": "සියල්ල නැවත උත්සාහ කරන්න",
"{{count}} failed_one": "{{count}} අසාර්ථකයි",
"{{count}} failed_other": "{{count}} අසාර්ථකයි"
}
@@ -1218,5 +1218,30 @@
"Edit OPDS Catalog": "Uredi katalog OPDS",
"Save Changes": "Shrani spremembe",
"Use Book Layout": "Uporabi postavitev knjige",
"End of this section. Continue to the next.": "Konec tega razdelka. Nadaljujte z naslednjim."
"End of this section. Continue to the next.": "Konec tega razdelka. Nadaljujte z naslednjim.",
"Auto-download": "Samodejni prenos",
"Auto-download new items": "Samodejno prenesi nove vsebine",
"Automatically download new publications when the app syncs": "Samodejno prenesi nove vsebine, ko se aplikacija sinhronizira",
"{{count}} new item(s) downloaded from OPDS_one": "Prenesena je {{count}} nova vsebina iz OPDS",
"{{count}} new item(s) downloaded from OPDS_two": "Preneseni sta {{count}} novi vsebini iz OPDS",
"{{count}} new item(s) downloaded from OPDS_few": "Prenesene so {{count}} nove vsebine iz OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "Preneseno je {{count}} novih vsebin iz OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Sinhronizacija {{count}} kataloga OPDS ni uspela",
"Failed to sync {{count}} OPDS catalog(s)_two": "Sinhronizacija {{count}} katalogov OPDS ni uspela",
"Failed to sync {{count}} OPDS catalog(s)_few": "Sinhronizacija {{count}} katalogov OPDS ni uspela",
"Failed to sync {{count}} OPDS catalog(s)_other": "Sinhronizacija {{count}} katalogov OPDS ni uspela",
"Last synced {{when}}": "Zadnja sinhronizacija {{when}}",
"Failed downloads": "Neuspeli prenosi",
"No failed downloads": "Ni neuspelih prenosov",
"Attempts: {{count}}_one": "Poskusi: {{count}}",
"Attempts: {{count}}_two": "Poskusi: {{count}}",
"Attempts: {{count}}_few": "Poskusi: {{count}}",
"Attempts: {{count}}_other": "Poskusi: {{count}}",
"Skip": "Preskoči",
"Skip all": "Preskoči vse",
"Retry all": "Poskusi znova vse",
"{{count}} failed_one": "{{count}} ni uspela",
"{{count}} failed_two": "{{count}} nista uspeli",
"{{count}} failed_few": "{{count}} niso uspele",
"{{count}} failed_other": "{{count}} jih ni uspelo"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "Redigera OPDS-katalog",
"Save Changes": "Spara ändringar",
"Use Book Layout": "Använd bokens layout",
"End of this section. Continue to the next.": "Slut på det här avsnittet. Fortsätt till nästa."
"End of this section. Continue to the next.": "Slut på det här avsnittet. Fortsätt till nästa.",
"Auto-download": "Autoladda ner",
"Auto-download new items": "Hämta nya objekt automatiskt",
"Automatically download new publications when the app syncs": "Hämta nya publikationer automatiskt när appen synkroniserar",
"{{count}} new item(s) downloaded from OPDS_one": "{{count}} nytt objekt nedladdat från OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "{{count}} nya objekt nedladdade från OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Det gick inte att synkronisera {{count}} OPDS-katalog",
"Failed to sync {{count}} OPDS catalog(s)_other": "Det gick inte att synkronisera {{count}} OPDS-kataloger",
"Last synced {{when}}": "Senast synkroniserad {{when}}",
"Failed downloads": "Misslyckade hämtningar",
"No failed downloads": "Inga misslyckade hämtningar",
"Attempts: {{count}}_one": "Försök: {{count}}",
"Attempts: {{count}}_other": "Försök: {{count}}",
"Skip": "Hoppa över",
"Skip all": "Hoppa över alla",
"Retry all": "Försök igen för alla",
"{{count}} failed_one": "{{count}} misslyckades",
"{{count}} failed_other": "{{count}} misslyckades"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "OPDS பட்டியலைத் திருத்து",
"Save Changes": "மாற்றங்களைச் சேமி",
"Use Book Layout": "புத்தகத்தின் அமைப்பைப் பயன்படுத்து",
"End of this section. Continue to the next.": "இந்தப் பிரிவின் முடிவு. அடுத்ததற்குத் தொடரவும்."
"End of this section. Continue to the next.": "இந்தப் பிரிவின் முடிவு. அடுத்ததற்குத் தொடரவும்.",
"Auto-download": "தானியங்கி பதிவிறக்கம்",
"Auto-download new items": "புதிய உருப்படிகளைத் தானாகப் பதிவிறக்கு",
"Automatically download new publications when the app syncs": "பயன்பாடு ஒத்திசைக்கப்படும்போது புதிய வெளியீடுகளைத் தானாகப் பதிவிறக்கு",
"{{count}} new item(s) downloaded from OPDS_one": "OPDS-இலிருந்து {{count}} புதிய உருப்படி பதிவிறக்கப்பட்டது",
"{{count}} new item(s) downloaded from OPDS_other": "OPDS-இலிருந்து {{count}} புதிய உருப்படிகள் பதிவிறக்கப்பட்டன",
"Failed to sync {{count}} OPDS catalog(s)_one": "{{count}} OPDS பட்டியலை ஒத்திசைக்க முடியவில்லை",
"Failed to sync {{count}} OPDS catalog(s)_other": "{{count}} OPDS பட்டியல்களை ஒத்திசைக்க முடியவில்லை",
"Last synced {{when}}": "கடைசியாக ஒத்திசைக்கப்பட்டது {{when}}",
"Failed downloads": "தோல்வியுற்ற பதிவிறக்கங்கள்",
"No failed downloads": "தோல்வியுற்ற பதிவிறக்கங்கள் இல்லை",
"Attempts: {{count}}_one": "முயற்சிகள்: {{count}}",
"Attempts: {{count}}_other": "முயற்சிகள்: {{count}}",
"Skip": "தவிர்",
"Skip all": "அனைத்தையும் தவிர்",
"Retry all": "அனைத்தையும் மீண்டும் முயற்சி செய்",
"{{count}} failed_one": "{{count}} தோல்வி",
"{{count}} failed_other": "{{count}} தோல்விகள்"
}
@@ -1179,5 +1179,18 @@
"Edit OPDS Catalog": "แก้ไขแคตตาล็อก OPDS",
"Save Changes": "บันทึกการเปลี่ยนแปลง",
"Use Book Layout": "ใช้เลย์เอาต์ของหนังสือ",
"End of this section. Continue to the next.": "สิ้นสุดส่วนนี้ ดำเนินการไปยังส่วนถัดไป"
"End of this section. Continue to the next.": "สิ้นสุดส่วนนี้ ดำเนินการไปยังส่วนถัดไป",
"Auto-download": "ดาวน์โหลดอัตโนมัติ",
"Auto-download new items": "ดาวน์โหลดรายการใหม่อัตโนมัติ",
"Automatically download new publications when the app syncs": "ดาวน์โหลดสิ่งพิมพ์ใหม่อัตโนมัติเมื่อแอปซิงค์",
"{{count}} new item(s) downloaded from OPDS_other": "ดาวน์โหลด {{count}} รายการใหม่จาก OPDS แล้ว",
"Failed to sync {{count}} OPDS catalog(s)_other": "ไม่สามารถซิงค์แคตตาล็อก OPDS {{count}} รายการได้",
"Last synced {{when}}": "ซิงค์ครั้งล่าสุด {{when}}",
"Failed downloads": "การดาวน์โหลดที่ล้มเหลว",
"No failed downloads": "ไม่มีการดาวน์โหลดที่ล้มเหลว",
"Attempts: {{count}}_other": "ความพยายาม: {{count}}",
"Skip": "ข้าม",
"Skip all": "ข้ามทั้งหมด",
"Retry all": "ลองอีกครั้งทั้งหมด",
"{{count}} failed_other": "{{count}} ล้มเหลว"
}
@@ -1192,5 +1192,22 @@
"Edit OPDS Catalog": "OPDS Kataloğunu Düzenle",
"Save Changes": "Değişiklikleri Kaydet",
"Use Book Layout": "Kitap düzenini kullan",
"End of this section. Continue to the next.": "Bu bölümün sonu. Sonrakine devam edin."
"End of this section. Continue to the next.": "Bu bölümün sonu. Sonrakine devam edin.",
"Auto-download": "Otomatik indirme",
"Auto-download new items": "Yeni öğeleri otomatik indir",
"Automatically download new publications when the app syncs": "Uygulama eşitlendiğinde yeni yayınları otomatik olarak indir",
"{{count}} new item(s) downloaded from OPDS_one": "OPDSden {{count}} yeni öğe indirildi",
"{{count}} new item(s) downloaded from OPDS_other": "OPDSden {{count}} yeni öğe indirildi",
"Failed to sync {{count}} OPDS catalog(s)_one": "{{count}} OPDS kataloğu eşitlenemedi",
"Failed to sync {{count}} OPDS catalog(s)_other": "{{count}} OPDS kataloğu eşitlenemedi",
"Last synced {{when}}": "Son senkronizasyon {{when}}",
"Failed downloads": "Başarısız indirmeler",
"No failed downloads": "Başarısız indirme yok",
"Attempts: {{count}}_one": "Denemeler: {{count}}",
"Attempts: {{count}}_other": "Denemeler: {{count}}",
"Skip": "Atla",
"Skip all": "Tümünü atla",
"Retry all": "Tümünü yeniden dene",
"{{count}} failed_one": "{{count}} başarısız",
"{{count}} failed_other": "{{count}} başarısız"
}
@@ -1218,5 +1218,30 @@
"Edit OPDS Catalog": "Редагувати каталог OPDS",
"Save Changes": "Зберегти зміни",
"Use Book Layout": "Використовувати макет книги",
"End of this section. Continue to the next.": "Кінець цього розділу. Продовжте до наступного."
"End of this section. Continue to the next.": "Кінець цього розділу. Продовжте до наступного.",
"Auto-download": "Автозавантаження",
"Auto-download new items": "Автоматично завантажувати нові елементи",
"Automatically download new publications when the app syncs": "Автоматично завантажувати нові публікації під час синхронізації застосунку",
"{{count}} new item(s) downloaded from OPDS_one": "Завантажено {{count}} новий елемент з OPDS",
"{{count}} new item(s) downloaded from OPDS_few": "Завантажено {{count}} нових елементи з OPDS",
"{{count}} new item(s) downloaded from OPDS_many": "Завантажено {{count}} нових елементів з OPDS",
"{{count}} new item(s) downloaded from OPDS_other": "Завантажено {{count}} нових елементів з OPDS",
"Failed to sync {{count}} OPDS catalog(s)_one": "Не вдалося синхронізувати {{count}} каталог OPDS",
"Failed to sync {{count}} OPDS catalog(s)_few": "Не вдалося синхронізувати {{count}} каталоги OPDS",
"Failed to sync {{count}} OPDS catalog(s)_many": "Не вдалося синхронізувати {{count}} каталогів OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "Не вдалося синхронізувати {{count}} каталогів OPDS",
"Last synced {{when}}": "Остання синхронізація {{when}}",
"Failed downloads": "Невдалі завантаження",
"No failed downloads": "Немає невдалих завантажень",
"Attempts: {{count}}_one": "Спроби: {{count}}",
"Attempts: {{count}}_few": "Спроби: {{count}}",
"Attempts: {{count}}_many": "Спроби: {{count}}",
"Attempts: {{count}}_other": "Спроби: {{count}}",
"Skip": "Пропустити",
"Skip all": "Пропустити всі",
"Retry all": "Повторити всі",
"{{count}} failed_one": "{{count}} помилка",
"{{count}} failed_few": "{{count}} помилки",
"{{count}} failed_many": "{{count}} помилок",
"{{count}} failed_other": "{{count}} помилок"
}
@@ -1179,5 +1179,18 @@
"Edit OPDS Catalog": "Chỉnh sửa danh mục OPDS",
"Save Changes": "Lưu thay đổi",
"Use Book Layout": "Sử dụng bố cục sách",
"End of this section. Continue to the next.": "Kết thúc phần này. Tiếp tục đến phần tiếp theo."
"End of this section. Continue to the next.": "Kết thúc phần này. Tiếp tục đến phần tiếp theo.",
"Auto-download": "Tự động tải xuống",
"Auto-download new items": "Tự động tải xuống mục mới",
"Automatically download new publications when the app syncs": "Tự động tải xuống các ấn phẩm mới khi ứng dụng đồng bộ hóa",
"{{count}} new item(s) downloaded from OPDS_other": "Đã tải xuống {{count}} mục mới từ OPDS",
"Failed to sync {{count}} OPDS catalog(s)_other": "Không thể đồng bộ {{count}} danh mục OPDS",
"Last synced {{when}}": "Đồng bộ lần cuối {{when}}",
"Failed downloads": "Tải xuống thất bại",
"No failed downloads": "Không có tải xuống thất bại",
"Attempts: {{count}}_other": "Số lần thử: {{count}}",
"Skip": "Bỏ qua",
"Skip all": "Bỏ qua tất cả",
"Retry all": "Thử lại tất cả",
"{{count}} failed_other": "{{count}} thất bại"
}
@@ -1179,5 +1179,18 @@
"Edit OPDS Catalog": "编辑 OPDS 目录",
"Save Changes": "保存更改",
"Use Book Layout": "使用书籍排版",
"End of this section. Continue to the next.": "本节结束。继续到下一节。"
"End of this section. Continue to the next.": "本节结束。继续到下一节。",
"Auto-download": "自动下载",
"Auto-download new items": "自动下载新内容",
"Automatically download new publications when the app syncs": "应用同步时自动下载新出版物",
"{{count}} new item(s) downloaded from OPDS_other": "已从 OPDS 下载 {{count}} 个新内容",
"Failed to sync {{count}} OPDS catalog(s)_other": "同步 {{count}} 个 OPDS 目录失败",
"Last synced {{when}}": "上次同步 {{when}}",
"Failed downloads": "失败的下载",
"No failed downloads": "没有失败的下载",
"Attempts: {{count}}_other": "尝试次数:{{count}}",
"Skip": "跳过",
"Skip all": "全部跳过",
"Retry all": "全部重试",
"{{count}} failed_other": "{{count}} 个失败"
}
@@ -1179,5 +1179,18 @@
"Edit OPDS Catalog": "編輯 OPDS 目錄",
"Save Changes": "儲存變更",
"Use Book Layout": "使用書籍排版",
"End of this section. Continue to the next.": "本節結束。繼續到下一節。"
"End of this section. Continue to the next.": "本節結束。繼續到下一節。",
"Auto-download": "自動下載",
"Auto-download new items": "自動下載新項目",
"Automatically download new publications when the app syncs": "應用同步時自動下載新出版物",
"{{count}} new item(s) downloaded from OPDS_other": "已從 OPDS 下載 {{count}} 個新項目",
"Failed to sync {{count}} OPDS catalog(s)_other": "同步 {{count}} 個 OPDS 目錄失敗",
"Last synced {{when}}": "上次同步 {{when}}",
"Failed downloads": "失敗的下載",
"No failed downloads": "沒有失敗的下載",
"Attempts: {{count}}_other": "嘗試次數:{{count}}",
"Skip": "跳過",
"Skip all": "全部跳過",
"Retry all": "全部重試",
"{{count}} failed_other": "{{count}} 個失敗"
}
@@ -0,0 +1,247 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { OPDSCatalog } from '@/types/opds';
import type { AppService } from '@/types/system';
import type { OPDSSubscriptionState, PendingItem } from '@/services/opds/types';
vi.mock('@/services/environment', () => ({
isWebAppPlatform: vi.fn(() => false),
isTauriAppPlatform: vi.fn(() => true),
getAPIBaseUrl: () => '/api',
getNodeAPIBaseUrl: () => '/node-api',
}));
vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn(),
}));
vi.mock('@/libs/storage', () => ({
downloadFile: vi.fn().mockResolvedValue({ 'content-disposition': '' }),
}));
vi.mock('@/app/opds/utils/opdsReq', () => ({
fetchWithAuth: vi.fn(),
probeAuth: vi.fn().mockResolvedValue(null),
needsProxy: vi.fn(() => false),
getProxiedURL: vi.fn((url: string) => url),
probeFilename: vi.fn().mockResolvedValue(''),
}));
vi.mock('@/services/opds/feedChecker', () => ({
checkFeedForNewItems: vi.fn().mockResolvedValue([]),
}));
vi.mock('@/services/opds/subscriptionState', () => ({
loadSubscriptionState: vi.fn().mockResolvedValue({
catalogId: 'cat-1',
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [],
}),
saveSubscriptionState: vi.fn().mockResolvedValue(undefined),
pruneKnownEntryIds: vi.fn((ids: string[]) => ids),
emptyState: vi.fn((id: string) => ({
catalogId: id,
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [],
})),
}));
import { syncSubscribedCatalogs } from '@/services/opds/autoDownload';
import { checkFeedForNewItems } from '@/services/opds/feedChecker';
import { saveSubscriptionState, loadSubscriptionState } from '@/services/opds/subscriptionState';
import { downloadFile } from '@/libs/storage';
const createMockAppService = () =>
({
resolveFilePath: vi.fn(async (path: string) => `/cache/${path}`),
importBook: vi.fn(async () => ({
hash: 'abc123',
format: 'EPUB',
title: 'Test Book',
author: 'Author',
createdAt: Date.now(),
updatedAt: Date.now(),
})),
copyFile: vi.fn(async () => {}),
deleteFile: vi.fn(async () => {}),
exists: vi.fn(async () => false),
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => {}),
createDir: vi.fn(async () => {}),
}) as unknown as AppService;
describe('OPDS auto-download orchestrator', () => {
let appService: AppService;
beforeEach(() => {
vi.clearAllMocks();
appService = createMockAppService();
});
it('skips catalogs without autoDownload enabled', async () => {
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Test', url: 'https://example.com/opds' },
];
const result = await syncSubscribedCatalogs(catalogs, appService, []);
expect(result.totalNewBooks).toBe(0);
expect(checkFeedForNewItems).not.toHaveBeenCalled();
});
it('skips disabled catalogs even with autoDownload', async () => {
const catalogs: OPDSCatalog[] = [
{
id: 'cat-1',
name: 'Test',
url: 'https://example.com/opds',
autoDownload: true,
disabled: true,
},
];
const result = await syncSubscribedCatalogs(catalogs, appService, []);
expect(result.totalNewBooks).toBe(0);
});
it('downloads new items and returns them', async () => {
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Shelf', url: 'https://shelf.example.com/opds', autoDownload: true },
];
const pendingItems: PendingItem[] = [
{
entryId: 'urn:shelf:1',
title: 'Issue 1',
acquisitionHref: '/dl/1.epub',
mimeType: 'application/epub+zip',
baseURL: 'https://shelf.example.com/opds',
},
];
vi.mocked(checkFeedForNewItems).mockResolvedValue(pendingItems);
const result = await syncSubscribedCatalogs(catalogs, appService, []);
expect(result.totalNewBooks).toBe(1);
expect(result.newBooks).toHaveLength(1);
expect(saveSubscriptionState).toHaveBeenCalled();
const savedState = vi.mocked(saveSubscriptionState).mock.calls[0]![1] as OPDSSubscriptionState;
expect(savedState.knownEntryIds).toContain('urn:shelf:1');
expect(savedState.lastCheckedAt).toBeGreaterThan(0);
});
it('handles import failure by adding to failedEntries', async () => {
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Test', url: 'https://example.com/opds', autoDownload: true },
];
vi.mocked(checkFeedForNewItems).mockResolvedValue([
{
entryId: 'urn:fail:1',
title: 'Bad Book',
acquisitionHref: '/dl/bad.epub',
mimeType: 'application/epub+zip',
baseURL: 'https://example.com',
},
]);
(appService.importBook as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('corrupt file'),
);
const result = await syncSubscribedCatalogs(catalogs, appService, []);
expect(result.totalNewBooks).toBe(0);
const savedState = vi.mocked(saveSubscriptionState).mock.calls[0]![1] as OPDSSubscriptionState;
expect(savedState.failedEntries).toHaveLength(1);
expect(savedState.failedEntries[0]!.entryId).toBe('urn:fail:1');
expect(savedState.failedEntries[0]!.attempts).toBe(1);
expect(savedState.knownEntryIds).not.toContain('urn:fail:1');
});
it('returns empty result when no catalogs have autoDownload', async () => {
const result = await syncSubscribedCatalogs([], appService, []);
expect(result).toEqual({ newBooks: [], totalNewBooks: 0, errors: [] });
});
it('does not re-attempt or duplicate an in-backoff failed entry that reappears in the feed', async () => {
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Test', url: 'https://example.com/opds', autoDownload: true },
];
// Entry X failed once, very recently — well within the backoff window,
// so isRetryEligible() returns false.
vi.mocked(loadSubscriptionState).mockResolvedValueOnce({
catalogId: 'cat-1',
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [
{
entryId: 'urn:backoff:1',
href: '/dl/x.epub',
title: 'Backed-off Book',
attempts: 1,
lastAttemptAt: Date.now(), // freshly attempted, still in backoff
},
],
});
// The entry is still in the feed (not in knownEntryIds), so discovery
// returns it again.
vi.mocked(checkFeedForNewItems).mockResolvedValue([
{
entryId: 'urn:backoff:1',
title: 'Backed-off Book',
acquisitionHref: '/dl/x.epub',
mimeType: 'application/epub+zip',
baseURL: 'https://example.com/opds',
},
]);
await syncSubscribedCatalogs(catalogs, appService, []);
// No download should have been attempted while in backoff.
expect(downloadFile).not.toHaveBeenCalled();
// And the saved state must not contain duplicate failedEntries for the
// same entryId.
const savedState = vi
.mocked(saveSubscriptionState)
.mock.calls.at(-1)![1] as OPDSSubscriptionState;
const ids = savedState.failedEntries.map((fe) => fe.entryId);
expect(ids).toEqual(Array.from(new Set(ids)));
expect(savedState.failedEntries.filter((fe) => fe.entryId === 'urn:backoff:1')).toHaveLength(1);
});
it('does not download the same entry twice when it is both pending and a retry-eligible failure', async () => {
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Test', url: 'https://example.com/opds', autoDownload: true },
];
vi.mocked(loadSubscriptionState).mockResolvedValueOnce({
catalogId: 'cat-1',
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [
{
entryId: 'urn:dup:1',
href: '/dl/dup.epub',
title: 'Dup Book',
attempts: 1,
lastAttemptAt: 0, // far in the past, retry eligible
},
],
});
vi.mocked(checkFeedForNewItems).mockResolvedValue([
{
entryId: 'urn:dup:1',
title: 'Dup Book',
acquisitionHref: '/dl/dup.epub',
mimeType: 'application/epub+zip',
baseURL: 'https://example.com/opds',
},
]);
await syncSubscribedCatalogs(catalogs, appService, []);
expect(downloadFile).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,868 @@
import { describe, it, expect, vi } from 'vitest';
import { getFeed } from 'foliate-js/opds.js';
import type { OPDSFeed, OPDSPublication } from '@/types/opds';
import {
getAcquisitionLink,
collectNewEntries,
getEntryId,
getNextPageUrl,
findNewestFeedURL,
} from '@/services/opds/feedChecker';
vi.mock('@/services/environment', () => ({
isWebAppPlatform: vi.fn(() => false),
isTauriAppPlatform: vi.fn(() => true),
getAPIBaseUrl: () => '/api',
getNodeAPIBaseUrl: () => '/node-api',
}));
vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn(),
}));
const MIME_XML = 'application/xml';
const parseXML = (xml: string): Document =>
new DOMParser().parseFromString(xml, MIME_XML as DOMParserSupportedType);
const makeAcquisitionFeed = (
entries: Array<{ id?: string; title: string; href: string }>,
nextUrl?: string,
) => {
const entryXml = entries
.map(
(e) => `
<entry>
<title>${e.title}</title>
${e.id ? `<id>${e.id}</id>` : ''}
<updated>2026-01-15T10:00:00.000Z</updated>
<link href="${e.href}" type="application/epub+zip"
rel="http://opds-spec.org/acquisition"/>
</entry>`,
)
.join('');
const nextLink = nextUrl
? `<link rel="next" href="${nextUrl}" type="application/atom+xml;profile=opds-catalog"/>`
: '';
return `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:opds="http://opds-spec.org/2010/catalog">
<id>urn:test:feed</id>
<title>Test Feed</title>
<updated>2026-01-15T10:00:00.000Z</updated>
${nextLink}
${entryXml}
</feed>`;
};
describe('OPDS feed checker', () => {
describe('getAcquisitionLink', () => {
it('finds the acquisition link from a publication', () => {
const xml = makeAcquisitionFeed([
{ id: 'urn:test:1', title: 'Test Book', href: '/download/book.epub' },
]);
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
const acqLink = getAcquisitionLink(pub);
expect(acqLink).toBeDefined();
expect(acqLink!.href).toBe('/download/book.epub');
expect(acqLink!.type).toBe('application/epub+zip');
});
it('returns undefined for a publication without acquisition links', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Preview Only</title>
<id>urn:test:preview</id>
<link href="/preview" type="text/html" rel="alternate"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
// entry without acquisition link goes to navigation, not publications
expect(feed.publications).toBeUndefined();
});
it('prefers EPUB over PDF when both formats are listed', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Multi-format Book</title>
<id>urn:test:multi</id>
<link href="/dl/book.pdf" type="application/pdf"
rel="http://opds-spec.org/acquisition"/>
<link href="/dl/book.epub" type="application/epub+zip"
rel="http://opds-spec.org/acquisition"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
const acqLink = getAcquisitionLink(pub);
expect(acqLink!.href).toBe('/dl/book.epub');
expect(acqLink!.type).toBe('application/epub+zip');
});
it('prefers MOBI over PDF when EPUB is absent', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Multi-format Book</title>
<id>urn:test:multi</id>
<link href="/dl/book.pdf" type="application/pdf"
rel="http://opds-spec.org/acquisition"/>
<link href="/dl/book.mobi" type="application/x-mobipocket-ebook"
rel="http://opds-spec.org/acquisition"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
const acqLink = getAcquisitionLink(pub);
expect(acqLink!.href).toBe('/dl/book.mobi');
});
it('prefers EPUB over MOBI', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Multi-format Book</title>
<id>urn:test:multi</id>
<link href="/dl/book.mobi" type="application/x-mobipocket-ebook"
rel="http://opds-spec.org/acquisition"/>
<link href="/dl/book.epub" type="application/epub+zip"
rel="http://opds-spec.org/acquisition"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
const acqLink = getAcquisitionLink(pub);
expect(acqLink!.href).toBe('/dl/book.epub');
});
it('falls back to first acquisition link when no preferred format matches', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Comic</title>
<id>urn:test:cbz</id>
<link href="/dl/comic.cbz" type="application/vnd.comicbook+zip"
rel="http://opds-spec.org/acquisition"/>
<link href="/dl/comic.txt" type="text/plain"
rel="http://opds-spec.org/acquisition"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
const acqLink = getAcquisitionLink(pub);
expect(acqLink!.href).toBe('/dl/comic.cbz');
});
it('handles MIME type parameters when matching format priority', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Book with profile</title>
<id>urn:test:profile</id>
<link href="/dl/book.pdf" type="application/pdf"
rel="http://opds-spec.org/acquisition"/>
<link href="/dl/book.epub" type="application/epub+zip; profile=opds"
rel="http://opds-spec.org/acquisition"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
const acqLink = getAcquisitionLink(pub);
expect(acqLink!.href).toBe('/dl/book.epub');
});
it('skips paid (buy) acquisition links', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Paid Book</title>
<id>urn:test:paid</id>
<link href="/buy/book.epub" type="application/epub+zip"
rel="http://opds-spec.org/acquisition/buy"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
expect(getAcquisitionLink(pub)).toBeUndefined();
});
it('skips borrow, subscribe, and sample acquisition links', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:lend', title: 'Library Loan' },
links: [
{
href: '/borrow/book.epub',
type: 'application/epub+zip',
rel: 'http://opds-spec.org/acquisition/borrow',
properties: {},
},
{
href: '/subscribe/book.epub',
type: 'application/epub+zip',
rel: 'http://opds-spec.org/acquisition/subscribe',
properties: {},
},
{
href: '/sample/book.epub',
type: 'application/epub+zip',
rel: 'http://opds-spec.org/acquisition/sample',
properties: {},
},
],
images: [],
};
expect(getAcquisitionLink(pub)).toBeUndefined();
});
it('prefers open-access over plain acquisition when both exist', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Mixed Book</title>
<id>urn:test:mixed</id>
<link href="/dl/book.epub" type="application/epub+zip"
rel="http://opds-spec.org/acquisition"/>
<link href="/oa/book.epub" type="application/epub+zip"
rel="http://opds-spec.org/acquisition/open-access"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
const acqLink = getAcquisitionLink(pub);
expect(acqLink!.href).toBe('/oa/book.epub');
});
it('picks open-access EPUB even when buy link is listed first', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Mixed Access</title>
<id>urn:test:mixed</id>
<link href="/buy/book.pdf" type="application/pdf"
rel="http://opds-spec.org/acquisition/buy"/>
<link href="/oa/book.epub" type="application/epub+zip"
rel="http://opds-spec.org/acquisition/open-access"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
const acqLink = getAcquisitionLink(pub);
expect(acqLink!.href).toBe('/oa/book.epub');
});
it('skips indirect acquisition links', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:indirect', title: 'Indirect Only' },
links: [
{
href: '/landing-page',
type: 'application/atom+xml;type=entry;profile=opds-catalog',
rel: 'http://opds-spec.org/acquisition/indirect',
properties: {},
},
],
images: [],
};
expect(getAcquisitionLink(pub)).toBeUndefined();
});
it('skips links with indirectAcquisition properties', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:indirect2', title: 'Indirect Chain' },
links: [
{
href: '/landing',
type: 'application/atom+xml;type=entry;profile=opds-catalog',
rel: 'http://opds-spec.org/acquisition',
properties: {
indirectAcquisition: [{ type: 'application/epub+zip' }],
},
},
],
images: [],
};
expect(getAcquisitionLink(pub)).toBeUndefined();
});
it('prefers Advanced EPUB (by link title) over compatible EPUB', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:adv1', title: 'Standard Ebooks Style' },
links: [
{
href: '/dl/book.compat.epub',
type: 'application/epub+zip',
rel: 'http://opds-spec.org/acquisition/open-access',
title: 'Recommended compatible epub',
properties: {},
},
{
href: '/dl/book.advanced.epub',
type: 'application/epub+zip',
rel: 'http://opds-spec.org/acquisition/open-access',
title: 'Advanced epub features',
properties: {},
},
],
images: [],
};
expect(getAcquisitionLink(pub)!.href).toBe('/dl/book.advanced.epub');
});
it('prefers EPUB3 (by .epub3 href) over plain EPUB', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:adv2', title: 'EPUB3 Variant' },
links: [
{
href: '/dl/book.epub',
type: 'application/epub+zip',
rel: 'http://opds-spec.org/acquisition/open-access',
properties: {},
},
{
href: '/dl/book.epub3',
type: 'application/epub+zip',
rel: 'http://opds-spec.org/acquisition/open-access',
properties: {},
},
],
images: [],
};
expect(getAcquisitionLink(pub)!.href).toBe('/dl/book.epub3');
});
it('prefers EPUB over AZW3', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Multi-format</title>
<id>urn:test:epub-vs-azw3</id>
<link href="/dl/book.azw3" type="application/x-mobi8-ebook"
rel="http://opds-spec.org/acquisition"/>
<link href="/dl/book.epub" type="application/epub+zip"
rel="http://opds-spec.org/acquisition"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
expect(getAcquisitionLink(pub)!.href).toBe('/dl/book.epub');
});
it('prefers AZW3 over PDF', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Test</title>
<entry>
<title>Multi-format</title>
<id>urn:test:azw3-vs-pdf</id>
<link href="/dl/book.pdf" type="application/pdf"
rel="http://opds-spec.org/acquisition"/>
<link href="/dl/book.azw3" type="application/x-mobi8-ebook"
rel="http://opds-spec.org/acquisition"/>
</entry>
</feed>`;
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
expect(getAcquisitionLink(pub)!.href).toBe('/dl/book.azw3');
});
it('prefers AZW over CBZ', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:azw-cbz', title: 'AZW vs CBZ' },
links: [
{
href: '/dl/book.cbz',
type: 'application/vnd.comicbook+zip',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
{
href: '/dl/book.azw',
type: 'application/vnd.amazon.ebook',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
],
images: [],
};
expect(getAcquisitionLink(pub)!.href).toBe('/dl/book.azw');
});
it('prefers CBZ over TXT (other formats)', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:cbz-txt', title: 'CBZ vs TXT' },
links: [
{
href: '/dl/book.txt',
type: 'text/plain',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
{
href: '/dl/book.cbz',
type: 'application/vnd.comicbook+zip',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
],
images: [],
};
expect(getAcquisitionLink(pub)!.href).toBe('/dl/book.cbz');
});
it('treats PDF and CBZ as the same tier (uses feed order to break ties)', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:pdf-cbz', title: 'PDF vs CBZ' },
links: [
{
href: '/dl/book.pdf',
type: 'application/pdf',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
{
href: '/dl/book.cbz',
type: 'application/vnd.comicbook+zip',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
],
images: [],
};
// PDF is listed first, so it wins on tie.
expect(getAcquisitionLink(pub)!.href).toBe('/dl/book.pdf');
});
it('infers EPUB tier from href extension when MIME type is missing', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:notype1', title: 'No Type' },
links: [
{
href: '/dl/book.pdf',
type: 'application/pdf',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
{
// No type attribute — server forgot to set it.
href: '/dl/book.epub',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
],
images: [],
};
expect(getAcquisitionLink(pub)!.href).toBe('/dl/book.epub');
});
it('infers EPUB tier from link title when MIME type is missing', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:notype2', title: 'Title-Only Format' },
links: [
{
href: '/download/2',
type: 'application/pdf',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
{
// Opaque href, but title labels the format.
href: '/download/1',
title: 'EPUB',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
],
images: [],
};
expect(getAcquisitionLink(pub)!.href).toBe('/download/1');
});
it('infers Advanced EPUB tier from link title when MIME type is missing', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:notype3', title: 'Advanced inference' },
links: [
{
href: '/download/compat',
title: 'Compatible epub',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
{
href: '/download/advanced',
title: 'Advanced epub features',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
],
images: [],
};
expect(getAcquisitionLink(pub)!.href).toBe('/download/advanced');
});
it('treats application/octet-stream as unspecified and falls back to href/title', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:octet', title: 'Octet stream' },
links: [
{
href: '/dl/book.pdf',
type: 'application/pdf',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
{
href: '/dl/book.epub',
type: 'application/octet-stream',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
],
images: [],
};
// The octet-stream link is actually an EPUB (per its href), so it wins.
expect(getAcquisitionLink(pub)!.href).toBe('/dl/book.epub');
});
it('keeps explicit MIME type even when href ext disagrees', () => {
const pub: OPDSPublication = {
metadata: { id: 'urn:test:explicit', title: 'Explicit MIME' },
links: [
{
// PDF that happens to live at a generic URL.
href: '/api/file/123',
type: 'application/pdf',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
{
href: '/dl/book.txt',
type: 'text/plain',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
],
images: [],
};
// PDF (tier 3) wins over TXT (tier 4) by declared MIME.
expect(getAcquisitionLink(pub)!.href).toBe('/api/file/123');
});
});
describe('getEntryId', () => {
it('returns Atom id when present', () => {
const xml = makeAcquisitionFeed([
{ id: 'urn:shelf:issue:abc', title: 'Issue', href: '/dl/abc' },
]);
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
expect(getEntryId(pub, 'https://example.com')).toBe('urn:shelf:issue:abc');
});
it('falls back to resolved acquisition URL when no id', () => {
const xml = makeAcquisitionFeed([{ title: 'No ID Book', href: '/dl/book.epub' }]);
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const pub = feed.publications![0]!;
const id = getEntryId(pub, 'https://example.com/opds/feed.xml');
expect(id).toBe('https://example.com/dl/book.epub');
});
});
describe('collectNewEntries', () => {
it('returns all entries when knownIds is empty', () => {
const xml = makeAcquisitionFeed([
{ id: 'urn:a', title: 'Issue 1', href: '/dl/a' },
{ id: 'urn:b', title: 'Issue 2', href: '/dl/b' },
]);
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const items = collectNewEntries(feed, new Set(), 'https://example.com');
expect(items).toHaveLength(2);
});
it('skips already-known entries', () => {
const xml = makeAcquisitionFeed([
{ id: 'urn:a', title: 'Issue 1', href: '/dl/a' },
{ id: 'urn:b', title: 'Issue 2', href: '/dl/b' },
{ id: 'urn:c', title: 'Issue 3', href: '/dl/c' },
]);
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const known = new Set(['urn:a', 'urn:b']);
const items = collectNewEntries(feed, known, 'https://example.com');
expect(items).toHaveLength(1);
expect(items[0]!.entryId).toBe('urn:c');
});
it('uses acquisition URL fallback for entries without id', () => {
const xml = makeAcquisitionFeed([{ title: 'No ID Book', href: '/dl/book.epub' }]);
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const items = collectNewEntries(feed, new Set(), 'https://example.com/opds/');
expect(items).toHaveLength(1);
expect(items[0]!.entryId).toBe('https://example.com/dl/book.epub');
});
it('returns empty when all entries are already known', () => {
const xml = makeAcquisitionFeed([{ id: 'urn:a', title: 'Issue 1', href: '/dl/a' }]);
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
const items = collectNewEntries(feed, new Set(['urn:a']), 'https://example.com');
expect(items).toHaveLength(0);
});
it('dedupes a publication that appears in both feed.publications and a group', () => {
const sharedPub: OPDSPublication = {
metadata: { id: 'urn:dup', title: 'Shared Book' },
links: [
{
href: '/dl/dup.epub',
type: 'application/epub+zip',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
],
images: [],
};
const feed: OPDSFeed = {
metadata: { title: 'Test' },
links: [],
publications: [sharedPub],
groups: [
{
metadata: { title: 'Featured' },
links: [],
publications: [sharedPub],
},
],
};
const items = collectNewEntries(feed, new Set(), 'https://example.com');
expect(items).toHaveLength(1);
expect(items[0]!.entryId).toBe('urn:dup');
});
it('collects entries from groups as well', () => {
const feed: OPDSFeed = {
metadata: { title: 'Test' },
links: [],
publications: [],
groups: [
{
metadata: { title: 'Group 1' },
links: [],
publications: [
{
metadata: { id: 'urn:g1', title: 'Grouped Book' },
links: [
{
href: '/dl/g1',
type: 'application/epub+zip',
rel: 'http://opds-spec.org/acquisition',
properties: {},
},
],
images: [],
},
],
},
],
};
const items = collectNewEntries(feed, new Set(), 'https://example.com');
expect(items).toHaveLength(1);
expect(items[0]!.entryId).toBe('urn:g1');
});
});
describe('findNewestFeedURL', () => {
it('detects rel=http://opds-spec.org/sort/new on a top-level feed link (Calibre / Calibre-Web)', () => {
const feed: OPDSFeed = {
metadata: { title: 'Calibre Library' },
links: [
{ href: '/opds/new', rel: 'http://opds-spec.org/sort/new', properties: {} },
{ href: '/opds/all', rel: 'self', properties: {} },
],
};
expect(findNewestFeedURL(feed, 'https://calibre.example.com/opds')).toBe(
'https://calibre.example.com/opds/new',
);
});
it('detects rel=sort/new on a navigation entry', () => {
const feed: OPDSFeed = {
metadata: { title: 'Library' },
links: [],
navigation: [
{ title: 'By Author', href: '/by-author', properties: {} },
{
title: 'New Books',
href: '/new',
rel: 'http://opds-spec.org/sort/new',
properties: {},
},
],
};
expect(findNewestFeedURL(feed, 'https://example.com/opds')).toBe('https://example.com/new');
});
it('detects "Newest" by navigation title (Standard Ebooks-style)', () => {
const feed: OPDSFeed = {
metadata: { title: 'Standard Ebooks' },
links: [],
navigation: [
{ title: 'Subjects', href: '/subjects', properties: {} },
{ title: 'Newest Releases', href: '/new-releases', properties: {} },
{ title: 'Collections', href: '/collections', properties: {} },
],
};
expect(findNewestFeedURL(feed, 'https://standardebooks.org/feeds/opds')).toBe(
'https://standardebooks.org/new-releases',
);
});
it('detects "Recently Added" by title', () => {
const feed: OPDSFeed = {
metadata: { title: 'Library' },
links: [],
navigation: [{ title: 'Recently Added', href: '/recent', properties: {} }],
};
expect(findNewestFeedURL(feed, 'https://example.com/opds')).toBe(
'https://example.com/recent',
);
});
it('detects "Latest" by title', () => {
const feed: OPDSFeed = {
metadata: { title: 'Library' },
links: [],
navigation: [{ title: 'Latest Books', href: '/latest', properties: {} }],
};
expect(findNewestFeedURL(feed, 'https://example.com/opds')).toBe(
'https://example.com/latest',
);
});
it('detects ?sort_order=release_date in href (Project Gutenberg-style)', () => {
const feed: OPDSFeed = {
metadata: { title: 'Project Gutenberg' },
links: [],
navigation: [
{ title: 'Browse', href: '/ebooks/search.opds/?sort_order=title', properties: {} },
{
title: 'Sort by date',
href: '/ebooks/search.opds/?sort_order=release_date',
properties: {},
},
],
};
expect(findNewestFeedURL(feed, 'https://m.gutenberg.org/ebooks.opds/')).toBe(
'https://m.gutenberg.org/ebooks/search.opds/?sort_order=release_date',
);
});
it('detects /new-releases in href without explicit title or rel', () => {
const feed: OPDSFeed = {
metadata: { title: 'Library' },
links: [],
navigation: [
{ title: 'Browse', href: '/feeds/all', properties: {} },
{ title: 'Browse', href: '/feeds/new-releases', properties: {} },
],
};
expect(findNewestFeedURL(feed, 'https://example.com/opds')).toBe(
'https://example.com/feeds/new-releases',
);
});
it('returns undefined when nothing matches', () => {
const feed: OPDSFeed = {
metadata: { title: 'Library' },
links: [],
navigation: [
{ title: 'By Author', href: '/by-author', properties: {} },
{ title: 'By Title', href: '/by-title', properties: {} },
{ title: 'Search', href: '/search', properties: {} },
],
};
expect(findNewestFeedURL(feed, 'https://example.com/opds')).toBeUndefined();
});
it('prefers rel=sort/new over a title heuristic match', () => {
const feed: OPDSFeed = {
metadata: { title: 'Library' },
links: [
{
href: '/opds/sort-new',
rel: 'http://opds-spec.org/sort/new',
properties: {},
},
],
navigation: [{ title: 'Newest fiction', href: '/fiction/newest', properties: {} }],
};
expect(findNewestFeedURL(feed, 'https://example.com/opds')).toBe(
'https://example.com/opds/sort-new',
);
});
it('resolves relative hrefs against the base URL', () => {
const feed: OPDSFeed = {
metadata: { title: 'Library' },
links: [],
navigation: [{ title: 'Newest', href: '../newest', properties: {} }],
};
expect(findNewestFeedURL(feed, 'https://example.com/opds/feeds/root')).toBe(
'https://example.com/opds/newest',
);
});
});
describe('getNextPageUrl', () => {
it('returns the next page URL from feed links', () => {
const xml = makeAcquisitionFeed(
[{ id: 'urn:a', title: 'Book', href: '/dl/a' }],
'/opds/page2',
);
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
expect(getNextPageUrl(feed)).toBe('/opds/page2');
});
it('returns undefined when no next link', () => {
const xml = makeAcquisitionFeed([{ id: 'urn:a', title: 'Book', href: '/dl/a' }]);
const doc = parseXML(xml);
const feed = getFeed(doc) as OPDSFeed;
expect(getNextPageUrl(feed)).toBeUndefined();
});
});
});
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
loadSubscriptionState,
saveSubscriptionState,
deleteSubscriptionState,
pruneKnownEntryIds,
emptyState,
} from '@/services/opds/subscriptionState';
import { MAX_KNOWN_ENTRIES, OPDS_SUBSCRIPTIONS_DIR } from '@/services/opds/types';
import type { OPDSSubscriptionState } from '@/services/opds/types';
import type { AppService } from '@/types/system';
const createMockAppService = () =>
({
exists: vi.fn(async () => false),
readFile: vi.fn(async () => {
throw new Error('File not found');
}),
writeFile: vi.fn(async () => {}),
createDir: vi.fn(async () => {}),
deleteFile: vi.fn(async () => {}),
resolveFilePath: vi.fn(async (path: string) => path),
}) as unknown as AppService;
describe('OPDS subscription state', () => {
let appService: AppService;
beforeEach(() => {
appService = createMockAppService();
});
describe('emptyState', () => {
it('creates an empty state with the given catalogId', () => {
const state = emptyState('cat-1');
expect(state).toEqual({
catalogId: 'cat-1',
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [],
});
});
});
describe('loadSubscriptionState', () => {
it('returns empty state when file does not exist', async () => {
(appService.exists as ReturnType<typeof vi.fn>).mockResolvedValue(false);
const state = await loadSubscriptionState(appService, 'cat-1');
expect(state).toEqual(emptyState('cat-1'));
});
it('loads and parses existing state file', async () => {
const saved: OPDSSubscriptionState = {
catalogId: 'cat-1',
lastCheckedAt: 1000,
knownEntryIds: ['urn:a', 'urn:b'],
failedEntries: [],
};
(appService.exists as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(appService.readFile as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify(saved));
const state = await loadSubscriptionState(appService, 'cat-1');
expect(state.knownEntryIds).toEqual(['urn:a', 'urn:b']);
expect(state.lastCheckedAt).toBe(1000);
});
it('returns empty state on corrupted file', async () => {
(appService.exists as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(appService.readFile as ReturnType<typeof vi.fn>).mockResolvedValue('not json');
const state = await loadSubscriptionState(appService, 'cat-1');
expect(state).toEqual(emptyState('cat-1'));
});
});
describe('saveSubscriptionState', () => {
it('creates directory and writes state as JSON', async () => {
const state: OPDSSubscriptionState = {
catalogId: 'cat-1',
lastCheckedAt: 1000,
knownEntryIds: ['urn:a'],
failedEntries: [],
};
await saveSubscriptionState(appService, state);
expect(appService.createDir).toHaveBeenCalledWith(OPDS_SUBSCRIPTIONS_DIR, 'Data', true);
expect(appService.writeFile).toHaveBeenCalledWith(
`${OPDS_SUBSCRIPTIONS_DIR}/cat-1.json`,
'Data',
JSON.stringify(state, null, 2),
);
});
});
describe('deleteSubscriptionState', () => {
it('deletes the state file', async () => {
await deleteSubscriptionState(appService, 'cat-1');
expect(appService.deleteFile).toHaveBeenCalledWith(
`${OPDS_SUBSCRIPTIONS_DIR}/cat-1.json`,
'Data',
);
});
it('does not throw if file does not exist', async () => {
(appService.deleteFile as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('not found'));
await expect(deleteSubscriptionState(appService, 'cat-1')).resolves.toBeUndefined();
});
});
describe('pruneKnownEntryIds', () => {
it('keeps all entries when under limit', () => {
const ids = ['a', 'b', 'c'];
expect(pruneKnownEntryIds(ids)).toEqual(['a', 'b', 'c']);
});
it('trims oldest entries when over limit', () => {
const ids = Array.from({ length: MAX_KNOWN_ENTRIES + 100 }, (_, i) => `id-${i}`);
const pruned = pruneKnownEntryIds(ids);
expect(pruned.length).toBe(MAX_KNOWN_ENTRIES);
expect(pruned[pruned.length - 1]).toBe(`id-${MAX_KNOWN_ENTRIES + 99}`);
expect(pruned[0]).toBe('id-100');
});
});
});
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { RETRY_BACKOFF_MS, isRetryEligible } from '@/services/opds/types';
import type {
PendingItem,
OPDSSubscriptionState,
FailedEntry,
SyncResult,
} from '@/services/opds/types';
describe('OPDS types and constants', () => {
describe('isRetryEligible', () => {
it('returns true when attempts < max and backoff elapsed', () => {
const entry: FailedEntry = {
entryId: 'urn:test:1',
href: '/dl/1',
title: 'Test',
attempts: 1,
lastAttemptAt: Date.now() - RETRY_BACKOFF_MS * 4,
};
expect(isRetryEligible(entry)).toBe(true);
});
it('returns false when attempts >= max', () => {
const entry: FailedEntry = {
entryId: 'urn:test:1',
href: '/dl/1',
title: 'Test',
attempts: 3,
lastAttemptAt: 0,
};
expect(isRetryEligible(entry)).toBe(false);
});
it('returns false when backoff has not elapsed', () => {
const entry: FailedEntry = {
entryId: 'urn:test:1',
href: '/dl/1',
title: 'Test',
attempts: 1,
lastAttemptAt: Date.now(),
};
expect(isRetryEligible(entry)).toBe(false);
});
it('uses exponential backoff: attempt 2 waits 4x base', () => {
const entry: FailedEntry = {
entryId: 'urn:test:1',
href: '/dl/1',
title: 'Test',
attempts: 2,
lastAttemptAt: Date.now() - RETRY_BACKOFF_MS * 3,
};
expect(isRetryEligible(entry)).toBe(false);
});
});
it('type PendingItem has required fields', () => {
const item: PendingItem = {
entryId: 'urn:test:1',
title: 'Test Book',
acquisitionHref: '/dl/book.epub',
mimeType: 'application/epub+zip',
baseURL: 'https://example.com/opds',
};
expect(item.entryId).toBe('urn:test:1');
expect(item.updated).toBeUndefined();
});
it('type OPDSSubscriptionState has required fields', () => {
const state: OPDSSubscriptionState = {
catalogId: 'cat-1',
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [],
};
expect(state.catalogId).toBe('cat-1');
});
it('type SyncResult has required fields', () => {
const result: SyncResult = {
newBooks: [],
totalNewBooks: 0,
errors: [],
};
expect(result.totalNewBooks).toBe(0);
});
});
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { getFeed } from 'foliate-js/opds.js';
import { getFeed, getPublication } from 'foliate-js/opds.js';
import type { OPDSFeed, OPDSPublication } from '@/types/opds';
const MIME_XML = 'application/xml';
@@ -124,6 +125,15 @@ describe('OPDS feed parsing', () => {
expect(feed.navigation![1].title).toBe('Non-Fiction/');
});
it('should parse entry id and updated fields from publications', () => {
const doc = parseXML(copypartyRootFeed);
const feed = getFeed(doc);
// The book entry has an <updated> element but no <id>
expect(feed.publications).toBeDefined();
expect(feed.publications![0].metadata.updated).toBe('2025-11-02T17:50:21Z');
});
it('should handle navigation entry after publications', () => {
// Reverse order: publications first, then navigation
const reverseFeed = `<?xml version="1.0" encoding="UTF-8"?>
@@ -156,4 +166,78 @@ describe('OPDS feed parsing', () => {
expect(feed.navigation![0].title).toBe('Sub/');
});
});
describe('entry id and updated parsing', () => {
const shelfFeed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:opds="http://opds-spec.org/2010/catalog">
<id>urn:shelf:series:stratechery</id>
<title>Shelf Stratechery</title>
<updated>2026-01-15T10:30:00.000Z</updated>
<entry>
<title>Issue One</title>
<id>urn:shelf:issue:abc123</id>
<updated>2026-01-15T10:30:00.000Z</updated>
<published>2026-01-14T00:00:00.000Z</published>
<link href="/dl/abc123.token"
type="application/epub+zip"
rel="http://opds-spec.org/acquisition"/>
</entry>
<entry>
<title>Issue Two</title>
<id>urn:shelf:issue:def456</id>
<updated>2026-01-16T08:00:00.000Z</updated>
<link href="/dl/def456.token"
type="application/epub+zip"
rel="http://opds-spec.org/acquisition"/>
</entry>
</feed>`;
it('should parse id and updated on publications', () => {
const doc = parseXML(shelfFeed);
const feed = getFeed(doc);
expect(feed.publications).toHaveLength(2);
expect(feed.publications![0].metadata.id).toBe('urn:shelf:issue:abc123');
expect(feed.publications![0].metadata.updated).toBe('2026-01-15T10:30:00.000Z');
expect(feed.publications![0].metadata.published).toBe('2026-01-14T00:00:00.000Z');
expect(feed.publications![1].metadata.id).toBe('urn:shelf:issue:def456');
expect(feed.publications![1].metadata.updated).toBe('2026-01-16T08:00:00.000Z');
});
it('should parse id and updated on the feed itself', () => {
const doc = parseXML(shelfFeed);
const feed = getFeed(doc) as OPDSFeed;
expect(feed.metadata.id).toBe('urn:shelf:series:stratechery');
expect(feed.metadata.updated).toBe('2026-01-15T10:30:00.000Z');
});
it('should parse id and updated via getPublication directly', () => {
const doc = parseXML(shelfFeed);
const entry = doc.querySelector('entry')!;
const pub = getPublication(entry) as OPDSPublication;
expect(pub.metadata.id).toBe('urn:shelf:issue:abc123');
expect(pub.metadata.updated).toBe('2026-01-15T10:30:00.000Z');
});
it('should handle entries without id or updated gracefully', () => {
const minimalFeed = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Minimal</title>
<entry>
<title>No ID Book</title>
<link rel="http://opds-spec.org/acquisition"
href="book.epub" type="application/epub+zip"/>
</entry>
</feed>`;
const doc = parseXML(minimalFeed);
const feed = getFeed(doc);
expect(feed.publications).toHaveLength(1);
expect(feed.publications![0].metadata.id).toBeUndefined();
expect(feed.publications![0].metadata.updated).toBeUndefined();
});
});
});
@@ -107,69 +107,94 @@ async function handleRequest(request: NextRequest, method: 'GET' | 'HEAD') {
const contentType = response.headers.get('Content-Type') || 'text/xml';
const contentLength = response.headers.get('Content-Length');
const contentEncoding = response.headers.get('Content-Encoding');
const transferEncoding = response.headers.get('Transfer-Encoding');
const upstreamContentDisposition = response.headers.get('Content-Disposition');
console.log(
`[OPDS Proxy] upstream OK: ${url} ` +
`content-type=${contentType} content-length=${contentLength ?? '(none)'} ` +
`content-encoding=${contentEncoding ?? '(none)'} ` +
`transfer-encoding=${transferEncoding ?? '(none)'} ` +
`content-disposition=${upstreamContentDisposition ?? '(none)'}`,
);
// Headers that must NOT be forwarded as-is when we proxy the body:
// - content-encoding: fetch() has already decoded gzip/br/deflate, so
// the body the client receives is plain. Forwarding the original
// Content-Encoding makes the browser try to decode it again, which
// truncates or empties the response. This was the cause of
// Content-Length: 0 / 0-byte downloads from Calibre and similar.
// - content-length: must match the bytes we actually emit (post-decode).
// We set the right value below where we know it.
// - transfer-encoding / connection / keep-alive: hop-by-hop headers,
// must not cross the proxy boundary.
const excludedHeaders = new Set([
'content-encoding',
'content-length',
'transfer-encoding',
'connection',
'keep-alive',
]);
// Use a Headers object so name comparison is case-insensitive — this
// prevents the `content-type` (lowercase from Headers.entries) and
// `Content-Type` (title-case override) duplication that produced
// "application/epub+zip, application/epub+zip" responses.
const buildResponseHeaders = (extras: Record<string, string>) => {
const h = new Headers();
for (const [key, value] of response.headers.entries()) {
if (!excludedHeaders.has(key.toLowerCase())) {
h.set(key, value);
}
}
// Don't cache file downloads — a single broken response would otherwise
// be cached for 5 minutes and keep returning 0 bytes. Catalog feeds
// (XML/JSON) are still cacheable.
const isFileDownload =
stream === 'true' ||
(upstreamContentDisposition ?? '').toLowerCase().includes('attachment');
h.set('Cache-Control', isFileDownload ? 'no-store' : 'public, max-age=300');
h.set('Access-Control-Allow-Origin', '*');
h.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
h.set('Access-Control-Allow-Headers', 'Content-Type');
for (const [key, value] of Object.entries(extras)) {
h.set(key, value);
}
return h;
};
if (method === 'HEAD') {
console.log(`[OPDS Proxy] HEAD Success: ${url}`);
return new NextResponse(null, {
status: 200,
headers: {
...Object.fromEntries(response.headers.entries()),
headers: buildResponseHeaders({
'Content-Type': contentType,
'Content-Length': contentLength || '',
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
...(contentLength ? { 'Content-Length': contentLength } : {}),
}),
});
}
if (stream === 'true' && contentLength && parseInt(contentLength) > 1024 * 1024) {
console.log(`[OPDS Proxy] Streaming: ${url}`);
return new NextResponse(response.body, {
status: 200,
headers: {
...Object.fromEntries(response.headers.entries()),
'Content-Type': contentType,
'X-Content-Length': contentLength || '',
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Expose-Headers': 'X-Content-Length',
},
console.log(`[OPDS Proxy] Streaming: ${url} (${contentLength} bytes)`);
const headers = buildResponseHeaders({
'Content-Type': contentType,
// Surface the upstream length to the client without setting the real
// Content-Length header (which must match the streamed bytes — and
// we let Next.js / the runtime compute that).
'X-Content-Length': contentLength,
'Access-Control-Expose-Headers': 'X-Content-Length',
});
return new NextResponse(response.body, { status: 200, headers });
} else {
const buf = await response.arrayBuffer();
const length = buf.byteLength;
console.log(`[OPDS Proxy] Success: ${url} (${length} bytes)`);
const excludedHeaders = new Set([
'content-encoding',
'content-length',
'transfer-encoding',
'connection',
'keep-alive',
]);
const proxyHeaders: Record<string, string> = {};
for (const [key, value] of response.headers.entries()) {
if (!excludedHeaders.has(key.toLowerCase())) {
proxyHeaders[key] = value;
}
}
console.log(`[OPDS Proxy] Buffered Success: ${url} (${length} bytes)`);
return new NextResponse(buf, {
status: 200,
headers: {
...proxyHeaders,
headers: buildResponseHeaders({
'Content-Type': contentType,
'Content-Length': length.toString(),
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
}),
});
}
} catch (error) {
+10 -2
View File
@@ -35,6 +35,7 @@ import { useTheme } from '@/hooks/useTheme';
import { useUICSS } from '@/hooks/useUICSS';
import { useDemoBooks } from './hooks/useDemoBooks';
import { useBooksSync } from './hooks/useBooksSync';
import { useOPDSSubscriptions } from '@/hooks/useOPDSSubscriptions';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTransferStore } from '@/store/transferStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
@@ -162,12 +163,19 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useTransferQueue(libraryLoaded);
const { pullLibrary, pushLibrary } = useBooksSync();
const { checkOPDSSubscriptions } = useOPDSSubscriptions();
const { isDragging } = useDragDropImport();
usePullToRefresh(
scrollRef,
pullLibrary.bind(null, false, true),
pullLibrary.bind(null, true, true),
async () => {
await pullLibrary(false, true);
checkOPDSSubscriptions(true);
},
async () => {
await pullLibrary(true, true);
checkOPDSSubscriptions(true);
},
);
useScreenWakeLock(settings.screenWakeLock);
@@ -1,8 +1,18 @@
'use client';
import clsx from 'clsx';
import { useState } from 'react';
import { IoAdd, IoTrash, IoOpenOutline, IoBook, IoEyeOff, IoEye, IoPencil } from 'react-icons/io5';
import dayjs from 'dayjs';
import { useCallback, useEffect, useState } from 'react';
import {
IoAdd,
IoTrash,
IoOpenOutline,
IoBook,
IoEyeOff,
IoEye,
IoPencil,
IoCloudDownloadOutline,
} from 'react-icons/io5';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
@@ -11,7 +21,11 @@ import { isWebAppPlatform } from '@/services/environment';
import { saveSysSettings } from '@/helpers/settings';
import { OPDSCatalog } from '@/types/opds';
import { isLanAddress } from '@/utils/network';
import { eventDispatcher } from '@/utils/event';
import { deleteSubscriptionState, loadSubscriptionState } from '@/services/opds';
import type { OPDSSubscriptionState } from '@/services/opds/types';
import { validateOPDSURL } from '../utils/opdsUtils';
import { FailedDownloadsDialog } from './FailedDownloadsDialog';
import {
formatOPDSCustomHeadersInput,
hasOPDSCustomHeaders,
@@ -68,6 +82,7 @@ const EMPTY_NEW_CATALOG = {
password: '',
customHeadersInput: '',
proxyConsent: false,
autoDownload: false,
};
export function CatalogManager() {
@@ -85,6 +100,31 @@ export function CatalogManager() {
const [proxyConsentError, setProxyConsentError] = useState('');
const [isValidating, setIsValidating] = useState(false);
const popularCatalogs = appService?.isOnlineCatalogsAccessible ? POPULAR_CATALOGS : [];
const [subscriptionStates, setSubscriptionStates] = useState<
Record<string, OPDSSubscriptionState>
>({});
const [failedDialogCatalogId, setFailedDialogCatalogId] = useState<string | null>(null);
const reloadSubscriptionStates = useCallback(async () => {
if (!appService) return;
const eligible = catalogs.filter((c) => c.autoDownload);
const entries = await Promise.all(
eligible.map(async (c) => [c.id, await loadSubscriptionState(appService, c.id)] as const),
);
setSubscriptionStates(Object.fromEntries(entries));
}, [appService, catalogs]);
useEffect(() => {
reloadSubscriptionStates();
}, [reloadSubscriptionStates]);
useEffect(() => {
const handler = () => {
reloadSubscriptionStates();
};
eventDispatcher.on('opds-sync-complete', handler);
return () => eventDispatcher.off('opds-sync-complete', handler);
}, [reloadSubscriptionStates]);
const hasSensitiveWebOPDSInput =
newCatalog.username.trim().length > 0 ||
newCatalog.password.trim().length > 0 ||
@@ -157,6 +197,7 @@ export function CatalogManager() {
customHeaders: hasOPDSCustomHeaders(parsedHeaders.headers)
? parsedHeaders.headers
: undefined,
autoDownload: newCatalog.autoDownload || undefined,
};
if (editingCatalogId) {
@@ -183,6 +224,7 @@ export function CatalogManager() {
password: catalog.password || '',
customHeadersInput: formatOPDSCustomHeadersInput(catalog.customHeaders),
proxyConsent: false,
autoDownload: catalog.autoDownload || false,
});
setEditingCatalogId(catalog.id);
setShowAddDialog(true);
@@ -198,6 +240,21 @@ export function CatalogManager() {
const handleRemoveCatalog = (id: string) => {
saveCatalogs(catalogs.filter((c) => c.id !== id));
if (appService) {
// Don't await — leftover state files are harmless and we don't want to
// block UI removal if the filesystem call fails.
void deleteSubscriptionState(appService, id);
}
};
const handleToggleAutoDownload = (id: string) => {
const wasEnabled = catalogs.find((c) => c.id === id)?.autoDownload;
saveCatalogs(catalogs.map((c) => (c.id === id ? { ...c, autoDownload: !c.autoDownload } : c)));
// When the user just enabled auto-download, sync now instead of waiting
// for the next app launch / pull-to-refresh.
if (!wasEnabled) {
eventDispatcher.dispatch('check-opds-subscriptions');
}
};
const handleOpenCatalog = (catalog: OPDSCatalog) => {
@@ -296,6 +353,56 @@ export function CatalogManager() {
)}
</div>
</div>
<div className='mt-2 flex items-center gap-2'>
<label
className={clsx(
'label gap-2 p-0',
catalog.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
)}
>
<input
type='checkbox'
className='toggle toggle-sm toggle-primary'
checked={!!catalog.autoDownload}
disabled={!!catalog.disabled}
onChange={() => handleToggleAutoDownload(catalog.id)}
/>
<span className='label-text text-xs'>
<IoCloudDownloadOutline className='mr-1 inline h-3.5 w-3.5' />
{_('Auto-download')}
</span>
</label>
</div>
{(() => {
const subState = subscriptionStates[catalog.id];
if (!catalog.autoDownload || !subState) return null;
const lastCheckedAt = subState.lastCheckedAt;
const failedCount = subState.failedEntries.length;
if (lastCheckedAt === 0 && failedCount === 0) return null;
return (
<div className='text-base-content/60 mt-1 flex items-center gap-2 text-xs'>
{lastCheckedAt > 0 && (
<span>
{_('Last synced {{when}}', {
when: dayjs(lastCheckedAt).fromNow(),
})}
</span>
)}
{failedCount > 0 && (
<>
{lastCheckedAt > 0 && <span aria-hidden>·</span>}
<button
type='button'
onClick={() => setFailedDialogCatalogId(catalog.id)}
className='text-error hover:underline'
>
{_('{{count}} failed', { count: failedCount })}
</button>
</>
)}
</div>
);
})()}
<div className='card-actions mt-4 justify-end'>
<button
onClick={() => handleOpenCatalog(catalog)}
@@ -536,6 +643,26 @@ export function CatalogManager() {
/>
</div>
<div className='form-control'>
<label className='label cursor-pointer justify-start gap-3 p-0'>
<input
type='checkbox'
className='toggle toggle-sm toggle-primary'
checked={newCatalog.autoDownload}
onChange={(e) =>
setNewCatalog({ ...newCatalog, autoDownload: e.target.checked })
}
disabled={isValidating}
/>
<div>
<span className='label-text'>{_('Auto-download new items')}</span>
<p className='text-base-content/60 text-xs'>
{_('Automatically download new publications when the app syncs')}
</p>
</div>
</label>
</div>
<div className='modal-action'>
<button
type='button'
@@ -563,6 +690,19 @@ export function CatalogManager() {
</dialog>
</ModalPortal>
)}
{failedDialogCatalogId && (
<FailedDownloadsDialog
catalogId={failedDialogCatalogId}
catalogName={catalogs.find((c) => c.id === failedDialogCatalogId)?.name ?? ''}
onClose={() => {
setFailedDialogCatalogId(null);
// The dialog mutates failedEntries / knownEntryIds — refresh the
// status row so changes are visible without waiting for a sync.
reloadSubscriptionStates();
}}
/>
)}
</div>
);
}
@@ -0,0 +1,162 @@
'use client';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import { IoClose, IoRefresh } from 'react-icons/io5';
import ModalPortal from '@/components/ModalPortal';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { loadSubscriptionState, saveSubscriptionState } from '@/services/opds';
import type { FailedEntry, OPDSSubscriptionState } from '@/services/opds/types';
import { eventDispatcher } from '@/utils/event';
interface Props {
catalogId: string;
catalogName: string;
onClose: () => void;
}
export function FailedDownloadsDialog({ catalogId, catalogName, onClose }: Props) {
const _ = useTranslation();
const { appService } = useEnv();
const [state, setState] = useState<OPDSSubscriptionState | null>(null);
useEffect(() => {
if (!appService) return;
let cancelled = false;
const refresh = async () => {
const next = await loadSubscriptionState(appService, catalogId);
if (!cancelled) setState(next);
};
refresh();
const handler = () => {
refresh();
};
eventDispatcher.on('opds-sync-complete', handler);
return () => {
cancelled = true;
eventDispatcher.off('opds-sync-complete', handler);
};
}, [appService, catalogId]);
if (!appService || !state) return null;
const persist = async (next: OPDSSubscriptionState) => {
setState(next);
await saveSubscriptionState(appService, next);
};
const retryEntry = async (entry: FailedEntry) => {
await persist({
...state,
failedEntries: state.failedEntries.map((fe) =>
fe.entryId === entry.entryId ? { ...fe, lastAttemptAt: 0 } : fe,
),
});
eventDispatcher.dispatch('check-opds-subscriptions');
};
const skipEntry = async (entry: FailedEntry) => {
await persist({
...state,
failedEntries: state.failedEntries.filter((fe) => fe.entryId !== entry.entryId),
knownEntryIds: state.knownEntryIds.includes(entry.entryId)
? state.knownEntryIds
: [...state.knownEntryIds, entry.entryId],
});
};
const retryAll = async () => {
await persist({
...state,
failedEntries: state.failedEntries.map((fe) => ({ ...fe, lastAttemptAt: 0 })),
});
eventDispatcher.dispatch('check-opds-subscriptions');
};
const skipAll = async () => {
const skipped = state.failedEntries.map((fe) => fe.entryId);
const knownSet = new Set(state.knownEntryIds);
for (const id of skipped) knownSet.add(id);
await persist({
...state,
failedEntries: [],
knownEntryIds: Array.from(knownSet),
});
};
const failed = state.failedEntries;
return (
<ModalPortal>
<dialog className='modal modal-open'>
<div className='modal-box max-w-lg'>
<div className='mb-4 flex items-start justify-between gap-4'>
<div className='min-w-0'>
<h3 className='font-bold'>{_('Failed downloads')}</h3>
<p className='text-base-content/60 truncate text-xs'>{catalogName}</p>
</div>
<button
type='button'
onClick={onClose}
className='btn btn-ghost btn-sm btn-circle'
aria-label={_('Close')}
>
<IoClose className='h-4 w-4' />
</button>
</div>
{failed.length === 0 ? (
<p className='text-base-content/60 py-8 text-center text-sm'>
{_('No failed downloads')}
</p>
) : (
<ul className='max-h-96 space-y-2 overflow-y-auto'>
{failed.map((entry) => (
<li
key={entry.entryId}
className='border-base-300 flex items-start justify-between gap-3 rounded border p-3'
>
<div className='min-w-0 flex-1'>
<p className='truncate text-sm font-medium'>{entry.title}</p>
<p className='text-base-content/60 truncate text-xs'>{entry.href}</p>
<p className='text-base-content/50 mt-1 text-xs'>
{_('Attempts: {{count}}', { count: entry.attempts })}
{' · '}
{dayjs(entry.lastAttemptAt).fromNow()}
</p>
</div>
<div className='flex flex-shrink-0 flex-col gap-1'>
<button
type='button'
onClick={() => retryEntry(entry)}
className='btn btn-xs btn-primary'
>
<IoRefresh className='h-3 w-3' />
{_('Retry')}
</button>
<button type='button' onClick={() => skipEntry(entry)} className='btn btn-xs'>
{_('Skip')}
</button>
</div>
</li>
))}
</ul>
)}
{failed.length > 0 && (
<div className='modal-action'>
<button type='button' onClick={skipAll} className='btn btn-sm'>
{_('Skip all')}
</button>
<button type='button' onClick={retryAll} className='btn btn-sm btn-primary'>
{_('Retry all')}
</button>
</div>
)}
</div>
</dialog>
</ModalPortal>
);
}
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { syncSubscribedCatalogs } from '@/services/opds';
import { AUTO_CHECK_INTERVAL_MS } from '@/services/opds/types';
import { transferManager } from '@/services/transferManager';
import { eventDispatcher } from '@/utils/event';
export function useOPDSSubscriptions() {
const _ = useTranslation();
const { appService } = useEnv();
const { user } = useAuth();
const { libraryLoaded } = useLibraryStore();
const isSyncingRef = useRef(false);
const checkOPDSSubscriptions = useCallback(
async (verbose = false) => {
if (!appService || !libraryLoaded) return;
if (isSyncingRef.current) return;
const { settings } = useSettingsStore.getState();
const catalogs = settings.opdsCatalogs ?? [];
const hasAutoDownload = catalogs.some((c) => c.autoDownload && !c.disabled);
if (!hasAutoDownload) return;
console.log(`[OPDS] checking subscriptions`);
try {
isSyncingRef.current = true;
const librarySnapshot = [...useLibraryStore.getState().library];
const { newBooks, totalNewBooks, errors } = await syncSubscribedCatalogs(
catalogs,
appService,
librarySnapshot,
);
if (totalNewBooks > 0) {
const currentLibrary = useLibraryStore.getState().library;
const existingHashes = new Set(currentLibrary.map((b) => b.hash));
const uniqueNewBooks = newBooks.filter((b) => !existingHashes.has(b.hash));
if (uniqueNewBooks.length > 0) {
const merged = [...uniqueNewBooks, ...currentLibrary];
useLibraryStore.getState().setLibrary(merged);
appService.saveLibraryBooks(merged);
}
// Mirror the manual OPDS download path: queue cloud upload for each
// newly imported book when the user is logged in and has the global
// autoUpload setting on. Delay so the transfer manager has a chance
// to finish initializing if this fires right after libraryLoaded.
const { settings: currentSettings } = useSettingsStore.getState();
if (user && currentSettings.autoUpload && uniqueNewBooks.length > 0) {
const booksToUpload = uniqueNewBooks.filter((b) => !b.uploadedAt);
if (booksToUpload.length > 0) {
setTimeout(() => {
for (const book of booksToUpload) {
transferManager.queueUpload(book);
}
}, 3000);
}
}
}
if (verbose && totalNewBooks > 0) {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('{{count}} new item(s) downloaded from OPDS', { count: totalNewBooks }),
});
}
if (verbose && errors.length > 0) {
eventDispatcher.dispatch('toast', {
type: 'error',
timeout: 4000,
message: _('Failed to sync {{count}} OPDS catalog(s)', { count: errors.length }),
});
}
} catch (error) {
console.error('OPDS subscription sync error:', error);
} finally {
isSyncingRef.current = false;
// CatalogManager listens for this to refresh the per-catalog status
// (last-checked time, failed-entries count) without polling.
eventDispatcher.dispatch('opds-sync-complete');
}
},
[_, appService, libraryLoaded, user],
);
// Auto-trigger on startup after library is loaded
useEffect(() => {
if (!libraryLoaded) return;
checkOPDSSubscriptions();
}, [libraryLoaded, checkOPDSSubscriptions]);
// Listen for explicit re-check requests (e.g. user enables auto-download
// on a catalog and we want to sync immediately rather than wait for the
// next app launch).
useEffect(() => {
const handler = () => checkOPDSSubscriptions(true);
eventDispatcher.on('check-opds-subscriptions', handler);
return () => eventDispatcher.off('check-opds-subscriptions', handler);
}, [checkOPDSSubscriptions]);
// Periodic background check. Silent (no toasts) so it doesn't surprise the
// user with notifications every 5 minutes; new books just appear in the
// library when they finish downloading. The function is a no-op when no
// catalogs have autoDownload enabled, so the timer is cheap.
useEffect(() => {
if (!libraryLoaded) return;
const intervalId = setInterval(() => {
checkOPDSSubscriptions(false);
}, AUTO_CHECK_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [libraryLoaded, checkOPDSSubscriptions]);
return { checkOPDSSubscriptions };
}
@@ -0,0 +1,266 @@
import type { Book } from '@/types/book';
import type { AppService } from '@/types/system';
import type { OPDSCatalog } from '@/types/opds';
import { downloadFile } from '@/libs/storage';
import { getFileExtFromMimeType } from '@/libs/document';
import { needsProxy, getProxiedURL, probeAuth, probeFilename } from '@/app/opds/utils/opdsReq';
import { resolveURL, parseMediaType, getFileExtFromPath } from '@/app/opds/utils/opdsUtils';
import { normalizeOPDSCustomHeaders } from '@/app/opds/utils/customHeaders';
import { READEST_OPDS_USER_AGENT } from '@/services/constants';
import { checkFeedForNewItems } from './feedChecker';
import {
loadSubscriptionState,
saveSubscriptionState,
pruneKnownEntryIds,
} from './subscriptionState';
import { isRetryEligible, DOWNLOAD_CONCURRENCY, MAX_RETRY_ATTEMPTS } from './types';
import type { PendingItem, SyncResult, OPDSSubscriptionState, FailedEntry } from './types';
/**
* Download a single item and import it into the library.
*/
async function downloadAndImport(
item: PendingItem,
catalog: OPDSCatalog,
appService: AppService,
books: Book[],
): Promise<Book> {
const url = resolveURL(item.acquisitionHref, item.baseURL);
const username = catalog.username ?? '';
const password = catalog.password ?? '';
const customHeaders = normalizeOPDSCustomHeaders(catalog.customHeaders);
const useProxy = needsProxy(url);
let downloadUrl = useProxy ? getProxiedURL(url, '', true, customHeaders) : url;
const headers: Record<string, string> = {
'User-Agent': READEST_OPDS_USER_AGENT,
Accept: '*/*',
...(!useProxy ? customHeaders : {}),
};
if (username || password) {
const authHeader = await probeAuth(url, username, password, useProxy, customHeaders);
if (authHeader) {
if (!useProxy) {
headers['Authorization'] = authHeader;
}
downloadUrl = useProxy ? getProxiedURL(url, authHeader, true, customHeaders) : url;
}
}
const parsed = parseMediaType(item.mimeType);
const rawPathname = new URL(url).pathname;
let pathname: string;
try {
pathname = decodeURIComponent(rawPathname);
} catch {
pathname = rawPathname;
}
const ext = getFileExtFromMimeType(parsed?.mediaType) || getFileExtFromPath(pathname);
// Use the last non-empty path segment as the base; falling back to the
// entry id avoids producing 200+ char filenames from deep URLs and keeps
// us comfortably under the ~255-byte filesystem limit.
const lastSegment = pathname.split('/').filter(Boolean).pop() ?? '';
const sanitized = (lastSegment || item.entryId).replaceAll(/[/\\:*?"<>|]/g, '_').slice(0, 200);
const basename = sanitized || 'opds-download';
const filename = ext ? `${basename}.${ext}` : basename;
let dstFilePath = await appService.resolveFilePath(filename, 'Cache');
console.log(`[OPDS] downloading "${item.title}" from ${url}`);
const responseHeaders = await downloadFile({
appService,
dst: dstFilePath,
cfp: '',
url: downloadUrl,
headers,
singleThreaded: true,
});
const probedFilename = await probeFilename(responseHeaders);
if (probedFilename) {
const newFilePath = await appService.resolveFilePath(probedFilename, 'Cache');
await appService.copyFile(dstFilePath, newFilePath, 'None');
await appService.deleteFile(dstFilePath, 'None');
dstFilePath = newFilePath;
}
const book = await appService.importBook(dstFilePath, books);
if (!book) throw new Error(`importBook returned null for ${item.title}`);
console.log(`[OPDS] imported "${item.title}"`);
return book;
}
/**
* Run a batch of async tasks with bounded concurrency.
*/
async function runWithConcurrency<T, R>(
items: T[],
concurrency: number,
fn: (item: T) => Promise<R>,
): Promise<Array<{ item: T; result: R } | { item: T; error: unknown }>> {
const results: Array<{ item: T; result: R } | { item: T; error: unknown }> = [];
let index = 0;
async function worker() {
while (index < items.length) {
const currentIndex = index++;
const item = items[currentIndex]!;
try {
const result = await fn(item);
results[currentIndex] = { item, result };
} catch (error) {
results[currentIndex] = { item, error };
}
}
}
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
await Promise.all(workers);
return results;
}
/**
* Sync a single catalog: discover new items, retry failed, download, update state.
*/
async function syncCatalog(
catalog: OPDSCatalog,
appService: AppService,
books: Book[],
): Promise<{ newBooks: Book[]; state: OPDSSubscriptionState }> {
const state = await loadSubscriptionState(appService, catalog.id);
// Discovery: find new items from feeds
const pendingItems = await checkFeedForNewItems(catalog, state);
// Failed entries still in their backoff window must not be re-attempted
// until they become retry-eligible. They naturally reappear in
// pendingItems (still in feed, not yet in knownEntryIds), so we have to
// filter them out here. Without this, every sync would re-download the
// same in-backoff entry and append a second copy to failedEntries —
// surfacing as duplicate-key warnings in the failed-downloads dialog.
const inBackoffIds = new Set(
state.failedEntries.filter((fe) => !isRetryEligible(fe)).map((fe) => fe.entryId),
);
const eligiblePendingItems = pendingItems.filter((p) => !inBackoffIds.has(p.entryId));
// Collect retry-eligible failed entries as PendingItems
const retryItems: PendingItem[] = state.failedEntries.filter(isRetryEligible).map((fe) => ({
entryId: fe.entryId,
title: fe.title,
acquisitionHref: fe.href,
mimeType: 'application/octet-stream',
baseURL: catalog.url,
}));
// Dedupe: a retry-eligible failed entry can also reappear in pendingItems
// (because the entry isn't in knownEntryIds yet). Prefer the pending copy
// since it carries the freshly-discovered MIME type from the feed.
const seenIds = new Set<string>();
const allItems: PendingItem[] = [];
for (const item of [...eligiblePendingItems, ...retryItems]) {
if (seenIds.has(item.entryId)) continue;
seenIds.add(item.entryId);
allItems.push(item);
}
if (allItems.length === 0) {
state.lastCheckedAt = Date.now();
await saveSubscriptionState(appService, state);
return { newBooks: [], state };
}
// Acquisition: download with bounded concurrency
const downloadResults = await runWithConcurrency(allItems, DOWNLOAD_CONCURRENCY, (item) =>
downloadAndImport(item, catalog, appService, books),
);
// Process results and update state
const newBooks: Book[] = [];
const newKnownIds: string[] = [];
const updatedFailedEntries: FailedEntry[] = [
// Keep non-retry-eligible failures as-is
...state.failedEntries.filter((fe) => !isRetryEligible(fe)),
];
for (const outcome of downloadResults) {
const item = outcome.item;
if ('result' in outcome) {
newBooks.push(outcome.result);
newKnownIds.push(item.entryId);
} else {
const existingFailed = state.failedEntries.find((fe) => fe.entryId === item.entryId);
const attempts = (existingFailed?.attempts ?? 0) + 1;
if (attempts >= MAX_RETRY_ATTEMPTS) {
newKnownIds.push(item.entryId);
console.error(
`OPDS sync: permanently skipping "${item.title}" after ${attempts} failed attempts`,
);
} else {
updatedFailedEntries.push({
entryId: item.entryId,
href: item.acquisitionHref,
title: item.title,
attempts,
lastAttemptAt: Date.now(),
});
}
}
}
state.knownEntryIds = pruneKnownEntryIds([...state.knownEntryIds, ...newKnownIds]);
state.failedEntries = updatedFailedEntries;
state.lastCheckedAt = Date.now();
await saveSubscriptionState(appService, state);
return { newBooks, state };
}
/**
* Sync all OPDS catalogs that have autoDownload enabled.
*
* Catalogs are processed sequentially: the per-catalog pool already runs
* DOWNLOAD_CONCURRENCY parallel downloads, and a parallel fan-out across
* catalogs would multiply that (N × DOWNLOAD_CONCURRENCY) and hammer
* cellular connections. One failure does not block the others each
* catalog's errors are isolated and surfaced in the result.
*/
export async function syncSubscribedCatalogs(
catalogs: OPDSCatalog[],
appService: AppService,
books: Book[],
): Promise<SyncResult> {
const eligible = catalogs.filter((c) => c.autoDownload && !c.disabled);
if (eligible.length === 0) {
return { newBooks: [], totalNewBooks: 0, errors: [] };
}
const allNewBooks: Book[] = [];
const errors: SyncResult['errors'] = [];
for (const catalog of eligible) {
try {
const { newBooks } = await syncCatalog(catalog, appService, books);
allNewBooks.push(...newBooks);
} catch (reason) {
console.error(`OPDS sync: catalog "${catalog.name}" failed:`, reason);
errors.push({
catalogId: catalog.id,
catalogName: catalog.name,
error: reason instanceof Error ? reason.message : String(reason),
});
try {
const state = await loadSubscriptionState(appService, catalog.id);
state.lastCheckedAt = Date.now();
await saveSubscriptionState(appService, state);
} catch {
// Best effort
}
}
}
return {
newBooks: allNewBooks,
totalNewBooks: allNewBooks.length,
errors,
};
}
@@ -0,0 +1,389 @@
import { getFeed, isOPDSCatalog } from 'foliate-js/opds.js';
import type {
OPDSCatalog,
OPDSFeed,
OPDSLink,
OPDSNavigationItem,
OPDSPublication,
} from '@/types/opds';
import { REL } from '@/types/opds';
import { MIMETYPES } from '@/libs/document';
import { isWebAppPlatform } from '@/services/environment';
import { fetchWithAuth } from '@/app/opds/utils/opdsReq';
import { resolveURL, parseMediaType } from '@/app/opds/utils/opdsUtils';
import { normalizeOPDSCustomHeaders } from '@/app/opds/utils/customHeaders';
import type { OPDSSubscriptionState, PendingItem } from './types';
import { MAX_PAGES_PER_FEED } from './types';
const SORT_NEW_REL = 'http://opds-spec.org/sort/new';
// Title keywords that strongly indicate a "by newest" / "recently added"
// navigation entry.
const NEWNESS_TITLE_RE =
/\b(newest|new\s+(?:books|titles|releases?|additions?)|recently\s+added|recent|latest|most\s+recent|by\s+date)\b/i;
// Href hints for catalogs that don't expose rel or human-readable titles.
const NEWNESS_HREF_RE =
/(?:sort_order=release_date|sort=(?:new|date|added|date_added|recent|release_date|release_date_desc)|\b(?:new[-_]?releases?|newest|recently[-_]?added|by[-_]?date)\b|\/new(?:[/?#]|$))/i;
const MIME_XML = 'application/xml';
// Acquisition rels safe for unattended download. Excludes buy / borrow /
// subscribe / sample (need user action) and indirect (link points to a
// landing document, not the file).
const SAFE_ACQ_RELS = [REL.ACQ, `${REL.ACQ}/open-access`];
function isSafeAcquisitionLink(link: OPDSLink): boolean {
if (link.properties?.indirectAcquisition?.length) return false;
const rels = Array.isArray(link.rel) ? link.rel : [link.rel ?? ''];
return rels.some((r) => SAFE_ACQ_RELS.includes(r));
}
// Tier 0 (best) → 4 (worst). See getAcquisitionLink for the policy.
type FormatTier = 0 | 1 | 2 | 3 | 4;
// When a feed omits the link `type` (or returns the uninformative
// application/octet-stream), fall back to inferring the format from the
// href extension and the human-readable link title. Order matters here:
// AZW3 must match before AZW, EPUB 3 before plain EPUB.
const INFERENCE_RULES: ReadonlyArray<{ mime: string; href: RegExp; title: RegExp }> = [
{ mime: 'application/epub+zip', href: /\.epub3(?:[?#]|$)/i, title: /\bepub\s*3\b|\bepub3\b/i },
{ mime: 'application/epub+zip', href: /\.epub(?:[?#]|$)/i, title: /\bepub\b/i },
{ mime: 'application/x-mobi8-ebook', href: /\.azw3(?:[?#]|$)/i, title: /\bazw\s*3\b|\bazw3\b/i },
{ mime: 'application/vnd.amazon.ebook', href: /\.azw(?:[?#]|$)/i, title: /\bazw\b/i },
{ mime: 'application/x-mobipocket-ebook', href: /\.mobi(?:[?#]|$)/i, title: /\bmobi\b/i },
{ mime: 'application/pdf', href: /\.pdf(?:[?#]|$)/i, title: /\bpdf\b/i },
{ mime: 'application/vnd.comicbook+zip', href: /\.cbz(?:[?#]|$)/i, title: /\bcbz\b/i },
{ mime: 'application/x-fictionbook+xml', href: /\.fb2(?:[?#]|$)/i, title: /\bfb2\b/i },
];
function inferMediaType(link: OPDSLink): string {
const title = link.title ?? '';
for (const rule of INFERENCE_RULES) {
if (rule.href.test(link.href) || rule.title.test(title)) return rule.mime;
}
return '';
}
function getEffectiveMediaType(link: OPDSLink): string {
const declared = parseMediaType(link.type)?.mediaType ?? '';
// Treat octet-stream as "the server didn't actually tell us" — most
// OPDS feeds emit a real media type for known formats, and falling back
// to href/title inference recovers from servers that don't.
if (declared && declared !== 'application/octet-stream') return declared;
return inferMediaType(link);
}
function isAdvancedEpub(link: OPDSLink, mediaType: string): boolean {
if (mediaType !== 'application/epub+zip') return false;
const version = parseMediaType(link.type)?.parameters?.['version'];
if (version && /^3(\.|$)/.test(version)) return true;
const title = link.title?.toLowerCase() ?? '';
if (title.includes('advanced')) return true;
if (title.includes('epub3') || title.includes('epub 3')) return true;
if (/\.epub3(?:[?#.]|$)/i.test(link.href)) return true;
return false;
}
function getFormatTier(link: OPDSLink): FormatTier {
const mediaType = getEffectiveMediaType(link);
if (MIMETYPES.EPUB.includes(mediaType)) {
return isAdvancedEpub(link, mediaType) ? 0 : 1;
}
if (
MIMETYPES.MOBI.includes(mediaType) ||
MIMETYPES.AZW.includes(mediaType) ||
MIMETYPES.AZW3.includes(mediaType)
) {
return 2;
}
if (MIMETYPES.PDF.includes(mediaType) || MIMETYPES.CBZ.includes(mediaType)) {
return 3;
}
return 4;
}
/**
* Pick the best acquisition link on a publication for unattended download.
*
* 1. Filter to safe rels (acquisition / acquisition/open-access), drop
* indirect links leaves entries we can fetch directly.
* 2. Prefer open-access over plain acquisition.
* 3. Within those, rank by format tier:
* Advanced EPUB / EPUB3 > EPUB > MOBI/AZW/AZW3 > PDF/CBZ > other.
* Ties resolve by feed order.
*
* Returns undefined when no safe link exists the entry is skipped.
*/
export function getAcquisitionLink(pub: OPDSPublication): OPDSLink | undefined {
const acqLinks = pub.links.filter(isSafeAcquisitionLink);
if (acqLinks.length === 0) return undefined;
const openAccess = acqLinks.filter((link) => {
const rels = Array.isArray(link.rel) ? link.rel : [link.rel ?? ''];
return rels.includes(`${REL.ACQ}/open-access`);
});
const candidates = openAccess.length > 0 ? openAccess : acqLinks;
let best = candidates[0]!;
let bestTier = getFormatTier(best);
for (let i = 1; i < candidates.length && bestTier > 0; i++) {
const tier = getFormatTier(candidates[i]!);
if (tier < bestTier) {
best = candidates[i]!;
bestTier = tier;
}
}
return best;
}
/**
* Derive a stable entry ID from a publication.
* Primary: Atom <id>. Fallback: resolved acquisition URL.
*/
export function getEntryId(pub: OPDSPublication, baseURL: string): string | undefined {
if (pub.metadata.id) return pub.metadata.id;
const acqLink = getAcquisitionLink(pub);
if (acqLink) return resolveURL(acqLink.href, baseURL);
return undefined;
}
/**
* Extract the rel=next pagination URL from a feed.
*/
export function getNextPageUrl(feed: OPDSFeed): string | undefined {
const nextLink = feed.links?.find((link) => {
const rels = Array.isArray(link.rel) ? link.rel : [link.rel ?? ''];
return rels.includes('next');
});
return nextLink?.href;
}
/**
* Collect new PendingItems from a feed, skipping entries already in knownIds
* and de-duplicating entries that appear multiple times within the same feed
* (e.g. listed under both feed.publications and a group).
*/
export function collectNewEntries(
feed: OPDSFeed,
knownIds: Set<string>,
baseURL: string,
): PendingItem[] {
const allPubs: OPDSPublication[] = [
...(feed.publications ?? []),
...(feed.groups?.flatMap((g) => g.publications ?? []) ?? []),
];
const items: PendingItem[] = [];
const seenInBatch = new Set<string>();
for (const pub of allPubs) {
const entryId = getEntryId(pub, baseURL);
if (!entryId) continue;
if (knownIds.has(entryId)) continue;
if (seenInBatch.has(entryId)) continue;
const acqLink = getAcquisitionLink(pub);
if (!acqLink) continue;
seenInBatch.add(entryId);
items.push({
entryId,
title: pub.metadata.title,
acquisitionHref: acqLink.href,
mimeType: acqLink.type ?? 'application/octet-stream',
updated: pub.metadata.updated,
baseURL,
});
}
return items;
}
/**
* Fetch and parse an OPDS feed URL.
*/
async function fetchFeed(
url: string,
username: string,
password: string,
customHeaders: Record<string, string>,
): Promise<{ feed: OPDSFeed; baseURL: string } | null> {
const useProxy = isWebAppPlatform();
const res = await fetchWithAuth(url, username, password, useProxy, {}, customHeaders);
if (!res.ok) {
console.error(`OPDS sync: failed to fetch ${url}: ${res.status} ${res.statusText}`);
return null;
}
const responseURL = res.url;
const text = await res.text();
if (text.startsWith('<')) {
const doc = new DOMParser().parseFromString(text, MIME_XML as DOMParserSupportedType);
const { localName } = doc.documentElement;
if (localName === 'feed') {
return { feed: getFeed(doc) as OPDSFeed, baseURL: responseURL };
}
// HTML auto-discovery
const htmlDoc = new DOMParser().parseFromString(text, 'text/html' as DOMParserSupportedType);
const link = htmlDoc.head
? Array.from(htmlDoc.head.querySelectorAll('link')).find((el) =>
isOPDSCatalog(el.getAttribute('type') ?? ''),
)
: null;
if (link?.getAttribute('href')) {
const resolvedURL = resolveURL(link.getAttribute('href')!, responseURL);
return fetchFeed(resolvedURL, username, password, customHeaders);
}
} else {
try {
const feed = JSON.parse(text) as OPDSFeed;
return { feed, baseURL: responseURL };
} catch {
// not valid JSON
}
}
console.error(`OPDS sync: could not parse feed at ${url}`);
return null;
}
type LinkLike = Pick<OPDSLink, 'href' | 'rel' | 'title'> | OPDSNavigationItem;
function hasRel(item: LinkLike, target: string): boolean {
const rels = Array.isArray(item.rel) ? item.rel : [item.rel ?? ''];
return rels.includes(target);
}
/**
* Find the catalog's "by newest" feed URL the one that lists publications
* in reverse-chronological order. Auto-download follows only this feed (plus
* its rel=next pages); we deliberately don't crawl the rest of the navigation
* tree because subscribing to a whole catalog is rarely what the user wants.
*
* Detection order:
* 1. Authoritative: any link or navigation entry with
* rel="http://opds-spec.org/sort/new" (Calibre / Calibre-Web emit this).
* 2. Title heuristics: "Newest", "Recently Added", "Latest", "Recent",
* "By date", etc. (Standard Ebooks, ManyBooks, custom catalogs.)
* 3. Href heuristics: ?sort_order=release_date (Project Gutenberg),
* /new-releases, /recently-added, ?sort=new, etc.
*
* Returns undefined when no candidate matches the caller should treat the
* catalog as not auto-download-capable rather than fall back to a deep crawl.
*/
export function findNewestFeedURL(feed: OPDSFeed, baseURL: string): string | undefined {
const candidates: LinkLike[] = [...(feed.links ?? []), ...(feed.navigation ?? [])];
// 1. Strong match by rel.
for (const c of candidates) {
if (c.href && hasRel(c, SORT_NEW_REL)) return resolveURL(c.href, baseURL);
}
// 2. Title keyword.
for (const c of candidates) {
if (c.href && c.title && NEWNESS_TITLE_RE.test(c.title)) {
return resolveURL(c.href, baseURL);
}
}
// 3. Href shape — last resort for catalogs that don't label their links.
for (const c of candidates) {
if (c.href && NEWNESS_HREF_RE.test(c.href)) return resolveURL(c.href, baseURL);
}
return undefined;
}
function feedHasContent(feed: OPDSFeed): boolean {
if ((feed.publications?.length ?? 0) > 0) return true;
if (feed.groups?.some((g) => (g.publications?.length ?? 0) > 0)) return true;
return false;
}
/**
* Resolve a catalog URL down to its acquisition feed:
* - If the root already contains publications, use it as-is.
* - Otherwise look for a "by newest" link and follow it.
*
* Returns null when no acquisition feed can be found auto-download will
* skip the catalog rather than crawl through every navigation branch.
*/
async function resolveAcquisitionFeed(
url: string,
username: string,
password: string,
customHeaders: Record<string, string>,
visited: Set<string>,
): Promise<{ feed: OPDSFeed; baseURL: string } | null> {
visited.add(url);
const root = await fetchFeed(url, username, password, customHeaders);
if (!root) return null;
const newestURL = findNewestFeedURL(root.feed, root.baseURL);
if (newestURL && !visited.has(newestURL)) {
visited.add(newestURL);
const newest = await fetchFeed(newestURL, username, password, customHeaders);
if (newest && feedHasContent(newest.feed)) return newest;
}
if (feedHasContent(root.feed)) return root;
return null;
}
/**
* Check a catalog's "by newest" feed for new items.
*
* Pure discovery no downloads, no state mutations. Resolves the catalog
* URL to its newest-acquisition feed (see resolveAcquisitionFeed) and walks
* up to MAX_PAGES_PER_FEED of rel=next pagination, collecting entries that
* aren't already in knownEntryIds.
*/
export async function checkFeedForNewItems(
catalog: OPDSCatalog,
state: OPDSSubscriptionState,
): Promise<PendingItem[]> {
const knownIds = new Set(state.knownEntryIds);
const customHeaders = normalizeOPDSCustomHeaders(catalog.customHeaders);
const username = catalog.username ?? '';
const password = catalog.password ?? '';
const visited = new Set<string>();
const acquisition = await resolveAcquisitionFeed(
catalog.url,
username,
password,
customHeaders,
visited,
);
if (!acquisition) {
console.warn(
`OPDS sync: catalog "${catalog.name}" has no recognizable "by newest" feed; skipping`,
);
return [];
}
const items: PendingItem[] = [];
let { feed, baseURL } = acquisition;
items.push(...collectNewEntries(feed, knownIds, baseURL));
let pageCount = 1;
while (pageCount < MAX_PAGES_PER_FEED) {
const nextHref = getNextPageUrl(feed);
if (!nextHref) break;
const nextUrl = resolveURL(nextHref, baseURL);
if (visited.has(nextUrl)) break;
visited.add(nextUrl);
const next = await fetchFeed(nextUrl, username, password, customHeaders);
if (!next) break;
items.push(...collectNewEntries(next.feed, knownIds, next.baseURL));
feed = next.feed;
baseURL = next.baseURL;
pageCount++;
}
return items;
}
@@ -0,0 +1,9 @@
export { syncSubscribedCatalogs } from './autoDownload';
export { checkFeedForNewItems, getAcquisitionLink, getEntryId } from './feedChecker';
export {
loadSubscriptionState,
saveSubscriptionState,
deleteSubscriptionState,
} from './subscriptionState';
export type { PendingItem, OPDSSubscriptionState, FailedEntry, SyncResult } from './types';
export { isRetryEligible } from './types';
@@ -0,0 +1,78 @@
import type { AppService } from '@/types/system';
import { MAX_KNOWN_ENTRIES, OPDS_SUBSCRIPTIONS_DIR } from './types';
import type { OPDSSubscriptionState } from './types';
export function emptyState(catalogId: string): OPDSSubscriptionState {
return {
catalogId,
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [],
};
}
export function pruneKnownEntryIds(ids: string[]): string[] {
if (ids.length <= MAX_KNOWN_ENTRIES) return ids;
return ids.slice(ids.length - MAX_KNOWN_ENTRIES);
}
function statePath(catalogId: string): string {
return `${OPDS_SUBSCRIPTIONS_DIR}/${catalogId}.json`;
}
// Heal state files written by older versions that could append the same
// entryId to failedEntries multiple times during back-off retries. Keeps
// the entry with the highest attempts count (most recent attempt) so
// retry-eligibility is computed correctly.
function dedupeFailedEntries(entries: OPDSSubscriptionState['failedEntries']) {
const byId = new Map<string, OPDSSubscriptionState['failedEntries'][number]>();
for (const entry of entries) {
const prev = byId.get(entry.entryId);
if (!prev || entry.attempts > prev.attempts || entry.lastAttemptAt > prev.lastAttemptAt) {
byId.set(entry.entryId, entry);
}
}
return Array.from(byId.values());
}
export async function loadSubscriptionState(
appService: AppService,
catalogId: string,
): Promise<OPDSSubscriptionState> {
const path = statePath(catalogId);
try {
const fileExists = await appService.exists(path, 'Data');
if (!fileExists) return emptyState(catalogId);
const content = await appService.readFile(path, 'Data', 'text');
const parsed = JSON.parse(content as string) as OPDSSubscriptionState;
return {
...parsed,
failedEntries: dedupeFailedEntries(parsed.failedEntries ?? []),
};
} catch {
console.error(`OPDS: failed to load subscription state for ${catalogId}, using empty state`);
return emptyState(catalogId);
}
}
export async function saveSubscriptionState(
appService: AppService,
state: OPDSSubscriptionState,
): Promise<void> {
await appService.createDir(OPDS_SUBSCRIPTIONS_DIR, 'Data', true);
const path = statePath(state.catalogId);
const content = JSON.stringify(state, null, 2);
await appService.writeFile(path, 'Data', content);
}
export async function deleteSubscriptionState(
appService: AppService,
catalogId: string,
): Promise<void> {
try {
await appService.deleteFile(statePath(catalogId), 'Data');
} catch {
// File may not exist — that's fine
}
}
@@ -0,0 +1,53 @@
import type { Book } from '@/types/book';
// --- Constants ---
export const MAX_PAGES_PER_FEED = 5;
export const MAX_KNOWN_ENTRIES = 2000;
export const MAX_RETRY_ATTEMPTS = 3;
export const RETRY_BACKOFF_MS = 60_000;
export const DOWNLOAD_CONCURRENCY = 3;
export const OPDS_SUBSCRIPTIONS_DIR = 'OPDS';
// How often to check subscribed feeds for new items, in addition to the
// app-startup check and pull-to-refresh trigger.
export const AUTO_CHECK_INTERVAL_MS = 5 * 60 * 1000;
// --- Types ---
export interface PendingItem {
entryId: string;
title: string;
acquisitionHref: string;
mimeType: string;
updated?: string;
baseURL: string;
}
export interface FailedEntry {
entryId: string;
href: string;
title: string;
attempts: number;
lastAttemptAt: number;
}
export interface OPDSSubscriptionState {
catalogId: string;
lastCheckedAt: number;
knownEntryIds: string[];
failedEntries: FailedEntry[];
}
export interface SyncResult {
newBooks: Book[];
totalNewBooks: number;
errors: Array<{ catalogId: string; catalogName: string; error: string }>;
}
// --- Helpers ---
export function isRetryEligible(entry: FailedEntry): boolean {
if (entry.attempts >= MAX_RETRY_ATTEMPTS) return false;
const backoff = RETRY_BACKOFF_MS * Math.pow(2, entry.attempts);
return Date.now() - entry.lastAttemptAt >= backoff;
}
+5
View File
@@ -24,10 +24,13 @@ export interface OPDSCatalog {
username?: string;
password?: string;
customHeaders?: Record<string, string>;
autoDownload?: boolean;
}
export interface OPDSFeed {
metadata: {
id?: string;
updated?: string;
title?: string;
subtitle?: string;
};
@@ -40,6 +43,8 @@ export interface OPDSFeed {
export interface OPDSPublication {
metadata: {
id?: string;
updated?: string;
title: string;
subtitle?: string;
author?: OPDSPerson[];