From e5eebb78d550eb0a9df743a5e7217094548cf565 Mon Sep 17 00:00:00 2001 From: Efox Date: Fri, 30 Dec 2022 20:50:10 -0300 Subject: [PATCH] v16.7.6 Added support for some more features in M3U playlists: - #EXTINF type="playlist" - [color]...[/color] - #EXTVLCOPT:http-user-agent=? (#21) - http://a.b.c.d:8008/stream.ts|User-Agent=? --- config.xml | 2 +- package.json | 2 +- www/nodejs-project/assets/css/index.css | 4 + www/nodejs-project/lang/ar.json | 4 +- www/nodejs-project/lang/de.json | 4 +- www/nodejs-project/lang/el.json | 4 +- www/nodejs-project/lang/en.json | 10 +- www/nodejs-project/lang/es.json | 4 +- www/nodejs-project/lang/fr.json | 4 +- www/nodejs-project/lang/hi.json | 4 +- www/nodejs-project/lang/it.json | 4 +- www/nodejs-project/lang/pl.json | 4 +- www/nodejs-project/lang/pt.json | 6 +- www/nodejs-project/lang/ru.json | 4 +- www/nodejs-project/lang/sq.json | 4 +- www/nodejs-project/lang/tr.json | 5 +- www/nodejs-project/lang/zh.json | 4 +- www/nodejs-project/main.js | 31 +- .../modules/autoconfig/autoconfig.js | 2 +- www/nodejs-project/modules/base64/base64.js | 2 +- .../modules/channels/channels.js | 51 ++- www/nodejs-project/modules/cloud/cloud.js | 4 +- www/nodejs-project/modules/config/defaults.js | 3 +- .../modules/diagnostics/diagnostics.js | 3 +- .../modules/download/download-cache.js | 42 ++- .../modules/download/download-p2p-client.js | 1 - .../modules/download/download.js | 74 +++- .../modules/download/stream-p2p.js | 3 + .../modules/downloads/downloads.js | 1 - www/nodejs-project/modules/driver/driver.js | 13 +- .../modules/driver/web-worker.js | 3 +- www/nodejs-project/modules/driver/worker.js | 2 + www/nodejs-project/modules/epg/epg.js | 6 +- www/nodejs-project/modules/explorer/client.js | 110 +++++- .../modules/icon-server/icon-server.js | 6 +- .../modules/iptv-stream-info/package.json | 5 - www/nodejs-project/modules/iptv/iptv.js | 4 +- www/nodejs-project/modules/lists/common.js | 154 +++----- .../modules/lists/driver-updater.js | 107 ++---- www/nodejs-project/modules/lists/index.js | 2 +- .../modules/lists/list-index.js | 3 +- www/nodejs-project/modules/lists/list.js | 31 +- www/nodejs-project/modules/lists/list.zip | Bin 0 -> 47341 bytes www/nodejs-project/modules/lists/lists.js | 77 ++-- www/nodejs-project/modules/lists/manager.js | 340 ++++++++++-------- www/nodejs-project/modules/lists/parser.js | 72 +++- .../modules/lists/update-list-index.js | 171 +++++---- www/nodejs-project/modules/omni/omni.js | 5 +- www/nodejs-project/modules/options/options.js | 22 +- www/nodejs-project/modules/search/search.js | 10 +- .../modules/streamer/adapters/base.js | 14 +- .../modules/streamer/engines/yt.js | 2 - .../modules/streamer/streamer.js | 42 ++- .../modules/streamer/utils/downloader.js | 8 +- .../modules/streamer/utils/proxy-hls.js | 4 +- .../modules/streamer/utils/proxy.js | 3 +- .../utils/stream-info.js} | 26 +- .../modules/supercharge/supercharge.js | 2 + www/nodejs-project/modules/theme/theme.js | 2 +- .../modules/tuner/auto-tuner.js | 16 +- www/nodejs-project/modules/tuner/tuner.js | 2 +- .../modules/watching/watching.js | 4 +- www/nodejs-project/modules/zap/zap.js | 2 +- www/nodejs-project/package.json | 2 +- 64 files changed, 925 insertions(+), 637 deletions(-) delete mode 100644 www/nodejs-project/modules/iptv-stream-info/package.json create mode 100644 www/nodejs-project/modules/lists/list.zip rename www/nodejs-project/modules/{iptv-stream-info/iptv-stream-info.js => streamer/utils/stream-info.js} (92%) diff --git a/config.xml b/config.xml index fa676728..9c8db7ad 100644 --- a/config.xml +++ b/config.xml @@ -1,6 +1,6 @@ - + Megacubo An intuitive, free and open source IPTV player. diff --git a/package.json b/package.json index 3af8ad2d..e7018c31 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tv.megacubo.app", "displayName": "Megacubo", - "version": "16.7.5", + "version": "16.7.6", "description": "A intuitive and multi-language IPTV player.", "main": "index.js", "scripts": { diff --git a/www/nodejs-project/assets/css/index.css b/www/nodejs-project/assets/css/index.css index de8c1597..10f4a7ac 100644 --- a/www/nodejs-project/assets/css/index.css +++ b/www/nodejs-project/assets/css/index.css @@ -1465,4 +1465,8 @@ body.menu-playing #main { } #explorer content a.selected span.entry-wrapper.entry-cover-active { box-shadow: 0 1px 2px white; +} +.funny-text { + display: inline-block; + font-weight: bold; } \ No newline at end of file diff --git a/www/nodejs-project/lang/ar.json b/www/nodejs-project/lang/ar.json index 1865568b..7432624c 100644 --- a/www/nodejs-project/lang/ar.json +++ b/www/nodejs-project/lang/ar.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "مسح الملفات المؤقتة ({0}) سيتم تنزيل القوائم والرموز مرة أخرى ، وهذا سيجعل البرنامج أبطأ في الدقائق القليلة القادمة. هل تريد المتابعة؟", "CLOSE": "إغلاق", "COMMUNITY_LISTS": "قوائم المجتمع", - "COMMUNITY_MODE": "وضع المجتمع", - "COMMUNITY_MODE_INTERESTS_HINT": "اذكر هنا اسم القنوات المباشرة والمحتوى الذي ترغب في مشاهدته ، مفصولة بفواصل ، وهذا سيجعل Megacubo يختار القوائم المشتركة الأكثر ملاءمة لك.", + "COMMUNITY_LISTS_INTERESTS_HINT": "اذكر هنا اسم القنوات المباشرة والمحتوى الذي ترغب في مشاهدته ، مفصولة بفواصل ، وهذا سيجعل Megacubo يختار القوائم المشتركة الأكثر ملاءمة لك.", "COMMUNITY_THANKS_YOU": "مجتمع مستخدم Megacubo شكرا لك.", "COMPAT_MODE": "وضع التوافق", "COMPLETE": "مكتمل", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "القوائم المشتركة والمحملة معك", "SHARED_FROM_ALL": "مشاركة من جميع المستخدمين", "SHOULD_RESTART": "أعد تشغيل Megacubo بحيث يكون للتغييرات تأثير", + "SHOW_FUN_LETTERS": "إظهار عناوين ممتعة في الفئة \"{0}\"", "SHOW_LOGOS": "عرض الشعارات", "SHOW_UNSUPPORTED_VERSIONS": "إظهار الإصدارات الغير مدعومة", "SHUTDOWN": "إيقاف التشغيل", diff --git a/www/nodejs-project/lang/de.json b/www/nodejs-project/lang/de.json index 7a6da545..f321bd99 100644 --- a/www/nodejs-project/lang/de.json +++ b/www/nodejs-project/lang/de.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "Durch das Löschen der temporären Dateien ({0}) werden die Listen und Symbole erneut heruntergeladen, dadurch wird das Programm für die nächsten Minuten verlangsamt. Willst du fortfahren?", "CLOSE": "Nah dran", "COMMUNITY_LISTS": "Gemeinschaft listen", - "COMMUNITY_MODE": "Gemeinschaft modus", - "COMMUNITY_MODE_INTERESTS_HINT": "Listen Sie hier den Namen von Live -Kanälen und Inhalten auf, die Sie gerne von Comma getrennt sehen möchten. Dadurch wird Megacubo die am besten geeigneten gemeinsam genutzten Listen für Sie auswählen.", + "COMMUNITY_LISTS_INTERESTS_HINT": "Listen Sie hier den Namen von Live -Kanälen und Inhalten auf, die Sie gerne von Comma getrennt sehen möchten. Dadurch wird Megacubo die am besten geeigneten gemeinsam genutzten Listen für Sie auswählen.", "COMMUNITY_THANKS_YOU": "Megacubo User Community Danke.", "COMPAT_MODE": "Kompatibilitätsmodus", "COMPLETE": "Vollständig", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "Mit Ihnen geteilt und geladen", "SHARED_FROM_ALL": "Von allen Benutzern geteilt", "SHOULD_RESTART": "Starten Sie Megacubo neu, damit die Änderungen wirksam werden", + "SHOW_FUN_LETTERS": "Zeigen Sie lustige Titel in der Kategorie \"{0}\"", "SHOW_LOGOS": "Logos anzeigen.", "SHOW_UNSUPPORTED_VERSIONS": "Zeigen Sie nicht unterstützte Versionen", "SHUTDOWN": "Stilllegen", diff --git a/www/nodejs-project/lang/el.json b/www/nodejs-project/lang/el.json index 471bfe2e..2d553361 100644 --- a/www/nodejs-project/lang/el.json +++ b/www/nodejs-project/lang/el.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "Με την εκκαθάριση των προσωρινών αρχείων ({0}) οι λίστες και τα εικονίδια θα μεταφορτωθούν ξανά, αυτό θα επιβραδύνει το πρόγραμμα για τα επόμενα λεπτά. Θέλετε να συνεχίσετε?", "CLOSE": "Κλείσε", "COMMUNITY_LISTS": "Λίστα κοινότητας", - "COMMUNITY_MODE": "Λειτουργία", - "COMMUNITY_MODE_INTERESTS_HINT": "Λίστα εδώ το όνομα των ζωντανών καναλιών και του περιεχομένου που σας αρέσει να παρακολουθείτε, χωρισμένο από κόμμα, αυτό θα κάνει το Megacubo να επιλέξει τις καταλληλότερες λίστες για εσάς.", + "COMMUNITY_LISTS_INTERESTS_HINT": "Λίστα εδώ το όνομα των ζωντανών καναλιών και του περιεχομένου που σας αρέσει να παρακολουθείτε, χωρισμένο από κόμμα, αυτό θα κάνει το Megacubo να επιλέξει τις καταλληλότερες λίστες για εσάς.", "COMMUNITY_THANKS_YOU": "Η κοινότητα χρηστών Megacubo σας ευχαριστώ.", "COMPAT_MODE": "Λειτουργία συμβατότητας", "COMPLETE": "Πλήρης", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "Μοιράστηκε μαζί σας και φορτωμένο", "SHARED_FROM_ALL": "Που μοιράζονται από όλους τους χρήστες", "SHOULD_RESTART": "Επανεκκινήστε το megacubo για τις αλλαγές που πρέπει να τεθούν σε ισχύ", + "SHOW_FUN_LETTERS": "Εμφάνιση τίτλων διασκέδασης στην κατηγορία \"{0}\"", "SHOW_LOGOS": "Εμφάνιση λογότυπων", "SHOW_UNSUPPORTED_VERSIONS": "Εμφάνιση μη υποστηριζόμενων εκδόσεων", "SHUTDOWN": "ΤΕΡΜΑΤΙΣΜΟΣ ΛΕΙΤΟΥΡΓΙΑΣ", diff --git a/www/nodejs-project/lang/en.json b/www/nodejs-project/lang/en.json index 6bfbf6ea..08c60f69 100644 --- a/www/nodejs-project/lang/en.json +++ b/www/nodejs-project/lang/en.json @@ -86,7 +86,7 @@ "CATEGORY_SPORTS": "Sports", "CATEGORY_TRAVEL": "Travel", "CHANGE_DEST_FOLDER": "Change destination folder", - "CHANGE_HOTKEY_MESSAGE": "Enter a new key combination for this action or press {0} to cancel...", + "CHANGE_HOTKEY_MESSAGE": "Enter a new key combination for this action or press {0} to cancel...", "CHANNEL_ADDED": "Channel added", "CHANNEL_EPG_NOT_FOUND": "EPG not found for this channel", "CHANNEL_LIST_SORTING": "Channel list sorting", @@ -104,7 +104,7 @@ "CLEAR_CACHE_WARNING": "By clearing the temporary files ({0}) the lists and icons will be downloaded again, this will slow down the program for the next few minutes. Do you want to proceed?", "CLOSE": "Close", "COMMUNITY_LISTS": "Community lists", - "COMMUNITY_MODE_INTERESTS_HINT": "List here the name of the live channels and content you like to watch, separated by commas, this will make Megacubo choose the most appropriate shared lists for you.", + "COMMUNITY_LISTS_INTERESTS_HINT": "List here the name of the live channels and content you like to watch, separated by commas, this will make Megacubo choose the most appropriate shared lists for you.", "COMMUNITY_THANKS_YOU": "Megacubo user community thanks you.", "COMPAT_MODE": "Compatibility mode", "COMPLETE": "Complete", @@ -195,8 +195,8 @@ "IMPROVE_YOUR_RECOMMENDATIONS": "Improve your recommendations", "INCORRECT_FORMAT": "Incorrect format", "INSERT_ACTIVATION_KEY": "Insert activation key", - "INSTALL_CORRUPTED": "Oops, your installation has corrupted files. Please reinstall the app.", "INSTALLING": "Installing...", + "INSTALL_CORRUPTED": "Oops, your installation has corrupted files. Please reinstall the app.", "INTERESTS": "Interests", "INVALID_ACTIVATION": "Invalid activation key.", "INVALID_DATE": "Invalid date", @@ -305,6 +305,7 @@ "PROCESSING": "Processing...", "PROVIDER_DISABLE_LISTS": "The added IPTV provider wants to deactivate your other lists for better results.", "PROVIDER_DISABLE_PARENTAL_CONTROL": "The added IPTV provider wants to deactivate parental control.", + "REACH": "Range", "RECEIVED_LISTS": "Received lists", "RECOMMENDATIONS_IMPROVE_HINT": "The more you watch TV shows that you like from your program guide, the better your recommendations will be.", "RECOMMENDATIONS_INITIAL_HINT": "Watch shows from your programming guide to get recommendations.", @@ -366,6 +367,7 @@ "SHARED_AND_LOADED": "Shared with you and loaded", "SHARED_FROM_ALL": "Shared from all users", "SHOULD_RESTART": "Restart Megacubo for the changes to take effect", + "SHOW_FUN_LETTERS": "Show fun titles in category \"{0}\"", "SHOW_LOGOS": "Show logos", "SHOW_UNSUPPORTED_VERSIONS": "Show unsupported versions", "SHUTDOWN": "Shutdown", @@ -432,9 +434,9 @@ "WANT_SHARE_COMMUNITY": "Want to share this list with the community?", "WATCH": "Watch", "WATCHED": "Watched", - "WATCH_NOW": "Watch now", "WATCH_IN_BROWSER": "Watch in a web browser", "WATCH_IN_BROWSER_INFO": "To watch, open your device's web browser and go to the following address:", + "WATCH_NOW": "Watch now", "WELCOME_PREMIUM": "Welcome to Megacubo Premium", "WHAT_TO_WATCH": "What do you wanna watch?", "WHEN_READY_CLICK_BACK": "When ready, click \"{0}\" to save", diff --git a/www/nodejs-project/lang/es.json b/www/nodejs-project/lang/es.json index e9d7e1dd..8c6c6e67 100644 --- a/www/nodejs-project/lang/es.json +++ b/www/nodejs-project/lang/es.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "Al borrar los archivos temporales ({0}) las listas y los iconos se descargarán nuevamente, esto ralentizará el programa durante los próximos minutos. ¿Desea continuar?", "CLOSE": "Cerrar", "COMMUNITY_LISTS": "Listas de la comunidad", - "COMMUNITY_MODE": "Modo comunidad", - "COMMUNITY_MODE_INTERESTS_HINT": "Enumere aquí el nombre de los canales y el contenido en vivo que le gusta ver, separado por Coma, esto hará que Megacubo elija las listas compartidas más apropiadas para usted.", + "COMMUNITY_LISTS_INTERESTS_HINT": "Enumere aquí el nombre de los canales y el contenido en vivo que le gusta ver, separado por Coma, esto hará que Megacubo elija las listas compartidas más apropiadas para usted.", "COMMUNITY_THANKS_YOU": "La comunidad de usuarios de Megacubo te agradece.", "COMPAT_MODE": "Modo de compatibilidad", "COMPLETE": "Completo", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "Compartido contigo y cargado", "SHARED_FROM_ALL": "Compartido de todos los usuarios", "SHOULD_RESTART": "Reinicie la aplicación para que los cambios surtan efecto", + "SHOW_FUN_LETTERS": "Mostrar títulos divertidos en la categoría \"{0}\"", "SHOW_LOGOS": "Mostrar logos", "SHOW_UNSUPPORTED_VERSIONS": "Mostrar versiones no compatibles", "SHUTDOWN": "Shutdown", diff --git a/www/nodejs-project/lang/fr.json b/www/nodejs-project/lang/fr.json index d8f5a1af..bafe49d6 100644 --- a/www/nodejs-project/lang/fr.json +++ b/www/nodejs-project/lang/fr.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "En nettoyant les fichiers temporaires ({0}), les listes et les icônes seront à nouveau téléchargées, cela ralentira le programme pendant les prochaines minutes. Voulez-vous poursuivre?", "CLOSE": "Fermer", "COMMUNITY_LISTS": "Listes de communauté", - "COMMUNITY_MODE": "Mode de communité", - "COMMUNITY_MODE_INTERESTS_HINT": "Liste ici le nom des chaînes en direct et du contenu que vous aimez regarder, séparés par virgule, cela fera que Megacubo choisisse les listes partagées les plus appropriées pour vous.", + "COMMUNITY_LISTS_INTERESTS_HINT": "Liste ici le nom des chaînes en direct et du contenu que vous aimez regarder, séparés par virgule, cela fera que Megacubo choisisse les listes partagées les plus appropriées pour vous.", "COMMUNITY_THANKS_YOU": "Communauté utilisateur Megacubo vous remercie.", "COMPAT_MODE": "Le mode de compatibilité", "COMPLETE": "Compléter", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "Partagé avec vous et chargé", "SHARED_FROM_ALL": "Partagé de tous les utilisateurs", "SHOULD_RESTART": "Redémarrez Megacubo pour que les changements prennent effet", + "SHOW_FUN_LETTERS": "Afficher les titres amusants dans la catégorie \"{0}\"", "SHOW_LOGOS": "Montrer des logos", "SHOW_UNSUPPORTED_VERSIONS": "Afficher les versions non soutenues", "SHUTDOWN": "Fermer", diff --git a/www/nodejs-project/lang/hi.json b/www/nodejs-project/lang/hi.json index 56602846..9f0c9bfa 100644 --- a/www/nodejs-project/lang/hi.json +++ b/www/nodejs-project/lang/hi.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "अस्थायी फ़ाइलों ({0}) को साफ़ करके सूचियों और आइकन को फिर से डाउनलोड किया जाएगा, यह अगले कुछ मिनटों के लिए कार्यक्रम को धीमा कर देगा। क्या आप आगे बढ़ना चाहते हैं?", "CLOSE": "बंद करे", "COMMUNITY_LISTS": "सामुदायिक सूचियाँ", - "COMMUNITY_MODE": "सामुदायिक विधा", - "COMMUNITY_MODE_INTERESTS_HINT": "यहां लाइव चैनलों और सामग्री का नाम सूचीबद्ध करें जिसे आप देखना पसंद करते हैं, कॉमा द्वारा अलग किया गया है, इससे मेगाकाबो आपके लिए सबसे उपयुक्त साझा सूचियों का चयन करेगा।", + "COMMUNITY_LISTS_INTERESTS_HINT": "यहां लाइव चैनलों और सामग्री का नाम सूचीबद्ध करें जिसे आप देखना पसंद करते हैं, कॉमा द्वारा अलग किया गया है, इससे मेगाकाबो आपके लिए सबसे उपयुक्त साझा सूचियों का चयन करेगा।", "COMMUNITY_THANKS_YOU": "Megacubo उपयोगकर्ता समुदाय धन्यवाद।", "COMPAT_MODE": "अनुकूलता प्रणाली", "COMPLETE": "पूर्ण", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "आपके साथ साझा और लोड किया गया", "SHARED_FROM_ALL": "सभी उपयोगकर्ताओं से साझा किया", "SHOULD_RESTART": "प्रभावी होने के लिए परिवर्तनों के लिए मेगाकाबो को पुनरारंभ करें", + "SHOW_FUN_LETTERS": "श्रेणी में मजेदार शीर्षक दिखाएं \"{0}\"", "SHOW_LOGOS": "लोगो दिखाएं", "SHOW_UNSUPPORTED_VERSIONS": "असमर्थित संस्करण दिखाएं", "SHUTDOWN": "बंद करना", diff --git a/www/nodejs-project/lang/it.json b/www/nodejs-project/lang/it.json index e20887c9..a9a2fc83 100644 --- a/www/nodejs-project/lang/it.json +++ b/www/nodejs-project/lang/it.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "Eliminando i file temporanei ({0}) gli elenchi e le icone verranno scaricati di nuovo, questo rallenterà il programma per i prossimi minuti. Continuare?", "CLOSE": "Vicino", "COMMUNITY_LISTS": "Elenchi comunitari", - "COMMUNITY_MODE": "Modalità condivisa", - "COMMUNITY_MODE_INTERESTS_HINT": "Elenca qui il nome dei canali in diretta e dei contenuti che ti piace guardare, separato da virgola, questo farà scegliere MegaCubo gli elenchi condivisi più appropriati per te.", + "COMMUNITY_LISTS_INTERESTS_HINT": "Elenca qui il nome dei canali in diretta e dei contenuti che ti piace guardare, separato da virgola, questo farà scegliere MegaCubo gli elenchi condivisi più appropriati per te.", "COMMUNITY_THANKS_YOU": "La comunità utente di MegaCubo ti ringrazia.", "COMPAT_MODE": "Modalità compatibilità", "COMPLETE": "Completo", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "Condiviso con te e caricato", "SHARED_FROM_ALL": "Condiviso da tutti gli utenti", "SHOULD_RESTART": "Riavviare l'applicazione per rendere effettive le modifiche", + "SHOW_FUN_LETTERS": "Mostra titoli divertenti nella categoria \"{0}\"", "SHOW_LOGOS": "Mostra loghi", "SHOW_UNSUPPORTED_VERSIONS": "Mostra versioni non supportate", "SHUTDOWN": "Spegnere", diff --git a/www/nodejs-project/lang/pl.json b/www/nodejs-project/lang/pl.json index f786160e..c5719164 100644 --- a/www/nodejs-project/lang/pl.json +++ b/www/nodejs-project/lang/pl.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "Wyczyszczając pliki tymczasowe ({0}) listy i ikony zostaną pobrane ponownie, spowalnia program przez następne kilka minut. Czy chcesz kontynuować?", "CLOSE": "Blisko", "COMMUNITY_LISTS": "Listy Communiction", - "COMMUNITY_MODE": "Tryb komunitowy", - "COMMUNITY_MODE_INTERESTS_HINT": "Wypisz tutaj nazwę kanałów i treści na żywo, które lubisz oglądać, oddzielone przecinkiem, dzięki czemu Megacubo wybierze dla Ciebie najbardziej odpowiednie listy udostępnione.", + "COMMUNITY_LISTS_INTERESTS_HINT": "Wypisz tutaj nazwę kanałów i treści na żywo, które lubisz oglądać, oddzielone przecinkiem, dzięki czemu Megacubo wybierze dla Ciebie najbardziej odpowiednie listy udostępnione.", "COMMUNITY_THANKS_YOU": "Społeczność użytkownika MegaCubo dziękuje.", "COMPAT_MODE": "Tryb zgodności", "COMPLETE": "Kompletny", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "Udostępniony z tobą i załadowany", "SHARED_FROM_ALL": "Udostępniony ze wszystkich użytkowników", "SHOULD_RESTART": "Uruchom ponownie Megacubo, aby wprowadzić efekt zmian", + "SHOW_FUN_LETTERS": "Pokaż zabawne tytuły w kategorii „{0}”", "SHOW_LOGOS": "Pokaż logo.", "SHOW_UNSUPPORTED_VERSIONS": "Pokaż nieobsługiwane wersje", "SHUTDOWN": "Zamknąć", diff --git a/www/nodejs-project/lang/pt.json b/www/nodejs-project/lang/pt.json index 576d5fef..6a230b5e 100644 --- a/www/nodejs-project/lang/pt.json +++ b/www/nodejs-project/lang/pt.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "Limpando os arquivos temporários ({0}) as listas e ícones serão baixados novamente, isso deixará o programa mais lento pelos próximos minutos. Deseja prosseguir?", "CLOSE": "Fechar", "COMMUNITY_LISTS": "Listas da comunidade", - "COMMUNITY_MODE": "Modo comunitário", - "COMMUNITY_MODE_INTERESTS_HINT": "Liste aqui o nome dos canais ao vivo e conteúdos que você gosta de assistir, separados por vírgula, isso fará o Megacubo escolher as listas compartilhadas mais apropriadas para você.", + "COMMUNITY_LISTS_INTERESTS_HINT": "Liste aqui o nome dos canais ao vivo e conteúdos que você gosta de assistir, separados por vírgula, isso fará o Megacubo escolher as listas compartilhadas mais apropriadas para você.", "COMMUNITY_THANKS_YOU": "A comunidade de usuários do Megacubo agradece.", "COMPAT_MODE": "Modo de compatibilidade", "COMPLETE": "Completo", @@ -206,7 +205,7 @@ "INVALID_URL_MSG": "O endereço informado não é válido. Tente outro endereço", "IPTV_INFO": "As listas desta seção são fornecidas e mantidas por uma comunidade.", "IPTV_LISTS": "Listas IPTV", - "IPTV_LIST_EXPIRED": "A lista IPTV abaixo pode ter expirado. Verifique outros canais e se eles não abrirem o contato com o seu provedor de IPTV.", + "IPTV_LIST_EXPIRED": "A lista IPTV abaixo pode ter expirado. Verifique outros canais e se eles não abrirem contate o seu provedor de IPTV.", "I_AGREE": "Estou ciente, prosseguir", "KEEP_WATCHING": "Continue assistindo", "KEY_MAPPING": "Mapeamento de teclas", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "Compartilhado com você e carregado", "SHARED_FROM_ALL": "Compartilhado de todos os usuários", "SHOULD_RESTART": "Reinicie o Megacubo para que as alterações tenham efeito", + "SHOW_FUN_LETTERS": "Mostre títulos divertidos na categoria \"{0}\"", "SHOW_LOGOS": "Mostrar logos", "SHOW_UNSUPPORTED_VERSIONS": "Mostre versões não suportadas", "SHUTDOWN": "Desligar", diff --git a/www/nodejs-project/lang/ru.json b/www/nodejs-project/lang/ru.json index f8c2b307..b399ff02 100644 --- a/www/nodejs-project/lang/ru.json +++ b/www/nodejs-project/lang/ru.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "Очистка временных файлов ({0}) списков и значки снова будут загружены, это замедлит программу в течение следующих нескольких минут. Вы хотите продолжить?", "CLOSE": "Закрывать", "COMMUNITY_LISTS": "Коммунальные списки", - "COMMUNITY_MODE": "Коммунационный режим", - "COMMUNITY_MODE_INTERESTS_HINT": "Список здесь название живых каналов и контента, которые вы любите смотреть, разделенные запятыми, это заставит Мегакубо выбрать наиболее подходящие общие списки для вас.", + "COMMUNITY_LISTS_INTERESTS_HINT": "Список здесь название живых каналов и контента, которые вы любите смотреть, разделенные запятыми, это заставит Мегакубо выбрать наиболее подходящие общие списки для вас.", "COMMUNITY_THANKS_YOU": "Сообщество пользователей мегакубо спасибо.", "COMPAT_MODE": "Режим совместимости", "COMPLETE": "Полный", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "Поделиться с вами и загружена", "SHARED_FROM_ALL": "Поделиться от всех пользователей", "SHOULD_RESTART": "Перезапустите MegaCubo для изменения вступления в силу", + "SHOW_FUN_LETTERS": "Показать забавные названия в категории \"{0}\"", "SHOW_LOGOS": "Показать логотипы", "SHOW_UNSUPPORTED_VERSIONS": "Показать неподдерживаемые версии", "SHUTDOWN": "Неисправность", diff --git a/www/nodejs-project/lang/sq.json b/www/nodejs-project/lang/sq.json index 1170cb83..7e94191d 100644 --- a/www/nodejs-project/lang/sq.json +++ b/www/nodejs-project/lang/sq.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "Duke pastruar dosjet e përkohshme ({0}) listat dhe ikonat do të shkarkohen përsëri, kjo do të ngadalësojë programin për disa minuta të ardhshme. A doni të vazhdoni?", "CLOSE": "Afër", "COMMUNITY_LISTS": "Listat komunistë", - "COMMUNITY_MODE": "Modaliteti Komunitar", - "COMMUNITY_MODE_INTERESTS_HINT": "Renditni këtu emrin e kanaleve të drejtpërdrejta dhe përmbajtjes që ju pëlqen të shikoni, të ndara me presje, kjo do të bëjë që Megacubo të zgjedhë listat më të përshtatshme të përbashkëta për ju.", + "COMMUNITY_LISTS_INTERESTS_HINT": "Renditni këtu emrin e kanaleve të drejtpërdrejta dhe përmbajtjes që ju pëlqen të shikoni, të ndara me presje, kjo do të bëjë që Megacubo të zgjedhë listat më të përshtatshme të përbashkëta për ju.", "COMMUNITY_THANKS_YOU": "Komuniteti i Përdoruesit Megacubo Faleminderit.", "COMPAT_MODE": "Modaliteti i pajtueshmërisë", "COMPLETE": "I plotë", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "Ndahet me ju dhe të ngarkuar", "SHARED_FROM_ALL": "Ndahen nga të gjithë përdoruesit", "SHOULD_RESTART": "Restart Megacubo për ndryshimet që do të hyjnë në fuqi", + "SHOW_FUN_LETTERS": "Trego tituj argëtues në kategorinë \"{0}\"", "SHOW_LOGOS": "Trego logot", "SHOW_UNSUPPORTED_VERSIONS": "Tregoni versione të pambështetura", "SHUTDOWN": "Fike", diff --git a/www/nodejs-project/lang/tr.json b/www/nodejs-project/lang/tr.json index 3f5b1a96..f8dbc69e 100644 --- a/www/nodejs-project/lang/tr.json +++ b/www/nodejs-project/lang/tr.json @@ -103,9 +103,7 @@ "CLEAR_CACHE": "Geçici dosyaları temizle", "CLEAR_CACHE_WARNING": "Geçici dosyaları temizleyerek ({0}) Listeler ve simgeler tekrar indirilecektir, bu önümüzdeki birkaç dakika boyunca programı yavaşlatır. Devam etmek istiyor musunuz?", "CLOSE": "Kapat", - "COMMUNITY_LISTS": "Küme Listeleri", - "COMMUNITY_MODE": "Kumsal modu", - "COMMUNITY_MODE_INTERESTS_HINT": "Liste Burada canlı kanalların adı ve izlemek istediğiniz içerik, virgülle ayrılmış, bu megacubo'nun sizin için en uygun paylaşılan listeleri seçmesini sağlayacaktır.", + "COMMUNITY_LISTS_INTERESTS_HINT": "Liste Burada canlı kanalların adı ve izlemek istediğiniz içerik, virgülle ayrılmış, bu megacubo'nun sizin için en uygun paylaşılan listeleri seçmesini sağlayacaktır.", "COMMUNITY_THANKS_YOU": "Megacubo kullanıcı topluluğu teşekkür ederiz.", "COMPAT_MODE": "Uyumluluk modu", "COMPLETE": "Tamamlayınız", @@ -368,6 +366,7 @@ "SHARED_AND_LOADED": "Seninle paylaştı ve yüklü", "SHARED_FROM_ALL": "Tüm kullanıcılardan paylaşılan", "SHOULD_RESTART": "Etkilemek için yapılan değişiklikler için MegaCubo'yu yeniden başlatın", + "SHOW_FUN_LETTERS": "\"{0}\" kategorisinde eğlenceli başlıklar göster", "SHOW_LOGOS": "Logoları göster", "SHOW_UNSUPPORTED_VERSIONS": "Desteklenmemiş sürümleri göster", "SHUTDOWN": "Kapat", diff --git a/www/nodejs-project/lang/zh.json b/www/nodejs-project/lang/zh.json index be1f339e..f1f676f3 100644 --- a/www/nodejs-project/lang/zh.json +++ b/www/nodejs-project/lang/zh.json @@ -104,8 +104,7 @@ "CLEAR_CACHE_WARNING": "清洁临时文件({0})列表和图标将再次下载,这将使程序在接下来的几分钟内较慢。 你想继续吗?", "CLOSE": "关闭", "COMMUNITY_LISTS": "社区清单", - "COMMUNITY_MODE": "社区模式", - "COMMUNITY_MODE_INTERESTS_HINT": "在此处列出您喜欢观看的实时频道和内容的名称,以逗号分隔,这将使Megacubo选择最合适的共享列表。", + "COMMUNITY_LISTS_INTERESTS_HINT": "在此处列出您喜欢观看的实时频道和内容的名称,以逗号分隔,这将使Megacubo选择最合适的共享列表。", "COMMUNITY_THANKS_YOU": "Megacubo用户社区谢谢您。", "COMPAT_MODE": "兼容模式", "COMPLETE": "完全的", @@ -368,6 +367,7 @@ "SHARED_AND_LOADED": "与您分享并加载", "SHARED_FROM_ALL": "来自所有用户的共享", "SHOULD_RESTART": "重新启动Megacubo,使变化有效", + "SHOW_FUN_LETTERS": "显示“ {0}”类别中的有趣标题", "SHOW_LOGOS": "显示频道徽标", "SHOW_UNSUPPORTED_VERSIONS": "显示不支持的版本", "SHUTDOWN": "关闭", diff --git a/www/nodejs-project/main.js b/www/nodejs-project/main.js index 80110977..a430ff79 100644 --- a/www/nodejs-project/main.js +++ b/www/nodejs-project/main.js @@ -126,9 +126,7 @@ ffmpeg = new FFMPEG() lang = false activeEPG = '' isStreamerReady = false -areListsReady = false downloadsInBackground = {} -activeLists = {my: [], community: [], length: 0} displayErr = (...args) => { console.error.apply(null, args) @@ -281,9 +279,6 @@ function init(language){ osd = new OSD() lists = new Lists() lists.setNetworkConnectionState(Download.isNetworkConnected).catch(console.error) - lists.manager.on('lists-updated', () => { - if(setupCompleted()) areListsReady = true - }) autoconfig = new AutoConfig() autoconfig.start().catch(console.error) @@ -534,18 +529,13 @@ function init(language){ group: [] } } - const next = () => { + lists.manager.waitListsReady().then(() => { if(isStreamerReady){ streamer.play(e) } else { playOnLoaded = e } - } - if(parts && !areListsReady){ - lists.manager.once('lists-updated', next) - } else { - next() - } + }).catch(console.error) } } }) @@ -659,7 +649,7 @@ function init(language){ } } const afterListUpdate = async () => { - if(!lists.manager.isUpdating() && !activeLists.length && config.get('communitary-mode-lists-amount')){ + if(!lists.activeLists.length && config.get('communitary-mode-lists-amount')){ lists.manager.UIUpdateLists() } let c = await cloud.get('configure') @@ -673,11 +663,7 @@ function init(language){ console.log('updated') } } - if(areListsReady){ - afterListUpdate().catch(console.error) - } else { - lists.manager.once('lists-updated', () => afterListUpdate().catch(console.error)) - } + lists.manager.waitListsReady().then(afterListUpdate).catch(console.error) analytics = new Analytics() diagnostics = new Diagnostics() @@ -694,7 +680,7 @@ function init(language){ ui.on('streamer-ready', () => { isStreamerReady = true if(!streamer.active){ - let next = () => { + lists.manager.waitListsReady().then(() => { if(playOnLoaded){ streamer.play(playOnLoaded) } else if(config.get('resume')) { @@ -705,12 +691,7 @@ function init(language){ histo.resume() } } - } - if(areListsReady){ - next() - } else { - lists.manager.once('lists-updated', next) - } + }).catch(console.error) } }) ui.once('close', () => { diff --git a/www/nodejs-project/modules/autoconfig/autoconfig.js b/www/nodejs-project/modules/autoconfig/autoconfig.js index c8026ea9..58fdc6d1 100644 --- a/www/nodejs-project/modules/autoconfig/autoconfig.js +++ b/www/nodejs-project/modules/autoconfig/autoconfig.js @@ -19,7 +19,7 @@ class AutoConfig { } } async detect(){ - return await global.Download.promise({ + return await global.Download.get({ url: global.cloud.server +'/configure/auto', responseType: 'json' }) diff --git a/www/nodejs-project/modules/base64/base64.js b/www/nodejs-project/modules/base64/base64.js index d6867689..0beef2e1 100644 --- a/www/nodejs-project/modules/base64/base64.js +++ b/www/nodejs-project/modules/base64/base64.js @@ -35,7 +35,7 @@ class Base64 { } fromHTTP(url){ return new Promise((resolve, reject) => { - global.Download.promise({ + global.Download.get({ url, responseType: 'buffer', retries: 2 diff --git a/www/nodejs-project/modules/channels/channels.js b/www/nodejs-project/modules/channels/channels.js index 6720696f..bf036d62 100644 --- a/www/nodejs-project/modules/channels/channels.js +++ b/www/nodejs-project/modules/channels/channels.js @@ -588,11 +588,9 @@ class ChannelsEditing extends ChannelsEPG { let entries = [] if(global.config.get('show-logos')){ entries.push({name: global.lang.SELECT_ICON, details: o.name, type: 'group', renderer: () => { - console.warn('render icons', terms) return new Promise((resolve, reject) => { let images = [] global.icons.search(terms).then(ms => { - console.warn('render icons', ms, terms) images = images.concat(ms.map(m => m.icon)) }).catch(console.error).finally(() => { let ret = images.map((image, i) => { @@ -750,7 +748,7 @@ class ChannelsEditing extends ChannelsEPG { let category = Object.assign({}, cat) Object.assign(category, {fa: 'fas fa-tasks', path: undefined}) if(useCategoryName !== true){ - Object.assign(category, {name: global.lang.EDIT_CATEGORY, type: 'select', details: category.name}) + Object.assign(category, {name: global.lang.EDIT_CATEGORY, rawName: global.lang.EDIT_CATEGORY, type: 'select', details: category.name}) } category.renderer = (c, e) => { return new Promise((resolve, reject) => { @@ -847,7 +845,37 @@ class ChannelsAutoWatchNow extends ChannelsEditing { } } -class Channels extends ChannelsAutoWatchNow { +class ChannelsKids extends ChannelsAutoWatchNow { + constructor(){ + super() + global.ui.once('init', () => { + global.explorer.addFilter(async (entries, path) => { + const term = global.lang.CATEGORY_KIDS // lang can change in runtime, check the term here so + if(path.substr(term.length * -1) == term){ + entries = entries.map(e => { + if((e.rawName || e.name).indexOf('[') == -1 && ( + (!e.type || e.type == 'stream') || + (e.class && e.class.indexOf('entry-meta-stream') != -1) + )){ + e.rawName = '[fun]'+ e.name +'[|fun]' + } + return e + }) + } else if([global.lang.LIVE, global.lang.MOVIES, global.lang.SERIES].includes(path)) { + entries = entries.map(e => { + if((e.rawName || e.name).indexOf('[') == -1 && e.name == term){ + e.rawName = '[fun]'+ e.name +'[|fun]' + } + return e + }) + } + return entries + }) + }) + } +} + +class Channels extends ChannelsKids { constructor(opts){ super() if(opts){ @@ -865,7 +893,7 @@ class Channels extends ChannelsAutoWatchNow { } } let url = 'https://www.google.com/search?btnI=1&lr=lang_{0}&q={1}'.format(global.lang.locale, encodeURIComponent('"'+ name +'" site')) - const body = String(await Download.promise({url}).catch(console.error)) + const body = String(await Download.get({url}).catch(console.error)) const matches = body.match(new RegExp('href *= *["\']([^"\']*://[^"\']*)')) if(matches && matches[1] && matches[1].indexOf('google.com') == -1){ url = matches[1] @@ -1043,7 +1071,6 @@ class Channels extends ChannelsAutoWatchNow { type: 'live', limit: 1024 }).then(sentries => { - console.warn('sentries', sentries) let entries = sentries.results global.watching.order(entries).then(resolve).catch(err => { resolve(entries) @@ -1320,7 +1347,7 @@ class Channels extends ChannelsAutoWatchNow { keywords(){ return new Promise((resolve, reject) => { this.ready(() => { - let keywords = []; + let keywords = [], badChrs = ['|', '-']; ['histo', 'bookmarks'].forEach(k => { if(global[k]){ global[k].get().forEach(e => { @@ -1331,7 +1358,7 @@ class Channels extends ChannelsAutoWatchNow { this.getDefaultCategories(true).then(data => { keywords = keywords.concat(Object.values(data).flat().map(n => this.expandName(n).terms.name).flat()) }).catch(reject).finally(() => { - keywords = [...new Set(keywords)].filter(w => w.charAt(0) != '-') + keywords = [...new Set(keywords)].filter(w => !badChrs.includes(w.charAt(0))) resolve(keywords) }) }) @@ -1339,10 +1366,10 @@ class Channels extends ChannelsAutoWatchNow { } entries(){ return new Promise((resolve, reject) => { - if(global.lists.manager.isUpdating(true)){ + if(!global.lists.loaded()){ return resolve([global.lists.manager.updatingListsEntry()]) } - if(!global.activeLists.length){ // one list available on index beyound meta watching list + if(!global.lists.activeLists.length){ // one list available on index beyound meta watching list return resolve([global.lists.manager.noListsEntry()]) } const editable = global.config.get('allow-edit-channel-list') && !this.isEPGSyncActive() @@ -1567,10 +1594,10 @@ class Channels extends ChannelsAutoWatchNow { } } async groupsRenderer(type){ - if(global.lists.manager.isUpdating(true)){ + if(!global.lists.loaded()){ return [global.lists.manager.updatingListsEntry()] } - if(!global.activeLists.length){ // one list available on index beyound meta watching list + if(!global.lists.activeLists.length){ // one list available on index beyound meta watching list return [global.lists.manager.noListsEntry()] } const namedGroups = {}, isSerie = type == 'series' diff --git a/www/nodejs-project/modules/cloud/cloud.js b/www/nodejs-project/modules/cloud/cloud.js index c69d41ce..5dc1007e 100644 --- a/www/nodejs-project/modules/cloud/cloud.js +++ b/www/nodejs-project/modules/cloud/cloud.js @@ -22,7 +22,7 @@ class CloudData { this.cachingDomain = 'cloud-' + this.locale + '-' } async testConfigServer(baseUrl){ - let data = await Download.promise({url: baseUrl + '/configure.json', responseType: 'json'}) + let data = await Download.get({url: baseUrl + '/configure.json', responseType: 'json'}) if(data && data.version) return true throw 'Bad config server URL' } @@ -76,7 +76,7 @@ class CloudData { if(this.debug){ console.log('cloud: get', key, url) } - global.Download.promise({ + global.Download.get({ url, responseType: raw === true ? 'text' : 'json', timeout: 60, diff --git a/www/nodejs-project/modules/config/defaults.js b/www/nodejs-project/modules/config/defaults.js index dd2f4302..487fe5f4 100644 --- a/www/nodejs-project/modules/config/defaults.js +++ b/www/nodejs-project/modules/config/defaults.js @@ -50,6 +50,7 @@ module.exports = { 'F1 Ctrl+I': 'ABOUT', 'F11 Alt+Enter': 'FULLSCREEN' }, + 'kids-fun-titles': true, 'lists': [], 'live-window-time': 300, 'locale': '', @@ -76,7 +77,7 @@ module.exports = { 'tune-concurrency': 12, 'tune-ffmpeg-concurrency': 3, 'tuning-icon': 'fas fa-sync-alt', - 'tuning-prefer-hls': true, + 'prefer-hls': true, 'uppercase-menu': false, 'use-epg-channels-list': false, 'use-keepalive': true, diff --git a/www/nodejs-project/modules/diagnostics/diagnostics.js b/www/nodejs-project/modules/diagnostics/diagnostics.js index d6970802..babe4096 100644 --- a/www/nodejs-project/modules/diagnostics/diagnostics.js +++ b/www/nodejs-project/modules/diagnostics/diagnostics.js @@ -18,6 +18,7 @@ class Diagnostics extends Events { const listsRequesting = global.listsRequesting const listsUpdating = await global.lists.manager.updater.info() const tuning = global.tuning ? global.tuning.logText(false) : '' + const updaterResults = global.lists.manager.updaterResults ['lists', 'parental-control-terms', 'parental-control-pw', 'premium-license'].forEach(k => delete config[k]) Object.keys(lists).forEach(url => { lists[url].owned = myLists.includes(url) @@ -32,7 +33,7 @@ class Diagnostics extends Events { diskSpace.free = global.kbfmt(diskSpace.free) diskSpace.size = global.kbfmt(diskSpace.size) } - return {diskSpace, freeMem, config, lists, listsRequesting, listsUpdating, tuning} + return {diskSpace, freeMem, config, lists, listsRequesting, listsUpdating, updaterResults, tuning} } async saveReport(){ const file = global.downloads.folder +'/report.txt' diff --git a/www/nodejs-project/modules/download/download-cache.js b/www/nodejs-project/modules/download/download-cache.js index 65aac46f..0c67685b 100644 --- a/www/nodejs-project/modules/download/download-cache.js +++ b/www/nodejs-project/modules/download/download-cache.js @@ -237,6 +237,7 @@ class DownloadCacheMap extends Events { constructor(){ super() this.index = {} + this.debug = false this.minDiskAllocation = 100 * (1024 * 1024) // 100MB this.maxDiskAllocation = 1024 * (1024 * 1024) // 1GB this.maxMaintenanceInterval = 60 @@ -250,11 +251,13 @@ class DownloadCacheMap extends Events { } async reload(){ const data = await this.readIndexFile() - Object.keys(data).forEach(url => { - if(typeof(this.index[url]) == 'undefined' || (this.index[url].ttl < data[url].ttl)){ - this.index[url] = data[url] - } - }) + if(typeof(data) == 'object'){ + Object.keys(data).forEach(url => { + if(typeof(this.index[url]) == 'undefined' || (this.index[url].ttl < data[url].ttl)){ + this.index[url] = data[url] + } + }) + } } async readIndexFile(){ let ret = {} @@ -284,7 +287,9 @@ class DownloadCacheMap extends Events { const file = String(this.index[url].data) if(!caches.includes(file) && this.index[url].type == 'file' && this.index[url].size > 0){ if(!changed) changed = true - console.warn('DLCACHE RM file missing') + if(this.debug){ + console.warn('DLCACHE RM file missing') + } delete this.index[url] // cache file missing } }) @@ -292,12 +297,16 @@ class DownloadCacheMap extends Events { const indexFiles = Object.values(this.index).map(r => String(r.data)) caches.forEach(file => { if(!indexFiles.includes(file) && file != this.indexFile){ - console.warn('DLCACHE RM orphaned file', file, Object.values(this.index).filter(r => r.type == 'file').map(r => String(r.data))) + if(this.debug){ + console.warn('DLCACHE RM orphaned file', file, Object.values(this.index).filter(r => r.type == 'file').map(r => String(r.data))) + } fs.promises.unlink(file).catch(console.error) } }) } - console.warn('DLCACHE RM result', Object.keys(this.index)) + if(this.debug){ + console.warn('DLCACHE RM result', Object.keys(this.index)) + } this.emit('update', this.export()) } else if(caches.length) { // index file missing this.truncate() @@ -318,7 +327,9 @@ class DownloadCacheMap extends Events { } truncate(){ if(Object.keys(this.index).length){ - console.warn('DLCACHE RM truncate', global.traceback()) + if(this.debug){ + console.warn('DLCACHE RM truncate', global.traceback()) + } this.index = {} global.rmdir && global.rmdir(this.folder, false, () => {}) this.emit('update', this.export()) @@ -367,13 +378,15 @@ class DownloadCacheMap extends Events { }) } if(expired.length){ - console.warn('DLCACHE RM expired', expired) - expired.forEach(row => { + if(this.debug){ + console.warn('DLCACHE RM expired', expired) + } + for(let row of expired){ if(this.index[row.url].type == 'file'){ fs.promises.unlink(this.index[row.url].data).catch(console.error) } delete this.index[row.url] - }) + } this.emit('update', this.export()) } this.saveIndex().catch(console.error) @@ -386,7 +399,9 @@ class DownloadCacheMap extends Events { } ms *= 1000 this.maintenanceTimer && clearTimeout(this.maintenanceTimer) - console.warn('DLCACHE maintenance', ms) + if(this.debug){ + console.warn('DLCACHE maintenance', ms) + } this.maintenanceTimer = setTimeout(() => this.maintenance().catch(console.error), ms) } async saveIndex(){ @@ -438,7 +453,6 @@ class DownloadCacheMap extends Events { this.emit('update', this.export()) } if(this.index[url] && this.index[url].type == 'saving' && this.index[url].uid == opts.uid) { - let hasErr if(chunk){ this.index[url].size = this.index[url].chunks.size this.index[url].chunks.push(chunk) diff --git a/www/nodejs-project/modules/download/download-p2p-client.js b/www/nodejs-project/modules/download/download-p2p-client.js index 6446a326..f74e297b 100644 --- a/www/nodejs-project/modules/download/download-p2p-client.js +++ b/www/nodejs-project/modules/download/download-p2p-client.js @@ -285,7 +285,6 @@ class P2PRequest extends P2PEncDec { const finished = timeouted || vs.length >= this.clients.length const failed = finished && vs.every(s => s == -1) const candidates = ks.filter(i => validate(i)) - console.log('PUMP', finished, failed, timeouted) if(finished && failed) { // no peer has the file this.fail('No peer has the file.') } else { diff --git a/www/nodejs-project/modules/download/download.js b/www/nodejs-project/modules/download/download.js index ae65181f..2b6d8b63 100644 --- a/www/nodejs-project/modules/download/download.js +++ b/www/nodejs-project/modules/download/download.js @@ -13,7 +13,7 @@ class Download extends Events { p2p: false, cacheTTL: 0, uid: parseInt(Math.random() * 10000000000000), - debug: false, + debug: Download.debug || false, keepalive: false, maxAuthErrors: 2, maxAbortErrors: 2, @@ -108,7 +108,7 @@ class Download extends Events { const now = global.time() if(this.opts.authURL && now > this.authURLPingAfter){ this.authURLPingAfter = now + 10 - Download.promise({ + Download.get({ url: this.opts.authURL, timeout: 10, retry: 0, @@ -151,20 +151,17 @@ class Download extends Events { return String(url).split('?')[0].split('#')[0].split('.').pop().toLowerCase(); } getDomain(u, includePort){ + let d = u if(u && u.indexOf('//') != -1){ - let d = u.split('//')[1].split('/')[0] - if(d == 'localhost' || d.indexOf('.') != -1) { - if(d.indexOf(':') != -1) { - if(!includePort) { - d = d.split(':')[0] - } else if(d.substr(-3) == ':80') { - d = d.substr(0, d.length - 3) - } - } - return d - } + d = u.split('//')[1].split('/')[0] + } + if(d.indexOf('@') != -1){ + d = d.split('@')[1] + } + if(d.indexOf(':') != -1 && !includePort) { + d = d.split(':')[0] } - return '' + return d } titleCaseHeaders(headers){ const nheaders = {} @@ -195,8 +192,8 @@ class Download extends Events { } checkRequestingRange(range){ const ranges = this.parseRange(range) - if (Array.isArray(ranges)) { // TODO: enable multi-ranging support - this.requestingRange = ranges[0] + if (ranges && typeof(ranges.start) == 'number') { // TODO: enable multi-ranging support + this.requestingRange = ranges if(this.requestingRange.end && this.requestingRange.end > 0){ this.contentLength = (this.requestingRange.end - this.requestingRange.start) + 1 } @@ -303,6 +300,10 @@ class Download extends Events { } } requestHeaders.host = this.getDomain(opts.url, true) + const match = opts.url.match(new RegExp('//([^/]+:[^/]+)@')) + if(match){ + requestHeaders.authorization = 'Basic '+ Buffer.from(match[1]).toString('base64') + } opts.headers = requestHeaders opts.timeout = this.getTimeoutOptions() if(this.opts.debug) { @@ -503,9 +504,12 @@ class Download extends Events { this.statusCode = 200 } if(this.opts.debug){ - console.log('>> Download response emit', this.statusCode, headers, this.isResponseCompressed) + console.log('>> Download response emit', this, this.requestingRange, this.statusCode, headers, this.isResponseCompressed) } this.headersSent = true + if(this.totalContentLength > 0 && !this.requestingRange){ + headers['content-length'] = this.totalContentLength + } this.emit('response', this.statusCode, headers) } response.on('data', chunk => { @@ -988,7 +992,41 @@ Download.isNetworkConnected = true Download.setNetworkConnectionState = state => { Download.isNetworkConnected = state } -Download.promise = (...args) => { +Download.head = (...args) => { + let _reject, g, resolved, opts = args[0] + let promise = new Promise((resolve, reject) => { + _reject = reject + g = new Download(opts) + g.once('error', err => { + if(resolved) return + resolved = true + reject(err) + }) + g.once('response', (statusCode, headers) => { + if(resolved) return + resolved = true + // console.log('Download', g, global.traceback(), buf) + resolve({statusCode, headers}) + g.destroy() + }) + g.once('end', buf => { + if(resolved) return + resolved = true + // console.log('Download', g, global.traceback(), buf) + reject('no response') + g.destroy() + }) + g.start() + }) + promise.cancel = () => { + if(!g.ended){ + _reject('Promise was cancelled') + g.destroy() + } + } + return promise +} +Download.get = (...args) => { let _reject, g, resolved, opts = args[0] let promise = new Promise((resolve, reject) => { _reject = reject diff --git a/www/nodejs-project/modules/download/stream-p2p.js b/www/nodejs-project/modules/download/stream-p2p.js index 8ff2e78f..681eacba 100644 --- a/www/nodejs-project/modules/download/stream-p2p.js +++ b/www/nodejs-project/modules/download/stream-p2p.js @@ -57,6 +57,9 @@ class DownloadStreamP2P extends DownloadStream { } } this.lmap['download-p2p-response-data-'+ this.opts.uid] = data => { + if(!this.response){ + return this.emitError('Bad P2P response order', true) + } this.setTimeout(10000) // reset it if(!data || !data.data || !data.data.length) return if(data.range){ diff --git a/www/nodejs-project/modules/downloads/downloads.js b/www/nodejs-project/modules/downloads/downloads.js index f0e3001a..aed66eed 100644 --- a/www/nodejs-project/modules/downloads/downloads.js +++ b/www/nodejs-project/modules/downloads/downloads.js @@ -287,7 +287,6 @@ class Downloads extends Events { } } clear(){ - return console.log('serve clear', traceback()) fs.access(this.folder, error => { if (error) { diff --git a/www/nodejs-project/modules/driver/driver.js b/www/nodejs-project/modules/driver/driver.js index b143ed21..5c4c871a 100644 --- a/www/nodejs-project/modules/driver/driver.js +++ b/www/nodejs-project/modules/driver/driver.js @@ -59,10 +59,13 @@ module.exports = (file, opts) => { } terminate(){ this.finished = true - global.config.removeListener('change', this.configChangeListener) - if(this.worker && this.worker.terminate){ - this.worker.terminate() - this.worker = null + this.configChangeListener && global.config.removeListener('change', this.configChangeListener) + if(this.worker){ + //this.worker.postMessage({method: 'unload', id: 0}) + setTimeout(() => { // prevent closing by bug in nwjs + this.worker.terminate() + this.worker = null + }, 5000) } this.removeAllListeners() } @@ -95,6 +98,7 @@ module.exports = (file, opts) => { }, true, true) this.worker.on('exit', () => { this.finished = true + this.worker = null console.warn('Worker exit. ' + file, this.err) }) this.worker.on('message', ret => { @@ -140,6 +144,7 @@ module.exports = (file, opts) => { if(typeof(err.preventDefault) == 'function'){ err.preventDefault() } + global.crashlog.save('Worker error at '+ file.split('/').pop() +': ', err) return true } this.worker.onmessage = e => { diff --git a/www/nodejs-project/modules/driver/web-worker.js b/www/nodejs-project/modules/driver/web-worker.js index 00491e66..54f5fe78 100644 --- a/www/nodejs-project/modules/driver/web-worker.js +++ b/www/nodejs-project/modules/driver/web-worker.js @@ -33,8 +33,9 @@ const Driver = require(file), driver = new Driver() onmessage = e => { const msg = e.data if(msg.method == 'configChange'){ - //console.log('CONFIG CHANGED!', file) global.config.reload() + } else if(msg.method == 'unload'){ + //setTimeout(() => close(), 10) // caused NW.js to close } else if(typeof(driver[msg.method]) == 'undefined'){ postMessage({id: msg.id, type: 'reject', data: 'method not exists ' + JSON.stringify(msg.data)}) } else { diff --git a/www/nodejs-project/modules/driver/worker.js b/www/nodejs-project/modules/driver/worker.js index b77f4bbd..3441e6cd 100644 --- a/www/nodejs-project/modules/driver/worker.js +++ b/www/nodejs-project/modules/driver/worker.js @@ -40,6 +40,8 @@ parentPort.on('message', msg => { setTimeout(() => { global.config.reload() // read again after some seconds, the config file may delay on writing }, 3000) + } else if(msg.method == 'unload'){ + // close() } else if(typeof(driver[msg.method]) == 'undefined'){ data = {id: msg.id, type: 'reject', data: 'method not exists'} parentPort.postMessage(data) diff --git a/www/nodejs-project/modules/epg/epg.js b/www/nodejs-project/modules/epg/epg.js index ee2930bc..02748b68 100644 --- a/www/nodejs-project/modules/epg/epg.js +++ b/www/nodejs-project/modules/epg/epg.js @@ -143,7 +143,7 @@ class EPG extends EPGPaginateChannelsList { return } hasErr = true - console.error('EPG FAILED DEBUG', initialBuffer) + //console.error('EPG FAILED DEBUG', initialBuffer) errorCount++ console.error(err) if(errorCount >= 128){ @@ -169,7 +169,7 @@ class EPG extends EPGPaginateChannelsList { this.applyMetaCache() this.clean() this.save() - this.parser.destroy() + this.parser && this.parser.destroy() // TypeError: Cannot read property 'destroy' of null this.parser = null this.scheduleNextUpdate() }) @@ -605,8 +605,6 @@ class EPG extends EPGPaginateChannelsList { maxScore = score candidates.push({name, score}) } - } else { - console.error('Not an array', this.terms[name], name) } }) } diff --git a/www/nodejs-project/modules/explorer/client.js b/www/nodejs-project/modules/explorer/client.js index 2f69234d..961c1445 100644 --- a/www/nodejs-project/modules/explorer/client.js +++ b/www/nodejs-project/modules/explorer/client.js @@ -900,7 +900,80 @@ class ExplorerPointer extends ExplorerSelectionMemory { } } -class ExplorerModal extends ExplorerPointer { +class ExplorerBBCode extends ExplorerPointer { + constructor(jQuery, container, app){ + super(jQuery, container, app) + this.funnyTextColors = ['#3F0', '#39F', '#8d45ff', '#ff02c9', '#e48610'] + this.bbCodeMap = { + 'b': (fragment) => { + if(fragment.substr(0, 2) == '[|'){ + return '' + } + return '' + }, + 'i': (fragment) => { + if(fragment.substr(0, 2) == '[|'){ + return '' + } + return '' + }, + 'color': (fragment, name) => { + if(fragment.substr(0, 2) == '[|'){ + return '' + } + let color = name.split(' ').slice(1).join(' ') + return '' + } + } + + } + removeBBCode(text){ + return text.replace(new RegExp('\\[\\|?([^\\]]*)\\]', 'g'), '') + } + parseBBCode(text){ + if(typeof(this.replaceBBCodeFnPtr) == 'undefined'){ + this.replaceBBCodeFnPtr = this.replaceBBCode.bind(this) + this.replaceBBCodeFunnyTextFnPtr = this.replaceBBCodeFunnyText.bind(this) + } + text = text.replace(new RegExp('\\[fun\\]([^\\[]*)\\[\\|?fun\\]', 'gi'), this.replaceBBCodeFunnyTextFnPtr) + return text.replace(new RegExp('\\[\\|?([^\\]]*)\\]', 'g'), this.replaceBBCodeFnPtr) + } + replaceBBCode(fragment, name, i, text){ + const tag = name.split(' ').shift().toLowerCase() + if(!this.bbCodeMap[tag]) { + return text + } + return this.bbCodeMap[tag](fragment, name, text) + } + replaceBBCodeFunnyText(fragment, name, i, text){ + return this.makeFunnyText(name) + } + makeFunnyText(text){ + if(text.indexOf('&') != -1 && text.indexOf('&') != -1){ + text = jQuery('').html(text).text() + } + if(!config['kids-fun-titles']){ + return text + } + const lettersPerColor = (text.length > 15 ? 3 : (text.length > 5 ? 2 : 1)) + const scales = [1, 0.9, 0.8] + const a = (this.funnyTextColors.length * lettersPerColor) + return text.split('').map((chr, i) => { + i-- + const scale = i < 2 ? 1 : scales[Math.floor(Math.random()*scales.length)] + const oi = i + const r = Math.floor(i / a) + if(r){ + i -= r * a + } + const j = Math.floor(i / (lettersPerColor)) + if(chr == ' ') chr = ' ' + return ''+chr+'' + }).join('') + } +} + +class ExplorerModal extends ExplorerBBCode { constructor(jQuery, container, app){ super(jQuery, container, app) this.inputHelper = new ExplorerURLInputHelper() @@ -913,7 +986,7 @@ class ExplorerModal extends ExplorerPointer { if(!this.inModalMandatory()){ this.endModal() } - } + } }) } plainText(html) { @@ -948,11 +1021,22 @@ class ExplorerModal extends ExplorerPointer { } } replaceTags(text, replaces, noSlashes) { + if(replaces['name'] && !replaces['rawName']){ + replaces['rawName'] = replaces['name'] + } Object.keys(replaces).forEach(before => { let t = typeof(replaces[before]) if(['string', 'number', 'boolean'].indexOf(t) != -1){ let addSlashes = typeof(noSlashes) == 'boolean' ? !noSlashes : (t == 'string' && replaces[before].indexOf('<') == -1) - text = text.split('{' + before + '}').join(addSlashes ? this.addSlashes(replaces[before]) : String(replaces[before])) + let to = addSlashes ? this.addSlashes(replaces[before]) : String(replaces[before]) + if(to.indexOf('[') != -1){ + if(before == 'name'){ + to = this.removeBBCode(to) + } else if(before == 'rawName') { + to = this.parseBBCode(to) + } + } + text = text.split('{' + before + '}').join(to) if(text.indexOf("\r\n") != -1){ text = text.replace(new RegExp('\r\n', 'g'), '
') } @@ -1286,10 +1370,10 @@ class ExplorerDialog extends ExplorerDialogQueue { {template: 'option', text: 'OK', id: 'submit', fa: 'fas fa-check-circle'} ]; this.dialog(mpt, id => { - if(this.debug){ - console.log('infocb', complete) - } if(!complete){ + if(this.debug){ + console.log('infocb', id, complete) + } complete = true this.endModal() if(typeof(cb) == 'function'){ @@ -1716,11 +1800,9 @@ class Explorer extends ExplorerLoading { } }) this.app.on('render', (entries, path, icon) => { - //console.log('ENTRIES', path, entries, icon) this.render(entries, path, icon) }) this.app.on('explorer-select', (entries, path, icon) => { - //console.log('ENTRIES', path, entries, icon) this.setupSelect(entries, path, icon) }) this.app.on('info', (a, b, c) => { @@ -1765,7 +1847,7 @@ class Explorer extends ExplorerLoading { {details} @@ -1779,7 +1861,7 @@ class Explorer extends ExplorerLoading { @@ -1793,7 +1875,7 @@ class Explorer extends ExplorerLoading { {details} @@ -1808,7 +1890,7 @@ class Explorer extends ExplorerLoading { {details} {value} @@ -1823,7 +1905,7 @@ class Explorer extends ExplorerLoading { {value} @@ -1838,7 +1920,7 @@ class Explorer extends ExplorerLoading { {details} diff --git a/www/nodejs-project/modules/icon-server/icon-server.js b/www/nodejs-project/modules/icon-server/icon-server.js index 0162dd6f..051e79e2 100644 --- a/www/nodejs-project/modules/icon-server/icon-server.js +++ b/www/nodejs-project/modules/icon-server/icon-server.js @@ -47,7 +47,7 @@ class IconDefault { }) } saveDefault(terms, data, cb){ - const updating = global.lists.manager.isUpdating(true) || !global.activeLists.length // we may find a better logo after + const updating = !global.lists.loaded() || !global.lists.activeLists.length // we may find a better logo after if(!updating && terms && terms.length){ let name = this.prepareDefaultName(terms) + '.png', file = this.opts.folder + path.sep + name if(this.opts.debug){ @@ -65,7 +65,7 @@ class IconDefault { } } saveDefaultFile(terms, sourceFile, cb){ - if(global.lists.manager.isUpdating(true) || !global.activeLists.length){ // we may find a better logo later + if(!global.lists.loaded() || !global.lists.activeLists.length){ // we may find a better logo later if(cb) cb() return } @@ -429,7 +429,7 @@ class IconServer extends IconFetchSem { this.listen() } listsLoaded(){ - return !global.lists.manager.isUpdating(true) && global.activeLists.length + return global.lists.loaded() && global.lists.activeLists.length } debug(...args){ global.osd.show(Array.from(args).map(s => String(s)).join(', '), 'fas fa-info-circle', 'active-downloads', 'persistent') diff --git a/www/nodejs-project/modules/iptv-stream-info/package.json b/www/nodejs-project/modules/iptv-stream-info/package.json deleted file mode 100644 index 9ffbeeeb..00000000 --- a/www/nodejs-project/modules/iptv-stream-info/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "iptv-stream-info", - "main": "iptv-stream-info.js" - } - \ No newline at end of file diff --git a/www/nodejs-project/modules/iptv/iptv.js b/www/nodejs-project/modules/iptv/iptv.js index c9dce50a..1b5dab03 100644 --- a/www/nodejs-project/modules/iptv/iptv.js +++ b/www/nodejs-project/modules/iptv/iptv.js @@ -51,7 +51,7 @@ class IPTV extends Events { if(!url){ throw 'unknown file' } - let body = await global.Download.promise({ + let body = await global.Download.get({ url, responseType: file ? 'text' : 'json', timeout: 60, @@ -114,7 +114,7 @@ class IPTV extends Events { renderer: async () => { let list = await global.lists.directListRendererParse(await this.get(name)) let url = this.url(name) - if(global.activeLists.my.includes(url)){ + if(global.lists.activeLists.my.includes(url)){ list.unshift({ type: 'action', name: global.lang.LIST_ALREADY_ADDED, diff --git a/www/nodejs-project/modules/lists/common.js b/www/nodejs-project/modules/lists/common.js index d29dd43c..b7e6d584 100644 --- a/www/nodejs-project/modules/lists/common.js +++ b/www/nodejs-project/modules/lists/common.js @@ -2,16 +2,53 @@ const Events = require('events'), fs = require('fs'), ParentalControl = require(global.APPDIR + '/modules/lists/parental-control') const M3UParser = require(global.APPDIR + '/modules/lists/parser'), M3UTools = require(global.APPDIR + '/modules/lists/tools'), MediaStreamInfo = require(global.APPDIR + '/modules/lists/media-info') const Parser = require(global.APPDIR + '/modules/lists/parser') +const List = require(global.APPDIR + '/modules/lists/list') +const UpdateListIndex = require(global.APPDIR + '/modules/lists/update-list-index') LIST_DATA_KEY_MASK = 'list-data-1-{0}' class Fetcher extends Events { - constructor(){ + constructor(url, atts, master){ super() - this.cancelables = [] - this.minDataLength = 512 - this.maxDataLength = 32 * (1024 * 1024) // 32Mb - this.ttl = 24 * 3600 + this.atts = atts + this.url = url + this.playlists = [] + this.master = master + this.file = global.storage.raw.resolve(global.LIST_DATA_KEY_MASK.format(url)) + process.nextTick(() => { + this.start().catch(console.error).finally(() => { + this.isReady = true + this.emit('ready') + }) + }) + } + ready(){ + return new Promise((resolve, reject) => { + if(this.isReady){ + resolve() + } else { + this.once('ready', resolve) + } + }) + } + start(){ + return new Promise((resolve, reject) => { + this.list = new List(this.url, this.master, []) + this.list.start().then(resolve).catch(err => { + this.updater = new UpdateListIndex(this.url, this.url, this.file, this.master, {}) + this.updater.start().then(() => { + this.list.start().then(resolve).catch(err => { + this.list.destroy() + this.updater.destroy() + reject(err) + }) + }).catch(err => { + this.list.destroy() + this.updater.destroy() + reject(err) + }) + }) + }) } validateCache(content){ return typeof(content) == 'string' && content.length >= this.minDataLength @@ -29,104 +66,9 @@ class Fetcher extends Events { } } } - fetch(path, atts){ - return new Promise((resolve, reject) => { - if(path.substr(0, 2)=='//'){ - path = 'http:' + path - } - if(this.isLocal(path)){ - let stream = fs.createReadStream(path), entries = [], parser = new Parser() - if(atts){ - if(atts.meta){ - parser.on('meta', meta => atts.meta(meta)) - } - } - parser.on('entry', e => entries.push(e)) - parser.once('end', () => { - stream.destroy() - stream = null - if(entries.length){ - resolve(entries) - } else { - reject(global.lang.INVALID_URL_MSG) - } - parser.destroy() - }) - stream.on('data', chunk => { - parser.write(chunk) - }) - stream.once('close', () => { - parser.end() - }) - } else if(path.match('^https?:')) { - const dataKey = LIST_DATA_KEY_MASK.format(path) - global.storage.raw.get(dataKey, data => { - const process = () => { - const opts = { - url: path, - keepalive: false, - retries: 10, - followRedirect: true, - headers: { - 'accept-charset': 'utf-8, *;q=0.1' - }, - downloadLimit: 28 * (1024 * 1024), // 28Mb - p2p: true, - cacheTTL: 3600 - } - let entries = [], stream = new global.Download(opts) - stream.once('response', (statusCode, headers) => { - if(statusCode >= 200 && statusCode < 300) { - let parser = new Parser(stream) - if(atts){ - if(atts.meta){ - parser.on('meta', meta => atts.meta(meta)) - } - if(atts.progress){ - stream.on('progress', p => atts.progress(p)) - } - } - parser.on('entry', entry => { - entries.push(entry) - }) - parser.once('end', () => { - stream.destroy() - stream = null - if(entries.length){ - global.storage.raw.set(dataKey, entries.map(JSON.stringify).join("\r\n"), true) - resolve(entries) - } else { - reject(global.lang.INVALID_URL_MSG) - } - parser.destroy() - }) - } else { - stream.destroy() - stream = null - reject('http error '+ statusCode) - } - }) - stream.start() - } - if(this.validateCache(data)){ - try{ // SyntaxError: Unexpected token \u0003 in JSON at position 73 - let entries = data.split("\n").filter(s => s.length > 8).map(global.parseJSON) - let last = entries.length - 1 - if(entries[last].length){ // remove index entry - entries.splice(last, 1) - } - resolve(entries) - } catch(e) { - process() - } - } else { - process() - } - }) - } else { - reject('bad URL') - } - }) + async fetch(){ + await this.ready() + return await this.list.fetchAll() } } @@ -159,7 +101,7 @@ class Common extends Events { if(typeof(n) != 'number'){ n = global.config.get('communitary-mode-lists-amount') } - return Math.min(n * satisfyLevel, foundCommunityListsCount) + return Math.min(Math.floor(n * satisfyLevel), foundCommunityListsCount) } loadSearchRedirects(){ if(!this.searchRedirects.length){ @@ -168,7 +110,7 @@ class Common extends Events { if(err){ console.error(err) } else { - let data = global.parseJSON(String(content)) + let data = global.parseJSON(content) if(data && typeof(data) == 'object'){ let results = [] Object.keys(data).forEach(k => { @@ -211,7 +153,7 @@ class Common extends Events { }) txt = txt.toLowerCase() let tms = this.applySearchRedirects(txt.replace(this.parser.regexes['plus-signal'], 'plus'). - replace(this.parser.regexes['between-brackets'], ' $1 '). + replace(this.parser.regexes['between-brackets'], ''). normalize('NFD').toLowerCase().replace(this.parser.regexes['accents'], ''). // replace/normalize accents split(' '). map(s => { diff --git a/www/nodejs-project/modules/lists/driver-updater.js b/www/nodejs-project/modules/lists/driver-updater.js index fa8c592b..5b39e2d5 100644 --- a/www/nodejs-project/modules/lists/driver-updater.js +++ b/www/nodejs-project/modules/lists/driver-updater.js @@ -46,95 +46,63 @@ class ListsUpdater extends Common { this.racing = new ConnRacing(urls, {retries: 1, timeout: 5}) const retries = [] urls.forEach(url => this.info[url] = 'started') - async.eachOfLimit(urls, this.updateListsConcurrencyLimit, (url, i, done) => { - if(this.racing.ended){ - if(this.debug){ - console.log('updater - racing ended') - } - done() - } else { - this.racing.next(res => { - if(res && res.valid){ - this.info[res.url] = 'updating' - if(this.debug){ - console.log('updater - updating', res.url) - } - this.updateList(res.url).then(updated => { - this.info[res.url] = 'updated' - if(this.debug){ - console.log('updater - updated', res.url, updated) - } - if(updated){ - emit('list-updated', res.url) - } - }).catch(err => { - this.info[res.url] = 'update failed, '+ String(err) - console.error('updater - err: '+ err, global.traceback()) - }).finally(done) - } else { - this.info[res.url] = 'failed, '+ JSON.stringify(res) - if(this.debug){ - console.log('updater - failed', res.url, res) - } - if(res){ - retries.push(res.url) - } - done() + const run = (urls, cb) => { + async.eachOfLimit(urls, this.updateListsConcurrencyLimit, (url, i, done) => { + if(this.racing.ended){ + if(this.debug){ + console.log('updater - racing ended') } - }) - } - }, () => { - this.racing.end() - - // now retry the failed ones - if(this.debug){ - console.log('updater - retry', retries) - } - this.retryRacing = new ConnRacing(retries, {retries: 3, timeout: 20}) - async.eachOfLimit(retries, this.updateListsConcurrencyLimit, (url, i, done) => { - if(this.retryRacing.ended){ done() } else { - this.retryRacing.next(res => { + this.racing.next(res => { if(res && res.valid){ + this.info[res.url] = 'updating' if(this.debug){ console.log('updater - updating', res.url) } this.updateList(res.url).then(updated => { + this.info[res.url] = 'already updated' if(this.debug){ console.log('updater - updated', res.url, updated) } - done() if(updated){ + this.info[res.url] = 'updated' emit('list-updated', res.url) } }).catch(err => { - console.error('updater - err: '+ err) - done() - }) + this.info[res.url] = 'update failed, '+ String(err) + console.error('updater - err: '+ err, global.traceback()) + }).finally(done) } else { + this.info[res.url] = 'failed, '+ res.status if(this.debug){ console.log('updater - failed', res.url, res) } + if(res){ + retries.push(res.url) + } done() } }) } }, () => { - this.retryRacing.end() + this.racing.end() + cb() + }) + } + run(urls, () => { + run(retries, () => { this.isUpdating = false - this.emit('finish') - emit('finish') - resolve(true) + resolve(this.info) }) }) }) } - async updateList(url){ + async updateList(url, force){ if(this.debug){ console.log('updater updateList', url) } - const should = await this.updaterShouldUpdate(url) + const should = force || (await this.updaterShouldUpdate(url)) const now = global.time() if(this.debug){ console.log('updater - should', url, should) @@ -145,20 +113,15 @@ class ListsUpdater extends Common { const updater = new UpdateListIndex(url, url, file, this, Object.assign({}, updateMeta)) updateMeta.updateAfter = now + 180 this.setListMeta(url, updateMeta) - let ret, haserr - await updater.start().catch(err => { - haserr = err - if(this.debug){ - console.log('updater - result', url, err) - } - }) + let ret + await updater.start() if(updater.index){ updateMeta.contentLength = updater.contentLength updateMeta.updateAfter = now + (24 * 3600) this.setListMeta(url, updater.index.meta) this.setListMeta(url, updateMeta) ret = true - } + } updater.destroy() return ret || false } else { @@ -168,22 +131,24 @@ class ListsUpdater extends Common { async validateIndex(url){ const list = new List(url, null, this.relevantKeywords) await list.start() + const validated = list.index.length > 0 list.destroy() - return true + return validated } - async updaterShouldUpdate(url, cb){ + async updaterShouldUpdate(url){ const updateMeta = await this.getListMeta(url) if(this.debug){ console.log('updater shouldUpdate', updateMeta, url) } let now = global.time() let should = !updateMeta || now >= updateMeta.updateAfter - if(should){ - return should - } else { + if(!should){ const valid = await this.validateIndex(url).catch(console.error) - return valid !== true + if(valid === true) { + return false + } } + return true } } diff --git a/www/nodejs-project/modules/lists/index.js b/www/nodejs-project/modules/lists/index.js index 12054147..01252ede 100644 --- a/www/nodejs-project/modules/lists/index.js +++ b/www/nodejs-project/modules/lists/index.js @@ -288,7 +288,7 @@ class Index extends Common { adjustSearchResults(entries, opts, limit){ let map = {}, nentries = []; ( - (opts && opts.type == 'live' && global.config.get('tuning-prefer-hls')) ? + (opts && opts.type == 'live' && global.config.get('prefer-hls')) ? this.preferHLS(entries) : entries ).forEach(e => { diff --git a/www/nodejs-project/modules/lists/list-index.js b/www/nodejs-project/modules/lists/list-index.js index c7ca25f1..b209a548 100644 --- a/www/nodejs-project/modules/lists/list-index.js +++ b/www/nodejs-project/modules/lists/list-index.js @@ -7,6 +7,7 @@ class ListIndex extends ListIndexUtils { this.indexateIterator = 0 } fail(err){ + console.warn('Bad index file', this.file, err) this.hasFailed = err this.emit('error', err) this.emit('end') @@ -36,7 +37,7 @@ class ListIndex extends ListIndexUtils { this.readIndex().then(index => { this.emit('data', index) this.emit('end') - }).catch(console.error) + }).catch(err => this.fail(err)) } else { this.fail('file not found or empty') } diff --git a/www/nodejs-project/modules/lists/list.js b/www/nodejs-project/modules/lists/list.js index bb37cb23..9aa81ca1 100644 --- a/www/nodejs-project/modules/lists/list.js +++ b/www/nodejs-project/modules/lists/list.js @@ -7,7 +7,7 @@ class List extends Events { if(url.substr(0, 2) == '//'){ url = 'http:'+ url } - this.url = url + this.url = url this.relevance = {} this.reset() this.dataKey = global.LIST_DATA_KEY_MASK.format(url) @@ -35,14 +35,24 @@ class List extends Events { } start(){ return new Promise((resolve, reject) => { - let resolved, hasErr - this.once('destroy', () => { + if(this.started){ + return resolve(true) + } + let resolved, destroyListener = () => { if(!resolved){ reject('destroyed') } - }) + }, cleanup = () => { + this.removeListener('destroy', destroyListener) + if(!this.isReady) this.isReady = true + } + this.once('destroy', destroyListener) this.indexer = new ListIndex(this.file) - this.indexer.on('error', reject) + this.indexer.on('error', err => { + reject(err) + cleanup() + this.emit('ready') + }) this.indexer.on('data', index => { if(index.length){ this.setIndex(index, err => { @@ -50,20 +60,26 @@ class List extends Events { if(err){ reject(err) } else { + this.started = true resolve(true) } - this.isReady = true + cleanup() this.emit('ready') }) } else { reject('empty index') - this.isReady = true + cleanup() this.emit('ready') } }) this.indexer.start() }) } + reload(){ + this.indexer && this.indexer.destroy() + this.started = false + return this.start() + } setIndex(index, cb){ this.index = index this.verify(this.index).then(ret => { @@ -224,6 +240,7 @@ class List extends Events { } async fetchAll(){ await this.ready() + if(!this.indexer) return [] return await this.indexer.entries() } reset(){ diff --git a/www/nodejs-project/modules/lists/list.zip b/www/nodejs-project/modules/lists/list.zip new file mode 100644 index 0000000000000000000000000000000000000000..5c41e1186938940704551e9a691f31b95cd4073f GIT binary patch literal 47341 zcmY(qQ?Musu(Y>q+tyyTZQHhO+qP}nwr$(C@t>Pia+BA2p6crUrbk{17z70X0006Y z6eL6CRvyFe85RJ*jS2t&^1rE(osEs1Ev=lC6Wd6SQcl4NGHjq%#uROV{3XJc`7IY?c)$N#gi{_nbZZ<7`wlWr?BP zop1#SmtoAiNdg;DEJ%#7M4n9G17u9j6IiK-gJMc$1r)r#rZQW%c--9FUiVkOZiBg< zZVY-zyq5@V%68Svq(Et!=tS(n!Sy~kKb6Q6w{<-LN8n1DipsbdwJL<1)d>3;dL^Rw zZ*8{n96!TYN)Qej2gk1+p{wq7H@lCU!^g$U!^uP0-R|$%(#FGxn?HqM`#?GsF9s}L zSQP|eQ=rsDFDhjgAj?C(Fh}ZYd>j{@=deuExbn&{B;50HA=-U(oMq}1^N|S|lrL~K zKuXn>t6^@Mo&RP9MVTd-l3wiV4vrthDctME88sg4Tc1DIE>de7c##>~4N}Rx5XAm| z)d9k&+P&LY6pk?xISxtq0eg*%?|DwM&040ZiGx>-UwJvETb;CHZXeWePY9NRKW(0@PPe%5P2`{NUv7`={??M zhv&WbNO<2KU!0ddP0}!hJQ0s%lF&t*jhwdVM#SE+r>cb(T;K)K>$W|Nn2O8hPV^bb zG@?~SVg>%U@Jd{^j~xB}SfDO7Scc)tQbiS2RJ=2oXu5D8GN^y)sg!F#UXoj4rsmXH z52xxQLNZQtm$K2#nXg1MN64(SIRA`s-pnN&r28qU(r;gz+6Rmb6J+TLER3- z#wyzpyuovGL_$@5H|k;_724bWC5HxiH{@bs3%j{3>hNU@x~7sVVTi$+Q#+G}1n3R7 z{!0-$(O{{`AoG(5xcBA5-~(fvEavCDz-d;F$P7NJTju11=OSVl3QmQcZ_czRcHmxE z0-7lf!N~g@?m`t>`{}9})uJd~PpQO4QtEW}4F`-h=#FU6UF{*W^do6T{-zCOhU17N zR-9<}@-#IRJ*o^Rg%sJ`sgbW_j^m-Pxsgy?^fBpSLq*_`KP^ zoL%o~V&+u*VLd4w*ry7Z=`;eJ)~ytFJ|8fFUu0_W&i1a@aLNj*%8CG)@?v1%=XFE= z33Hs4vq%em3%Dn2VF>+bA-n(L$L@xanmcn}mn2sDJ#U;n6BLqygJ;W#Y~Fo1Q7Ku~ zKZ9q@l#m0a;~^t%EratptN>w^R+>NBTrX%c590&N_TYA)6of{5k;AM91ti&k6{1CX zgRk$v$W;`S-|+SQtow(xUDrMYyMgYrxX2i{%#xqtGlF-FM9L{*Ka$ z3^*e-*Hea=Yj39t5093-bAdB{4(hvbUl`N|+IQp6E8nhY#V~IL)6&YqT%#6)uYt+J z=0j@|6;^RD&TrM;8#m$Q(vO@|5fq+E8@$#)K3u9L&o zEP*ZWZ~mDR7hjnHLIaRoqV?fMU)g|kkI8(X-WB9{_R#MU1&4X1So5DN+GjgA*h~!C zQce%M^CZMPY{qjJ=W$2?3{u8hd@YLH#bspyW<`C0l>>XG?GV&@guOyJeo z4zBI{F<&5q-FY}Yl2g2O%Q|*3ww*89i<^KT%K(e0K2JuW#HIToPKiy!{o%mu9t^6 z(t3pSuhke8I>)4|$&a0$sB@pkJA&1-C#XOmYj2ws!CLNJdQqh=oKk&zo3;v`0iwyR znRWWN9z(f$0Ndo8gvMdM+ZpTM^9>9wP3El0R({}6KXZ+-Z1O_*y^WjBd5OIqug!`N zWLcZCZO;b44fg&vSdKE|NJw)oNdN8qT|#%3of-fgt(oUbH+jlUDUUo_Xs`qh#FK$; z6pX}yFEa^L9Csrkv!nB)#{To>|GFfwBn(mnjs#h6q37x7YojIT+VlHe_2Y(%74PSj@95Fj|a2a_d9mG94- zmZt`s5C$M}Z8Am(fMq2y$0dt!J4?gItWZCjs88O>N2{*(R()aI7RoH$!J$FhOtBD^ zb7o}!jOB%ExFtgidrtrm5Tvorj%+ou3{Xb7N!ZFnq%+1Ar=cA`g^u)}EtY zH90>~d=N3m0KZd!SiWQ=tz&C$J)^9kiFzLh2(l%n$S(sC;qyK625a?%I_wZW+hk zS&}s6p)j9cA3zZi$^ipz6<5a&)3;1zC?I;Y&@~+aqHI+swJ{N9BFGJ;FM+PvfNl7Q zVgTj;w~wKu;uhA5DCeFxKmDbc#o{&Nn=jqjcL&#y&QerjKovo^y3>=(jQ?YM=e+Y}pW0rh zKShz{(-+CC_;#S&r3aka1+fs(1#s-@s4$#~e{A)V2cGr(=n$n8{)H>^F??7T6~IX8 z0U*U$86BD3n4{go-Ia>q;Lx(GaoAM}Fhw25=kEp^ z12)|+lh}GY87^;E7s9trWC{iWaY+n8IhQOp4EBOl(puHIa{)Ll)nvxjz206zjm)Yg+48CK$DU|3q^-B*{=+NEi=fiWC@? zQI|2ciqPQwN~^&?n(bB?{7aJv;}T0W?G!Gv^WX6o8H~QdrYL~*mke)Q((DGezx_c{ zbF*=17!yT;3$xXvP3h8|Ztwz9R+x8@b!!;36a*O2if0WewND)jY2=Fz$VMr*8WQ`U z0VGa1__t2x4p>)xiI;`J2yn(xbT;WS&uU`>6jO{R??otSbe&_ofg0H8NP=UK2@U+F1&boE84vmp zm$LtZ1j4WQ8xc*>axzj$1p;h|JcJ^J`JkADxWx`4>&qD~n%5|-eaz_fjQO44%Oq^I zRm+N1JubW8wN;)TXBdP`wEy;+EWrJAo2VzSiPU;724|`WZ)a`bqX)JnztNCHp#A$t ze=qT0!Nf}kax3o=A7e%q+xv^AI0YPcD|KXl+;i)GYJ?tIx6KyTgUyxG6j;w5VhM|Q zKb0>&M}D&L$N4n;`nbhJJ|5`iz~3+0`p?P`Y4SURu@?2qg^^-zMQbg~3*YXl?+brW zW^*tug3xfxGb6>?{Tq=R5$E&rMZp_;s|?{cUak=`iw-mETHQZ+Gu4$_%z7BzAr`a; z+v|eg^f-}{gU|4Q#B4UdZj7@5@2P-}J?6l@&%+(U(X)v&hKDrhbLThoi7|(fr{kya zZ`4Pg5nhugv++LDznjN8kr)Y6b`TCAuOzz%DS_@+;@nplAC&CG8;@?D0xX;Te{AQC z5I*-TdqHI~E#+KK%%O!M0||3RuE1p+#may#&AESR~f!j?UBsZU1z!XTJ!#HcemS zy44|S&o18A<@=hRL6r{Xo-e7j$i!ZJNZyd**KMe?Fwy3&pTN;XJKYx&75y zr4I@!b7pLrUt$Z#fVPEk(x#v}^?o77rrzFtEM>kp7eBB4eXSOMYY}09(%rVn`F@z> z#v5H$8kmjp3qS3>Wiz42 z2?<^hB@dg;o^ZWA)F;QVR?knB8iPwe_-$!EFcS(gVr?EZVexh@WyB{59*d==jghNa zhmm?gYqSJj@c>4@GW}Hsz0dflV8lwwW=8V;_?M=VxIGxOiEgOh;5S;-MmE}-+ATr| z&eRh9ijNo)X0b(R@smP2KMJ<0kn~PaH~RPAqvGxCI;qS;vm8x~8vsDs6jPdhyZppL z9_OkNI8ozc6Gq+nSK^>^9%`Gv7rBsw(yVgzmNVQ`XzV^k1J2x!Mz0SLdf~y{mav{_ zTh-_AuC1isn89F3R+RJ z*g%Ui{DPdNyjlQNfk?NIvggF^d+-+kj|T;0tZ~-87mqlmqX|06Ro4h=QS=~>fcjBj zIZdX;DF&1PyMB$}w-#8lBx`O^j$}D{sBV>e!)#_%1fQL)*aQ5vCNZgt5~r-@qR6bm?y+_Cw_?hZtv)yVMT%<_G@eUj3Cc_Id|vCtD15 zeTH7brm6M5aw^$H!Kp$Mb)#`lVOS?wYZjIlFdi5KF0aQc3m#>qoD z;kz(LAr8V+g=mnjQ28FvoUt9k1Qcd;ZnJp#qZO#4ktP0KR}N_vGK{gsI(e(fEu&Lh zkQnvZSWxR#y0AJK>sabzrSEp zv(o3W+ULv?@vphP-3WOzzl`Rma9az3`N@Qblj*(h?Vd5p zhPpi!q8aK8rz~e}SDPw%+1j~|$B*26)8_`k1;=gZ;jssLgsGYpH)AnE?wcN0&`K>} zv4|Pcpa6%ep}{rSV{NeQ+_ljp+*Uao5%D}%5~s`6`K{)(gThY1u)YtL*&rNBB=p1q zI@61f$+x?@nhns+5KVD_n)=6HkIL~lMMz0qsd%=zxdq=%2X`}NJ)3u1!sz}KyN4V0 z2l&5A?N*(l^0M4`rTCvjZwUMk)&l^rur)Su|4*QAxVLP##~bFF{Qn9(WOcYN319u-oRc)4fq+(a53u>D< zDkT@w3)y7Hed#1#k1I)arz!@~o+@ZIFTul@r&fed1J@`5OR~>bJg}|Yxxh~JeFE*cRRNwi1 zsH-G(Q1P2D{h^hr4?Mc&YcyGFD3?0{`Fpf4J=B1v{V%M1qOMdsm0XNENd9&AeuJ7G z)7vi6AWb-`ACTrt)U8~+sd5INe18HVbX1xBT8`6P6t3v(t#)&7A6=?}Qde%ZaL5aU zz{&b4+ijZaVC9t7u*$p$;QMnBc3>_r5DUhWrVGLxvacr`g@+%6z_lw7H}rD!}Q z;CB`3kn{+7p1k{bPn~B=bC!bgCWX&+b%UuCWaqZoIAo=T_Ab$qb7O|j=mxHf13Mp$ zMMPzsNa<^#r7SRyACk%jp_ny^&oKQ`wZIO`Sh|Ylmh8TD_Jn*0BdIqm_zjS=!zx>& zjv~v0n(A2NG`IC6#|ziTA~xa#hDMm_|NS%6pU>8&Q`)OCn$xFNRZSit6_r34x3vDP zyMz}}p^cO+^ZC!A%WMCdn&r2vVF}xmFg>QZFE$Gs(7+(hI9fu5AL2lCj4$t2NOm8Q z$i_8=fB{;y7*PW6LR+=BIU+=2b6`p8>n9mI$dVW*l_kh9LP2zG)(K80h$X*tU!rdo zPm~Q47Wfyv00Hq761D4PHp(KW7~Ee95$h+<>QJ;OeMw4I?hsIx;QqiV+kgrmW1iVl71Te&)F z)M2o%18tu?RTcW|mW&}x@!y0A?AT6tFJ)1Mviy)0m_xoy>2TL(_hYMXZi0>_i5R^*^okF_3$+*xBEkt{$3qJ@ec z=U?{#gI**lDq8qL$kd9Z^ZG!wUMS#=@^A3pQv`A%RhBJ}#yjDR_|&5P-l4o>^uVsT z?iVJ-=yL%fN!ltRDG2lQ6=@L8exTk?;O?+8_7oxamG_$HsnCbF0lR(+>xYysKC-e9 z@@=R75#h|-zkcwl1JhC>!3A~gZ*<%Zuf4u`Bn#Kp!WFW52!MT1T zIi6*x$<%-e$>kK%61 zrsp&fc#obAA)81d%G?9N*AFbW{xe|iaB9k-)u7v*9*y0%nN~-Y^kOo$BfM)BedEPX z&lxo1EgT>7!29_KsZcnLCEJtqcK0;iJRNY?)m(Zf{+h? z@K@lDlA>uOfN&XGVxJ9Vp9XQiExhek8N=oPW$II?XqDY$}$jBnd z*eJ5Vw0O1?EOO*E2%A5FNazU^8zVlFXjXJn2))1NAJPiWaYjGGYSiZG>6&ZbdOF{Y zZwibf9+6pG0k`z1rx24_ShfBuTf<%8DO9M;Z30ka=5WE1YeZ{AWwo(o;*Y zGaymS(i`ynbhY=rx-k7iDV9IAOdD+J>Si8%oc=iWVg|R&l1RmNov%S~<#~>GvgiUs z_m$H2y?*ceeM;`~`uMSh{+|--<^DVmburNoFejoPZ;*(sOCS|3%2c7k83UMG{EPml z<`VhRZV|0iNbcHMmnotuhU}nta`dDB zN!ejfdv)0f5qPR8+Q>k@Y&*v?|Bdwm?AU+|uMGKjnl_6Wiu?c!!X?@eznSR>`^Fd? z8;+C0w-nki#H`!n_3`?_6)nqPsFdQAgZI@MhQ4Ucm<8Np6eTC7a-?dBClvci6;`q# z!V&5EM71U^;3e5o<5(@_Ffd1&L8&SN$)#A-KgefJkz~=Om^GTb+@SZYi6|D9oJz^5 z6@cJYQ5OksveXvf7z?>n9I`A{e`&nGnSZB`6Y&jJYk4rlSeN8kz*M*Nk@wMLU6+&7n`T*>-WRo-& zXK?cj=$6a)$BdZljHh4>}HiYTCT~ z%0vLzc?RKwfyEa#EgnHP`C$C5U&j$j}Xe=@o55UTRnrI z&Ky9fin;o*d6b%%4MR&S<~ZwImF}JMqDV;`;#f^|UgFG1tesws;Xhy!?A{%O@5%sd?s+y>NTLu(! z%8v`qXg6fF<;>%Mm=5@_-aG2(-$a@;<_P(x-Z;f^Zt~LBIN)jnwHn7bpNxK#`sM3= zC8In>1Pte^1r?ZEMB;LO;dr*}t9J%8hwe>_0WZGQ&0o3pI z8Zv%Cc9SY3A;vnRCzxg!Dm}G#AmD$3DP?1Oo)d7aQFbaFeR-g_wJoQ*DIzx|`|GJS zw>%PD>YMg4cXBnQDbk-);?xH))z7k_!sSW_#!PV;!vv*yX|)iz*7C0-z<^jERcgz4 zw5b(Ss_1JZ+TTZ7EW3Es4OdyTAvroz-IG9h3U;AlId|N|dk$`Y$U6bpr&*>+rtz=e z^WMuh&=8`uWvx>&G%D3ocS0`omp5eYaY<=Yayq<g4_Zd2^<(N$;>sIpx{{+KEJzK8FrJD`2&3Jo% z4yd0Id!X1({<t$Ue@im0uE8e5AKBpwly}pk~ z>x)j44Ahm?ynU6UcMa2lNuO6sqy0x>pL`K45CheD+*DBg-l=xh6FDkY~?sV%gq;jNp^7zm&T{kXN zQ+CoWjA)-3bc}{S)f?I^EyW;=qE+G8hqf4#z2EO;V02JlBK4BPU`cS`2+u?ED5k)m z(w+#5lY8KEi77TyEc>Vs$;kMx=U*kX`GS3>D(Qxw2}2hulS&aZ0?H@BO!sGpz)kN> zJbl;ic$I>Z+B2?d?8tZ&r>4yzHPeHDWzVy+5LrH#?QIw?8vsj6&NYL}yq>NF+7wkD z;uS9%mZEhLxeo=FSGhrU+(41nFOr*PQ+l_*eS$o9+QW zQNdxevAf|Yrr;++;$WYunsQ!1h^G6)!NI-y_KdMen>EHMLI?i(jjlf+8?B>l# zC1JzBAu!+2NhQE#1FtjcLVJK&=ahqC@h5zc*@q-&>#6#t<&AL zUL^VX_;GszpAnbW}X_e~)^?(tO0G%Z+BdikvJglfM5 z*0fo_dmQbD`pfm3v9I9b?r9BMp9bq~wq?LD}*-!p0lQjWCilTW=W0?t8bm9^S@%N9xO9X?!g zj*Tv*PScD?4BcIz?yt~pqJOqqEClXlu)vgvKoRPdqUD8Oe@o?7$OipV=(F|loJ?lo zFl?|f-cCS0I;E7@Qe(@{Te=VEb3vH?zNyIku#0W=&-v0!_UN9X?NJ+&?4*7O3oQ=- zDNIy)RD>YU7*PdiXf$nvX`*~=#>?S3*K!=*hps(p$K$ZszI#YC$$^L_F>-a-ta-p} zh-8k8qQvVa>WQbdK-bxX2Q{2V#utEoWlcqidLAd;{F5ufq|p!B>QcXK(7I?g!^#pY z3E4)rZ;-z>LD5?+yn=um>I?_pWe5PAGbx;i;UjM^SZ~SGZ^nfw&w=RTS?+5vRxeJwJ znhJv=@ksK2ttn(;008j+xQn%glkk*y>Y8&H^|N@N;?ZU z()G>r!Z1Jx0i{`bY;!!3NnN?-4{ztD)JkH=stY$a{;*^M5HS3xR9}iQ;MVN_FEdcrY#@EMTUU` z%HML%+xz!DmFAY#r|tV;|B^??r{nt@Pd#Nh1Hy>wwVtXm+f~B?5w-JBY#1kAHjU5T z67k+u5}nZ)xWiXlN6R-o{!!&KwUyQ_Jg_64kxqOxWb-~;BU>!suGIG@Yip~uEGYpX z_7CFkE_E@Ud%i>>vHnuF3SGI>1(K4g%^MLIa6uWAmPmaO<%LoU>N6HH7v+RFKPO&o z>4MocHpx2EQ-st|CTeAi2-MoTp(HYWP#Wr2oLA+9vx=icGuzy^??G8S*DVLd9s{rG z6Dwh4ml3Wgd0l4{8Lsr=XowIoRbP@h5l7T0Xh>{<=XPd-zb)l`FI%Dzr~-VBgrV(K zl?Ax_V7!BVK8j%hYlOZZTDL$X7-Il%x|<$bD0^RCtU$qvLcts2oS_*%iTSMBoK##cWw>4fs|7XG6?VBiG%z0C&G?i8#jjw0=eyhYde=zOMXiphwk^53q1o4fR$73 ziSvu0iqq1K-?YlK|1H27?-Z1NO3v;kKyAZOimRO+kxI8eg&3%oWT2OodTv;?6mF0k zbpQGqig3#eoi(ScQAUw$5f%2UajgFtV&ekv-4z*ZTWjAnM)1VOV<>oQ?zwm5l{jcWY@WYB7+;(A*t zY>@&LRS5W9x*t`BV$HAW@PIkxl7b=hX8+&$Q4cGBrUoN1mjYDk+_*B>HD*M;CgDC> zc8yiN8QkC03F90T9%x}&gcp~L(>B9l*8`%F;<_i%`z{l*)NO2cugWKD468-g9BUsX zbYP*>)+$AIT}-n`^!BxN%EX~Q<;#qcU&Us) z+ymjNb%88#g_(Xd!2I<)pg|pypP0E@ra_MO8dH zY-aUFsN;4&a@#3W5mi`z^UV|)f=k8|NL0mnK?^X^kDwNZu?$iIb#_T}vG`bG(R|3- zRZA(N|LCR*SiLD|G8a7%_eOL#5aUK>T1(Z;bOf_^IflKo8vA_M+-&R+ZUi4YST|4@_h-E2mofJI%|SBu2YpH^&>i6O7wnky zvBv42^^%{e9Jtu|IpBP}@lW=w2?s-JUyDRk!LB+`%p2ZGKQGjf@(154j91i&>|k2%xVN>-x|r z2+t4@Qtwn(0a^8Yn{dugTGEFqUZ|f9!YWeRH2s}b5?|#ko%(Xz?UR9$$EmX6O?Pdv zE`e;h|NYYCBfqWLEz`z8;)0S}-W>~=Qx4|h5q$weV@;xR$)^0?*UU2IJonAPgpBCT z1*&@}^j~BED3or^SC_hB*TIP*TN6Ta@4#OhgNK7aAy{de`~h_#@7xu%zOUT!Q`|3b zR&^7gG&B=gj$Z_-4LA&ygR^*73iJ(xD+aY@u9c=_ zb852*25QBe1l1of|C6S3S?*Y9{;EgW9Rt)v*FR}-A7d2(Vs8_P+B#m{AJ31cEvhp1 z`)jA`a~%cMQ6iOC%XCDoa)(DPNKn50Xv2$h8zF&!b1jY_jB${%DWH$tbl;Lr4BDzl zo22U0Jd{);ABKK4!yfcj5bWv;hqH#v-Q;^0NP*zDV22f>0c|hHb|6g2EhfNpXdk&& zIxFOl(pEn0wu)${y8tH>(5WXH(1q;HI^HxKf4Zmb3)UvFR7fY-NwuP#%+pz*2T~J= z^R%6=h4ba^U>2RDpejUQPS?9_509alaof9@Jr$ECNSf*neWtUPuiX+@(??y+C~2>< z%%#?VUCo%!t_7Pdz zO#4p;iKoWX%CK$O*6>X{eLX$TlRkqH*aXkA18w~uU6klgwj7)qz7_YjfoK$NE|#L!2fI9j2tpmidl#`ihuwB^uPcB z{wu%#U&f8*|GAolC=b|f(ZleN^Zx~ODI;ZeUO9xJorA%g%Ug#TloetqXUQ^CsFF=s zHTD1TDyAZH!P4Usg&lhpPmrh9LLy!QukD?eeYQr=PHn0yTW5#32I(T9ACuQgQ>@Ef zeC+2NOLe#me}(HJLw@YN)z|V_+EfeQWw`4f70((z6#5X4i0KFvN=zF%h_?cO;i)t% z9xyo=id2mI9FMd_D8}!2I3VQ$XG)e!c?C4V4__{ zSAnxmDNMlR5HVepBlLwJa`AD5zNfA7SZP@|!@r+=wbdzdM@I2-ndS3JL9?~#Lr2Xp z*6yS6mLH}uEl-BX+Y{!VzgQWy0$L+e)^S&}tiy3~ZKxbM(;o!_vg*yt=lS-rSSqE-9yTK3GcP;_Ry zua6L$G(()$Q|@Y$*{$NXfvt#a9~6Ccx`^Twy;k_)sd8RcmJlre6D`y7pgg5%R1!(8 zd()QO>o8b5wgYVjlvD8tN3*D4iB+vD$^vR0uTa$hr7AET`%?s{Z4$@-Q$8;uUx4|0@+?3i z2fQE7Lo)-zJ6r)MmdJy0e$=(}z zPMRXNs(GtE)Ne&cAUmaV^7MKfJb{cI{xw|Azwy*SsqZbyV5!FYw4=R$ zP4gA384rtN`ODD@qT-Z5$uM{Ib$T1ay3pE$=Tj#PM1%Swq1CC^30W*jq(e=LupuPt z;2Fpf%1?d<(MowHv$EN7L-SF%F{Ud|vogn43mn}#qKL6R- zbT>(pF1d}>!x(xa0_I?xZGU1<7oI@AL)U~<##n1ZuBEtO1>o{*nJv=MN=mnS?vgSw z`NM5eAQVz`($lSQ&`&o76%A;I&&%*dSY`=|`sfDL0(o;b`(0>|^2gy!B&BiCTTZ1r zX>Pf7~o0NW2bhOHGs8HUh zu}Wv)UF5Up7cx33lJ`%R(cw~W08_1hRP{Ct*8Pz|zos$}-d#WROCl965Xg zyW)Veh5xvo696`TRnwfBUey?Tk~*>g8Ewqr?0@`uTGqj}FQvzdlr%d!_}}Pu*F*p> z;Xs9huRh%N-r`uo58XQnOKhuQ!C@nUIXTbEC62Fg{F$4^qO*3j<6&quv0Z6XQN{WA zg?m(G6BQeXO3r?4B17R#Xk^~Q=?l;6|Jp?#8Df3KeDctzLsIh+>DRPuvV(*k7VhoS zJA210XHF{RE-b|@5YW#+G+^$tfNq~hkFM&i&4Vhr#m%1Xoe=xJm3m=>sUAwMkS|vS z0#nb!2ZK381a`A!fCE>5j#?V92@kA$$Gkrr7n6+vB`NWL^B9|C*|bN)iqm0byiR~< zILJsIa32hGrs!G+id^Z zmUb_(l%?MHLt9NYk$jhQTtqLG)ms=#+A2z7PYn21LS-s*GrI5KYRNF)`ZBOlv8XjDKC%uo$8T1?MToxFXTAPX?bTrmt(3{% zqWM3q{l~-qPc2b1GE2oTJuS%#9RL8y=>IOe{10^bABmgcv2xmCPt0Rc`GCLoRZm3M z@HMuz9A%kJosxKUJmQ|6&|b+|%PJ%tKg=X*@m^mrecr3?zX53{OqfnG*NwGuXWcsee;G<{=m*-h2-*? zEq|iO8p8r-*tN+5&~9~`WOjewpPbB=b5|MIEW9A1vHgfXpv>1|Hl00Zh)EHeNy0?v zqFih6WIW=qYsaL_WQzt?P@kK2={){fy1mH&BRsUEH46~u=auT6PfECENJLpWmk`cQ z2o@D$xQY%(*tIkJ8N_OeT%_3!&%K0?t9r9Q^y1H)94$tnaEQH4*? zI(6k^fQX1h>nS}^`a8TNTVyznxvDzbzxBo?B{aw@q=r3TqdVL9*rYFJaIqt3OfNV| z2lsSu0i#QPYsolD+$#lJu|=v!@eDe=mx()a*YwCWXeo-otnoN@LbiU z5z@SXylmE&E|ZW7Q&p!Y6ycy%PW!f<}hs`(!vNrh{h>nm6SGu=9U<~U*aEjINXoNug51!8dLnu zEiD)g{*c3e`Mz3MTJ<`yaYTNURxU#KKE{JP{Ws_`V7BdKs(#a%-fr8m;P$|;e5Rvt ztW#PsXcDPw_kvjE41r0 z=deaESkbRX^j~i}49NNTQ9ID{3GQmzz8L)?SWdv+x~-sKFfP|1Ys^4k%Z*IlJZ;pA zVa?;2FJTstAK)5pSW&x%Q1|mll6v-IYc1yt6CMs*)T94>H8S(bWjSp;iFHsiJes{q zw}+QfZ{Q3P5&t$TN9+~o63Ia6?-tJ(ChjgH&GvH18P?pK-y`73`C#=0qR2$VX()vS zI`w50AVhv`Jr0m1ORelErNTv_LSR-==>B17sH${Xjb$AZRxfief5rI~E2t$DxH!ku z@FMTvMV7cyaNGO`0bnIU2MwdH-T6VO4=01mmPWchKso!?M??A*KcQStF#-)G!x|Z~ zQb64TtxuZDpHQ8Nw-j7=; zw^p>KPyEXb6be-reb4py*GL%5!(FP?y0f)z_a}k8bKd%n;`7GTRV5thn9MC4#{m`OQUz$urR!Wl{oXga3k=5}-mdr7oABY_W$5Hu zmalb?V&$QMKI&kOuFJ6VW+^-5Q)i;z4t3jP5m|k&S2nD~Z3|_8CFaMX6GLy6*0d_* zbufE9GEXS>fc|~EB}FTLn?F1gUoQ3yg8Vh_fF$WswNDo1W|I2v|% z;iiipouDDFL3y4j^oT3;`qntUs1H`U&)zK+euUKFPgu!HBAfVP2kmbG~+mcS#Ad`GR}^ZI%^ygpnn z(b4^#EoC*q_er0cv`Ax9q9V?nv7ERt00Fyz#RP)@Vu@##u{lKZ$?xCa70><-Fre~r za{ux!1d!#k^&qPgg&?py=q21EP7YPaJ@%4NKsrIG>?R|sC|phKO;(5h-KSmBjo>E^ zhlHXahXBurG;cr!j=6j{7iIted+@^^vHFwwMhCUvdd=5-3Oy7J3Rj|hz_aa;HNhQV zY1!3a-?_2j4T{8U#PJNkKk+t~*V5B2cA5eB;?3{^r9T4=h4CmIjMXbH21ge7^ehN^ zePN-5!_N5U&11wTh)>5_SPd;=rZ#^bfM7_@Eb=Ci|Ew9rVnAQ_0iD=4MIv(U_%O5_ zg7@7W6^|)GoI@%oS`QOqHbT8CtU4l$`-ghGC?Q6w#3PupR7$E=)eSmJnS8F?fK}7u z)`SypYstD#W$8u1sC(9C<9j5#Uw)%tI*g>9@C@7CP|nduXEU^|r9XJ-)^lK(vre5l z#U2o8shB9q4op#%C7q(mnOUvPNz6seo{+{%XGvf#7@LcToIcy)iGhPNZpYUL_nMkh zy~VBPsZYt~d+qG{2BrVtZQmeWU7$cxqI0x%wN|WV}@C1toHg)MG=RRjW_SE{7j;D@3qv26wA*z;b?QxHc|j*yd0!NIhuw@(VeNT}@EJNw7lkST z_I-62iLaa-0?f5H`NR;YBZqcQI%wXR`ohFuP7o6{p5N{V-cs5azJW+rnR+GO)qVK% zlY<`+dgSApFII~LkX2zzvjMb!?G}MfdGw!Vzmd$zsHQEwnE{3A|&X#kfe>#wGqsDY z-qyKWh%sB(ok%UX=G_gysFD{mQE3Q(l716cB^r&pl)(>}8MhX**Aqzn_M2Hg3?hnC z6+c#P*cae8@E@ek=3oXgQtdmUgB|N%TxE`yyrZ*+q#01G=j*ej+?WV>6%p9W&_u7w ziU5071hTp}uq0ik4k>1cEg0}CG#{@zBq8?lx7q|1P4zfhB#T2|^F!&6GZMGzr2+M{ z1%f%RMQ&vK+Wk7%4hDciE?~a(VhxuGLlMOL35HCORpI;{mdS?aoeg=-I~5 z;jf^|UjN+PxC9G910=Nwm`v)jYT`H@vl~j=ZrDCcp%oGe9v^xt;FH?56t?lv79ymR ziA9CT)8GEhRTfKj>bNj(ciIq;Beff_F@&RkzJ(2mGG}0uG5(+` zj(TW}q$e61%ZkIFC#u-_L72DBSY&MKl$)~2h0*M&?7!II&`KRECVu1XW>^zvJ zS7YUAQjLOJJOHv+YgCjEP1>}fa~FLo97VRYzi*eT7{5>kLuV=lDBy)|rVZzQ#^`>) zp1siiF~HJrgIgjN2u%hhmK3Oppj6s=$M9{1COP7PbKH`BsJa&v^`tHwE9Yb)_iLhl z)Z^wA$u$sJz>@T}W4W%$0gvD!)_&F~Ewy+C#-%^;;Lp+#9{G4xp5$^OM@CAB+j5jJ+}?R{AQ(Xr94B*ZBVFPe)` zH6aALB*kFrXsZcnA;z=t-#zvA*5u&hmWLpXLTt|M9s!y78q|+G();;-na+$(IVkE9 z04gli0hsNg!i*D#@_H9qFUmHbUGm(T_IFt1vA0OWBX>o_p`u;h@G2M){BFd-PnbaX z5&iK*N5MJPtmGb$x1km`0bfI(XV-!8d@i_SJs2*3oL3QR%h>5;Cjx&fUG}1cI72 zY*vB!t@h=fK$i$kuHlp_D0mK*=-3_R@3g;vaDtg${^84 z>6cz!P<@6Jh!?XHGkv6@G&!?6CFWgIQfI>m|IT1LpzBH@Dr}rgnguUnhu?kb6xw>| zU+NZ9!Dk@?>u}c~YOW>zbL4QFAd0gFV|LTp6!y5Y3%6hycuT?BG<&&mt(%c@|D>;_bIgY|+K~+RrP8 zQ)h&g-Y=toN*om$u>Q_|nmez#in`6^@9go}qf{3obBYonV*)uxiqVl#x<_ZUdb`aM z?OpwmRPCOv2(n?o^3j8s3T>uYI$^wY_$FP^`Ipy)$|vHGJ(OQmc~HfU!P5u_2a5UP zip|Blvlk4Hu+vSD9;Qpc)bP0Ia+?TzMbtMe03Ya%ihasL{PEHU*SBHY35#@6KkAW* z!AQO{W!D9n0H`PRkq~Xuh7U&8$K&xlE7Ck)$4(kRYiXCR4o8LMZ1gl&$ySY^0pyxU-62?xpY$M(oy<07j_!xxnoE;)V z!_ZGdC`$%QvdB9*UO?l}%pFdBCDGg%0BInAtE!Eh$`^Zkl#=_Rlu0xQN`dFRGxgpB zfs2}!81cZyFrd~D0V-`UNLYm_fd*L+cd+Lq zfl2#ECTQ#H^|Sq4!0vR?>M7?wWp@Utjgl1k(4A3247Uc5As6gV?}&$CgA$0IoCZOe z8%@28pcyS2qz-`J#3ZGOBLk^DgOC?I_wq_yl_F5$rW1ap_fct`qlb0~fiTteC>v1c znv|9#^k=|#b5#m-O`i|}gvGuUT~7xqo!#QAt4O|OCM=lNxhko5_BCMfjIL&#(v4Tq z@Q8cQ&$irq-g78P#zeg7O0mO}80ABTVlpy4F5TVw0|Y_=(}@gIz4Uc8%>?hFFQSfY zKzEk*B)+?bpPigltzCv9b63E|#3uNcSQL)9U}hy4vYS1-OLIRcv*K9&ZTPaThV67y zMHc_|E(oYgG1*W0X3{g|n!7nBL2>t_?ZYR^0*ixcv>Baib7~ZBBRq5!f@r%z4*=%_2RZw(sp* zrXiGf4wn0rx*5>;Qh@+uGlY8hqcR}h@F?uzkK**Ywt+!hzSz$vn6jUP=2Zx%5DCqf zsk`Mey*j$~PR4ao2pdc5(il30JD zSt<5?4UlM6W%szb+WXRqP+ypM59Qxb*E!&ZL$n5tLiuw?4ZrmWwHDJrwXUyNC+Ad= zklcc8bL*~r*ta=ut-CyMZM}hln$wcYN7(}M$o-0LC$P8>Wj1!vbXQpD1Z0VOK%P^O zkUPhh)r>dSY+wIKWko?il4qM04MGQJl~dg2$_y5aa! zeAg}X@?L?V>KzqiQa9=J4v%ijViCS}gk5rcLs}eN{q=1Bf7AVc9Pa5RF;nl|P^K!l-g^vTJ8Q;> zIo?A6lR0mu3qOGsqg2Rd%0=!|bVt0JEYkWFy;vkso91(>a|&aJd*={6(=1{g`MP`T z2)vkl>y1?nab_b3XL;`5HMdX0cIosUR3WW+Ea8tCL9;-ZDzTwP=d>ZcfCHcCw-3{F zuS8P}g6!ijdkc8+y~rb^zNjPfC&WR0=R)gQV09KzzJ85L&glhk2*Bc!dJi0R7k>g1 z;dKkO(P&HF;|~)Vdf}Pum3m7zL7*Q}^TZSJmQ)ym2TR1MEiqocEGqFj)B9r!>m9g) z@T$-)2Pd{=lVcyMtT9Wpp8&kjMah0*Nv@y}k5vfvbp-PiyLT=!gMES04ns{`vC&pc zzAvI_z{}HHrs!>r%&W75F@m^-$%26$p%RFKf5zB}*IuMxy{wm;x9RBc5S0?asx!N! zF6DfVBKvUx^jeg;N%ePFpiWZ|%@c;Yg7{Eo=Pz^>OCeY_vY_E zMv9$%S7uStGB5aIsB)U-^wuU*J{7V@Z6sGBB2?-gsF*KddDwljr0qC6HvDUR1(D(P zm!t6yo8$x|!z@Zv47EgYTdKSS1BWH#l~$GS2%`sTYs!oAfmi2gjhD#K4&j863=8P^ zA^K8$Z(Ui5S6Z#)J4Y<6btTO4TbWp_&QDL-F(N~4(?wdHHSwXC^C0pc90?K~L;?{= z);@#JktWKY)eSr4rVwx5^5&te?m$orP>!rD@J5vSOKae zQJx1U(IUEIS-?IPY%$3tXrKQ4rvM6dldZ-@nst^@2A{g(3zmGfWLc+M{ZP!}d@zsl z0y^9acmv~W66eL+=m~hR17)SK#M6#B_pVVz={e0OgIPQQqCo`qu~FF7Yb)S9buG|ft)}rE zfj6E-i#vvJ!J6c~0Ol2WAjArTcri{^6P9@!HuMvCT#E! zWjG{_9+V?11KUjrEjcTW=F2oIhUY&Xh~W@Nm+>5k99v#_z`1UR7iDu0R&cBK^CBu% zIRLmsd~iVE_YwWvS$0Kx(IqE|v(aHk#Q@*y_edgVxBq10VsIZND?p!{kh^BfRs2rW zfgbJwfOO6}1kZACALsFH!h$_c!b;nO6+v{*GTQEY832k@b*(j@&mpT*NcWxlciTv)*Y3>FjHP<1fpE7U^> z2GdBx$Rf?=QDU-CEN-kg#^LN(bm{8I&xSNEqA{4jVk8-hCXcjlB%KAXgTMZ@r}K$c zoTeo!DP<{#3dhzqofr#wxOXJZ6U^13HH7K7R#Dr{zGZGVP07-h4xy$N7_#C@O2gA__*!Fd$MzO&nnzL><`1ilE8tPHp&@}XnjDt#UvW%I-3nMHiZSA*^5#V94=nG5X5{=a?(#h z^XT!GUGP4&CM`K=oJ+m|EJS4;&t+D6Z^{Ro@Lva(rXdT6pH;SS=owf{DdCkq-d??ixv0K_N z1K83Z@+adnJnO-c0PV#tR*4uXi@M+S1(Qb7K=jx zSr@C)220YrPUs=sP$#q=t+j5E(dq-q(7J=VKhK>8m@vF^7G;3Is`4OTCNXF@J@ltJ z@RLVVwi|4zeQ>{4lpLf$04c8KzaIBj1xTl!-5xceT@KuwP2%Vh{@n}gdUsj6TACF} zX-eXI78)4s4vHCDbkHm1R5+3Ha*&$jIuhIwB!-I12E(RF^kOkZNp~n7m2vQ8Fe~73Kas$?|dKo7;@j`5bs#5oM44c{*Wz&e@|mA|mWMI%zL- z{19+6J{lLWfFu?z$A!5Dl~<3=ktjh=UoHE9et=xJq%}<99M!JK2$n`gFCaK72B{#1 zW(IRa%`mvSu6h82rh~o2dpq9;@ehdtJ^Xun+o8H_?Ppo^2;7?f>`;@d_}a;_Ck#4g zfvn@;B~CYqt$1KT8_yg>PMHHP`~b9BbSU#K*r-Vi!hXlbE*8Pms2>yO?=Ft>&!Oa$ z3I(nPttB}rQmQIDO@R-?4)g09(PYvAan}n_PJpq#h<^wD2OEhT*MB6JY|?=+kh#d0^?#(#QO8ViwP4*0I97%cJ_ z^)mR8pz1+|w=vn$+!n}>({Yq^Yz;t@JBX~^UBF#fA`Wp#gUK%Kscwm}PM#MeB(^YDAiOXPLhE43#v$iC=tm}MR4*{LTH z==Mhkh;qkOM6A~-ISMGoyt2pAkxT*U9ViIbuingYAQ%nQoBdr#n>tYD=W2tYdh!hNP{4R84u3orcV0sYB0uv5h?qFwrLjfV*d6QSeUP!Vwp!BZIv zRo<)Wx@$UAy(fpFBb_k2L7f*&Aym^zt@tj2?Jx)%-OUw^dp*vw*qRF0pST8JqeKZ6 z-)~=H@z9C`4lJ~x<3*gKTF;lD)@kVI9hyf;4K1og-JDz~1aCmr2fN;_sOZ4 zeT5ut4ZIxFRlT0rNV%>PD|Li{0%Z-6gvF=qV^@ByBxCdRo}5_vu}+mWF4)|uF_Pq?uUiO(^sC$JfTej) zDzxU+cwwr!WoF=ygzd`AaIuwj?FsCba~%cWwHi9M(7>HVxk+}?Ls9A3K_crrLSOJT) zDA+ii09hOzr1NFE0Ae0RQ|6MnqPvPLf{+Zx{!7$e^-tSAictWd78wJ3X3X}J1RKEu z1vMc{Sfoj;5v)FG%b0YqstN%>jSo>a86{CZW1Rm;+5q_4$(YfB}jM$X#)zK+q3BHhEU>&EV_*u$GEIun10JvD^+6@?bC2l$;ISIFd0d z0{R5(K0XrXYX*cbUN|fdXr_Bgq|ow_gikG(y1s)q_*cg-M`cl`=o8HDmJQQw)`+oG zdMdRc|9wuIFGQBVRcEBQK-cB$9|(BeawwEqX+B>_o;XDh8BFPmFRIDYZ|dZEg)iiB z;ul|F4_}b-gMMnL?$CPv(IK|<>1FRfpSrt(Gj+vCbMkU1KKRbOlAy#C5aIM&ksf1w z@IeGyg?lA&K#U^A_~J_M4WO6g`2|5j&`bScFL;H( z&~M{el z%uHtlh>34rslRKhPzm?{!#);LzVpd(a;(^(xo2bqlgYj`au@x~S$4;y7Y8b6)*g}{ z#dS+Bk9`ub$n|hl2UATy1kUg+RyFl`cu-30h(+{qG znXt1K&AT3pparJeTH0!mSqTK%VDL{ETOK4piORW07m0U_KLR;cty>xG?F{pnLd=PF zxyS2ejamf5$$wH|=yui<*A6%>oB{wC;@NPbh{dpB6DlB5fNS_t)`0z;iT zdZ>XzF%wdilx9ZJmFcRev^8k2vnzg|Cz?!-VPYK$I<@&p7<%aB7;zRyIAPX$xTD1R(UOchI{(q>H4QO77Yr(O`~)>LQ=g+< zxR|Cr*o&a9>{ysq215O066et^SZhvR`7i^8rW2C_wuQZtnu~wAA}aB_L*jnl{Xro% z+GXGE>~uX{;COp#198@dAKC=0SW{O3$0;fQ8~}Qq0!6PPxgVLefI(E(ykdd(L`4rRU~#AgULMl3_JY4(Wj{ivur~G%s5#-oqMvbve}_H*S$t}qW)6ha z@AhZyhT2`AV$TYwS66(MgZOn4RogxA%l_%*4aEfClp{6ux9$!oW~=!vhCw$%x)Ip} zeSI*=-9zT>$h%G;SIfbHy7~3&T_u`lEXpw0W}IaGj3U0v$iYVuGFZ%_#fRK4sx2)- zu~miI6fU#)p5F=+v)S5nPS+$yPb8)@?pD`U^H~H&JqU5sEA>M>u3OAviu`qG6-}X& zuhr0R`+!aOja^4(Z|NWR#94ThyGo-dk_y=tcp%tXva3!p&wD{W zhWYsJ_Vc|Xs^E@U0N-YrbGuM2==Nnj_nKEeEQHJN#*&k+)&}c)b${sxHh*^ralxM= z@+i8N&+l3cHmgCbr3Kntqr^*$2yIgX_v6kVksxo-3b)m<=X8K>+D9TYbS@00pmlV*-D@dh#@J?z4m%|N zz9~)RW)j2r@smr~G^LPV92o$wDAMea-yJcKmD|YTt?-%^?^HC`Au9*W>ycL0Q<0Yc z&mb*tK-$b8Dw?Q+pk_9XPz#obanV%S^{gH}Wjz%=GXp)J8!xjOyp)wRLMkEnP|6Uc zIcInC=p)R7PQ>A_9hgrwjRGMnIHyuul09RrLaibPx(>suC7RxeDmI@|wKn;ZjpJ#| zCPCRm$3{7<>Vx4sgnyDQkCxaifvAZkD3L2i07Ut9aaG()SUEAJS@E|=(7u+PkWd;5 z<5a~pR@HH&LWj!hw^js#F$_Oh>IE9rDo{y*Qjx#-I0nVNVDES?Am441j?C~R)_wo{ z{P5@k;}u@OQm+!0zo27IPzGwMkY+C`id(N)==4zYkKC951!0WA0P)1sg~}so4t*fc z9Nda;sqYn8G-jB{$gwfT-0o;e;kv1!Eg(S+ssTSwBd4WQHz_vJzO{vQnyUOD0W>L= zSY!m73DlPP=S&UN;2oJ8{o-4N1~ zMrVqc=Z7=1cnU)S6ASZTk*ZOyHb8g)z+tv)I5ky2;@L-_Y6JCx4$KAjGci7hvahV= z49(8mVL5y+MG2)wv2(iAUV22f7*9Nkw`%{}mMt2#RhX_UPg3~mKUcd%{7uRZIw*e~ zY#HEJmInptnG-ga#=z1ux~7mM2g)h>L|+64!Z@bXygF`^f`EykiL!Hgxpg0>AkhwQ=C#0lNjIwa4Q3FN1t_0mKBd(m%Y13*M%6aQ6hHQ zCxc9$O4e@f!UoYM3of!$3U&73@jZB^D-^IKjV1?Ry0b|wzv-1@cFo>o@vV!{5v1a0 zs48|VQJ=EkAr_U?8e;2tNlaM?ggVe#)`=8(?_VTSQ!ef}lO(FeJ8BRuCX_|10e~k% zq_!AKoOsY^i?q;2>3Kvk7UZIMDJ*&Ha22g_c3dv;Yvx)eLi;%N_Z7F)po zEd|Hbgdk1F>U_v?=?s%Mi@-Z~Zuz$m7SCB|3ojTJYZvDV^N=`Qk49B(xrqcM1)*gJ z8?>;JOqGKvY56wWg6pWz+~rQ5dv9>cCYp0BZcGTUZ5zq!>a5riS6JjCwOeHj5E2w6 z#tG_#iA)l??zyilwdcPf<|`VnZq1WUJOy7jlA!MJ7qbS7_=mrrA6*O&gYEE#+bnqT zn*TT4E_c~K(->r~__^D5aN|5TJo`Bmr?}|{V{V@#OhTR8#qi|p{g7rShRo*XxpWrg zv${YD0MflnDeOC46cokhZcm)MAQlY+*D*7RVl<2dN7mc~bDm&(pxlJ@iO)t#_a9)Y zn{L9A7&g*=wfDR{rzN-eLk(`Xo6b~-kY5RQ1#}?fVwC6c-Jwh(4QkckMgkaYJ-fY(QBcuiUwn}@#rwO zeN!w=CY-d7v71UC;1T>Qac{3ifUnd2mY7PM|GY>a7IgK6$A$9^WOg=%^hHY2a6a6OlG>qBDM*~S5p4cfHy4G&$2$2^1vV8Z-F zKa~Fh;maLax~|rgQ;Uq(HIjYl9nqN|^-|;8smuF~MNHoru4G0}Y?yZ&i+Qx~_lp3e zpC~bLoWOGL64*T@y~~cbD|L}e-#R}UXf%ZRqQv>TqYIIL+Q`Bn@Un{O`vL3+bfowU zJ4JMGbyA4okc{YFHIUnCt3J~)7|1Z{j0}J34;{mcun3T1)wBz0A;c_FLdpdzvd2TN zSGuiu>j1yp_1?W%=iFLW36fzW*lm^j2)uuhvZAKX9^X=Rd%rG1zErO%I@?e$j!rL! z7pKF^n}f5{)8WD8(b=h`f;j4^V{2^3gZ9tFc|dWcgBcsGtph`0K1NX0&D*ngr-z>- zLR8+sfC)D>Emd%?V)T5$bUbGK3rT%g2$h!{e7|Udp_w(OZQRWTxCL9@i z#6B0>@*GIvg3)E}G3&epI<9AqC94qzDYpj!GVDfN*-$x3i8pc#sENY=x9tav^~1!> zvXW~%tZ|s&NtrOu5j8N>blWtqtqGgsW)ng)LMk<$Rt|P6-DEMfId|z zo)nTcFUi%-moC~Xr}%geKyvGe!Kx4imEsJ%sW&tI9Xl8+U8Y2@ z!`ae`<|uzJV?0~<-tyHgNTpi;TX&No)HOdlAD-TveDm(+?a}d2j7v!q^iij2GGQ5) z=+7S%3GLlzcyV*SKa$r)FrSbyD41#5U?90%E1}cHW)QWwy(1T8ror^2uRj^8ZeKI# z>GP$Yy>x%{^GzuydLm0MPt7BAjLQF}`jmTgkGWUPRYwv6QRcpIOI`h*Z$;{u?A9T= zt4?W|EM?i->pMW*d6efZdk5rNeIr`Ao3G}mTx?W>M?lBCWz9trNAl?!-z!E6Rab?C zg^0(uG!7kO`1y048f2pmaN?DVjy1D6{JM#Qgkg>p9Kry^efwM^?jtSr)g(^R=MsD! zXzmze1u6xb9@;>6Ge~q_Zd<7#@yFV>wzh(!lk>BS%K#WufD9^t-$L)}eqt~&RYVIV z1xjKD08<;8$TO)+#i}g=CAi_uM8Ay*K%Z)Bn1`9B34$!PFF0`ZZM8(Zm8Yr(9EQx5|>R=V$7EkDcSXi`++Cyy?7cJWJD@dHI|BEUmSf+=2s}CTQE^v(dXB ze;kf34Q)5r{_?l%x5bzGC0Jz1dcC?)b)z@B%a|#{j3fY#v%Q=BA1{W(_M?cGKaD}| zv+`V~pRj4+9P4lU2R|ze36IV=%)0!L3%yrt!#CTrNtdaO#^cY z5EpSmwR`gMwidpidt450Knr;fiU?>V7emxVqzs;=d9jNRrwL&!URaPA$Bvlzbakzx zZ;QjymEvNNO6UNJ`*@aL0{M7+54&ehBzp3C7I9_0vQEpwD zX+ZSom$QpQfn{5-JY=qjuPjRRJ6*%Q`f5-#ztDwUti)@5lNB`t|2)msO4 z9~Kc~5sOM08AWH?;R~>F2Fuy<4PqCKuRT1rUzm9`JA5&=!>@2=7}Dga#}NVb&?MWN zf3vzlOYN(aPImW-JXEJt!RFUDs@NRW`gVo<*t&){{>T|V#J?$c{ZEmtEs7KIWZ+f! z?aoW2fp2%d;eXJL%5PupsA*W6y6%5toC=FG&CT5=C#8JSYfe}Jz~`R04Xe5^qvyTmlk_w#MtIBoCw`Os*@iq8ng|`K zVnvFu!2Rvl(XavEUr^7R9+&*Ye5yni-9Y_UU?(7!MaiM5wirl<5)QgUnW(t;bB!d9 z*tH-Qv-fCK%+d^d4MTt9cua4jYc1(FXy-@|ZZ^jo^Uxge>%NnZbF06VAG`^yO2Mv4 z8h#L}NKAYGs%*yJ!!(KR2nW2QH_j-PB8y=Yf~sHTc>pLogn+6AWu*vARns%J-)Ak$ zGGm~?qn`Fd8~`kNzV{{YZvuEb{POh=FXGon zFou!>l^Y*CQ42FKewxqYiRd0y7x?1stVq)&r(BWo6p_Kh?R{@!$kH%TnBQu_-~Dbe zL0j6No#R=Xc`o8A>h@z3^4c~x61y*F35JHZ7=`h$r(M^9w5zn3v@G`IFNWNhXy3<1 zNRUM!BVK^|x>iFsZ(5^+@(crb?tL3ZWfs^^_w+mmztga0Yb+$0Bg|)PTU79KFW`Ia z++78jBxTX{{qA~=xlMt2Vbd}Kkj~!xc^KOiF-huHCQ=6*Y+Hyt`s@7$pf*40Q&$93 zEttQnG%z7o5z6KL`YnlPs#%ja!IoIqO&3u_5ZRP^$MUO+QX7PnubAD^(K>;Gj-gmLtntx$8dCKIG#q)g8*?q7aDq@AxiVQgYHfGEpy0Krz=ymWsB7d{{;>EvTyug>gOqne@Tq(~A zg*_fa(39~5~obd&!s!P8$3^RzS>0nTL($R zx?vc8r|m#q^l>?)(S@f=tcA} z6!+4~L~M+bNSDqby85-nOrC2qR%K*0&l~>Ky{2?tDp5#r0$(-e06!e3(F9-=_o?9r z{x_3LR8IL&KSng%bY**U0rH2Kyn?a5m%>H|F;&)*ev?ygz5qHu%*}rj^&J z#XE4-n!cpR;r_BI%GZTuc)I_$n$iMIjD zY~!bz9stlY3Si2GXt1D@$jKWV54i5Qye{f!a%J)7zZ9|5!nVX?5`N^nr|-(qucrt2 z6vfTKPy4{1jz>+S%fbfaWI<2{6dA@Qb#JoE(!v^(@~^&TlTl`3mLKEMY0R<=->EXI zF7J+j$JX@ev*6k&j#Sb7!QeeDZkw*T@v*ep{8$_oOr>@-5xoNJh5@_CFL-PqH*b0_ zO9u;0NHvi7hU0dL6K&1r29~B^SA2mQSautLXhEm=E=3`%$Evx<{=qeWOoqG?9(XKGN_%rJ4dS%sbIqsb&>$>y z%2{8f@qVgW^yho}NtE3=FTJXz3{kR)8snmDOH&D97qL_r*}dS#XF$PIO=C94FJUPU z;@ciURU30H3DTCp^-i1K%~E8_dfm%?hz4RCbr6dX>GbH9ye!tD>3Umjz4_PkP_?MQ zle4@n^vzXl4c2*W)-3Dqd7mmRq*j}_Bm2zCGAx=y_h@(4Z9g@-Mc5o|wwGh=S2^qB z`b&Z(2Xa{TTHhgEEa4SfkY6RHT8ms+h`QoAUfzIK2Lf0Maksi`X@{7lNrJTAWt*^o zI$EteD-_t5?A5_B-3#nf_u6g%=SB0+cE?;TVvO=G9lwkc>K9K)50}?`27|#1nDOA_ zT)9K1nGMKXLm+=dOm$IZJyZC}hbx(LOW>^CkwY6}L4^4)?@n7N+E`ppqdliDvS}N} zQ>U5TVl!-FtDB{@?QWL3UDawkqk*;I#N9vx(do!AJg3T`K2O;648Bn#q=0~Y9Ldux zF5f7#Odx*Co3a+w=i20y>m9gpMK<@sTD`P%fo|ZbRZGK+v9xe^sYMfNo?K*VaZJc} z4@H(%K)0ry`c?H2qR~-JhQfT7u9C@-Q!`jOueIsMMI{%iHx^rqPXU8lFL95)u0PPA z`C|a~G=0zp5<6g0e+a>Wq#HUO&S}a&&YBM3|92_6JLx%spV^~p!K8p_S`MC(kF=H` zx{KlkwB%P)C-DSR^5N)r03~!5%z+oW=|=O)NxeFt8{j;l|(t7)1X&8h{H9Z|+6BGCE zm^GC5Cwoan;!q#rt1aLTI&~nRtuimQJ~@U_r3IT8Be_$Fnl|Lk+x?^C`c%$=_7pPL zbUp7LW8o~wAyR6XujZ{LekUtdO}N%N&7!+GqsyftV6Cbl96rE*G2l&}0m~Miy9va_ zseus&r$v)tu}`p}l{O6-Y*zU$R$8E(c?K|~69C;cQt3zdd!0(er|a6{d`|W`fbE2N zvbx;D#?>JT=$A3>3fbH>oFxJGD$F?Qxm?RmWkLzQ!`-4H)vk=9N%(V`Oq$qwIeDPn zWHAgvK{yIhnwuRTy&tx?46~*~s{J8~L1kkZZM=$G$tghxKG2^d;9c$O92-dG-e^-S zb?go~-Qm%QOw)?R6(C~rD(#BN(tHD9X}>OI_NsR3jE`17kCbx&=t~*sRkLdsqHYn6 zPT%hzA06IMf}+nRW*k%Ec|SZud-3;t-G2{y3GUmQ(HSPa^`uOyrxOEajWLRaw&X7s)mBmnqUjxl zx|MDv5V>O#S44yZ`p!)@-it%y{B=XbVsSekT@upt6<%HpPcc*!C8}EzQ$GFtm$;ZY zq-wP}j7D64!y64EcLj|5%6p!-hU|4uwUM!J&ehw!*YW=qx@B1d?Xq|e*`6c)xRvFw zu3K1GZJcMVb|D&H?=FTIHO!-Im7ML**qWP|VCYcxn&w&@E;q!YB*7#+ZcST=N{6`d zxWU$zc*;CX$b=lMU{fK};oQuF;y8+^>A-HUzOmqMXsn!b!gw%)>(H^bU1df#!AB;h zn!v{~<5r%V5+hBU}-*J5G1&c9oGxK;jh%7U-P?pyzsBT zb9l#)hGrX!+-7uR>iK%D8DOe^l?T1mtx7$+TDPe>f;*Z;$PQaHahxL$@hG>h8_|(020i&Vmn&ufl-cu3WBMZarNvaMAOiFQuN4WiUf_y= zzM3O}prg`(#-(JuZI-qpBj#iJQ22)QxGbX8Fc7C0+#q%{aaCl(6cK@tIP_G9; z9s6h!br-{fA=qk%a>?FoSoc2 z-A%v;&GX>1&7htf9rHUmdv|(ybN1Hxo{m|ZC@{LX`RVBNvR%Y@p2QR4GWE4Jc9%A% z7nw)-2WatW=ZVbM8WA)o(&O}jWd{-H1>L58*G)IH@#b&fPfc)UFjIi@6M0Z}gYS9) z1<(KAS4|Sg&^TzmZ6~_{59j@F&WvjhMX>x)n}%LD8*aE=M#J;{i~Y;t%`ZonKap~> zZzWqci=Jt){chnY4O=eyI=KX6Eq@?7O7NepNIgc29JIvTPT=7yZD_0Nwta#*Q}YGB z(Z@bu+ca%LEDb7E({B3%*@=SV=fOznd1myKl24aEIXfKQlslpg_n)tlB2MB3YnAG@ zO&+y1LiVt<2x2yUl+{0V2L_L>P*RKWXIqrzq{KZu4mMN#X9Af{SFHY;8~I#aZ|jOt z<)sGC3%kN=#Zy+x2#j&YFn)sns4B!fyg}4fp3dblV5{=#i|&pt=Z9AxH3u&q3dxVI zSdpqBW|^5U#S=x&<3hyb3?wx^k~_~aowft7vhZoco(YV6J>`?CGZsxKHS=yDCuh3Z z`DoUpWPu6b07@ouR^YisvC1hQ4^3*Zn%{zwvw&lo@KJ zmu)J2Q&$NKM|z0#ATyZ{8scp|89!v%Q9z;MkD|wq6g_U@_HYw9#0uWWLJrS~!1Z z0i~zXtN;c>(fGqfzRK`iGb(}y7Q)H_=2vqjyfDyAhl!@h(O>PAK(!^G03ruGE6gP_gn^LaRDZ&syj&#$z&eU024Z&q*@0KUt!_vy*hXYoCj zvs*ko8AN#=-z_?yM7%msUYwsG^Q_`MhzaqyBl2$z=8siQk9$Hewkn-qbxliOpV@=C zL4d2jjqq_M=xs}jox*;u?m4B)_i!BFcnB^0SoWk3Q9b*3cyuv5z)Kl?=Gt6(6(IKd zI7tPcLX^6{SM}FO#jW=z;;AJkmvAijz4btecwhu+U_gI2EVX5p-T@KjyO;rO?Wlis zp22n)sBd*H@tLr{gwL5i7L^;fNyQ#pPn_UDcAim=RrPdP4}aLg8lHDb=#%$&_~iWZ zSKWuK^9b{zu}r^=&ul#Q$$s)Z_KR@QQfASED=-78187xjC#U^|pf_X*Xq5}Zo;dNx z{o{+_{^76qv>|XwcT=ZJtwrK&6{Yid;e8rj7UKrD_RZY_iz+-!wq%w+T3mR>YkL~z zQl?w$a-WS-yjSkq5*9w(eR(EsoM7YvIx)uHRo4;M0?H?ZO<1w->UOr6c zh0-zC1ADq#)EED($0BrGQm@J~9Q-iODHA7>Ky&@`>F9rDDwuL!e z;mfQm{yuHFv*@+f=$4gNn1ky2vPlx|eF1+c>**9rI#QE#vWc~>9|)N1~24LYaR?%J-yXk&YsRRCsHi-EDq`%6FU1^?w*9I;6hWy!w^`ZYiqjX~kt$ z(6Sklwr=sdJn41418{8H7B(8&*)ex)+qP}nwyho8wrwXnwryKG&da^${qLOr)_JRI zR##W`H^yAmz1AA~#&D3iJ9sO{tKphK6&=}3CyqCjRG2?PYkxtm+?Y97Yjd=QWO#w; z{IJUNiJYQKF7$3E>6}}Xy;eQXxdJbw0P_r~Hd`uB^r6Si0sQmFrEyyaoVG7l{_v?| z_Qv4Hg+`C_q`aN#Pcp3<<{qeo5`kQGeN27B79{JXq;%TG)MrNp>yzi$q22mha6We? zJNp1d!m?oB?d|)7TnU9bi{S1D*xbsSr$whgWz=hFdG%}!qKlNG)i>j0mo2&`Hew%2 zc~3ku(ntxcGE4;>zcF;WO<|(CnbQN7^o_W$JB)|V)Sf_G2Xm)Lc&4`CxE;K5#o_zp z_VJvede;_Q+Sl!g`hul*)m!`IVmuB3-&4>dOG+%8sZ?HmsZyvHO4EKlV%@mdpz3AS z=qcK5XwwGhj(V)^GP%VKN66isi-*hEm~W%`brx~2==O;}A7VNk3moi4K{3d3hp)*9 zGf2Me5>&_9AT)c|*Y^{d%!HCDm}k#ejVJ`!2NJU9gY|DBkZVSx5^KLhJNI{ALjLa- zoB!#uX>Dv|uK(R;)5P{a9SK!GY}Q#2zScos;iOoDkm5&9kzlmJ4aFirv10WE&tO-u z!E=e%mPo<_#Mo~wcqg)rX`A=s;>|>CQfTn>#I}yFeO#UPqQu2*+~Vv1l~WLGyjeO5be2Ho>659*wR;Y3k=R*qjAW0Y1z zk-I%>=fGj@qI+@C>#7vN06|DxJp^2{ML!xCi(rYlzdRfxJ`BCDKSTkfgU=@M$dl!| z1&*Pmq}5*(jc-b!1I81STCD=iU%^5LwySsN5kaci&o~-Rzh!&Eu(4PyStMc6$cYw# zjf>Po)0PU(0nPw4nm=XQncwY-GU@^51T7=oomEv8NKJ*BEp$Ld9k8}CTW4YFoQMkP z`)-aqvl|-%UXZZ76c;jeV&<1lVD?k2W78lw9R$-Jf&-a;JXwH3f(6U6#1XWJ2%K>4 z)gqtS;Ui5C@R7fo(-sCFv{P=eeexKfWi3m1M$1xEY2tII&Vr69fY2aIaCQsH?66Ty z4Tm;hVJ$*q8I$8{M0#ZSBD@jIUJl^|6apxGu5pN-e~jrjb&((zSa$J>bI56~nR9>a z#dC2xaGbv8ocU)ui58c<@Jv*%Ff4Ehik+N*4y=vT~|4D#hv z8r?<)@mYo@uw9=6oLoA^&QX}$GY>&?cBI5YkxZO|AMSfVqhwI2|5o3pitGWOCN%ex zOWi{)+VzN?n9gaBPTXobk=N8&<>|E2R_4cDv0!(Ba|6Z$!tfu)JUYkilQd|`tY+n2 z4FgcIw?&TbrAZE-uOQTE7=B5Ph`<=RRagvZffx#LXH6WnOkeaEUlv4+4C}@gF0_cR zFT1zR>)WfJN4hQ5yo6tn-JlA~FnFNajcgUjG{Cfu>;9Jjt{qm?7Ru9ZKO#F%nWZmE z7-pk9=g~;LkxlQV*al^}pchDaypH--mu9zA#(>4=b5JbZ64gYMpgzLT>PEsU#}$+! zNQHi^rdKM6R9D}{)4H@}5lYokU6wy+09v$)bsQB5erP|(jJmhZLs~a}6x^m2O4Kc4 z3aEqTpyL^ae6Y>nTONO8g9*1>ZCJ*6jHqHlHA^6t-eITk-qP`YmyQ@`@80#Aw&@HR zJcif#$FeIw=|q_V9SKcHm70<$HE^1eh@cvBm>@<5>x{vy%8kehZy?rZY)Yqk?02(F zg*tn^KW*#2%IeR9d84E;vaNj^e~7*$9y#m|R8=l`*vDmmFqgYlS-qq+ZW1C3r`UqZ z-_(6upFR~mUiNhVzRwhP9LUw1e*m+7-(MuY7tHsv(>JvIW*mOsWNd9Jjt&*%#jE~wXJ@+w zs5M2q%Y*)eb9-zzx`~SMN?StIzCSc#0FwFg`7;&epi*_F*{nxPM_-~oIiWQ9+@879 zDQv7wlE`S8I|8f=N8%F$^0Vz-7~;VFluONM2zZjVG-`M zS$7yo%fR^`yc*w>2sa&1*`XtpiWCJX+eqJ*=O|gdIEw-)Ie#$a>cP>|2yHei8?ate z2K345;pK$&+fs0_R{YP*rJI?W6!P(So=LWoAy#6mCp6_cnq6fCkQ^eI!8ZKHt%JIP zjud(sBoWIAcAS_bew=h=k5mgbSN86(2xTfbp zi-0pQSa8DRxr|AK*?X7nko!!N7OI=uBKsAx2OFf@VZK)rFd_xF%h_9eO9%*Vq0TYx}( z5}g&;P?P<+#zk)2jWVDO53-r{_x)5GbzUbARN8#unGZX-2L}&k8_kkYroqj{$8n`= zM;az|JRHM#Sjg|)@T$x(4ysRsgApkvA+VphfJEFflW+ty!L+0|5_nqQm^v^vHobAS zyIa(d!TiIpw>VKc@lg{oey!q<1NFq1njD29(sm~zd{mpk=ACs*lCrWeaarwi&04{R z{qVZD{Si$KH(kbEE+=gWl4W=Rr#B!OUy0GWiJX(j1larwIaolu!H^OQ9xs-FjQ`VrAm|2cxD)`Yn zioH=9SAZQoJ8m=Ss{PUjg+u0hnnD3EATBj2Ze5+`cEJL-jHW90`H+VVZp+V?sqaK2 zLx=}bT#XgZBtv|eq|rH_(Tk*l2!WOwM+JnUMnI}4b4lxoK6S&Ziho5}0o*de!G%Hz zjG+n@LcRlRF6-TO@t-d*Z0V2|#;8755&`FA1Xsb*G_Zu-i}T;3;A9XyzPKCA7^1?x zOti}?8r!Ohr!OklM%;G`5y5(Xq;fKgC2B%fK_Xb9qWF!pV>)>1%v(1+>J zfIAwUy+3_u$_shG{4|zl?qgGVt_+yQ)Vg;h4~sSyXpb!964~ueCGLZJ z3Ra^*4{@jC;pTU9%TGuoEN`opP99E-I(xr_W|J@0^Wj((gs@U~R z-Fn_%jVcZ*`q%`Yj3B9e3JE?_pW;ciTSE}}lTf?PU=lcuF0*xxkhd!^rr1hwYz5vR ze7?ocwRJQq!qht<{ER`6oj=7a+ay<~7i)BZraDT>r;Ugg`nyX5HThlcZNWeh?5l3L z8gLT)*;YF2u1U=_Nc@jO5quAtOMG&0&Va%_PZF^LDTg8!$an-oL?i*DY-#CzP;vV9 zdRofKwHf2w`F#8ce`yFwn#mv-7Zjd;G$}EKR|U%hgb5UDEjKudz8*&d>GipNOoa85 zP#@*>CI}la1)xZAW3@w_gV=h^y%R%?EhW&<`gwE>>GsfaADLNO(Lv&EC29KH@eN?w zgVd5z$_9KqSax}ryvkX@bke=e%qnoZ=>lnG9Y$vP33twNC&z)Yj5NnWKU5$t3_ZE1 zq17@03{ zsK*xg%$=2TH$SQZBJEmC>;@y+Zk_^+4^zVHt|K(uo$sYz*PXz?bWN<&9% zzD9?=%1EFrmj0o9|14-eN(XELz1)U&hGIg_mnZ3q85`$^enrRH@AAPJm5diXonbKH zl?9E=#1*S$?=Y7?05i=LZK(XrvBt;cqV1yIJ*2W8(;I&HD{l@49@`19pg#dpCGD2& zf$hVAag+yKlWsXCe8?+(w$!{CFh+-{(VNylBb?&}&hj$!m7Ov5T)49*c;HD?@dT*( zK`-EFo2E>`oi0x{bEnUs)e*cZwySMI-NHW%-w#!s@U*`+VM!alhC;`JIm2FGsPm;i zTQM-N8v3pSW0tF(vY?~U<^W*d)CJmS9v}M7B%Fe@yh1$X3cZh=E3yljC{g>AsC*8s zKyUU|xcwr`CzNstJL3%;eF}H-T74=o%V!KZH|;7{uuX8jL;wG5tN%+n{wMZ zF6qFDTEBvP_H{rKReTkTo&<{yaw7^bJy$bxw-d*>z*{|A%uQvu)OfgxzNO0UA`afd5N{>0VB<*!54OT zn8j|-L&JzdeNyvqNTdx>v6^NM4$a!rlKMzz!O5K3)4RMfITr!6mZqdOb~e^Or~??| zr?Kx4oATNCdKrHNfqmiIS3y+-mLs8+$N!;6T>i0odB1RYjvAj!5pi!OoYHf0FRyV;RrAZVE;` zF40I=W5JI)f-w_gij{o3v#wmCY-L_io?!sGXV>OJV z=!LA!egEi}a$k|rwC>l<{&|4zk2WP+Wy0qxX4^uuR5sOoEy?ktlnmCVFx&ZRrNzN` zJLJr*1Z(Paw!XZzBZqu&?rxtq`*4_Bv51lTuc5Ky(w7YC&iH+Y}}xbqEPF*CQh+D)K|A z#`Ife^Rm~u&F15;L8j*CCo1NtCYRYV`u1bnRuR^LqvA^3bxf$I%@Cu#SIR9U7q#$5 z;HXC{%houQ+bGP4yIgwP0}Y&8`op(7&|3afg|%V@-vzkp3GrZ_dK59*!^VL>W{{5vp|s!YGr(JKv(ZbY zB|@q^O{iy*upicYf9Q-Z;G`GgV0$*UuJ1Kx@Z_~UIyAtW0EzC3#7+Z)ggDhIto$)e zWY$N@o-^$?=vB88$}0ro5I;n#sI#ECrFrM`82_16!t48~`TBC5^A+dwd3@|! zl=I~cEyUjXj`FOU49l?;U9p%9NGVb|=0ck8HMFBmu|=lB??SmOl3yfeDv)?uzgqz> z;61B@jVz%M0@6Ulq~EO}&jEKjd35jcs>dOH`toQJRIF}>Q=PQ4qCOhwZv;Nj6gI`w zuG7<1+LDh=B`vy=j3#CHMyXh9*Iu#oLQ?YlI69bcpi0J%q8_eM=QWUfA!eu3$KnpW zXxVi5UBRx2a;ZT#jz3#zJgJ&r!|sC69^YOnY?0L*8dX4xDCOKQYb>su>?NLRr~&xMna=8xuKk7axH{3mSzhygMS1i^D;rv1nfJ>sn|2fLL9#k~IXDL3H$IHhKyz4(EOTT>Q}T!?+s# zxM_{Y=@0`kCYDxeQA}7mVu+=fl?a@tRr-VJn_c4%ln1tpN@xa`$ct>%>ge?XVtdpp zhY}gQdNH2-XbuFetd>XQ)OtWdp<>~(=^?s)0jFhXq$Jy@0`$2M4zuapATqfTJckw5 zFnc@~Q$>N;e%0*PsBBo|Bcks5L%B($IxF`#pQ zYPll>8~gLnKg4v7pkQ91BCM!5sYf9}qS?NDfHCgL2q5P?S=jX790al|L(=h6wVw#q zW>cp{&5VSb4nWJVqgc;dX5egtK`^Rw>1jg3+Y|EnV8Za0(0~{>!y`%{IeA3rIy#Lu zE14+t$vaFDfnLi;U2l~M=v}g;Zrz?>gqfD&hOwOqw=g5ULVp?PAt`s2P6^!yNg|Sp zFvPcU?a80a#hnNFNG}R<>*SY;#(^^ri)dcE;ZQy}854<-WOT}*$9kwxE^q|@oL?`- zUN0C*J3*@w?72(>U33E!*y9{=dJN!q^{<$SsJC$G1R&o#S#03dksMaK4QAN_k-TKf zx8{Jsg1;>_JZ44TSJFy33g?Wrka)~@q`V`bl~y2Fgc`mGHV2q0xg1u8Jh@fX0>#*F z&77Jp!DpsAziZGX0r0Kl(u;DBl8(57cyY%W?dwB>3lpk5KF}`R^rvAbu)Pkjes4U7 zL~s0oy6FU6d~!0g#h6!r93cxnqf@5KG*?2?=oi-`>1goeA~WiGHs@snx`Y9P5?14 z3{}H1lzG{FfhtGGofHM(oyqXY#0hh)tRGLDaC93t7~5r8k0T8cw@74u0L4rSMRB z^AUOY^Foe{t-2Id?2u0{UNsrN;6V zAE#+Wyn8woni`TS20@6Q`O}hta@9}=>vWD%M>|E{zS}sfz&>)j`bASibFYC_aBg7s z6E$uBo#$`h`9Sc5ZISk0x5v1r>|KPfmhhW=_3L`aNCnc5Zy2(^Iv2cL9QoatY;M2G zPbEeVmI|Be_yd3mu|6R{1T_?p?TSwWaAszA0ff`sEBf})_C>#5-fxY*C<)Onf76nq zA1VJN$v4jx5}9_>;nOUnFtH^;H-VW09Z=*7ObvGm!_J2Zr?r2mCeqbP2wHgOrd58A zuedDSJ#6oH1lszuFB|*exyZjt71TGB6yN9kpi;(RU1X*Jr?8hKd(f4pnJ2LeWU*-Gu%MLGQB$ zSyiiub3QdHFEUUXlRt27F@Es8Txu4!+ue!2I)QVsvfBJcGSyYmYF!rJ{6@Q&4p~No zw&L=%Skh6Ua&3hmod1~q*0#Gd^YCfM#yEA^R2^ml!NSFNx8@=l4BG_iPCcw8RB-|u zQzVvqLH4J+H-4Y3a)FT`z5@W_@3J<1*4G72=v1q78`JkCrVDEhc!){cxK!7zwty|) zQ*lGS9f4(AK&Z`#Jei9svgm=S4~aBx3#B6I6LG9S=S6n=KH`i1ACZDJsRS}|Y^8xE z%*CO6QNUvTTgsB88rw-7Zl|4TF|W6+ks3t~+p7TKLKwEN*YbP!fJ83$FJNJJ;{pKof zwSpdskmonHlPc>~=Y?=NKGpSkzM8ZIQ%)V`?RLBSILFhHaxQ^4U|@5Oy$Sqgm9Jip zgy}>VdOX8hvz;mWwu*^9sV_l~d7;N^#F?g5Wg_S-*=n1f@A$~`p8>Ruoyg1+axP-N z_1GlqwygGr_g-XpzWK9WK-w#^p*LW?-=vx6nP6J;zYM}~gR`3Q1#J*!j@04)im}wb z52I=Qxa|9R>5AFBuTH)o7`nUQa!|coWf~cN)s5KTD3bm_w#}*)L-0Mwn&a#Q*FaK& z@gsF1kpq(EBd{yvbZW%5ob>Jf5{$zq6* zsWv#Rx#FRblZS)17P_;kAlcQW#mq?g4?|;@F{avf*;wYu#RqwIjyW2CUq_K*hT}ZV zVz_v6O&n9*qwH?F#ED^T^{#2ng@PwGH00}XH*JRlD^q(S_w!sFJFxsD1Cr5aT6OiA z8=rV|0$>uzwq-(JxTc=9K*qg4m>AFj?$k&&U-Y6I@}ZmirkX1=Xi199pFVHH>xAOZ zP{P@U1a~b*fH{u2oB$?{rdGBysB?u#2u?ae%(cvVY%g3#`)-gmNm^u(R@`yR5!sp| z_FRML4#@^^)rZ^2VLiNYe&jkS4oE6NDo{qPZipUL*fxiOrZ+pi7wvn*jtrv@_|>&W zu&d3Tbh$O<5;w^^piYo$U?Z!GC&({*JJl-9P7byj%B=e8SG@iV*3W;;mP3gPlNV&} zULyW>TA05V%J*_K)^{*8qjvaCemNK${&!T(Utwtq32AX5JH-gpiOxW&lxDY_HdwUF%((ez^zYn-(PWe(Rg- zfA*sjxF{)qj(1oQfaS~Ksfg$G^Z7#Lm&j)Gq!rdv4H;*b6g|H`h?@qW4_PQ23a8Rm zF!aY{S;QsMcLSk(Bf>n@5rWGJP$mqVpVFJzxw6ZC$}lLmaorxg)(_B7l! zr7PY3YNwp^;2Mu4WLFQ`l_mmv%Lk0{O^(@}giy;X;n?s#*z$)yC}d178at?h;%}}d zYtz%P)x*aA4)Y^OnWS*8xnvO_@ZEHr6vl7La~S}643L--2A8NiSD%5lk?=H zRT{!Qp^Sqy0cFTFE{Ge-OM7Lssd);1U3MKsb`^7m4(R#9N|=`3APC>MU@Rr! z07d0dB4O{F6!*BAt|IglAPdDPP8AVt0$E8e zf?8KB=8QT@r03?_fGjRc6eX7{>mN;E;)e0k5W_lP`=9a^n`4J<>6H--FTzE`)*S{6 zAt1@emdS+bYLVQt;Pc@h{(O;79#~Q*X}dklWepgG00_ftX4+6O5r_~J(M_;cZgM~F zXk;WlY#|y6Bs)(L+bfkD-Ds6GC~nnzm=K{rD^bf!^__~LR+Ob#^*k<F4t7hc^Ffp|sR$Vqp;-aMi&y5p=S(_@BEEhA%7wh%=2Hr|f$Fyl1RQbUmX#*XG>^1JP&#)+ zNh)L59gRPy^%OhYIRG_urV{*WgIM61{#C3U zjC+>O$9FRYYMET9mnoMAUWVR}pZ_duCptDzJDL_Ie=@H*=s7&D+y*gPqL?%%H`qe;^Som=^oY%nmX*9ZS?b@ACS_K{8$`} zjTC`WUwz)>bgtf|0O>WF13hv>ZnRsEMIR`Q_}&d}7Auv!K(#-0R+65QkFe>oXjobi zVi}sr26)&xH^%C_v}^lRfb*@4gLx8EsKVW04vxNqZ%Lfq?&tRpaBWXLBn3{(81|5M z@#|i;%d4y&U``gS*1HRNgA6x8wr+>)K?09L(~!eN@99GRruC_6ayDUBF8FAJ`a zH1mc});@iDVCTjj*&aezo$8#KKYENO8upgDS8Vgy!}hu+8Nx4U7_ z%8{L{Q3cbZlHxf=-_|h#WVbU0e+58pha2Slw{UMUvJoPJ-)0tCyGD_-QQ+gO7zp z`D6(*e>l}Gjt$O%UQSX}wZ;_Dry(I#pBN#9)Kk9C!4oG>^aTs9Evd8@qt1aQ(um>N z3gKacjF=S+D*MFyDh>UV(wKp|h_oL!4P9vd9v9e%P!_7j$(6&Oo<$`vv^9XKBn+4r zV+{$l)O_TDlw5ax;dEVMRY0b|$i~5Oyl%w8C{eMf!zjY*slrGUMh|LNdz3>vC1c2_ zygx!kXfideIBGfiQpQ3oky*v}W%$ACF^kvtN#0A|zs4Zl@dz zc32M1Hrs=KpX14eUS1pjY;bvJkbK%WJTyMYdHV+zfqE`5I41{g@$)ky*PZ!DTRNV% zMo8ax?@sS8ACCU~bdcONgBHkJTcFiXV8Es@|BcFw2=}PUw$4$Xs`7;p&*6jNZOOEy z1h(lm`HVUZtJL;`K8W_+hDls?DArJYko|&MFk|cD$KC*bI0?*y5Cb$OLFYBbPm_wk z(SQ(z{&oR6P0`$>4}M3B>h(8CXGL0KU3u2wHlG%g3-;`#8@{(QERRj(1u~s%?T_)t z^jeUP59_vr&BoAWUi(0Z$KAXa{b!8eP3TvC)fVw)Ndo5!*tgBiD?HIZGm6}BZ`yy{ z|9JWhP2#OFE7R?lpVp;mpZwC({&B3KY-x(X;}Eo3>6}&;m$Wij-Ir0VyR6R!RAUX` za)(7%h8tcM^;VbB`J}Ek-99YKg}^h10ds~0U(;2P1IAU}4$E_o?Mq!L6aDGqCzyo+*zSQj|+4IUSqN#>-kXU=wn!91L5uQloB{T zz#E=3H6>iTSzXu8vzEy3QcXV4XpDujJYn8;M2K*1-1|q3gW&i5?dMfw7^_XPI8XhZ z9C^N`4C2kg?w9jb-)xzAuEG^fm%5}WgrhbvS6a#NyYNS|`u+l-Us`)o3LB@$3b^r?pC)R+YTZPGC{x$^F zK*;A9=cWTy#Z?n2Y{cWE!O~C-tET1@RrHwKyykw+Ua5nDP^R#^Tb~nNMl?>ml`tmj z;cS^z{5DPAER{1U`H%|$l-e*#{YB z+}Ni6uN-l19D@{_s4nK*ywISy3>!5f-Bp{=Q9vn651JPe);Iuxgm@`g=(2wB##J1Tjix8d9fa{=~lC5DjZsMMXKrZ-)K1u%os>`Vn zwf+z|sdDwkXbrKMksILV$UsN)GBUN{(nvUj1 zC%|~o5NkvffrZ|>EBMh1xJz!*Wl&+$B?P-k;1LggLudni(@S4h$?(I($@X;!Q#COg zQ>u}qjAJ79YH}0aBA0u|BEtFr=vAP5cFo8h?rp6~i_>g=)cq^@I)mya$ry)Cz2QAl z*xQU?jp)VhR~9qIG6Nr>WGnU~zT>0kf%JPSWnS`j{-EJZEYEgiCmyg@7xzmE@7KBa zdq$nK=PbW97D__@^{uOW%ZpPmcyCT}E;UooXZY4U=@;PN=@RVkbV&xJ>b}jlXW&N# z0Py46<8Zb!(sweZ{?A~{?`k_EW4G`6ly5J=cAf3c5$b`L&NEVCjjcZ6DI(i;GylXS z5__&uZ0;O4vr&#}ZAqjiiHx*uoPk|=p(cfyV>GlgwzEuESiu;+ zA;Mj$vPCrL>0l=kzhs627Cc%BT%$`5)c?Bn=kl%}AV*mYGY-~7u@4R6(;MW;$#NQD zi!cubQ0;XJRl+_89|O7uB)LjdmVrhaL0Vm5qVVnojOzbV;oS=WeD53Wq>Tym)B>lm zc3VuBy9W$>bj(njM4GGA)sQm)1lVoI#pq0iwqTr!*V>P}VDD}aeU8R1nfO4cEz<=t zPbKy{Aa$4-IEv%m+mR{>hH0kHKg7uLdjKaYY5~cPxpG*xr=_hq*6q?Ct=@w1eEVu$ zBeV@+>gn)cJHe(Aih0||&gCi*%p5VlXCD_p-r7AZ;%#+Ecpu_wOPfr{lsOUygQqpQ z*YwDmXc)EPDQcMIiZXGS|9WgD+Mn>YsTsv(Rm&juZA#+BiSG~ncUQhAo-ChM7w4f|9t8^!T zU5&b9+8m5mEasl*2=<$gl^G0ZPjP}PN)C%d9*V=rRQnP8w43TO0^*B`o%*Gl`qJdk zWLdmZGQ%2C5u0ptP<0HZcVC_fw86pB>V`Z0`vB2PYQn435I_k;P>Vi?n{dw>DL5>} zY_e4oKsCKNyivmv>S{3`vS}rLt=43w6t8gcqCt}cXPpARR#Oo!-iVH6M87g-wZ~zP8l-nLZmu$vr=7S0K+kYY z3K-~E@)lFGd39s?#{zyMY#*ZqnKYm_libjK`=@8;zGvCb_&e!s0SORw^{IXM0X7y0 zqu0#fXm@O(nCx!ZY)yb+b!^iXQ>7p}Z=gEVCciB?JCGCiHzRDnggQTIbA&k{F%7lh zBai~b$<)hNOn^B^bYR#UEzeKAwT47Sw^#mdbC?O66BrjKWu+Xq%@OG%wGEm+;hS}&5s^MiQYDuD} zL+-=zCB8lwUTM7>_m&x#&)i^(7Tj0s2B-|HaSTm3%H zDH@evc8|_BfqADCG5}#pBw_Dx?bc;}w3|uDQ<2*$AtXpr+J~A~34?Ll6hQY?&Ycm^ zk%Ksm(JT2qJD9`AaKYEiT0HdmgOa6`VDglJ?48XetJ1)I2uzKNpaZzxcV-Ep@LJWJ z{nzk>wZ4B81Kdmvbsb*-jV_zH_3c`}u#l6cP*h5>1u!>D~JlUmWyEekwja()kMm&jsrKe z8?;Se*kWiB_#oZTy(kxyQg<>|&63DKw^`VYUVb&ElMG?sIiCW60N1*l270g;C+H|7 z{SqjhdNdss`ja64Vp5<-@`~5Byn;f*M~{+LmIuoE3=^~rVQ<`>f{7(3Kt>-LlUdu; z)fEjp&&S~j>)qkS^WO6VH#-OCn;V!Z2*5)^9yc@t$u)Tc?T<(%EX$Oyl_+abrO^Yk z75^b6a?Rq*HVPs~qj%w${7x1kq0Z_AKj45vl1_*6kqfU*k$xY!CxL4(Ehc6prZUpDIaS4Yyv_Sc_YSQU@a$4 z&wwi9ovD_@>O>=@0fAtoFAm-+y%9c&KpZ)?y7W834OEsqZUYoBgeE$wrigYRTxXr8 z-u|k(*uY=1K-V`i;$^UU#YIUpo#0VYBC&Vu>#o2fcVlOQZh&fn0gQ^G^_N?7T`Ef)c;|9<0YC*5e{no^-KT$woW!HxZ@c?FDP z0gXiAi9qx2VJY0@sJi5DvXx{YDcy5(UJ|%3pjhtvTr8D9p8XR_6*5oWAJO`A_35-( zMv+EBTIqTTvY$-XZ=USjTFCXmXSL=nei6Ve>G&n{B6@bnz|8?pJ>PFvm*%p`#7!^`frPE=QZfuHX2PQ`z)d%T-rU@KA@prsh^V$%&W&gKfb3heW!jGs7r=4LE$6b)Pk#yaRyN0*o)*>Xc zeuSA43ayyTGb%8K43?lBYBNvZm-REcDrQr7gQretH@V4H1=UIc_okto4|dk9^7dnw zm3A;i?&rjza3M-hp-l}0`eo$K+>(M6qm$R!QydBgfK_N&w}FvxFak$)2-$Np7{TsA zj{DoD&1k?ZY-^Rl_k#zan|L$#?e4k;4qm6*Ym3WZ_3^sUT^z_WVdtt*Lgn=n$BaIa zLUF2A45LvUymJ8KDB9EW4iV<1FJcR_urpo7vz3E=`*F5_D3h-l z&EKv23sTo3>n8A=l4R7S3F}qcUHFu`AHrO-q+LsCN31@tez}XZa#&!sN+tm@0&tsZ z@l)}rpFpl%9<^B!7kG`RWkZA9>cN%sV7PIj-_+THjiUIGVQKEgbX{xdwN~Tgxd}NH z$N1p1>JfuVCstC0pTP+d&L=4C5&%s~XRUm{W{5OQjxP@sq2LVj&RHKF zEA@E4;w%5r&ojN<-m?;T;1sxN>BZ{OJEnkHyS}U3K~vU9@yy|Q!IP5&1p4t`TO0x) z1SC!QN)Fxk;hO^EO8Hko)3=ch00@BppSSPB(AL`8*5=zt|0fL6DP5Tk4{*a16aZii z0RRB;ukZf9Ff2F#03!!;7h?zNzpQ|`yS`aM!QUtOy{-Qqf9-OR{-024XD9RjCOrNrKqDeU z**7&M!4nMt0Lk!wPqUuoF9DAKv*Ul8QuOzZ6=nW8v*@4N{*Bc1H%=?ye<)7>g#H^( z;cw_*$p6h+_$Tt;2nc^8i9`Qe9{0a=gntV7w~D&I1ti7(qrUE+uz$;C{S8yd`v1vn z{ZquhrLF!JQT{FB|0J>giTXE}_HWc+*}prua+09m|2+Tz`0tk^@b}fx^q)`v4}ZmR Ai2wiq literal 0 HcmV?d00001 diff --git a/www/nodejs-project/modules/lists/lists.js b/www/nodejs-project/modules/lists/lists.js index d8bc8dd4..3ecc912e 100644 --- a/www/nodejs-project/modules/lists/lists.js +++ b/www/nodejs-project/modules/lists/lists.js @@ -203,6 +203,11 @@ class Lists extends ListsEPGTools { super(opts) this.debug = false this.lists = {} + this.activeLists = { + my: [], + community: [], + length: 0 + } this.epgs = [] this.myLists = [] this.sharingUrls = [] @@ -265,7 +270,8 @@ class Lists extends ListsEPGTools { }) } async loadCachedLists(myLists, communityLists, relevantKeywords){ - if(relevantKeywords && relevantKeywords.length){ + let hits = 0 + if(relevantKeywords && relevantKeywords.length){ this.relevantKeywords = relevantKeywords } if(this.debug){ @@ -301,18 +307,18 @@ class Lists extends ListsEPGTools { } for(let url of myLists.concat(communityLists)) { if(typeof(this.lists[url]) == 'undefined') { - await this.syncList(url).catch(console.error) + hits++ + await this.syncList(url).catch(err => { + console.error(err) + }) } } if(this.debug){ console.log('sync ended') } - return true + return hits } - async isUpdating(){ - return !this.isUpdaterFinished || this.syncingListsCount() - } - syncListProgressMessage(url){ + status(url=''){ let progress = 0, progresses = [], firstRun = true, satisfyAmount = this.myLists.length let isUpdatingFinished = this.isUpdaterFinished && !this.syncingListsCount() if(this.syncListProgressData){ @@ -321,12 +327,12 @@ class Lists extends ListsEPGTools { if(this.myLists.length){ progresses = progresses.concat(this.myLists.map(url => this.lists[url] ? this.lists[url].progress() : 0)) } - if(camount){ - satisfyAmount = this.communityListsRequiredAmount(camount, this.syncListProgressData.communityLists.length) + if(camount > satisfyAmount){ + satisfyAmount += this.communityListsRequiredAmount(camount, this.syncListProgressData.communityLists.length) progresses = progresses.concat(Object.keys(this.lists).filter(url => !this.syncListProgressData.myLists.includes(url)).map(url => this.lists[url].progress()).sort((a, b) => b - a).slice(0, satisfyAmount)) } if(this.debug){ - console.log('syncListProgressMessage() progresses', progresses) + console.log('status() progresses', progresses) } progress = parseInt(progresses.length ? (progresses.reduce((a, b) => a + b, 0) / satisfyAmount) : 0) if(progress == 100){ @@ -339,12 +345,15 @@ class Lists extends ListsEPGTools { } } } - let ret = {url, progress, firstRun, satisfyAmount} - if(progress > 99){ - ret.activeLists = this.getLists() + if(this.debug){ + console.log('status() progresses', progress) } + let ret = {url, progress, firstRun, satisfyAmount} return ret } + loaded(){ + return this.status().progress > 99 + } isSyncing(url){ return Object.keys(this.syncListsQueue).some(u => { if(u == url){ @@ -375,9 +384,6 @@ class Lists extends ListsEPGTools { this.syncListsQueue[url].rejects.push(reject) }) } - async querySyncStatus(){ - return this.syncListProgressMessage('') - } syncPump(syncedUrl, err){ if(syncedUrl && typeof(this.syncListsQueue[syncedUrl]) != 'undefined'){ if(err){ @@ -386,7 +392,7 @@ class Lists extends ListsEPGTools { this.syncListsQueue[syncedUrl].resolves.forEach(r => r()) } delete this.syncListsQueue[syncedUrl] - this.emit('sync-status', this.syncListProgressMessage(syncedUrl)) + this.emit('sync-status', this.status(syncedUrl)) } if(this.syncingActiveListsCount() < this.syncListsConcurrencyLimit){ return Object.keys(this.syncListsQueue).some(url => { @@ -538,9 +544,7 @@ class Lists extends ListsEPGTools { if(!global.listsRequesting[url] || (global.listsRequesting[url] == 'loading')){ global.listsRequesting[url] = String(err) } - if(this.debug){ - console.log('syncLoadList end: ', err) - } + console.error('syncLoadList error: ', err) if(!resolved){ resolved = true reject(err) @@ -548,6 +552,8 @@ class Lists extends ListsEPGTools { if(this.lists[url] && !this.myLists.includes(url)){ this.remove(url) } + }).finally(() => { + this.updateActiveLists() }) }) } @@ -619,9 +625,9 @@ class Lists extends ListsEPGTools { loadedListsCount(){ return Object.values(this.lists).filter(l => l.isReady).length } - getLists(){ + updateActiveLists(){ let communityUrls = Object.keys(this.lists).filter(u => !this.myLists.includes(u)) - return { + this.activeLists = { my: this.myLists, community: communityUrls, length: this.myLists.length + communityUrls.length @@ -634,9 +640,10 @@ class Lists extends ListsEPGTools { info[url] = {url} info[url].owned = this.myLists.includes(url) info[url].score = this.lists[url].relevance.total - if(this.lists[url].meta){ - info[url].name = this.lists[url].meta.name - info[url].epg = this.lists[url].meta.epg + if(this.lists[url].index.meta){ + info[url].name = this.lists[url].index.meta.name + info[url].icon = this.lists[url].index.meta.icon + info[url].epg = this.lists[url].index.meta.epg } info[url].length = this.lists[url].index.length current.forEach(c => { @@ -688,17 +695,21 @@ class Lists extends ListsEPGTools { if(this.debug){ console.log('Removed list', u) } + this.updateActiveLists() } - async directListRenderer(v){ - if(typeof(this.lists[v.url]) != 'undefined' && this.lists[v.url].isReady){ // if not loaded yet, fetch directly + async directListRenderer(v, opts){ + console.warn('directListRenderer()', v, opts) + if(typeof(this.lists[v.url]) != 'undefined' && (!opts.fetch || (this.lists[v.url].isReady && !this.lists[v.url].indexer.hasFailed))){ // if not loaded yet, fetch directly let entries = await this.lists[v.url].fetchAll() - ret = this.directListRendererPrepare(entries, v.url) - return ret - } else { - let fetcher = new this.Fetcher() - let entries = await fetcher.fetch(v.url) + return this.directListRendererPrepare(entries, v.url) + } else if(opts.fetch) { + let fetcher = new this.Fetcher(v.url, { + progress: opts.progress + }, this), entries = await fetcher.fetch() return await this.directListRendererPrepare(entries, v.url) - } + } else { + throw 'List not loaded' + } } directListRendererParse(content){ return new Promise((resolve, reject) => { diff --git a/www/nodejs-project/modules/lists/manager.js b/www/nodejs-project/modules/lists/manager.js index 2ef75dbd..ac1f62c8 100644 --- a/www/nodejs-project/modules/lists/manager.js +++ b/www/nodejs-project/modules/lists/manager.js @@ -10,10 +10,11 @@ class Manager extends Events { // this.listFaIcon = 'fas fa-broadcast-tower' this.key = 'lists' this.openingList = false - this.updatingLists = {} + this.updatingProcesses = {} this.lastProgress = 0 this.firstRun = true this.IPTV = new IPTV() + this.updaterResults = {} global.ui.on('explorer-back', () => { if(this.openingList){ global.osd.hide('list-open') @@ -25,38 +26,25 @@ class Manager extends Events { this.setImportEPGChannelsListTimer(data['use-epg-channels-list']) } }) - this.master.on('sync-status', p => this.syncStatus(p)) + this.master.on('sync-status', p => this.updateOSD(p)) } - syncStatus(p) { - const ready = p.progress > 99 - if(ready){ - global.activeLists = p.activeLists - } - this.lastStatus = p - if(Object.keys(this.updatingLists).length){ - if(ready){ - if(global.activeLists.length) { // at least one list available - this.updatedLists(global.lang.LISTS_UPDATED, 'fas fa-check-circle') - this.emit('lists-updated') + updateOSD(){ + const p = global.lists.status() + console.warn('UPDATEISD', p.progress) + if(p.progress < 100){ + this.uiShowing = true + global.osd.show(global.lang[p.firstRun ? 'STARTING_LISTS_FIRST_TIME_WAIT' : 'UPDATING_LISTS'] +' '+ p.progress +'%', 'fa-mega spin-x-alt', 'update', 'persistent') + } else { + if(this.uiShowing){ + this.uiShowing = false + let ret = Object.values(this.updatingProcesses).filter(p => !!p.ret).map(p => p.ret).shift() + if(ret){ + global.osd.show(ret.message, ret.fa, 'update', 'normal') } else { - const n = global.config.get('communitary-mode-lists-amount') - if(n) { - console.warn('data-fetch-fail', n, global.activeLists) - this.updatedLists(global.lang.DATA_FETCHING_FAILURE, 'fas fa-exclamation-circle') - if(Array.isArray(this.updatingLists.onErr)) { - this.updatingLists.onErr.forEach(f => f()) - } - } else { - this.updatedLists(global.lang.NO_LIST_PROVIDED, 'fas fa-exclamation-circle') // warn user if there's no lists - } + global.osd.hide('update') } - this.lastProgress = 0 this.setImportEPGChannelsListTimer(global.config.get('use-epg-channels-list')) - } else if(typeof(global.osd) != 'undefined' && p.progress > this.lastProgress) { - this.lastProgress = p.progress - this.firstRun = p.firstRun - global.osd.show(global.lang[this.firstRun ? 'STARTING_LISTS_FIRST_TIME_WAIT' : 'UPDATING_LISTS'] + (p.progress ? ' '+ p.progress +'%' : ''), 'fa-mega spin-x-alt', 'update', 'persistent') - } + } } if(global.explorer && global.explorer.currentEntries) { if( @@ -69,20 +57,9 @@ class Manager extends Events { } } } - isUpdating(ui){ - if(Object.keys(this.updatingLists).length){ - if(ui){ - if(this.lastProgress > 0){ - return true - } - } else { - return true - } - } - } waitListsReady(){ return new Promise((resolve, reject) => { - if(!Object.keys(this.updatingLists).length){ + if(!Object.keys(this.updatingProcesses).length){ return resolve(true) } this.once('lists-updated', () => resolve(true)) @@ -126,8 +103,8 @@ class Manager extends Events { if( !global.config.get('lists').length && !global.config.get('communitary-mode-lists-amount') && - !Object.keys(this.updatingLists).length && - !global.activeLists.length + !Object.keys(this.updatingProcesses).length && + !global.lists.activeLists.length ){ global.ui.emit('setup-restart') } @@ -152,6 +129,7 @@ class Manager extends Events { } add(url, name, unique){ return new Promise((resolve, reject) => { + url = String(url).trim() if(url.substr(0, 2) == '//'){ url = 'http:'+ url } @@ -172,8 +150,7 @@ class Manager extends Events { } } console.log('name::add', name, url) - const fetcher = new this.master.Fetcher() - fetcher.fetch(url, { + const fetcher = new this.master.Fetcher(url, { meta: meta => { if(!name && meta.name){ name = meta.name @@ -182,7 +159,8 @@ class Manager extends Events { progress: p => { global.osd.show(global.lang.PROCESSING +' '+ p +'%', 'fa-mega spin-x-alt', 'list-open', 'persistent') } - }).then(entries => { + }, this.master) + fetcher.fetch().then(entries => { if(entries.length){ console.log('name::add') let finish = name => { @@ -359,7 +337,8 @@ class Manager extends Events { } async addList(value, name){ global.osd.show(global.lang.PROCESSING, 'fa-mega spin-x-alt', 'list-open', 'persistent') - let err, ret = await this.add(value, name).catch(e => err = String(e)) + let err + await this.add(value, name).catch(e => err = String(e)) if(err){ global.osd.hide('list-open') if(err.match('http error') != -1){ @@ -417,13 +396,12 @@ class Manager extends Events { } }).map(l => l[1]) } - updatedLists(name, fa){ + updatingProcessOutput(uid, message, fa){ + this.updatingProcesses[uid].ret = {message, fa} if(global.explorer && global.explorer.currentEntries && global.explorer.currentEntries.some(e => [global.lang.LOAD_COMMUNITY_LISTS, global.lang.UPDATING_LISTS, global.lang.STARTING_LISTS, global.lang.STARTING_LISTS_FIRST_TIME_WAIT, global.lang.PROCESSING].includes(e.name))){ global.explorer.refresh() } - if(typeof(global.osd) != 'undefined'){ - global.osd.show(name, fa, 'update', 'normal') - } + this.updateOSD() } async communityModeKeywords(){ const badTerms = ['m3u8', 'ts', 'mp4', 'tv', 'channel'] @@ -479,37 +457,41 @@ class Manager extends Events { } return terms } + createUpdater(){ + return new (require(global.APPDIR + '/modules/driver')(global.APPDIR + '/modules/lists/driver-updater')) + } async updateLists(force, uid){ - console.log('Update lists', this.updatingLists, global.traceback()) + console.log('Update lists', this.updatingProcesses, global.traceback()) const camount = global.config.get('communitary-mode-lists-amount') - if(force === true || !global.activeLists.length || global.activeLists.length < camount){ + if(force === true || !global.lists.activeLists.length || global.lists.activeLists.length < camount){ let haserr, myLists = await this.getURLs(), communityLists = [] if(camount){ communityLists = await this.allCommunityLists(30000, true).catch(err => haserr = err) if(haserr){ global.displayErr(haserr) - this.updatedLists(global.lang.NO_LIST_PROVIDED, 'fas fa-exclamation-circle') // warn user if there's no lists + this.updatingProcessOutput(uid, global.lang.NO_LIST_PROVIDED, 'fas fa-exclamation-circle') // warn user if there's no lists throw haserr } communityLists = await this.extraCommunityLists(myLists, communityLists) } console.log('allCommunityLists', communityLists.length) - const alreadyUpdating = Object.keys(this.updatingLists).map(id => { - return this.updatingLists[id].urls + const alreadyUpdating = Object.keys(this.updatingProcesses).map(id => { + return this.updatingProcesses[id].urls }).flat() myLists = myLists.filter(url => !alreadyUpdating.includes(url)) communityLists = communityLists.filter(url => !alreadyUpdating.includes(url)) if(communityLists.length || myLists.length){ + this.uiUpdating = true let maxListsToTry = 2 * global.config.get('communitary-mode-lists-amount') if(communityLists.length > maxListsToTry){ communityLists = communityLists.slice(0, maxListsToTry) } const allLists = myLists.concat(communityLists) - this.updatingLists[uid] = {urls: allLists, rejects: []} + this.updatingProcesses[uid].urls = allLists console.log('Updating lists', {alreadyUpdating}, myLists, communityLists, global.traceback()) let keywords = await this.communityModeKeywords() this.master.loadCachedLists(myLists, communityLists, keywords) - const updater = new (require(global.APPDIR + '/modules/driver')(global.APPDIR + '/modules/lists/driver-updater')) + const updater = this.createUpdater() updater.on('list-updated', url => { console.log('List updated', url) this.master.syncList(url).then(() => { @@ -517,31 +499,44 @@ class Manager extends Events { }).catch(err => { console.error('List not synced', url, err) }).finally(async () => { - this.emit('sync-status', await this.master.querySyncStatus()) + this.emit('sync-status', this.master.status()) }) }) - updater.on('finished', url => { - console.log('Lists updated', url) - this.master.updaterFinished(true).catch(console.error) - }) this.master.updaterFinished(false).catch(console.error) await updater.setRelevantKeywords(keywords) - await updater.update(allLists) - updater.terminate() + const expired = [], results = await updater.update(allLists).catch(console.error) + if(results && typeof(results) == 'object'){ + Object.keys(results).forEach(url => { + this.updaterResults[url] = results[url] + if(myLists.includes(url) && this.updaterResultExpired(url)){ + expired.push(url) + } + }) + } + console.log('Lists updated', results) this.master.updaterFinished(true).catch(console.error) - this.syncStatus(await this.master.querySyncStatus()) + updater.terminate() + this.updatingProcessOutput(uid, global.lang.LISTS_UPDATED, 'fas fa-check-circle') // warn user if there's no lists + if(this.master.activeLists.length){ + this.emit('lists-updated') + } + if(expired.length) { + const ret = await global.explorer.dialog([ + {template: 'question', text: 'Megacubo', fa: 'fas fa-info-circle'}, + {template: 'message', text: global.lang.IPTV_LIST_EXPIRED +'

'+ expired.join('
')}, + {template: 'option', text: 'OK', id: 'ok', fa: 'fas fa-check-circle'}, + {template: 'option', text: global.lang.REMOVE_LIST, id: 'rm', fa: 'fas fa-trash'} + ], 'ok').catch(console.error) // dont wait + if(ret == 'rm'){ + expired.map(url => global.lists.manager.remove(url)) + } + } } else { this.master.delimitActiveLists() - global.activeLists = {my: [], community: [], length: 0} - this.updatedLists(global.lang.NO_LIST_PROVIDED, 'fas fa-exclamation-circle') // warn user if there's no lists - } - delete this.updatingLists[uid] - } else { - if(global.activeLists.length){ - this.emit('lists-updated') + this.updatingProcessOutput(uid, global.lang.NO_LIST_PROVIDED, 'fas fa-exclamation-circle') // warn user if there's no lists } } - console.error('LISTSUPDATED', Object.assign({}, global.activeLists)) + console.error('LISTSUPDATED', Object.assign({}, global.lists.activeLists)) } extraCommunityLists(myLists, communityLists){ return new Promise(resolve => { @@ -563,10 +558,12 @@ class Manager extends Events { UIUpdateLists(force){ if(global.Download.isNetworkConnected) { const uid = parseInt(Math.random() * 1000000000) - const starter = !Object.keys(this.updatingLists).length + const starter = !Object.keys(this.updatingProcesses).length if(starter){ + this.uiShowing = true global.osd.show(global.lang.STARTING_LISTS, 'fa-mega spin-x-alt', 'update', 'persistent') } + this.updatingProcesses[uid] = {visibility: true, progress: 0} this.updateLists(force === true, uid).catch(err => { const isUIReady = !Array.isArray(global.uiReadyCallbacks) console.error('lists-manager', err, isUIReady) @@ -574,12 +571,8 @@ class Manager extends Events { this.noListsRetryDialog(err) } }).finally(() => { - if(typeof(this.updatingLists[uid]) != 'undefined'){ - delete this.updatingLists[uid] - } - if(!Object.keys(this.updatingLists).length){ - global.osd.hide('update') - } + this.updatingProcesses[uid].progress = 100 + setTimeout(() => delete this.updatingProcesses[uid], 10000) }) } else { global.explorer.info(global.lang.NO_INTERNET_CONNECTION, global.lang.NO_INTERNET_CONNECTION) @@ -635,7 +628,8 @@ class Manager extends Events { } addListEntry(){ return {name: global.lang.ADD_LIST, fa: 'fas fa-plus-square', type: 'action', action: () => { - this.addListDialog(false).catch(global.displayErr) + const offerCommunityMode = !global.config.get('communitary-mode-lists-amount') + this.addListDialog(offerCommunityMode).catch(global.displayErr) }} } async addListDialog(offerCommunityMode){ @@ -644,7 +638,7 @@ class Manager extends Events { extraOpts.push({template: 'option', text: global.lang.OPEN_M3U_FILE, id: 'file', fa: 'fas fa-folder-open'}) extraOpts.push({template: 'option', text: global.lang.ADD_USER_PASS, id: 'code', fa: 'fas fa-key'}) if(offerCommunityMode){ - extraOpts.push({template: 'option', text: global.lang.DONT_HAVE_LIST, fa: 'fas fa-times-circle', id: 'sh'}) + extraOpts.push({template: 'option', text: global.lang.COMMUNITY_LISTS, fa: 'fas fa-users', id: 'sh'}) } let id = await global.explorer.prompt(global.lang.ASK_IPTV_LIST, 'http://', '', true, 'fas fa-info-circle', null, extraOpts) if(id == 'file'){ @@ -656,7 +650,7 @@ class Manager extends Events { if(active){ return true } else { - throw 'community list not activated' + return await this.addListDialog(offerCommunityMode) } } else { return await this.addList(id) @@ -671,7 +665,7 @@ class Manager extends Events { this.addList(file).then(resolve).catch(reject) }).catch(reject) }) - global.ui.emit('open-file', global.ui.uploadURL, id, 'audio/x-mpegurl', global.lang.OPEN_M3U_LIST) + global.ui.emit('open-file', global.ui.uploadURL, id, 'audio/x-mpegurl', global.lang.OPEN_M3U_FILE) }) } async communityModeDialog(){ @@ -699,13 +693,16 @@ class Manager extends Events { const url = server +'/get.php?username='+ encodeURIComponent(user) +'&password='+ encodeURIComponent(pass) +'&type=m3u_plus&output=ts' return await this.addList(url) } + updaterResultExpired(url){ + return this.updaterResults[url] && this.updaterResults[url].substr(0, 6) == 'failed' && ['401', '403', '404', '410'].includes(this.updaterResults[url].substr(-3)) + } myListsEntry(){ return { name: global.lang.MY_LISTS, details: global.lang.IPTV_LISTS, type: 'group', renderer: async () => { - let lists = this.get(), opts = [] + let lists = this.get() const extInfo = await this.master.info() const doNotShareHint = !global.config.get('communitary-mode-lists-amount') let ls = lists.map(row => { @@ -715,7 +712,8 @@ class Manager extends Events { let details = extInfo[url].author || '' let icon = extInfo[url].icon || undefined let priv = (row.length > 2 && typeof(row[2]['private']) != 'undefined') ? row[2]['private'] : doNotShareHint - let flag = priv ? 'fas fa-lock' : 'fas fa-users' + let expired = this.updaterResultExpired(url) + let flag = expired ? 'fas fa-exclamation-triangle faclr-red' : (priv ? 'fas fa-lock' : 'fas fa-users') return { prepend: ' ', name, url, icon, details, @@ -723,7 +721,10 @@ class Manager extends Events { type: 'group', class: 'skip-testing', renderer: async () => { - let es = await this.master.directListRenderer({url}).catch(err => global.displayErr(err)) + let es = await this.directListRenderer({url}, { + raw: true, + fetch: false + }).catch(err => global.displayErr(err)) if(!Array.isArray(es)){ es = [] } @@ -732,7 +733,7 @@ class Manager extends Events { es = this.master.tools.deepify(es, url) } es.unshift({ - name: global.lang.EDIT, + name: global.lang.OPTIONS, fa: 'fas fa-edit', type: 'select', entries: [ @@ -764,6 +765,13 @@ class Manager extends Events { }, safe: true }, + { + name: global.lang.RELOAD, + fa: 'fas fa-sync', + type: 'action', url, + class: 'skip-testing', + action: this.refreshList.bind(this) + }, { name: global.lang.REMOVE_LIST, fa: 'fas fa-trash', @@ -1166,7 +1174,7 @@ class Manager extends Events { value: () => { return global.config.get('communitary-mode-interests') }, - placeholder: global.lang.COMMUNITY_MODE_INTERESTS_HINT, + placeholder: global.lang.COMMUNITY_LISTS_INTERESTS_HINT, multiline: true, safe: true }) @@ -1176,6 +1184,56 @@ class Manager extends Events { } } } + async refreshList(data){ + let updateErr + global.osd.show(global.lang.UPDATING_LISTS, 'fa-mega spin-x-alt', 'refresh-list', 'persistent') + const updater = this.createUpdater() + await updater.updateList(data.url, true).catch(err => updateErr = err) + if(updateErr){ + if(updateErr == 'empty list'){ + let haserr, msg = updateErr + const ret = await global.Download.head({url: data.url}).catch(err => haserr = err) + if(ret && typeof(ret.statusCode) == 'number'){ + switch(String(ret.statusCode)){ + case '400': + case '401': + case '403': + msg = 'List expired.' + break + case '-1': + case '404': + case '410': + msg = 'List expired or deleted from the server.' + break + case '0': + case '421': + case '453': + case '500': + case '502': + case '503': + case '504': + msg = 'Server temporary error: '+ ret.statusCode + break + } + } else { + msg = haserr || 'Server offline error' + } + global.displayErr(msg) + } else { + global.displayErr(updateErr) + } + } else { + await global.lists.syncLoadList(data.url).catch(err => updateErr = err) + if(updateErr){ + global.displayErr(updateErr) + } else { + global.osd.show('OK', 'fas fa-check-circle', 'refresh-list', 'normal') + global.explorer.deepRefresh() + return true // return here, so osd will not hide + } + } + global.osd.hide('refresh-list') + } async removeList(data){ const info = await this.master.info(), key = 'epg-'+ global.lang.locale if(info[data.url] && info[data.url].epg && this.parseEPGURL(info[data.url].epg) == global.config.get(key)) { @@ -1189,64 +1247,53 @@ class Manager extends Events { global.explorer.resumeRendering() global.explorer.back(2, true) } - directListRenderer(data){ - console.warn('DIRECT', data, traceback()) - return new Promise((resolve, reject) => { - let v = Object.assign({}, data), isMine = global.activeLists.my.includes(v.url), isCommunity = global.activeLists.community.includes(v.url) - delete v.renderer - let onerr = err => { - console.error(err) - reject(global.lang.LIST_OPENING_FAILURE) + async directListRenderer(data, opts={}){ + let v = Object.assign({}, data), isMine = global.lists.activeLists.my.includes(v.url), isCommunity = global.lists.activeLists.community.includes(v.url) + const hasFailed = !this.master.lists[v.url] || this.master.lists[v.url].indexer.hasFailed + console.warn('DIRECT', isMine, isCommunity, hasFailed) + global.osd.show(global.lang.OPENING_LIST, 'fa-mega spin-x-alt', 'list-open', 'persistent') + let list = await this.master.directListRenderer(v, { + fetch: opts.fetch, + progress: p => { + global.osd.show(global.lang.OPENING_LIST +' '+ parseInt(p) +'%', 'fa-mega spin-x-alt', 'list-open', 'persistent') } - let cb = list => { - if(list.length){ - if(this.has(v.url)){ - list.unshift({ - type: 'action', - name: global.lang.LIST_ALREADY_ADDED, - details: global.lang.REMOVE_LIST, - fa: 'fas fa-minus-square', - action: () => { - this.remove(v.url) - global.osd.show(global.lang.LIST_REMOVED, 'fas fa-info-circle', 'list-open', 'normal') - global.explorer.refresh() - } - }) - } else { - list.unshift({ - type: 'action', - fa: 'fas fa-plus-square', - name: global.lang.ADD_TO.format(global.lang.MY_LISTS), - action: () => { - this.addList(v.url).catch(console.error).finally(() => global.explorer.refresh()) - } - }) - } + }).catch(global.displayErr) + if(!Array.isArray(list)){ + list = [] + } + if(!list.length){ + list.push({name: global.lang.EMPTY, fa: 'fas fa-info-circle', type: 'action', class: 'entry-empty'}) + } + if(!opts.raw){ + const actionIcons = ['fas fa-minus-square', 'fas fa-plus-square'] + if(!list.some(e => actionIcons.includes(e.fa))){ + if(this.has(v.url)){ + list.unshift({ + type: 'action', + name: global.lang.LIST_ALREADY_ADDED, + details: global.lang.REMOVE_LIST, + fa: 'fas fa-minus-square', + action: () => { + this.remove(v.url) + global.osd.show(global.lang.LIST_REMOVED, 'fas fa-info-circle', 'list-open', 'normal') + global.explorer.refresh() + } + }) } else { - list = [] + list.unshift({ + type: 'action', + fa: 'fas fa-plus-square', + name: global.lang.ADD_TO.format(global.lang.MY_LISTS), + action: () => { + this.addList(v.url).catch(console.error).finally(() => global.explorer.refresh()) + } + }) } - console.warn('DIRECT', list, JSON.stringify(list[0])) - resolve(list) } - console.warn('DIRECT', isMine, isCommunity) - if(isMine || isCommunity){ - this.master.directListRenderer(v).then(cb).catch(onerr) - } else { - const fetcher = new this.master.Fetcher() - fetcher.fetch(v.url, { - progress: p => { - global.osd.show(global.lang.OPENING_LIST +' '+ p +'%', 'fa-mega spin-x-alt', 'list-open', 'persistent') - } - }).then(es => { - es = this.master.parentalControl.filter(es) - es = this.master.tools.deepify(es, v.url) - cb(es) - }).catch(onerr).finally(() => { - this.openingList = false - global.osd.hide('list-open') - }) - } - }) + } + this.openingList = false + global.osd.hide('list-open') + return list } communityLists(){ return new Promise((resolve, reject) => { @@ -1353,8 +1400,9 @@ class Manager extends Events { v.renderer = data => { return new Promise((resolve, reject) => { this.openingList = true - global.osd.show(global.lang.OPENING_LIST, 'fa-mega spin-x-alt', 'list-open', 'persistent') - this.directListRenderer(data).then(ret => { + this.directListRenderer(data, { + fetch: true + }).then(ret => { resolve(ret) }).catch(err => { reject(err) diff --git a/www/nodejs-project/modules/lists/parser.js b/www/nodejs-project/modules/lists/parser.js index 654ded57..360e8d4f 100644 --- a/www/nodejs-project/modules/lists/parser.js +++ b/www/nodejs-project/modules/lists/parser.js @@ -41,12 +41,13 @@ class IPTVPlaylistStreamParser extends Events { this.regexes = { 'notags': new RegExp('\\[[^\\]]*\\]', 'g'), 'non-alpha': new RegExp('^[^0-9A-Za-zÀ-ÖØ-öø-ÿ!\n]+|[^0-9A-Za-zÀ-ÖØ-öø-ÿ!\n]+$', 'g'), // match non alphanumeric on start or end, - 'between-brackets': new RegExp('[\(\\[](.*)[\)\\]]'), // match data between brackets + 'between-brackets': new RegExp('\\[[^\\]]*\\]', 'g'), // match data between brackets 'accents': new RegExp('[\\u0300-\\u036f]', 'g'), // match accents 'plus-signal': new RegExp('\\+', 'g'), // match plus signal 'hyphen': new RegExp('\\-', 'g'), // match any hyphen 'hyphen-not-modifier': new RegExp('(.)\\-', 'g'), // match any hyphen except if it's the first char (exclude modifier) - 'spaces': new RegExp(' +', 'g') + 'spaces': new RegExp(' +', 'g'), + 'type-playlist': new RegExp('type[\s\'"]*=[\s\'"]*playlist[\s\'"]*') } if(stream){ stream.on('data', this.write.bind(this)) @@ -88,8 +89,8 @@ class IPTVPlaylistStreamParser extends Events { } } sanitizeName(s){ - if(s.indexOf('[') != -1){ - s = s.replace(this.regexes['notags'], '') + if(s.indexOf('[/') != -1){ + s = s.split('[/').join('[|') } if(s.indexOf('\\') != -1){ s = global.forwardSlashes(s) @@ -117,7 +118,7 @@ class IPTVPlaylistStreamParser extends Events { sanitizeGroup(s){ return s. replace(this.regexes['plus-signal'], 'plus'). - replace(this.regexes['between-brackets'], ' '). + replace(this.regexes['between-brackets'], ''). normalize('NFD'). replace(this.regexes['hyphen'], ' '). // replace(this.regexes['accents'], ''). // replace/normalize accents replace(this.regexes['non-alpha'], ''). @@ -174,10 +175,24 @@ class IPTVPlaylistStreamParser extends Events { isExtInf(line){ return String(line).toLowerCase().indexOf('#extinf') != -1 } + isExtInfPlaylist(line){ + const l = String(line).toLowerCase() + return l.indexOf('#extinf') != -1 && l.match(this.regexes['type-playlist']) + } isExtM3U(line){ let lcline = String(line).toLowerCase() return lcline.indexOf('#extm3u') != -1 || lcline.indexOf('#playlistv') != -1 } + trimQuotes(text){ + const quotes = ["'", '"'] + if(quotes.includes(text.charAt(0))){ + text = text.substr(1) + } + if(quotes.includes(text.charAt(text.length - 1))){ + text = text.substr(0, text.length - 1) + } + return text + } nameFromURL(url){ let name, ourl = url if(url.indexOf('?') != -1){ @@ -209,7 +224,13 @@ class IPTVPlaylistStreamParser extends Events { } } extractEntries(txt){ - let g = '', e = {url: '', icon: ''} + let attsMap = { + 'http-user-agent': 'user-agent', + 'referrer': 'referer', + 'http-referer': 'referer', + 'http-referrer': 'referer' + } + let g = '', a = {}, e = {url: '', icon: ''} txt.split("\n").filter(s => s.length > 6).map(s => s.trim()).forEach(line => { if(this.isExtM3U(line)) { if(this.expectingHeader){ @@ -220,8 +241,9 @@ class IPTVPlaylistStreamParser extends Events { if(this.destroyed) break if(t && t[2]){ if(this.headerAttrMap[t[1]]){ - this.meta[this.headerAttrMap[t[1]]] = t[2] - } else { + t[1] = this.headerAttrMap[t[1]] + } + if(!this.meta[t[1]]){ this.meta[t[1]] = t[2] } } @@ -234,6 +256,7 @@ class IPTVPlaylistStreamParser extends Events { this.expectingHeader = false this.emit('meta', this.meta) } + this.expectingPlaylist = this.isExtInfPlaylist(line) let n = '', sg = '', pos = line.lastIndexOf(',') if(pos != -1){ n = line.substr(pos + 1).trim() @@ -266,31 +289,56 @@ class IPTVPlaylistStreamParser extends Events { } } else if(line.charAt(0) == '#') { // parse here extra info like #EXTGRP and #EXTVLCOPT - let ucline = line.toLowerCase() - if(ucline.indexOf('#EXTGRP') != -1){ - let i = ucline.indexOf(':') + let lcline = line.toLowerCase() + if(lcline.indexOf('#EXTGRP') != -1){ + let i = lcline.indexOf(':') if(i != -1){ let nwg = line.substr(i + 1).trim() if(nwg.length && (!g || g.length < nwg.length)){ g = nwg } } + } else if(lcline.indexOf('#EXTVLCOPT') != -1){ + let i = lcline.indexOf(':') + if(i != -1){ + let nwa = line.substr(i + 1).trim().split('=') + if(nwa){ + nwa[0] = nwa[0].toLowerCase() + a[attsMap[nwa[0]] || nwa[0]] = this.trimQuotes(nwa[1] || '') + } + } } } else if(line.charAt(0) == '/' || line.substr(0, 7) == 'magnet:' || line.indexOf('://') != -1) { e.url = line if(e.url.substr(0, 2) == '//'){ e.url = 'http:' + e.url } + if(e.url.indexOf('|') != -1 && e.url.match(new RegExp('.*\\|[A-Za-z0-9\\-]*='))){ + let parts = e.url.split('|') + e.url = parts[0] + parts = parts[1].split('=') + parts[0] = parts[0].toLowerCase() + a[attsMap[parts[0]] || parts[0]] = this.trimQuotes(parts[1] || '') + } if(global.validateURL(e.url)){ if(!e.name){ e.name = e.gid || this.nameFromURL(e.url) } + e.rawName = e.name + e.name = e.name.replace(this.regexes['between-brackets'], '') g = this.preSanitizeGroup(g) e.groupName = g.split('/').pop() g = this.sanitizeGroup(g) + if(Object.keys(a).length){ + e.atts = a + } e.group = g e.groups = g.split('/') - this.emit('entry', e) + if(this.expectingPlaylist){ + this.emit('playlist', e) + } else { + this.emit('entry', e) + } } e = {url: '', icon: ''} g = '' diff --git a/www/nodejs-project/modules/lists/update-list-index.js b/www/nodejs-project/modules/lists/update-list-index.js index 94f99f31..997575b4 100644 --- a/www/nodejs-project/modules/lists/update-list-index.js +++ b/www/nodejs-project/modules/lists/update-list-index.js @@ -1,16 +1,16 @@ const fs = require('fs'), Events = require('events'), Parser = require('./parser') const ListIndexUtils = require('./list-index-utils') -class UpdateListIndex extends ListIndexUtils { +class UpdateListIndex extends ListIndexUtils { constructor(url, directURL, file, parent, updateMeta){ super() this.url = url - this.directURL = directURL this.file = file - this.tmpfile = file +'.'+ parseInt(Math.random() * 100000) + '.tmp' + this.playlists = [] + this.directURL = directURL this.updateMeta = updateMeta - this.contentLength = -1 this.parent = (() => parent) + this.tmpfile = file +'.'+ parseInt(Math.random() * 100000) + '.tmp' this.seriesRegex = new RegExp('(\\b|^)[st]?[0-9]+ ?[epx]{1,2}[0-9]+($|\\b)', 'i') this.vodRegex = new RegExp('[\\.=](mp4|mkv|mpeg|mov|m4v|webm|ogv|hevc|divx)($|\\?|&)', 'i') this.liveRegex = new RegExp('[\\.=](m3u8|ts)($|\\?|&)', 'i') @@ -59,12 +59,11 @@ class UpdateListIndex extends ListIndexUtils { this.index.groups[entry.group].push(i) return entry } - start(){ + connect(path){ return new Promise((resolve, reject) => { if(this.debug){ console.log('load', should) } - let now = global.time(), path = this.directURL, lastmtime = 0 if(path.match(new RegExp('^//[^/]+\\.'))){ path = 'http:' + path } @@ -77,7 +76,7 @@ class UpdateListIndex extends ListIndexUtils { headers: { 'accept-charset': 'utf-8, *;q=0.1' }, - downloadLimit: 28 * (1024 * 1024), // 28Mb + downloadLimit: 200 * (1024 * 1024), // 200Mb p2p: true, cacheTTL: 3600 } @@ -86,22 +85,13 @@ class UpdateListIndex extends ListIndexUtils { if(this.debug){ console.log('response', statusCode, headers, this.updateMeta) } - now = global.time() if(statusCode >= 200 && statusCode < 300){ this.contentLength = this.stream.totalContentLength - if(headers['last-modified']){ - lastmtime = Date.parse(headers['last-modified']) / 1000 - } if(this.stream.totalContentLength > 0 && (this.stream.totalContentLength == this.updateMeta.contentLength)){ this.stream.destroy() resolve(false) // no need to update } else { - this.parseStream(lastmtime).then(() => { - this.contentLength = this.stream.received - resolve(true) - }).catch(err => { - reject(err) - }) + resolve(true) } } else { this.stream.destroy() @@ -112,17 +102,12 @@ class UpdateListIndex extends ListIndexUtils { } else { fs.stat(path, (err, stat) => { if(stat && stat.size){ - lastmtime = stat.mtime this.contentLength = stat.size if(stat.size > 0 && stat.size == this.updateMeta.contentLength){ resolve(false) // no need to update } else { this.stream = fs.createReadStream(path) - this.parseStream(lastmtime).then(() => { - resolve(true) - }).catch(err => { - reject(err) - }) + resolve(true) } } else { reject('file not found or empty') @@ -131,17 +116,59 @@ class UpdateListIndex extends ListIndexUtils { } }) } - parseStream(lastmtime=0){ + async start(){ + let urls = [this.directURL] + let match, badfmt = new RegExp('output=(m3u|ts|mpegts)(&|$)', 'i') + if(global.config.get('prefer-hls') && (match = this.directURL.match(badfmt))){ + let fmt + switch(match[1]){ + case 'ts': + fmt = 'hls' + break + case 'mpegts': + fmt = 'm3u8' + break + case 'm3u': + fmt = 'm3u_plus' + break + } + urls.unshift(this.directURL.replace(match[0], 'output='+ fmt + match[2])) + console.warn('URLS', urls) + } + const writer = fs.createWriteStream(this.tmpfile, {highWaterMark: Number.MAX_SAFE_INTEGER}) + let connected + for(let url of urls){ + connected = await this.connect(url).catch(console.error) + if(connected === true){ + await this.parseStream(writer).catch(console.error) + } + } + while(this.playlists.length){ + const playlist = this.playlists.shift() + connected = await this.connect(playlist.url).catch(console.error) + if(connected === true){ + await this.parseStream(writer, playlist).catch(console.error) + } + } + await this.writeIndex(writer).catch(err => { + console.error('!!! INDEX WRITING ERROR', err) + }) + writer.destroy() + return true + } + parseStream(writer, playlist){ return new Promise((resolve, reject) => { - let resolved, groups = {}, groupIcons = {}, writer = fs.createWriteStream(this.tmpfile, {highWaterMark: Number.MAX_SAFE_INTEGER}) - this.indexateIterator = 0 - this.hlsCount = 0 - this.index.lastmtime = lastmtime + let resolved, count this.parser = new Parser(this.stream) this.parser.on('meta', meta => { Object.assign(this.index.meta, meta) }) + this.parser.on('playlist', e => { + console.warn('PARSER PLAYLIST', e) + this.playlists.push(e) + }) this.parser.on('entry', entry => { + count++ if(this.destroyed){ if(!resolved){ resolved = true @@ -152,11 +179,14 @@ class UpdateListIndex extends ListIndexUtils { if(this.ext(entry.url) == 'm3u8'){ this.hlsCount++ } + if(playlist){ + entry.group = global.joinPath(global.joinPath(playlist.group, playlist.name), entry.group) + } if(entry.group){ // collect some data to sniff after if each group seems live, serie or movie - if(typeof(groups[entry.group]) == 'undefined'){ - groups[entry.group] = [] + if(typeof(this.groups[entry.group]) == 'undefined'){ + this.groups[entry.group] = [] } - groups[entry.group].push({ + this.groups[entry.group].push({ name: entry.name, url: entry.url, icon: entry.icon @@ -167,7 +197,6 @@ class UpdateListIndex extends ListIndexUtils { this.indexateIterator++ }) this.once('destroy', () => { - writer.destroy() if(!resolved){ resolved = true fs.unlink(this.tmpfile, () => {}) @@ -175,41 +204,56 @@ class UpdateListIndex extends ListIndexUtils { } }) this.parser.once('end', () => { - this.index.length = this.indexateIterator - this.index.hlsCount = this.hlsCount - this.index.groupsTypes = this.sniffGroupsTypes(groups) - if(this.index.length){ - writer.write(JSON.stringify(this.index)) - const finished = err => { - if(err) console.error(err) - resolved = true - writer.destroy() - this.parser.destroy() - this.stream.destroy() - global.moveFile(this.tmpfile, this.file, err => { - resolved = true - if(err){ - reject(err) - } else { - resolve(true) - } - fs.access(this.tmpfile, err => { - if(!err) fs.unlink(this.tmpfile, () => {}) - }) - }, 10) - } - writer.on('finish', finished) - writer.on('error', finished) - writer.end() - } else { + if(!resolved){ resolved = true - writer.destroy() - fs.unlink(this.tmpfile, () => {}) - reject('empty list') + if(count){ + resolve(true) + } else { + reject('empty list') + } + if(this.contentLength <= 0){ + this.contentLength = this.stream.received + } + this.parser.destroy() + this.stream.destroy() + this.stream = this.parser = null } }) }) } + writeIndex(writer){ + return new Promise((resolve, reject) => { + let resolved + this.index.length = this.indexateIterator + this.index.hlsCount = this.hlsCount + this.index.groupsTypes = this.sniffGroupsTypes(this.groups) + if(this.index.length || !fs.existsSync(this.file)){ + const finish = err => { + if(resolved) return + resolved = true + if(err) console.error(err) + global.moveFile(this.tmpfile, this.file, err => { + if(err){ + reject(err) + } else if(this.index.length) { + resolve(true) + } else { + resolve(false) + } + fs.access(this.tmpfile, err => err || fs.unlink(this.tmpfile, () => {})) + }, 10) + } + writer.on('finish', finish) + writer.on('close', finish) + writer.on('error', finish) + writer.write(JSON.stringify(this.index)) + writer.end() + } else { + resolved = true + fs.unlink(this.tmpfile, () => reject('empty list')) + } + }) + } sniffGroupsTypes(groups){ let ret = {live: [], vod: [], series: []} Object.keys(groups).forEach(g => { @@ -258,6 +302,7 @@ class UpdateListIndex extends ListIndexUtils { return '' } reset(){ + this.groups = {} this.index = { length: 0, terms: {}, @@ -266,6 +311,8 @@ class UpdateListIndex extends ListIndexUtils { gids: {} } this.indexateIterator = 0 + this.contentLength = -1 + this.hlsCount = 0 } destroy(){ if(!this.destroyed){ diff --git a/www/nodejs-project/modules/omni/omni.js b/www/nodejs-project/modules/omni/omni.js index 1562772b..86dbdb71 100644 --- a/www/nodejs-project/modules/omni/omni.js +++ b/www/nodejs-project/modules/omni/omni.js @@ -37,11 +37,10 @@ class OMNI extends Events { global.ui.emit('omni-callback', text, true) }) }) - global.lists.manager.once('lists-updated', () => { + global.lists.manager.waitListsReady().then(() => { this.omniEnabled = true global.ui.emit('omni-enable') - }) - + }).catch(console.error) } } diff --git a/www/nodejs-project/modules/options/options.js b/www/nodejs-project/modules/options/options.js index 04976386..9e72f065 100644 --- a/www/nodejs-project/modules/options/options.js +++ b/www/nodejs-project/modules/options/options.js @@ -1,5 +1,5 @@ - -const Events = require('events'), fs = require('fs'), path = require('path'), async = require('async') +const Events = require('events'), fs = require('fs'), path = require('path') +const decodeEntities = require('decode-entities'), async = require('async') class Timer extends Events { constructor(){ @@ -138,7 +138,7 @@ class PerformanceProfiles extends Timer { 'ts-packet-filter-policy': 1, 'tune-concurrency': 4, 'tune-ffmpeg-concurrency': 2, - 'tuning-prefer-hls': true, + 'prefer-hls': true, 'ui-sounds': false } } @@ -776,9 +776,9 @@ class Options extends OptionsHardwareAcceleration { }}, { name: global.lang.PREFER_HLS, type: 'check', action: (data, checked) => { - global.config.set('tuning-prefer-hls', checked) + global.config.set('prefer-hls', checked) }, checked: () => { - return global.config.get('tuning-prefer-hls') + return global.config.get('prefer-hls') }}, { name: global.lang.TUNING_CONCURRENCY_LIMIT, @@ -1033,6 +1033,18 @@ class Options extends OptionsHardwareAcceleration { return global.config.get('folder-size-limit') } }, + { + name: global.lang.SHOW_FUN_LETTERS.format(global.lang.CATEGORY_KIDS), + rawName: '[fun]'+ decodeEntities(global.lang.SHOW_FUN_LETTERS.format(global.lang.CATEGORY_KIDS)) +'[|fun]', + type: 'check', + action: (e, checked) => { + global.config.set('kids-fun-titles', checked) + global.explorer.refresh() + }, + checked: () => { + return global.config.get('kids-fun-titles') + } + }, { name: global.lang.USE_LOCAL_TIME_COUNTER, type: 'check', diff --git a/www/nodejs-project/modules/search/search.js b/www/nodejs-project/modules/search/search.js index 00d46479..9b938672 100644 --- a/www/nodejs-project/modules/search/search.js +++ b/www/nodejs-project/modules/search/search.js @@ -170,10 +170,10 @@ class Search extends Events { name: u, url: global.mega.build(u, {terms, mediaType: this.searchMediaType}) } - if(global.lists.manager.isUpdating(true)){ + if(!global.lists.loaded()){ return [global.lists.manager.updatingListsEntry()] } - if(!global.activeLists.length){ // one list available on index beyound meta watching list + if(!global.lists.activeLists.length){ // one list available on index beyound meta watching list return [global.lists.manager.noListsEntry()] } console.log('will search', terms, { @@ -308,10 +308,10 @@ class Search extends Events { name: u, url: global.mega.build(u, {terms, mediaType: this.searchMediaType}) } - if(global.lists.manager.isUpdating(true)){ + if(!global.lists.loaded()){ return resolve([global.lists.manager.updatingListsEntry()]) } - if(!global.activeLists.length){ // one list available on index beyound meta watching list + if(!global.lists.activeLists.length){ // one list available on index beyound meta watching list return resolve([global.lists.manager.noListsEntry()]) } let es = await global.channels.search(terms, this.searchInaccurate) @@ -479,7 +479,7 @@ class Search extends Events { } hook(entries, path){ return new Promise((resolve, reject) => { - if(!global.lists.manager.isUpdating(true) && global.activeLists.length){ + if(global.lists.loaded() && global.lists.activeLists.length){ if(path == global.lang.LIVE){ entries.unshift(this.entry('live')) } else if([global.lang.SERIES, global.lang.MOVIES].includes(path)){ diff --git a/www/nodejs-project/modules/streamer/adapters/base.js b/www/nodejs-project/modules/streamer/adapters/base.js index eb0639f1..9680c4da 100644 --- a/www/nodejs-project/modules/streamer/adapters/base.js +++ b/www/nodejs-project/modules/streamer/adapters/base.js @@ -3,8 +3,9 @@ const path = require('path'), fs = require('fs'), http = require('http'), Events const WriteQueueFile = require(global.APPDIR + '/modules/write-queue/write-queue-file') class StreamerAdapterBase extends Events { - constructor(url, opts){ + constructor(url, opts, data){ super() + this.data = data || {} this.url = url this.opts = { addr: '127.0.0.1', @@ -33,6 +34,17 @@ class StreamerAdapterBase extends Events { this.bitrateCheckBuffer = {} this.downloadLogging = {} } + getDefaultRequestHeaders(headers={}){ + if(this.data.atts){ + if(this.data.atts['user-agent']){ + headers['user-agent'] = this.data.atts['user-agent'] + } + if(this.data.atts['referer']){ + headers['referer'] = this.data.atts['referer'] + } + } + return headers + } isTranscoding(){ if(this.transcoderStarting || this.transcoder){ return true diff --git a/www/nodejs-project/modules/streamer/engines/yt.js b/www/nodejs-project/modules/streamer/engines/yt.js index deb3161d..d468b348 100644 --- a/www/nodejs-project/modules/streamer/engines/yt.js +++ b/www/nodejs-project/modules/streamer/engines/yt.js @@ -133,7 +133,6 @@ class StreamerYTHLSIntent extends StreamerHLSIntent { this.connectAdapter(this.prx) await this.prx.start() this.endpoint = this.prx.proxify(ret.url) - console.warn('START', ret, this.endpoint, info) return {endpoint: this.endpoint, mimetype: this.mimetype} } async _start(){ @@ -157,7 +156,6 @@ class StreamerYTHLSIntent extends StreamerHLSIntent { await fs.promises.writeFile(file, this.generateMasterPlaylist(tracks)) let url = await global.downloads.serve(file) this.endpoint = this.prx.proxify(url) // proxify again to get tracks on super() - console.warn('START', url, this.endpoint) return {endpoint: this.endpoint, mimetype: this.mimetype} } } diff --git a/www/nodejs-project/modules/streamer/streamer.js b/www/nodejs-project/modules/streamer/streamer.js index aa317cd4..c4b04d57 100644 --- a/www/nodejs-project/modules/streamer/streamer.js +++ b/www/nodejs-project/modules/streamer/streamer.js @@ -1,6 +1,6 @@ const path = require('path'), Events = require('events'), fs = require('fs'), async = require('async') -const AutoTuner = require('../tuner/auto-tuner'), StreamInfo = require('../iptv-stream-info') +const AutoTuner = require('../tuner/auto-tuner'), StreamInfo = require('./utils/stream-info') if(!Promise.allSettled){ Promise.allSettled = ((promises) => Promise.all(promises.map(p => p @@ -66,10 +66,10 @@ class StreamerTools extends Events { } } } - info(url, retries = 2, source=''){ + info(url, retries=2, entry={}){ return new Promise((resolve, reject) => { - this.pingSource(source, () => { - this.streamInfo.probe(url, retries).then(nfo => { + this.pingSource(entry.source, () => { + this.streamInfo.probe(url, retries, entry).then(nfo => { let type = false Object.keys(this.engines).some(name => { if(this.engines[name].supports(nfo)){ @@ -167,7 +167,7 @@ class StreamerBase extends StreamerTools { if(!this.throttle(data.url)){ return reject('401') } - this.info(data.url, 2, data.source).then(nfo => { + this.info(data.url, 2, data).then(nfo => { this.intentFromInfo(data, opts, aside, nfo).then(resolve).catch(reject) }).catch(err => { if(this.opts.debug){ @@ -193,7 +193,7 @@ class StreamerBase extends StreamerTools { if(!global.streamerPingSourceTTLs[url] || global.streamerPingSourceTTLs[url] < now){ if(this.pingSourceQueue(url, cb, cb)){ let ret = '' - global.Download.promise({ + global.Download.get({ url, timeout: 10, retry: 0, @@ -612,7 +612,7 @@ class StreamerGoNext extends StreamerThrottling { if(next){ const start = global.time(), delay = 5, ret = {} global.osd.show(global.lang.GOING_NEXT_SECS_X.format(delay), 'fa-mega spin-x-alt', 'go-next', 'persistent') - ret.info = await this.info(next.url, 2, next.source).catch(err => ret.err = err) + ret.info = await this.info(next.url, 2, next).catch(err => ret.err = err) const now = global.time() if(!ret.err && (now - start) < 5){ await this.sleep((5 - (now - start)) * 1000) @@ -1202,7 +1202,7 @@ class Streamer extends StreamerAbout { return succeeded } async triggerCheckForListExpiral(){ - if(global.activeLists.my.length == 0 || !global.tuning){ + if(global.lists.activeLists.my.length == 0 || !global.tuning){ return } const expiralCheckLockTime = 300 @@ -1210,8 +1210,11 @@ class Streamer extends StreamerAbout { this.expiralCheckLock = {} } const now = global.time(), from = now - expiralCheckLockTime, validLiveTypes = ['hls', 'ts'] - const csources = global.activeLists.my.filter(u => { + const info = await global.lists.info() + const csources = Object.keys(info).filter(u => { return !this.expiralCheckLock[u] || this.expiralCheckLock[u] < from + }).filter(u => { + return info[u].owned && info[u].private }) if(!csources.length){ return @@ -1220,7 +1223,7 @@ class Streamer extends StreamerAbout { csources.forEach(s => { sources[s] = entries.filter(e => e.source == s) }) - Object.keys(sources).forEach(source => { + const expired = Object.keys(sources).forEach(source => { if(this.expiralCheckLock[source] && this.expiralCheckLock[source] > from){ return } @@ -1233,13 +1236,22 @@ class Streamer extends StreamerAbout { if(expiralScore > (sources[source].length / 2)){ console.warn('triggerCheckForListExpiral', source +' EXPIRED', expiralScore +'/'+ sources[source].length) this.expiralCheckLock[source] = now - global.explorer.dialog([ - {template: 'question', text: 'Megacubo', fa: 'fas fa-info-circle'}, - {template: 'message', text: global.lang.IPTV_LIST_EXPIRED +'

'+ source}, - {template: 'option', text: 'OK', id: 'ok'} - ], 'ok').catch(console.error) // dont wait + return true } }) + if(expired.length){ + const ret = await global.explorer.dialog([ + {template: 'question', text: 'Megacubo', fa: 'fas fa-info-circle'}, + {template: 'message', text: global.lang.IPTV_LIST_EXPIRED +'

'+ expired.join('
')}, + {template: 'option', text: 'OK', id: 'ok', fa: 'fas fa-check-circle'}, + {template: 'option', text: global.lang.REMOVE_LIST, id: 'rm', fa: 'fas fa-trash'} + ], 'ok').catch(console.error) // dont wait + if(ret == 'rm'){ + expired.map(url => { + global.lists.manager.remove(url) + }) + } + } } play(e, results, silent){ this.playPromise(e, results, silent).catch(console.error) diff --git a/www/nodejs-project/modules/streamer/utils/downloader.js b/www/nodejs-project/modules/streamer/utils/downloader.js index 100f6dd3..b5cc1bea 100644 --- a/www/nodejs-project/modules/streamer/utils/downloader.js +++ b/www/nodejs-project/modules/streamer/utils/downloader.js @@ -249,15 +249,17 @@ class Downloader extends StreamerAdapterBase { this.finishBitrateSample(this.currentDownloadUID) this.currentDownloadUID = String(connStart) this.lastConnectionStartTime = connStart - const download = this.currentRequest = new global.Download({ + let reqHeaders = { url: this.url, authURL: this.opts.authURL || false, keepalive: this.committed && global.config.get('use-keepalive'), followRedirect: true, acceptRanges: false, retries: 3, // strangely, some servers always abort the first try, throwing "The server aborted pending request" - debug: this.debugConns - }) + debug: this.debugConns, + headers: this.getDefaultRequestHeaders() + } + const download = this.currentRequest = new global.Download(reqHeaders) download.on('error', error => { let elapsed = global.time() - connStart console.warn('['+ this.type +'] ERR after '+ elapsed +'s', error, this.url) diff --git a/www/nodejs-project/modules/streamer/utils/proxy-hls.js b/www/nodejs-project/modules/streamer/utils/proxy-hls.js index 44a28eaf..bd9acde2 100644 --- a/www/nodejs-project/modules/streamer/utils/proxy-hls.js +++ b/www/nodejs-project/modules/streamer/utils/proxy-hls.js @@ -1,6 +1,5 @@ const http = require('http'), closed = require('../../on-closed') const StreamerProxyBase = require('./proxy-base'), decodeEntities = require('decode-entities') -const fs = require('fs'), async = require('async'), Events = require('events') const stoppable = require('stoppable'), m3u8Parser = require('m3u8-parser') class HLSJournal { @@ -632,7 +631,8 @@ class StreamerProxyHLS extends HLSRequests { if(reqHeaders['x-from-network-proxy']){ delete reqHeaders['x-from-network-proxy'] } - } + } + reqHeaders = this.getDefaultRequestHeaders(reqHeaders) if(this.opts.debug){ if(this.type == 'network-proxy'){ console.log('network serving', url, reqHeaders) diff --git a/www/nodejs-project/modules/streamer/utils/proxy.js b/www/nodejs-project/modules/streamer/utils/proxy.js index a047d6b6..c071a993 100644 --- a/www/nodejs-project/modules/streamer/utils/proxy.js +++ b/www/nodejs-project/modules/streamer/utils/proxy.js @@ -228,11 +228,9 @@ class StreamerProxy extends StreamerProxyBase { } } } - if(this.mapper && this.mapper(req, response)){ return } - if(this.opts.debug){ console.log('req starting...', req, req.url) } @@ -251,6 +249,7 @@ class StreamerProxy extends StreamerProxyBase { delete reqHeaders['x-from-network-proxy'] } } + reqHeaders = this.getDefaultRequestHeaders(reqHeaders) if(this.opts.debug){ console.log('serving', url, req, path.basename(url), url, reqHeaders, uid) } diff --git a/www/nodejs-project/modules/iptv-stream-info/iptv-stream-info.js b/www/nodejs-project/modules/streamer/utils/stream-info.js similarity index 92% rename from www/nodejs-project/modules/iptv-stream-info/iptv-stream-info.js rename to www/nodejs-project/modules/streamer/utils/stream-info.js index ca960b50..218db2a5 100644 --- a/www/nodejs-project/modules/iptv-stream-info/iptv-stream-info.js +++ b/www/nodejs-project/modules/streamer/utils/stream-info.js @@ -1,12 +1,12 @@ -class IPTVStreamInfo { +class StreamInfo { constructor(){ this.opts = { debug: false, probeSampleSize: 1024 } } - _probe(url, timeoutSecs, retries = 0, recursion = 10){ + _probe(url, timeoutSecs, retries=0, opts={}, recursion=10){ return new Promise((resolve, reject) => { let status = 0, timer = 0, headers = {}, sample = [], start = global.time() if(this.validate(url)){ @@ -18,8 +18,17 @@ class IPTVStreamInfo { followRedirect: true, acceptRanges: false, keepalive: false, - retries + retries, + headers: [] } + if(opts && typeof(opts) == 'object' && opts.atts){ + if(opts.atts['user-agent']){ + req.headers['user-agent'] = opts.atts['user-agent'] + } + if(opts.atts['referer']){ + req.headers['referer'] = opts.atts['referer'] + } + } let download = new global.Download(req), ended = false, finish = () => { if(this.opts.debug){ console.log('finish', ended, sample, headers, traceback()) @@ -47,7 +56,7 @@ class IPTVStreamInfo { if(!recursion){ return reject('Max recursion reached.') } - return this._probe(trackUrl, timeoutSecs, retries, recursion).then( resolve ).catch(err => { + return this._probe(trackUrl, timeoutSecs, retries, opts, recursion).then( resolve ).catch(err => { console.error('HLSTRACKERR', err, url, trackUrl) reject(err) }) @@ -58,7 +67,7 @@ class IPTVStreamInfo { if(!recursion){ return reject('Max recursion reached.') } - return this._probe(trackUrl, timeoutSecs, retries, recursion).then(ret =>{ + return this._probe(trackUrl, timeoutSecs, retries, opts, recursion).then(ret =>{ if(ret && ret.status && ret.status >= 200 && ret.status < 300){ done() // send data from m3u8 } else { @@ -107,12 +116,11 @@ class IPTVStreamInfo { } }) } - probe(url, retries = 2){ + probe(url, retries = 2, opts={}){ return new Promise((resolve, reject) => { const timeout = global.config.get('connect-timeout') * 2 if(this.proto(url, 4) == 'http'){ - this._probe(url, timeout, retries).then(ret => { - //console.warn('PROBED', ret) + this._probe(url, timeout, retries, opts).then(ret => { let cl = ret.headers['content-length'] || -1, ct = ret.headers['content-type'] || '', st = ret.status || 0 if(st < 200 || st >= 400 || st == 204){ // 204=No content reject(st) @@ -300,4 +308,4 @@ class IPTVStreamInfo { } } -module.exports = IPTVStreamInfo +module.exports = StreamInfo diff --git a/www/nodejs-project/modules/supercharge/supercharge.js b/www/nodejs-project/modules/supercharge/supercharge.js index 22759fad..96c936b2 100644 --- a/www/nodejs-project/modules/supercharge/supercharge.js +++ b/www/nodejs-project/modules/supercharge/supercharge.js @@ -262,6 +262,8 @@ function patch(scope){ return file.replaceAll('\\', '/').replaceAll('//', '/') } scope.joinPath = (folder, file) => { + if(!file) return folder + if(!folder) return file let ffolder = folder let ffile = file if(ffolder.indexOf('\\') != -1) { diff --git a/www/nodejs-project/modules/theme/theme.js b/www/nodejs-project/modules/theme/theme.js index ded83404..d1469e30 100644 --- a/www/nodejs-project/modules/theme/theme.js +++ b/www/nodejs-project/modules/theme/theme.js @@ -627,7 +627,7 @@ class Theme extends Events { }) } async remoteThemes(){ - let themes = await global.Download.promise({url: global.cloud.server +'/themes/feed.json', responseType: 'json'}) + let themes = await global.Download.get({url: global.cloud.server +'/themes/feed.json', responseType: 'json'}) if(Array.isArray(themes)){ return themes.map(t => { return { diff --git a/www/nodejs-project/modules/tuner/auto-tuner.js b/www/nodejs-project/modules/tuner/auto-tuner.js index 85bad1fd..a148ffe9 100644 --- a/www/nodejs-project/modules/tuner/auto-tuner.js +++ b/www/nodejs-project/modules/tuner/auto-tuner.js @@ -27,6 +27,7 @@ class AutoTuner extends Events { async start(){ if(!this.tuner){ this.entries = await this.ceilPreferredStreams(this.entries, this.preferredStreamServers(), this.opts.preferredStreamURL) + this.entries = await this.ceilMyListsStreams(this.entries, this.preferredStreamServers(), this.opts.preferredStreamURL) console.log('CEILED', this.entries.map(e => e.url)) this.tuner = new Tuner(this.entries, this.opts, this.optsmegaURL) this.tuner.opts.debug = false @@ -70,7 +71,7 @@ class AutoTuner extends Events { } async ceilPreferredStreams(entries, preferredStreamServers, preferredStreamURL){ let preferredStreamEntry - const preferHLS = global.config.get('tuning-prefer-hls') + const preferHLS = global.config.get('prefer-hls') const deferredStreams = [], deferredHLSStreams = [] const preferredStreamServersLeveledEntries = {} entries = entries.forEach(entry => { @@ -104,6 +105,19 @@ class AutoTuner extends Events { } return await global.watching.order(entries) } + async ceilMyListsStreams(entries){ + const deferredEntries = [] + const listsInfo = await global.lists.info() + entries = entries.filter(entry => { + const isMine = listsInfo[entry.source] && listsInfo[entry.source].owned + if(isMine){ + return true + } else { + deferredEntries.push(entry) + } + }) + return entries.concat(deferredEntries) + } pause(){ if(this.opts.debug){ console.log('autotuner PAUSE', traceback()) diff --git a/www/nodejs-project/modules/tuner/tuner.js b/www/nodejs-project/modules/tuner/tuner.js index 0fdda998..18b21a17 100644 --- a/www/nodejs-project/modules/tuner/tuner.js +++ b/www/nodejs-project/modules/tuner/tuner.js @@ -76,7 +76,7 @@ class TunerTask extends TunerUtils { 1 = success, queued 2 = success, emitted */ - this.streamer.info(e.url, 2, e.source).then(info => { + this.streamer.info(e.url, 2, e).then(info => { if(!this.aborted){ //console.warn('TEST SUCCESS', e, info, this.opts.allowedTypes) this.info[i] = info diff --git a/www/nodejs-project/modules/watching/watching.js b/www/nodejs-project/modules/watching/watching.js index cba237e8..1ecfcdf6 100644 --- a/www/nodejs-project/modules/watching/watching.js +++ b/www/nodejs-project/modules/watching/watching.js @@ -99,10 +99,10 @@ class Watching extends EntriesGroup { } entries(){ return new Promise((resolve, reject) => { - if(global.lists.manager.isUpdating(true)){ + if(!global.lists.loaded()){ return resolve([global.lists.manager.updatingListsEntry()]) } - if(!global.activeLists.length){ + if(!global.lists.activeLists.length){ return resolve([global.lists.manager.noListsEntry()]) } this.ready(() => { diff --git a/www/nodejs-project/modules/zap/zap.js b/www/nodejs-project/modules/zap/zap.js index e20fc838..3e0b3712 100644 --- a/www/nodejs-project/modules/zap/zap.js +++ b/www/nodejs-project/modules/zap/zap.js @@ -44,7 +44,7 @@ class Zap extends Events { } hook(entries, path){ return new Promise((resolve, reject) => { - if(path == global.lang.LIVE && !global.lists.manager.isUpdating(true) && global.activeLists.length){ + if(path == global.lang.LIVE && global.lists.loaded() && global.lists.activeLists.length){ let pos, has = entries.some((e, i) => { e.name == this.title() if(e.entries && typeof(pos) == 'undefined'){ diff --git a/www/nodejs-project/package.json b/www/nodejs-project/package.json index 8d0f0bab..722d5939 100644 --- a/www/nodejs-project/package.json +++ b/www/nodejs-project/package.json @@ -45,7 +45,7 @@ "description": "A intuitive, multi-language and cross-platform IPTV player.", "name": "megacubo", "icon": "./default_icon.png", - "version": "16.7.5", + "version": "16.7.6", "theme": { "fullScreen": true },