Repository navigation
Expand file tree
/
Copy pathModel.js
More file actions
1070 lines (982 loc) · 51 KB
/
Copy pathModel.js
File metadata and controls
1070 lines (982 loc) · 51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
.pragma library
// Display languages: `value` is the storage/API language code, `label` is the
// native name shown in the picker. The omarchy SearchableDropdown reads
// `{value, label}` — using `code` was why "language" persisted as "undefined".
var LANGUAGES = [
{ value: "ar", label: "العربية" },
{ value: "en", label: "English" },
{ value: "fr", label: "Français" },
{ value: "es", label: "Español" },
{ value: "tr", label: "Türkçe" },
{ value: "id", label: "Bahasa Indonesia" },
{ value: "ur", label: "اردو" },
{ value: "bn", label: "বাংলা" },
{ value: "ru", label: "Русский" },
{ value: "zh", label: "中文" }
]
var DEFAULT_LANGUAGE = "en"
var DEFAULT_RECITER = "ar.alafasy"
var CDN_BASE = "https://cdn.islamic.app/quran/audio-surah"
var API_RECITERS = "https://api.islamic.app/v1/audio/reciters"
var API_RECITERS_ENG = "https://www.mp3quran.net/api/v3/reciters?language=eng"
var API_RECITERS_AR = "https://www.mp3quran.net/api/v3/reciters?language=ar"
var STATE_VERSION = 2
var CATALOG_TTL_MS = 24 * 60 * 60 * 1000
// Per-surah cap (~300 MB), shared with the quranctl downloader.
var MAX_SURAH_BYTES = 314572800
// --- playback modes ---
var MODE_SINGLE = "single" // play one surah, then stop
var MODE_CONTINUE = "continue" // advance to next surah, stop after 114
var MODE_REPEAT_ONE = "repeat-one"
var MODE_REPEAT_ALL = "repeat-all"
var MODES = [MODE_SINGLE, MODE_CONTINUE, MODE_REPEAT_ONE, MODE_REPEAT_ALL]
// --- UI strings -----------------------------------------------------------
// Full chrome translation: tabs, placeholders, buttons, prompts, errors,
// tooltips. `tr(lang, key)` falls back to English.
var STRINGS = {
en: {
tabSurah: "Surah", tabBookmarks: "Bookmarks", tabReciter: "Reciter", tabQuranCom: "Quran.com",
searchSurah: "Search surah (any language)", searchReciter: "Search reciter",
language: "Language", noSurahSelected: "No surah selected",
pickup: "Pick up", bookmark: "Bookmark", savedBookmarks: "Saved Bookmarks",
download: "Download", streamOnly: "Stream only", close: "Close",
downloadPrompt: "Download full mushaf (%1)?",
downloadDesc: "~2 GB · 114 surahs · plays offline afterwards. You can also stream instead.",
downloadRemaining: "Download %1 remaining surahs",
downloading: "Downloading mushaf for %1",
single: "Single", continue: "Continue", repeatOne: "Repeat one", repeatAll: "Repeat all",
noResults: "No results for \"%1\"", retry: "Retry",
noAudio: "No audio available for this reciter",
reciterLoadFailed: "Couldn't load reciters",
playbackFailed: "Playback failed. Retry, or stream it instead.",
downloaded: "Downloaded", partial: "Partial", notDownloaded: "Not downloaded",
tooltip: "Quran", nowPlaying: "Now playing · %1",
surahTabTitle: "Surah", reciterTabTitle: "Reciter",
verses: "%1 verses", seekHint: "Click to seek",
invalidInput: "Invalid reciter or surah",
browse: "Browse", downloadingSurah: "Downloading surah %1",
clearCache: "Clear cache", cacheSize: "Cache: %1", downloadFailed: "Download failed", cacheClearFailed: "Cache unavailable", setupRequired: "Audio engine missing — run install.sh in the plugin folder",
mpvMissing: "Playback unavailable — is mpv installed?",
settings: "Settings", storage: "Storage & Cache", clear: "Clear",
loadingReciters: "Loading reciters…",
},
ar: {
tabSurah: "السورة", tabReciter: "القارئ",
searchSurah: "ابحث عن سورة (بأي لغة)", searchReciter: "ابحث عن قارئ",
language: "اللغة", noSurahSelected: "لم يتم اختيار سورة",
download: "تحميل", streamOnly: "بث فقط", close: "إغلاق",
downloadPrompt: "تحميل المصحف كاملاً (%1)؟",
downloadDesc: "حوالي 2 جيجا · 114 سورة · يعمل دون اتصال بعد ذلك. يمكنك أيضًا البث بدلاً من ذلك.",
downloadRemaining: "تنزيل السور المتبقية (%1)",
downloading: "جارٍ تحميل المصحف لـ %1",
single: "واحدة", continue: "متابعة", repeatOne: "تكرار السورة", repeatAll: "تكرار الكل",
noResults: "لا توجد نتائج لـ \"%1\"", retry: "إعادة المحاولة",
noAudio: "لا يتوفر صوت لهذا القارئ",
reciterLoadFailed: "تعذّر تحميل القُرّاء",
loadingReciters: "جارٍ تحميل القُرّاء…",
playbackFailed: "فشل التشغيل. أعد المحاولة أو قم بالبث.",
downloaded: "تم التنزيل", partial: "جزئي", notDownloaded: "لم يتم التنزيل",
tooltip: "القرآن", nowPlaying: "قيد التشغيل · %1",
surahTabTitle: "السورة", reciterTabTitle: "القارئ",
verses: "%1 آية", seekHint: "انقر للانتقال",
invalidInput: "قارئ أو سورة غير صالحة",
browse: "تصفح", downloadingSurah: "جارٍ تحميل السورة %1",
clearCache: "مسح التخزين المؤقت", cacheSize: "التخزين المؤقت: %1", downloadFailed: "فشل التنزيل", cacheClearFailed: "ذاكرة التخزين المؤقت غير متاحة", setupRequired: "محرك الصوت مفقود — شغّل install.sh في مجلد الإضافة",
mpvMissing: "التشغيل غير متاح — هل mpv مثبّت؟",
settings: "الإعدادات", storage: "التخزين والذاكرة", clear: "مسح",
},
fr: {
tabSurah: "Sourate", tabReciter: "Récitant",
searchSurah: "Rechercher une sourate (toute langue)", searchReciter: "Rechercher un récitant",
language: "Langue", noSurahSelected: "Aucune sourate sélectionnée",
download: "Télécharger", streamOnly: "Streaming uniquement", close: "Fermer",
downloadPrompt: "Télécharger le mushaf complet (%1) ?",
downloadDesc: "~2 Go · 114 sourates · écoute hors ligne ensuite. Le streaming reste possible.",
downloadRemaining: "Télécharger les %1 sourates restantes",
downloading: "Téléchargement du mushaf pour %1",
single: "Unique", continue: "Continuer", repeatOne: "Répéter la sourate", repeatAll: "Répéter tout",
noResults: "Aucun résultat pour \"%1\"", retry: "Réessayer",
noAudio: "Aucun audio disponible pour ce récitant",
reciterLoadFailed: "Impossible de charger les récitants",
loadingReciters: "Chargement des récitants…",
playbackFailed: "Échec de lecture. Réessayez ou passez en streaming.",
downloaded: "Téléchargé", partial: "Partiel", notDownloaded: "Non téléchargé",
tooltip: "Coran", nowPlaying: "Lecture en cours · %1",
surahTabTitle: "Sourate", reciterTabTitle: "Récitant",
verses: "%1 versets", seekHint: "Cliquer pour avancer",
invalidInput: "Récitant ou sourate invalide",
browse: "Parcourir", downloadingSurah: "Téléchargement de la sourate %1",
clearCache: "Vider le cache", cacheSize: "Cache : %1", downloadFailed: "Échec du téléchargement", cacheClearFailed: "Cache indisponible", setupRequired: "Moteur audio absent — lancez install.sh dans le dossier du plugin",
mpvMissing: "Lecture indisponible — mpv est-il installé ?",
settings: "Paramètres", storage: "Stockage et Cache", clear: "Vider",
},
es: {
tabSurah: "Sura", tabReciter: "Recitador",
searchSurah: "Buscar sura (cualquier idioma)", searchReciter: "Buscar recitador",
language: "Idioma", noSurahSelected: "Ninguna sura seleccionada",
download: "Descargar", streamOnly: "Solo streaming", close: "Cerrar",
downloadPrompt: "¿Descargar el mushaf completo (%1)?",
downloadDesc: "~2 GB · 114 suras · reproducción sin conexión después. También puede transmitirse.",
downloadRemaining: "Descargar las %1 suras restantes",
downloading: "Descargando mushaf para %1",
single: "Única", continue: "Continuar", repeatOne: "Repetir sura", repeatAll: "Repetir todo",
noResults: "Sin resultados para \"%1\"", retry: "Reintentar",
noAudio: "No hay audio disponible para este recitador",
reciterLoadFailed: "No se pudieron cargar los recitadores",
loadingReciters: "Cargando recitadores…",
playbackFailed: "Error de reproducción. Reintenta o usa streaming.",
downloaded: "Descargado", partial: "Parcial", notDownloaded: "No descargado",
tooltip: "Corán", nowPlaying: "Reproduciendo · %1",
surahTabTitle: "Sura", reciterTabTitle: "Recitador",
verses: "%1 versículos", seekHint: "Haz clic para buscar",
invalidInput: "Recitador o sura no válidos",
browse: "Explorar", downloadingSurah: "Descargando sura %1",
clearCache: "Vaciar caché", cacheSize: "Caché: %1", downloadFailed: "Error de descarga", cacheClearFailed: "Caché no disponible", setupRequired: "Motor de audio ausente — ejecuta install.sh en la carpeta del plugin",
mpvMissing: "Reproducción no disponible — ¿está instalado mpv?",
settings: "Ajustes", storage: "Almacenamiento y Caché", clear: "Vaciar",
},
tr: {
tabSurah: "Sure", tabReciter: "Okuyucu",
searchSurah: "Sure ara (her dilde)", searchReciter: "Okuyucu ara",
language: "Dil", noSurahSelected: "Sure seçilmedi",
download: "İndir", streamOnly: "Sadece akış", close: "Kapat",
downloadPrompt: "Tüm mushaf indirilsin mi (%1)?",
downloadDesc: "~2 GB · 114 sure · sonrasında çevrimdışı çalar. Akış da yapabilirsiniz.",
downloadRemaining: "Kalan %1 sureyi indir",
downloading: "%1 için mushaf indiriliyor",
single: "Tek", continue: "Devam et", repeatOne: "Sureyi tekrarla", repeatAll: "Tümünü tekrarla",
noResults: "\"%1\" için sonuç yok", retry: "Yeniden dene",
noAudio: "Bu okuyucu için ses yok",
reciterLoadFailed: "Okuyucular yüklenemedi",
loadingReciters: "Okuyucular yükleniyor…",
playbackFailed: "Oynatma başarısız. Yeniden deneyin veya akış kullanın.",
downloaded: "İndirildi", partial: "Kısmi", notDownloaded: "İndirilmedi",
tooltip: "Kuran", nowPlaying: "Şu an çalıyor · %1",
surahTabTitle: "Sure", reciterTabTitle: "Okuyucu",
verses: "%1 ayet", seekHint: "Gitmek için tıklayın",
invalidInput: "Geçersiz okuyucu veya sure",
browse: "Gözat", downloadingSurah: "%1. sure indiriliyor",
clearCache: "Önbelleği temizle", cacheSize: "Önbellek: %1", downloadFailed: "İndirme başarısız", cacheClearFailed: "Önbellek kullanılamıyor", setupRequired: "Ses motoru eksik — eklenti klasöründe install.sh çalıştırın",
mpvMissing: "Oynatma kullanılamıyor — mpv yüklü mü?",
settings: "Ayarlar", storage: "Depolama ve Önbellek", clear: "Temizle",
},
id: {
tabSurah: "Surah", tabReciter: "Qari",
searchSurah: "Cari surah (bahasa apa pun)", searchReciter: "Cari qari",
language: "Bahasa", noSurahSelected: "Tidak ada surah dipilih",
download: "Unduh", streamOnly: "Streaming saja", close: "Tutup",
downloadPrompt: "Unduh mushaf lengkap (%1)?",
downloadDesc: "~2 GB · 114 surah · bisa diputar offline setelahnya. Anda juga bisa streaming.",
downloadRemaining: "Unduh %1 surah tersisa",
downloading: "Mengunduh mushaf untuk %1",
single: "Tunggal", continue: "Lanjutkan", repeatOne: "Ulangi surah", repeatAll: "Ulangi semua",
noResults: "Tidak ada hasil untuk \"%1\"", retry: "Coba lagi",
noAudio: "Tidak ada audio untuk qari ini",
reciterLoadFailed: "Gagal memuat daftar qari",
loadingReciters: "Memuat daftar qari…",
playbackFailed: "Pemutaran gagal. Coba lagi atau gunakan streaming.",
downloaded: "Terunduh", partial: "Sebagian", notDownloaded: "Belum diunduh",
tooltip: "Al-Qur'an", nowPlaying: "Sedang diputar · %1",
surahTabTitle: "Surah", reciterTabTitle: "Qari",
verses: "%1 ayat", seekHint: "Klik untuk berpindah",
invalidInput: "Qari atau surah tidak valid",
browse: "Jelajahi", downloadingSurah: "Mengunduh surah %1",
clearCache: "Bersihkan cache", cacheSize: "Cache: %1", downloadFailed: "Unduhan gagal", cacheClearFailed: "Cache tidak tersedia", setupRequired: "Mesin audio tidak ada — jalankan install.sh di folder plugin",
mpvMissing: "Pemutaran tidak tersedia — apakah mpv terpasang?",
settings: "Pengaturan", storage: "Penyimpanan & Cache", clear: "Bersihkan",
},
ur: {
tabSurah: "سورة", tabReciter: "قاری",
searchSurah: "سورتہ تلاش کریں (کسی بھی زبان میں)", searchReciter: "قاری تلاش کریں",
language: "زبان", noSurahSelected: "کوئی سورت منتخب نہیں",
download: "ڈاؤن لوڈ", streamOnly: "صرف سٹریم", close: "بند کریں",
downloadPrompt: "مکمل مصحف ڈاؤن لوڈ کریں (%1)؟",
downloadDesc: "تقریباً 2 جی بی · 114 سورتیں · بعد ازاں آف لائن چلتی ہیں۔ سٹریم بھی کیا جا سکتا ہے۔",
downloadRemaining: "بقیہ %1 سورتیں ڈاؤن لوڈ کریں",
downloading: "%1 کا مصحف ڈاؤن لوڈ ہو رہا ہے",
single: "واحد", continue: "جاری رکھیں", repeatOne: "سورت دہرائیں", repeatAll: "سب دہرائیں",
noResults: "\"%1\" کے لیے کوئی نتیجہ نہیں", retry: "دوبارہ کوشش",
noAudio: "اس قاری کے لیے آڈیو دستیاب نہیں",
reciterLoadFailed: "قاریوں کی فہرست لوڈ نہیں ہو سکی",
loadingReciters: "قاریوں کی فہرست لوڈ ہو رہی ہے…",
playbackFailed: "پلے بیک ناکام۔ دوبارہ کوشش کریں یا سٹریم کریں۔",
downloaded: "ڈاؤن لوڈ شدہ", partial: "جزوی", notDownloaded: "ڈاؤن لوڈ نہیں ہوا",
tooltip: "قرآن", nowPlaying: "چل رہا ہے · %1",
surahTabTitle: "سورت", reciterTabTitle: "قاری",
verses: "%1 آیات", seekHint: "جگہ کے لیے کلک کریں",
invalidInput: "غیر درست قاری یا سورت",
browse: "براؤز کریں", downloadingSurah: "سورت %1 ڈاؤن لوڈ ہو رہی ہے",
clearCache: "کیشے صاف کریں", cacheSize: "کیشے: %1", downloadFailed: "ڈاؤن لوڈ ناکام", cacheClearFailed: "کیشے دستیاب نہیں", setupRequired: "آڈیو انجن موجود نہیں — پلگ ان فولڈر میں install.sh چلائیں",
mpvMissing: "پلے بیک دستیاب نہیں — کیا mpv نصب ہے؟",
settings: "سیٹنگز", storage: "سٹوریج اور کیشے", clear: "صاف کریں",
},
bn: {
tabSurah: "সূরা", tabReciter: "ক্বারী",
searchSurah: "সূরা খুঁজুন (যেকোনো ভাষায়)", searchReciter: "ক্বারী খুঁজুন",
language: "ভাষা", noSurahSelected: "কোনো সূরা নির্বাচিত নেই",
download: "ডাউনলোড", streamOnly: "শুধু স্ট্রিম", close: "বন্ধ",
downloadPrompt: "সম্পূর্ণ মুসহাফ ডাউনলোড করবেন (%1)?",
downloadDesc: "~২ জিবি · ১১৪ সূরা · পরে অফলাইনে চলে। স্ট্রিমও করতে পারবেন।",
downloadRemaining: "অবশিষ্ট %1 সূরা ডাউনলোড করুন",
downloading: "%1-এর জন্য মুসহাফ ডাউনলোড হচ্ছে",
single: "একটি", continue: "চালিয়ে যান", repeatOne: "সূরা পুনরাবৃত্তি", repeatAll: "সব পুনরাবৃত্তি",
noResults: "\"%1\" এর জন্য কোনো ফলাফল নেই", retry: "আবার চেষ্টা",
noAudio: "এই ক্বারীর জন্য অডিও নেই",
reciterLoadFailed: "ক্বারীদের তালিকা লোড করা যায়নি",
loadingReciters: "ক্বারীদের তালিকা লোড হচ্ছে…",
playbackFailed: "প্লেব্যাক ব্যর্থ। আবার চেষ্টা করুন বা স্ট্রিম করুন।",
downloaded: "ডাউনলোড হয়েছে", partial: "আংশিক", notDownloaded: "ডাউনলোড হয়নি",
tooltip: "কুরআন", nowPlaying: "চলছে · %1",
surahTabTitle: "সূরা", reciterTabTitle: "ক্বারী",
verses: "%1 আয়াত", seekHint: "যেতে ক্লিক করুন",
invalidInput: "অবৈধ ক্বারী বা সূরা",
browse: "ব্রাউজ করুন", downloadingSurah: "সূরা %1 ডাউনলোড হচ্ছে",
clearCache: "ক্যাশ মুছুন", cacheSize: "ক্যাশ: %1", downloadFailed: "ডাউনলোড ব্যর্থ", cacheClearFailed: "ক্যাশ উপলব্ধ নেই", setupRequired: "অডিও ইঞ্জিন অনুপস্থিত — প্লাগইন ফোল্ডারে install.sh চালান",
mpvMissing: "প্লেব্যাক পাওয়া যাচ্ছে না — mpv কি ইনস্টল আছে?",
settings: "সেটিংস", storage: "স্টোরেজ ও ক্যাশ", clear: "মুছুন",
},
ru: {
tabSurah: "Сура", tabReciter: "Чтец",
searchSurah: "Поиск суры (на любом языке)", searchReciter: "Поиск чтеца",
language: "Язык", noSurahSelected: "Сура не выбрана",
download: "Скачать", streamOnly: "Только стриминг", close: "Закрыть",
downloadPrompt: "Скачать полный мусхаф (%1)?",
downloadDesc: "~2 ГБ · 114 сур · затем воспроизведение офлайн. Можно и стримить.",
downloadRemaining: "Скачать оставшиеся %1 сур",
downloading: "Скачивание мусхафа для %1",
single: "Одна", continue: "Продолжить", repeatOne: "Повтор суры", repeatAll: "Повторять всё",
noResults: "Нет результатов для \"%1\"", retry: "Повторить",
noAudio: "Для этого чтеца аудио недоступно",
reciterLoadFailed: "Не удалось загрузить чтецов",
loadingReciters: "Загрузка чтецов…",
playbackFailed: "Ошибка воспроизведения. Повторите или включите стриминг.",
downloaded: "Скачано", partial: "Частично", notDownloaded: "Не скачано",
tooltip: "Коран", nowPlaying: "Сейчас играет · %1",
surahTabTitle: "Сура", reciterTabTitle: "Чтец",
verses: "%1 аятов", seekHint: "Нажмите для перемотки",
invalidInput: "Неверный чтец или сура",
browse: "Обзор", downloadingSurah: "Скачивание суры %1",
clearCache: "Очистить кэш", cacheSize: "Кэш: %1", downloadFailed: "Ошибка загрузки", cacheClearFailed: "Кэш недоступен", setupRequired: "Аудио-движок отсутствует — запустите install.sh в папке плагина",
mpvMissing: "Воспроизведение недоступно — установлен ли mpv?",
settings: "Настройки", storage: "Память и Кэш", clear: "Очистить",
},
zh: {
tabSurah: "章节", tabReciter: "诵读者",
searchSurah: "搜索章节(任何语言)", searchReciter: "搜索诵读者",
language: "语言", noSurahSelected: "未选择章节",
download: "下载", streamOnly: "仅流播放", close: "关闭",
downloadPrompt: "下载完整誊本(%1)?",
downloadDesc: "约 2 GB · 114 章 · 之后可离线播放。也可选择流播放。",
downloadRemaining: "下载剩余 %1 章",
downloading: "正在为 %1 下载誊本",
single: "单章", continue: "继续", repeatOne: "重复本章", repeatAll: "全部重复",
noResults: "没有“%1”的结果", retry: "重试",
noAudio: "该诵读者没有可用音频",
reciterLoadFailed: "无法加载诵读者列表",
loadingReciters: "正在加载诵读者列表…",
playbackFailed: "播放失败。请重试或改用流播放。",
downloaded: "已下载", partial: "部分", notDownloaded: "未下载",
tooltip: "古兰经", nowPlaying: "正在播放 · %1",
surahTabTitle: "章节", reciterTabTitle: "诵读者",
verses: "%1 节经文", seekHint: "点击跳转",
invalidInput: "诵读者或章节无效",
browse: "浏览", downloadingSurah: "正在下载章节 %1",
clearCache: "清除缓存", cacheSize: "缓存:%1", downloadFailed: "下载失败", cacheClearFailed: "缓存不可用", setupRequired: "缺少音频引擎 — 请在插件目录中运行 install.sh",
mpvMissing: "播放不可用 — 是否已安装 mpv?",
settings: "设置", storage: "存储与缓存", clear: "清除",
}
}
function tr(lang, key) {
var table = STRINGS[lang] || STRINGS["en"] || {}
var s = table[key]
if (s === undefined && STRINGS["en"]) s = STRINGS["en"][key]
return (s === undefined) ? key : String(s)
}
function trArgs(lang, key, args) {
var s = tr(lang, key)
for (var i = 0; i < args.length; i++) s = s.replace("%" + (i + 1), String(args[i]))
return s
}
// --- parsing -------------------------------------------------------------
function pad3(n) {
var s = String(n)
while (s.length < 3) s = "0" + s
return s
}
function parseReciters(json, jsonAr) {
if (!json) return []
var data = Array.isArray(json) ? json : (json.reciters || json.data || [])
// If data comes from mp3quran API
if (data.length > 0 && data[0].moshaf !== undefined) {
var arMap = {}
if (jsonAr && jsonAr.reciters) {
for (var j = 0; j < jsonAr.reciters.length; j++) {
var itemAr = jsonAr.reciters[j]
if (itemAr && itemAr.id) arMap[itemAr.id] = itemAr.name
}
}
var outMp3 = []
for (var i = 0; i < data.length; i++) {
if (outMp3.length >= 1000) break
var r = data[i]
if (!r || !r.moshaf || !Array.isArray(r.moshaf) || r.moshaf.length === 0) continue
var m = null
for (var k = 0; k < r.moshaf.length; k++) {
var itemM = r.moshaf[k]
if (itemM.rewaya_id === 1 || (itemM.name && itemM.name.indexOf("Hafs") !== -1)) {
m = itemM
break
}
}
if (!m) m = r.moshaf[0]
if (!m) continue
var server = sanitizeServer(m.server)
if (server === "") continue
var identifier = "mp3quran_" + r.id
if (r.id === 123 || (r.name && r.name.indexOf("Mishary Alafasi") !== -1)) identifier = "ar.alafasy"
if (!isSafeIdentifier(identifier)) continue
// Field limits: a hostile catalog must not be able to smuggle huge
// strings into the UI or persisted state.
if (String(r.name || "").length > 200 || String(r.englishName || "").length > 200) continue
if ((arMap[r.id] || "").length > 200) continue
outMp3.push({
identifier: identifier,
id: String(r.id),
name: arMap[r.id] || String(r.name || ""),
englishName: String(r.name || ""),
server: server
})
}
outMp3.sort(function(a, b) {
var key = a.englishName || a.name
var other = b.englishName || b.name
return key.localeCompare(other)
})
return outMp3
}
// Legacy / fallback parsing
var out = []
for (var idx = 0; idx < data.length; idx++) {
if (out.length >= 1000) break
var rec = data[idx]
if (!rec || !rec.identifier) continue
var identifier = String(rec.identifier)
if (!isSafeIdentifier(identifier)) continue
var levels = rec.audioLevels || []
if (levels.indexOf("surah") === -1) continue
if (String(rec.name || "").length > 200 || String(rec.englishName || "").length > 200) continue
out.push({
identifier: identifier,
name: String(rec.name || ""),
englishName: String(rec.englishName || ""),
levels: levels
})
}
out.sort(function(a, b) {
var key = a.englishName || a.name
var other = b.englishName || b.name
return key.localeCompare(other)
})
return out
}
function parseSurahs(json) {
if (!json) return []
var out = []
for (var i = 0; i < json.length; i++) {
var s = json[i]
if (!s || !s.number) continue
out.push({
number: Number(s.number),
name: String(s.name || ""),
transliteration: String(s.transliteration || ""),
type: String(s.type || ""),
totalVerses: Number(s.total_verses || 0),
translations: s.translations || {}
})
}
return out
}
// --- validation ------------------------------------------------------------
function isValidSurahNumber(n) {
return typeof n === "number" && n >= 1 && n <= 114 && Math.floor(n) === n
}
function reciterExists(reciters, id) {
if (!reciters || !id) return false
for (var i = 0; i < reciters.length; i++) {
if (reciters[i].identifier === id) return true
}
return false
}
function isValidLanguage(code) {
if (!code) return false
for (var i = 0; i < LANGUAGES.length; i++) {
if (LANGUAGES[i].value === code) return true
}
return false
}
// --- naming --------------------------------------------------------------
// Pure chosen-language labels: NO Arabic is appended for non-Arabic languages.
function surahLabel(surah, language) {
if (!surah) return ""
if (language === "ar") return surah.name
if (language === "en") return surah.transliteration
var t = surah.translations ? surah.translations[language] : ""
return t || surah.transliteration
}
function surahDisplayLabel(surah, language) {
return surahLabel(surah, language)
}
function surahListLabel(surah, language) {
var label = surahLabel(surah, language)
if (language === "ar") return label
var arabic = surah.name || ""
return arabic ? label + " " + arabic : label
}
function reciterLabel(reciter, language) {
if (!reciter) return ""
if (language === "ar") return reciter.name
return reciter.englishName || reciter.name
}
function reciterDisplayLabel(reciter, language) {
return reciterLabel(reciter, language)
}
// --- filtering ------------------------------------------------------------
//
// Surah and reciter search is diacritic-, punctuation- and article-insensitive
// ("ya sin", "yaseen", "Ya-Sin", "يس", "36", "surah 36", "36:5", "juz 30",
// "page 293" all find Ya-Sin), and results are ranked: exact hits first, then
// prefix matches, then substrings.
var ARABIC_DIACRITICS_RE = /[ؐ-ًؚ-ٰٟۖ-ۭـ]/g
var SEARCH_PUNCT_RE = /[‘’'`ʿʾ\-‐-―._,()\[\]!?]/g
// Lowercase, strip Arabic tashkeel/tatweel and Latin accents, fold common
// Arabic letter variants, drop punctuation, collapse whitespace.
function normalizeSearchText(s) {
var t = String(s || "").toLowerCase()
t = t.replace(ARABIC_DIACRITICS_RE, "")
t = t.replace(/[أإآٱ]/g, "ا") // أ إ آ ٱ -> ا
.replace(/ى/g, "ي") // ى -> ي
.replace(/ة/g, "ه") // ة -> ه
.replace(/ؤ/g, "و") // ؤ -> و
.replace(/ئ/g, "ي") // ئ -> ي
t = t.replace(/[àáâãäå]/g, "a").replace(/[èéêë]/g, "e").replace(/[ìíîï]/g, "i")
.replace(/[òóôõö]/g, "o").replace(/[ùúûü]/g, "u").replace(/ç/g, "c").replace(/ñ/g, "n")
.replace(/[şś]/g, "s").replace(/ğ/g, "g").replace(/ı/g, "i")
t = t.replace(SEARCH_PUNCT_RE, "")
return t.replace(/\s+/g, " ").trim()
}
// Compact form: no spaces at all, so "al fatiha" and "Al-Fatihah" compare equal.
function compactSearchText(s) {
return normalizeSearchText(s).replace(/ /g, "")
}
// Strip a leading Arabic definite article from a compacted transliteration
// ("alfatihah" -> "fatihah", "annisa" -> "nisa", "ashshams" -> "shams").
var ARTICLE_RE = /^(al|an|ar|as|ash|at|ad|az|ath|adh|ad|ar)/
function stripArticle(compact) {
var m = ARTICLE_RE.exec(compact)
if (m && compact.length > m[0].length + 1) return compact.substring(m[0].length)
return compact
}
// Common alternative spellings and English nicknames, keyed by surah number.
var SURAH_ALIASES = {
1: ["fatiha", "fateha", "alhamd", "opening"], 2: ["baqara", "bakara", "cow"],
3: ["imran", "ali imran", "aal imran"], 4: ["nisa", "women"], 5: ["maida", "maidah", "table"],
6: ["anam", "cattle"], 7: ["araf", "heights"], 8: ["anfal", "spoils"], 9: ["tawba", "taubah", "tauba", "baraah", "repentance"],
10: ["yunus", "jonah"], 11: ["hud"], 12: ["yusuf", "joseph"], 13: ["rad", "raad", "thunder"],
14: ["ibrahim", "abraham"], 15: ["hijr"], 16: ["nahl", "bee"], 17: ["isra", "israa", "bani israil", "night journey"],
18: ["kahf", "cave"], 19: ["maryam", "mary"], 20: ["taha", "ta ha"], 21: ["anbiya", "prophets"],
22: ["hajj", "pilgrimage"], 23: ["muminun", "mu'minun", "believers"], 24: ["nur", "noor", "light"],
25: ["furqan", "criterion"], 26: ["shuara", "poets"], 27: ["naml", "ant"], 28: ["qasas", "stories"],
29: ["ankabut", "spider"], 30: ["rum", "romans"], 31: ["luqman"], 32: ["sajda", "sajdah", "prostration"],
33: ["ahzab", "confederates"], 34: ["saba", "sheba"], 35: ["fatir", "originator"], 36: ["yasin", "yaseen", "ya sin", "ya seen"],
37: ["saffat"], 38: ["sad", "saad"], 39: ["zumar", "troops"], 40: ["ghafir", "mumin", "forgiver"],
41: ["fussilat", "ha mim sajda"], 42: ["shura", "consultation"], 43: ["zukhruf", "ornaments"], 44: ["dukhan", "smoke"],
45: ["jathiya", "kneeling"], 46: ["ahqaf"], 47: ["muhammad"], 48: ["fath", "victory"], 49: ["hujurat", "rooms"],
50: ["qaf"], 51: ["dhariyat", "zariyat"], 52: ["tur", "mount"], 53: ["najm", "star"], 54: ["qamar", "moon"],
55: ["rahman", "rahmaan", "merciful"], 56: ["waqia", "waqiah", "inevitable"], 57: ["hadid", "iron"], 58: ["mujadila"],
59: ["hashr", "exile"], 60: ["mumtahana"], 61: ["saff", "ranks"], 62: ["jumua", "jumuah", "juma", "friday"],
63: ["munafiqun", "hypocrites"], 64: ["taghabun"], 65: ["talaq", "divorce"], 66: ["tahrim"], 67: ["mulk", "kingdom", "sovereignty"],
68: ["qalam", "pen"], 69: ["haqqa", "haqqah"], 70: ["maarij"], 71: ["nuh", "noah"], 72: ["jinn"],
73: ["muzzammil", "muzammil"], 74: ["muddathir", "mudathir"], 75: ["qiyama", "qiyamah", "resurrection"], 76: ["insan", "dahr", "man"],
77: ["mursalat"], 78: ["naba", "nabaa"], 79: ["naziat"], 80: ["abasa"], 81: ["takwir"], 82: ["infitar"],
83: ["mutaffifin"], 84: ["inshiqaq"], 85: ["buruj"], 86: ["tariq"], 87: ["ala", "a'la"], 88: ["ghashiya"],
89: ["fajr", "dawn"], 90: ["balad", "city"], 91: ["shams", "sun"], 92: ["layl", "lail", "night"], 93: ["duha", "dhuha"],
94: ["sharh", "inshirah", "alam nashrah"], 95: ["tin", "fig"], 96: ["alaq", "iqra", "clot"], 97: ["qadr", "power"],
98: ["bayyina"], 99: ["zalzala", "zilzal", "earthquake"], 100: ["adiyat"], 101: ["qaria", "qariah"], 102: ["takathur"],
103: ["asr", "time"], 104: ["humaza"], 105: ["fil", "elephant"], 106: ["quraysh", "quraish"], 107: ["maun"],
108: ["kawthar", "kauthar"], 109: ["kafirun", "kafiroon"], 110: ["nasr"], 111: ["masad", "lahab"], 112: ["ikhlas"],
113: ["falaq"], 114: ["nas", "naas", "mankind"]
}
// "2:255", "2.255", "2/255" -> { surah: 2, ayah: 255 }; otherwise null.
function parseVerseKey(query) {
var m = /^\s*(\d{1,3})\s*[:.\/]\s*(\d{1,3})\s*$/.exec(String(query || ""))
if (!m) return null
var s = parseInt(m[1], 10), a = parseInt(m[2], 10)
if (s < 1 || s > 114 || a < 1) return null
return { surah: s, ayah: a }
}
function isVerseKeyQuery(query) { return parseVerseKey(query) !== null }
// "juz 30", "para 30", "page 293", "p293", "hizb 12" -> { kind, val }; else null.
function parseSpanQuery(query) {
var m = /^\s*(juz|juzz|para|page|pg|p|hizb|جزء|صفحة|حزب)\s*\.?\s*(\d{1,3})\s*$/i.exec(String(query || ""))
if (!m) return null
var word = m[1].toLowerCase(), n = parseInt(m[2], 10)
var kind = (word === "juz" || word === "juzz" || word === "para" || word === "جزء") ? "juz"
: (word === "hizb" || word === "حزب") ? "hizb" : "page"
var max = kind === "juz" ? 30 : (kind === "hizb" ? 60 : 604)
if (n < 1 || n > max) return null
return { kind: kind, val: n }
}
function isSpanQuery(query) { return parseSpanQuery(query) !== null }
// "36", "surah 36", "sura 36", "سورة 36" -> 36; else null.
function parseSurahNumberQuery(query) {
var m = /^\s*(?:surah|sura|surat|s|سورة|سوره)?\s*(\d{1,3})\s*$/i.exec(String(query || ""))
if (!m) return null
var n = parseInt(m[1], 10)
return n >= 1 && n <= 114 ? n : null
}
// First Madinah-mushaf page of each juz (index 1..30).
var JUZ_START_PAGE = [0, 1, 22, 42, 62, 82, 102, 121, 142, 162, 182, 201, 222, 242, 262, 282,
302, 322, 342, 362, 382, 402, 422, 442, 462, 482, 502, 522, 542, 562, 582]
// Whether surah `num` has any text inside juz `juz`. A surah runs from its
// start juz up to where the next surah begins; if the next surah begins on
// the very first page of `juz`, this surah ended in the previous juz.
function surahInJuz(num, juz) {
var start = getSurahJuz(num)
if (juz < start) return false
if (num >= 114) return true
var nextJuz = getSurahJuz(num + 1)
if (juz < nextJuz) return true
if (juz > nextJuz) return false
return getSurahPage(num + 1) > (JUZ_START_PAGE[juz] || 0)
}
function surahOnPage(num, page) {
var start = getSurahPage(num)
var end = num < 114 ? getSurahPage(num + 1) : 604
return page >= start && page <= end
}
// Rank of `surah` for `query`; lower is better, -1 means no match.
function surahScore(surah, query) {
var raw = String(query || "")
if (raw.trim() === "") return 0
var vk = parseVerseKey(raw)
if (vk) return vk.surah === surah.number ? 0 : -1
var span = parseSpanQuery(raw)
if (span) {
if (span.kind === "juz") return surahInJuz(surah.number, span.val) ? 0 : -1
if (span.kind === "page") return surahOnPage(surah.number, span.val) ? 0 : -1
return -1 // hizb jumps resolve through the semantic search only
}
var num = parseSurahNumberQuery(raw)
if (num !== null) {
if (num === surah.number) return 0
// Bare digits also prefix-match while the user is still typing ("3" -> 30-39).
if (/^\s*\d+\s*$/.test(raw) && String(surah.number).indexOf(raw.trim()) === 0) return 3
return -1
}
var q = compactSearchText(raw)
if (q === "") return 0
var qNoArticle = stripArticle(q)
var best = -1
function consider(score) { if (score >= 0 && (best < 0 || score < best)) best = score }
var translit = compactSearchText(surah.transliteration)
var arabic = compactSearchText(surah.name)
var primary = [translit, stripArticle(translit), arabic, stripArticle(arabic)]
for (var i = 0; i < primary.length; i++) {
var f = primary[i]
if (!f) continue
if (f === q || f === qNoArticle) consider(0)
else if (f.indexOf(q) === 0 || f.indexOf(qNoArticle) === 0) consider(1)
else if (f.indexOf(q) !== -1) consider(3)
}
var aliases = SURAH_ALIASES[surah.number] || []
for (var a = 0; a < aliases.length; a++) {
var al = compactSearchText(aliases[a])
if (al === q || al === qNoArticle) consider(0)
else if (al.indexOf(q) === 0 || al.indexOf(qNoArticle) === 0) consider(2)
else if (q.length >= 3 && al.indexOf(q) !== -1) consider(3)
}
var tr_ = surah.translations || {}
for (var l = 0; l < LANGUAGES.length; l++) {
var t = compactSearchText(tr_[LANGUAGES[l].value] || "")
if (!t) continue
if (t === q) consider(1)
else if (t.indexOf(q) === 0) consider(2)
else if (t.indexOf(q) !== -1) consider(3)
}
var type = normalizeSearchText(surah.type)
if (type && (type === normalizeSearchText(raw) || (q.length >= 3 && type.indexOf(q) === 0))) consider(2)
return best
}
function surahMatches(surah, query) {
return surahScore(surah, query) >= 0
}
// Rank of `reciter` for `query`; lower is better, -1 means no match. Every
// whitespace-separated token must appear somewhere in the reciter's English
// name, Arabic name, or identifier, in any order ("alafasy mishary" works).
function reciterScore(reciter, query) {
var qn = normalizeSearchText(query)
if (qn === "") return 0
var en = normalizeSearchText(reciter.englishName)
var ar = normalizeSearchText(reciter.name)
var id = normalizeSearchText(reciter.identifier)
var hay = en + " " + ar + " " + id
var hayCompact = hay.replace(/ /g, "")
var tokens = qn.split(" ")
for (var i = 0; i < tokens.length; i++) {
if (hay.indexOf(tokens[i]) === -1 && hayCompact.indexOf(tokens[i]) === -1) return -1
}
if (en === qn || ar === qn) return 0
if (en.indexOf(qn) === 0 || ar.indexOf(qn) === 0) return 1
var words = (en + " " + ar).split(" ")
for (var w = 0; w < words.length; w++) {
if (words[w].indexOf(tokens[0]) === 0) return 2
}
return 3
}
function reciterMatches(reciter, query) {
return reciterScore(reciter, query) >= 0
}
function _rankedFilter(items, query, scoreFn) {
var out = []
for (var i = 0; i < items.length; i++) {
var sc = scoreFn(items[i], query)
if (sc >= 0) out.push({ item: items[i], score: sc, idx: i })
}
out.sort(function(a, b) { return a.score - b.score || a.idx - b.idx })
var res = []
for (var j = 0; j < out.length; j++) res.push(out[j].item)
return res
}
function filterSurahs(surahs, query) {
if (!surahs) return []
if (!query || String(query).trim() === "") return surahs.slice()
return _rankedFilter(surahs, query, surahScore)
}
function filterReciters(reciters, query) {
if (!reciters) return []
if (!query || String(query).trim() === "") return reciters.slice()
return _rankedFilter(reciters, query, reciterScore)
}
function indexOfSurah(list, number) {
if (!list) return -1
for (var i = 0; i < list.length; i++) {
if (list[i] && list[i].number === number) return i
}
return -1
}
function indexOfReciter(list, identifier) {
if (!list) return -1
for (var i = 0; i < list.length; i++) {
if (list[i] && list[i].identifier === identifier) return i
}
return -1
}
// --- URL safety (catalog SSRF hardening) ----------------------------------
// Catalog `server` values are untrusted. Before a URL is given to curl or mpv
// it must be https, on an allowlisted audio CDN, with a public host (no
// loopback/private/link-local/ULA, no IPv4-mapped IPv6, no userinfo/query/
// fragment). Identifiers used as path segments are similarly restricted.
var SAFE_IDENTIFIER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
var ALLOWED_AUDIO_HOST_SUFFIXES = ["mp3quran.net", "islamic.app"]
function isSafeIdentifier(id) {
if (typeof id !== "string" || id.length === 0 || id.length > 64) return false
if (!SAFE_IDENTIFIER_RE.test(id)) return false
if (id === "." || id === "..") return false
return true
}
function _stripTrailingDots(host) {
var h = String(host)
while (h.length > 0 && h.charAt(h.length - 1) === ".") h = h.substring(0, h.length - 1)
return h
}
function _parseIpv4(host) {
var h = _stripTrailingDots(host)
var parts = h.split(".")
if (parts.length < 2 || parts.length > 4) return null
var nums = []
for (var i = 0; i < parts.length; i++) {
// Decimal octets only. Hex/octal encodings (0x7f, 0177) are rejected.
if (!/^[0-9]+$/.test(parts[i])) return null
if (parts[i].length > 1 && parts[i].charAt(0) === "0") return null
var n = parseInt(parts[i], 10)
if (isNaN(n) || n < 0 || n > 255) return null
nums.push(n)
}
while (nums.length < 4) nums.splice(nums.length - 1, 0, 0)
return nums
}
function _looksLikeIpv4(host) {
var h = _stripTrailingDots(host)
var parts = h.split(".")
if (parts.length < 2 || parts.length > 4) return false
for (var i = 0; i < parts.length; i++) {
if (!/^(0x[0-9a-f]+|0[0-7]*|[0-9]+)$/i.test(parts[i])) return false
}
return true
}
function _isBlockedIpv4(parts) {
if (!parts || parts.length !== 4) return true
var a = parts[0], b = parts[1], c = parts[2]
if (a === 0 || a === 10 || a === 127) return true
if (a === 169 && b === 254) return true // link-local
if (a === 172 && b >= 16 && b <= 31) return true // private
if (a === 192 && b === 168) return true // private
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT
if (a >= 224 && a <= 255) return true // multicast + reserved 240/4
if (a === 192 && b === 0 && c === 0) return true // 192.0.0.0/24
if (a === 192 && b === 0 && c === 2) return true // TEST-NET-1 (192.0.2.0/24)
if (a === 198 && b === 51 && c === 100) return true // TEST-NET-2 (198.51.100.0/24)
if (a === 203 && b === 0 && c === 113) return true // TEST-NET-3 (203.0.113.0/24)
return false
}
function isAllowedAudioHost(host) {
if (!host) return false
var h = _stripTrailingDots(String(host).toLowerCase())
for (var i = 0; i < ALLOWED_AUDIO_HOST_SUFFIXES.length; i++) {
var s = ALLOWED_AUDIO_HOST_SUFFIXES[i]
if (h === s) return true
if (h.length > s.length + 1 && h.substring(h.length - s.length - 1) === "." + s) return true
}
return false
}
// isBlockedHost(host) — true for loopback/private/link-local/ULA addresses and
// clearly-internal hostnames (localhost, *.local, *.internal, *.localhost,
// single-label). IPv4-mapped IPv6 and short/hex/octal IPv4 forms are blocked.
function isBlockedHost(host) {
if (!host) return true
var h = _stripTrailingDots(String(host).toLowerCase())
if (!h) return true
if (h === "localhost" || h === "local") return true
if (h.length > 10 && h.substring(h.length - 10) === ".localhost") return true
if (h.indexOf(".local") !== -1 || h.indexOf(".internal") !== -1) return true
if (h.indexOf(":") !== -1) {
if (h === "::" || h === "::1" || h === "0:0:0:0:0:0:0:1" || h === "0:0:0:0:0:0:0:0") return true
// IPv4-mapped / IPv4-compatible (e.g. ::ffff:127.0.0.1, ::ffff:7f00:1)
if (h.indexOf("ffff:") !== -1 || h.indexOf(":ffff") !== -1) return true
if (/:\d+\.\d+\.\d+\.\d+$/.test(h)) return true
if (h.indexOf("fe8") === 0 || h.indexOf("fe9") === 0
|| h.indexOf("fea") === 0 || h.indexOf("feb") === 0
|| h.indexOf("fec") === 0 || h.indexOf("fc") === 0
|| h.indexOf("fd") === 0) return true
return false
}
if (h.indexOf(".") === -1) return true
if (_looksLikeIpv4(h)) {
var parts = _parseIpv4(h)
if (!parts) return true
return _isBlockedIpv4(parts)
}
return false
}
// _isValidPort(p) — strict port: non-empty, decimal digits only, no leading
// zeros, 1..65535.
function _isValidPort(p) {
if (typeof p !== "string" || p.length === 0) return false
if (!/^[0-9]+$/.test(p)) return false
if (p.length > 1 && p.charAt(0) === "0") return false
var n = parseInt(p, 10)
return !isNaN(n) && n >= 1 && n <= 65535
}
// isSafeServerPrefix(value) — true for an absolute https URL on an
// allowlisted audio CDN, no userinfo, no query, no fragment, no control chars,
// no encoded delimiters, valid port, safe host.
function isSafeServerPrefix(value) {
if (typeof value !== "string") return false
var url = value.trim()
if (url.length === 0 || url.length > 512) return false
if (/[\x00-\x20\x7f]/.test(url)) return false
// Encoded delimiters (%2e %2f %3f %23 %40 %5c, case-insensitive) can be used
// to smuggle path/userinfo/query structure past naive URL checks.
if (/%(?:2e|2f|3f|23|40|5c)/i.test(url)) return false
var m = url.match(/^(https):\/\/([^/?#]+)/)
if (!m) return false
if (url.indexOf("#") !== -1 || url.indexOf("?") !== -1) return false
var authority = m[2]
if (authority.indexOf("@") !== -1) return false
var host = authority
if (host.charAt(0) === "[") {
// Bracketed IPv6: inner literal must be hex/colon-only, contain >= 1
// colon, and carry no zone id. Anything after the bracket must be a port.
var close = authority.indexOf("]")
if (close === -1) return false
var inner = authority.substring(1, close)
if (!/^[0-9a-fA-F:]+$/.test(inner)) return false
if (inner.indexOf(":") === -1) return false
if (inner.indexOf("%") !== -1) return false
host = inner
var after = authority.substring(close + 1)
if (after !== "") {
if (after.charAt(0) !== ":") return false
if (!_isValidPort(after.substring(1))) return false
}
} else {
var lastColon = host.lastIndexOf(":")
if (lastColon !== -1) {
if (!_isValidPort(host.substring(lastColon + 1))) return false
host = host.substring(0, lastColon)
}
}
if (!/^[A-Za-z0-9.:-]+$/.test(host) && host.indexOf(":") === -1) return false
if (isBlockedHost(host)) return false
return isAllowedAudioHost(host)
}
// sanitizeServer(value) — validated server prefix with a trailing slash, or "".
function sanitizeServer(value) {
if (!isSafeServerPrefix(value)) return ""
var url = String(value).trim()
if (url.charAt(url.length - 1) !== "/") url += "/"
return url
}
// isSafeRemoteUrl(url) — final-url guard applied after URL construction.
function isSafeRemoteUrl(url) {
return isSafeServerPrefix(url)
}
// isSafeReciter(reciter) — server prefix and identifier are both safe.
function isSafeReciter(reciter) {
if (!reciter) return false
if (!isSafeIdentifier(reciter.identifier)) return false
if (reciter.server !== undefined && reciter.server !== null) {
if (sanitizeServer(reciter.server) === "") return false
}
return true
}
// --- IPC / CLI argument parsers (strict; used by QML IPC and quranctl
// companion logic). These never throw and never accept junk like "1junk".
// isSafeReciterArg(id) — a reciter id usable as a path segment: alnum first,
// letters/digits/dot/dash/underscore only, <= 64 chars. No "/" is possible,
// so no traversal even with "." allowed mid-string.
function isSafeReciterArg(id) {
return isSafeIdentifier(id)
}
// parseSurahArg(str) — strict decimal 1..114 (no leading zeros), or null.
function parseSurahArg(str) {
if (typeof str !== "string") return null
if (!/^[0-9]+$/.test(str)) return null
if (str.length > 1 && str.charAt(0) === "0") return null
var n = parseInt(str, 10)
if (isNaN(n) || n < 1 || n > 114) return null
return n
}
// parseSeekArg(str) — strict optional-sign decimal, or null.
function parseSeekArg(str) {
if (typeof str !== "string") return null
if (!/^-?[0-9]+$/.test(str)) return null
var v = parseInt(str, 10)
return isNaN(v) ? null : v
}
// --- fetch cooldown (per reciter:surah) ------------------------------------
// After a failed fetch (or a forced playback error on a cached file), further
// fetches for the same key are refused for COOLDOWN_MS. A caller cannot force
// more than one real network fetch per reciter:surah inside the window, even
// via repeated IPC or forced playback errors. Pure functions over a plain map
// so the gate logic is independently testable; Service.qml owns the map.
var COOLDOWN_MS = 10000
function cooldownActive(map, key, now) {
if (!map || typeof key !== "string") return true
var until = map[key]
return typeof until === "number" && now < until
}
function markCooldown(map, key, now) {
if (!map || typeof key !== "string") return
map[key] = now + COOLDOWN_MS
}
function clearCooldown(map, key) {
if (map && typeof key === "string") delete map[key]
}
// --- audio ----------------------------------------------------------------
function audioUrl(reciterId, surahNumber, reciterObj) {
var n = Number(surahNumber)
if (!isValidSurahNumber(n)) return ""
if (reciterObj && reciterObj.server) {
var url = sanitizeServer(reciterObj.server)
if (url === "") return ""
url = url + pad3(n) + ".mp3"
return isSafeRemoteUrl(url) ? url : ""
}
var providerId = reciterId === "ar.ajamy" ? "ar.ahmedajamy" : reciterId
if (!isSafeIdentifier(providerId)) return ""
var url = CDN_BASE + "/" + providerId + "/" + n + ".mp3"
// Run the constructed default-CDN URL through the same final-url guard as
// server-based URLs, for consistency.
return isSafeRemoteUrl(url) ? url : ""
}
function localAudioUrl(dataDir, reciterId, surahNumber) {
if (typeof dataDir !== "string" || dataDir.indexOf("..") !== -1) return ""
if (!isSafeIdentifier(reciterId) || !isValidSurahNumber(Number(surahNumber))) return ""
return "file://" + dataDir + "/" + reciterId + "/" + Number(surahNumber) + ".mp3"
}
// --- format helpers --------------------------------------------------------
function formatTime(ms) {
if (!ms || ms < 0) ms = 0
var total = Math.floor(ms / 1000)
var h = Math.floor(total / 3600)
var m = Math.floor((total % 3600) / 60)
var s = total % 60
if (h > 0) return h + ":" + (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s
return m + ":" + (s < 10 ? "0" : "") + s
}
function formatDuration(ms) {
return formatTime(ms)
}