diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 6e9fc690..e16b3d05 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -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}} فشل" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 09f57365..88d7a629 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -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}}টি ব্যর্থ" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 91d8e9c8..ef694605 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -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}} ཕམ་ཁ།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 862e9f89..b20b4f53 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 7c6bff7f..fd96c73e 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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}} απέτυχαν" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 07570191..afb403dd 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index bbcbc7d2..94f48bec 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -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}} ناموفق" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index df46d260..11be4127 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index 54a0ecae..d3a256c4 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -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}} נכשלו" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 9f723244..24a9e5da 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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}} असफल" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 806a2023..6f85e36b 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 02c87e97..d0184801 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 8da64db4..9e8bf756 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 096b3b94..64ed2d14 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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}}件失敗" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 2ca78228..6b73a114 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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}}개 실패" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 094241cc..3e9cfe99 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 5ae4db66..fa3d596b 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 4e301453..a486b1c2 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 3b750875..b34cef7a 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 03b3467d..2da8db71 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index e39755ec..5ead6732 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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}} ошибок" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 27874988..77eef874 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -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}} අසාර්ථකයි" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index d902b384..7e265679 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index aae8d60c..88513da1 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 75aaab04..368fb259 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -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}} தோல்விகள்" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index cfcabd23..21c6d4bf 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -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}} ล้มเหลว" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index cece5861..c4dc6763 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 1dfa7a99..da7e8ee6 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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}} помилок" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 8e783107..8834e7f3 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index d9c0f7de..a0c6d1d5 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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}} 个失败" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 0266d872..ed8d8945 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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}} 個失敗" } diff --git a/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts b/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts new file mode 100644 index 00000000..2dd23567 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts @@ -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).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); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/opds-feed-checker.test.ts b/apps/readest-app/src/__tests__/services/opds-feed-checker.test.ts new file mode 100644 index 00000000..f019a0a4 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/opds-feed-checker.test.ts @@ -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) => ` + + ${e.title} + ${e.id ? `${e.id}` : ''} + 2026-01-15T10:00:00.000Z + + `, + ) + .join(''); + + const nextLink = nextUrl + ? `` + : ''; + + return ` + + urn:test:feed + Test Feed + 2026-01-15T10:00:00.000Z + ${nextLink} + ${entryXml} +`; +}; + +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 = ` + + Test + + Preview Only + urn:test:preview + + +`; + 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 = ` + + Test + + Multi-format Book + urn:test:multi + + + +`; + 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 = ` + + Test + + Multi-format Book + urn:test:multi + + + +`; + 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 = ` + + Test + + Multi-format Book + urn:test:multi + + + +`; + 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 = ` + + Test + + Comic + urn:test:cbz + + + +`; + 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 = ` + + Test + + Book with profile + urn:test:profile + + + +`; + 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 = ` + + Test + + Paid Book + urn:test:paid + + +`; + 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 = ` + + Test + + Mixed Book + urn:test:mixed + + + +`; + 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 = ` + + Test + + Mixed Access + urn:test:mixed + + + +`; + 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 = ` + + Test + + Multi-format + urn:test:epub-vs-azw3 + + + +`; + 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 = ` + + Test + + Multi-format + urn:test:azw3-vs-pdf + + + +`; + 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(); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/opds-subscription-state.test.ts b/apps/readest-app/src/__tests__/services/opds-subscription-state.test.ts new file mode 100644 index 00000000..3ddaa4f8 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/opds-subscription-state.test.ts @@ -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).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).mockResolvedValue(true); + (appService.readFile as ReturnType).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).mockResolvedValue(true); + (appService.readFile as ReturnType).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).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'); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/opds-types.test.ts b/apps/readest-app/src/__tests__/services/opds-types.test.ts new file mode 100644 index 00000000..631e5b15 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/opds-types.test.ts @@ -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); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/opds-feed.test.ts b/apps/readest-app/src/__tests__/utils/opds-feed.test.ts index 803c9ec9..85c0313f 100644 --- a/apps/readest-app/src/__tests__/utils/opds-feed.test.ts +++ b/apps/readest-app/src/__tests__/utils/opds-feed.test.ts @@ -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 element but no + 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 = ` @@ -156,4 +166,78 @@ describe('OPDS feed parsing', () => { expect(feed.navigation![0].title).toBe('Sub/'); }); }); + + describe('entry id and updated parsing', () => { + const shelfFeed = ` + + urn:shelf:series:stratechery + Shelf — Stratechery + 2026-01-15T10:30:00.000Z + + Issue One + urn:shelf:issue:abc123 + 2026-01-15T10:30:00.000Z + 2026-01-14T00:00:00.000Z + + + + Issue Two + urn:shelf:issue:def456 + 2026-01-16T08:00:00.000Z + + +`; + + 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 = ` + + Minimal + + No ID Book + + +`; + 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(); + }); + }); }); diff --git a/apps/readest-app/src/app/api/opds/proxy/route.ts b/apps/readest-app/src/app/api/opds/proxy/route.ts index dd4c9934..d9e8fa8d 100644 --- a/apps/readest-app/src/app/api/opds/proxy/route.ts +++ b/apps/readest-app/src/app/api/opds/proxy/route.ts @@ -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) => { + 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 = {}; - 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) { diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 4a5a8ce8..209c226b 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -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); diff --git a/apps/readest-app/src/app/opds/components/CatalogManager.tsx b/apps/readest-app/src/app/opds/components/CatalogManager.tsx index 295c3038..059538b3 100644 --- a/apps/readest-app/src/app/opds/components/CatalogManager.tsx +++ b/apps/readest-app/src/app/opds/components/CatalogManager.tsx @@ -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 + >({}); + const [failedDialogCatalogId, setFailedDialogCatalogId] = useState(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() { )} +
+ +
+ {(() => { + 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 ( +
+ {lastCheckedAt > 0 && ( + + {_('Last synced {{when}}', { + when: dayjs(lastCheckedAt).fromNow(), + })} + + )} + {failedCount > 0 && ( + <> + {lastCheckedAt > 0 && ·} + + + )} +
+ ); + })()}
+
+ +
+
); } diff --git a/apps/readest-app/src/app/opds/components/FailedDownloadsDialog.tsx b/apps/readest-app/src/app/opds/components/FailedDownloadsDialog.tsx new file mode 100644 index 00000000..47367248 --- /dev/null +++ b/apps/readest-app/src/app/opds/components/FailedDownloadsDialog.tsx @@ -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(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 ( + + +
+
+
+

{_('Failed downloads')}

+

{catalogName}

+
+ +
+ + {failed.length === 0 ? ( +

+ {_('No failed downloads')} +

+ ) : ( +
    + {failed.map((entry) => ( +
  • +
    +

    {entry.title}

    +

    {entry.href}

    +

    + {_('Attempts: {{count}}', { count: entry.attempts })} + {' · '} + {dayjs(entry.lastAttemptAt).fromNow()} +

    +
    +
    + + +
    +
  • + ))} +
+ )} + + {failed.length > 0 && ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/apps/readest-app/src/hooks/useOPDSSubscriptions.ts b/apps/readest-app/src/hooks/useOPDSSubscriptions.ts new file mode 100644 index 00000000..0f43bc97 --- /dev/null +++ b/apps/readest-app/src/hooks/useOPDSSubscriptions.ts @@ -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 }; +} diff --git a/apps/readest-app/src/services/opds/autoDownload.ts b/apps/readest-app/src/services/opds/autoDownload.ts new file mode 100644 index 00000000..a74900f4 --- /dev/null +++ b/apps/readest-app/src/services/opds/autoDownload.ts @@ -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 { + 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 = { + '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( + items: T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise> { + 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(); + 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 { + 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, + }; +} diff --git a/apps/readest-app/src/services/opds/feedChecker.ts b/apps/readest-app/src/services/opds/feedChecker.ts new file mode 100644 index 00000000..0b6918d4 --- /dev/null +++ b/apps/readest-app/src/services/opds/feedChecker.ts @@ -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 . 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, + baseURL: string, +): PendingItem[] { + const allPubs: OPDSPublication[] = [ + ...(feed.publications ?? []), + ...(feed.groups?.flatMap((g) => g.publications ?? []) ?? []), + ]; + + const items: PendingItem[] = []; + const seenInBatch = new Set(); + 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, +): 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 | 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, + visited: Set, +): 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 { + const knownIds = new Set(state.knownEntryIds); + const customHeaders = normalizeOPDSCustomHeaders(catalog.customHeaders); + const username = catalog.username ?? ''; + const password = catalog.password ?? ''; + const visited = new Set(); + + 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; +} diff --git a/apps/readest-app/src/services/opds/index.ts b/apps/readest-app/src/services/opds/index.ts new file mode 100644 index 00000000..b01b68fb --- /dev/null +++ b/apps/readest-app/src/services/opds/index.ts @@ -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'; diff --git a/apps/readest-app/src/services/opds/subscriptionState.ts b/apps/readest-app/src/services/opds/subscriptionState.ts new file mode 100644 index 00000000..635e1f49 --- /dev/null +++ b/apps/readest-app/src/services/opds/subscriptionState.ts @@ -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(); + 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 { + 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 { + 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 { + try { + await appService.deleteFile(statePath(catalogId), 'Data'); + } catch { + // File may not exist — that's fine + } +} diff --git a/apps/readest-app/src/services/opds/types.ts b/apps/readest-app/src/services/opds/types.ts new file mode 100644 index 00000000..ca97261b --- /dev/null +++ b/apps/readest-app/src/services/opds/types.ts @@ -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; +} diff --git a/apps/readest-app/src/types/opds.ts b/apps/readest-app/src/types/opds.ts index e2c2426a..1d297893 100644 --- a/apps/readest-app/src/types/opds.ts +++ b/apps/readest-app/src/types/opds.ts @@ -24,10 +24,13 @@ export interface OPDSCatalog { username?: string; password?: string; customHeaders?: Record; + 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[];