Repository navigation
Expand file tree
/
Copy pathxLoader.user.js
More file actions
3443 lines (3290 loc) · 172 KB
/
Copy pathxLoader.user.js
File metadata and controls
3443 lines (3290 loc) · 172 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
// ==UserScript==
// @name xLoader
// @namespace https://github.com/immerzu/xLoader
// @version 1.0.42
// @description Lädt Bilder, Videos, GIFs und unterstützte HLS-Aufzeichnungen von X.com/Twitter herunter. Schnelle Dateiauswahl vor dem Streaming, zuverlässige Medienauflösung in Timeline und Einzelansicht, Fortschritt, Abbruchbestätigung, GM_download-Fallback und Dateinamenoptionen.
// @description:en Downloads images, videos, GIFs and supported HLS recordings from X.com/Twitter. Fast file selection before streaming, reliable media resolution in timelines and individual posts, progress, cancel confirmation, GM_download fallback and filename options.
// @description:ru Загружает изображения, видео, GIF и поддерживаемые записи HLS с X.com/Twitter. Быстрый выбор файла до потоковой передачи, надёжное определение медиа в ленте и отдельном посте, прогресс, подтверждение отмены, резервный GM_download и настройка имён файлов.
// @author xLoader
// @license MIT
// @homepageURL https://github.com/immerzu/xLoader
// @supportURL https://github.com/immerzu/xLoader/issues
// @updateURL https://raw.githubusercontent.com/immerzu/xLoader/main/xLoader.user.js
// @downloadURL https://raw.githubusercontent.com/immerzu/xLoader/main/xLoader.user.js
// @match https://x.com/*
// @match https://twitter.com/*
// @grant GM_download
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @connect x.com
// @connect twitter.com
// @connect pbs.twimg.com
// @connect video.twimg.com
// @connect abs.twimg.com
// @connect pscp.tv
// @connect cdn.syndication.twimg.com
// @noframes
// @run-at document-end
// ==/UserScript==
/*
* xLoader v1.0.42 (ansichtsunabhängiger Timeline-Metadatenfallback)
* - Wenn Timeline-React-Daten und Conversation-API kein Video liefern,
* werden öffentliche X-Syndication-Metadaten anhand der Status-ID geladen.
* - Die Hauptansicht erhält damit dieselben vollständigen mediaDetails wie
* der bereits funktionierende geöffnete Tweet.
* ---------------------------------------------------------------------------
* xLoader v1.0.41 (laufende Timeline-Videos und Prozentfortschritt)
* - Bereits laufende Timeline-Player werden nicht mehr angehalten; vorhandene
* Netzwerkdaten bleiben erhalten und werden vor einem Resolver-Klick geprüft.
* - Progressive ext_tw_video-/amplify_video-Quellen werden einheitlich über
* ihre Media-ID zugeordnet.
* - Ein paralleler HEAD-Abruf ergänzt fehlende Stream-Gesamtgrößen, damit
* große direkte Videos wieder Prozentfortschritt anzeigen können.
* ---------------------------------------------------------------------------
* xLoader v1.0.40 (frische Videoquelle nach Timeline-Dateiauswahl)
* - Der schnelle Speicherdialog bleibt vor dem Datentransfer erhalten.
* - Timeline-Videos aus React-/Prefetch-Daten werden nach der Dateiauswahl
* noch einmal über die Tweet-API abgeglichen; abgelaufene Quellen erzeugen
* dadurch keine leere Zieldatei mehr.
* - Unvollständige Live-Antworten ersetzen keine zusätzlichen Cache-Medien.
* ---------------------------------------------------------------------------
* xLoader v1.0.39 (Härtung der DOM-Wiederanbindung)
* - Wiederverwendete Buttons sicher von Tasks anderer Tweet-IDs lösen.
* - Entfernte Buttons sofort aus WeakMap und Taskliste entfernen.
* - Fortschritt an alle verbundenen Darstellungen desselben Tweets spiegeln.
* ---------------------------------------------------------------------------
* xLoader v1.0.38-dev (Fortschritts-Badge nach Teil-Recycling wiederherstellen)
* - Bereits vorhandene xLoader-Buttons bei DOM-Änderungen erneut anbinden.
* - Auch reine Entfernungen innerhalb eines Tweets zur Wiederherstellung prüfen.
* ---------------------------------------------------------------------------
* xLoader v1.0.37-dev (Fortschritt nach Timeline-DOM-Recycling)
* - Laufende Downloads zusätzlich nach Tweet-ID verfolgen.
* - Neu gerenderte Downloadbuttons wieder an Status, Prozent und Abbruch binden.
* - Fortschritt bleibt nach Weg- und Zurückscrollen sichtbar.
* ---------------------------------------------------------------------------
* xLoader v1.0.36-dev (Fortschritt auch ohne bekannte Gesamtgröße)
* - Videotransfers zeigen sofort eine neutrale laufende Anzeige.
* - Sobald Bytes und Gesamtgröße verfügbar sind, wechselt sie auf Prozent.
* - GM_download ohne onprogress/total bleibt damit sichtbar aktiv.
* ---------------------------------------------------------------------------
* xLoader v1.0.35-dev (Quoted-Video statt Vorschaubild herunterladen)
* - Medien aus verschachtelten quoted_tweet-/GraphQL-Ergebnissen erkennen.
* - Syndication-mediaDetails als echte Videoquelle auswerten.
* - Ungelöste Video-Poster nie als JPG-Erfolg bzw. Download anbieten.
* ---------------------------------------------------------------------------
* xLoader v1.0.34-dev (Härtung von Medienintegrität und Ressourcen)
* - HTTP-Teilantworten, Fehlertexte und unplausible Medienantworten ablehnen.
* - HLS-Initialisierung, Fragmente, Track-IDs, Zeitachsen, Lücken und
* tatsächlichen TS-/MP4-Container vor dem Finalisieren prüfen.
* - Abbruchsignal bis in Metadaten-, Token-, Broadcast- und Medienabrufe führen.
* - Medienhosts/-pfade strikt validieren; Cache, Retries und Segmentgrößen
* begrenzen und Cacheleeren gegen alte Hintergrundaufgaben absichern.
* ---------------------------------------------------------------------------
* xLoader v1.0.33 (HLS-/Broadcast-Downloads und Abbruchbestätigung)
* - Erneuter Klick auf den laufenden Downloadpfeil zeigt kleine Bestätigung.
* - Nein/Schließen setzt ohne Unterbrechung fort; Ja bricht laufende
* Tampermonkey-, Browser- und HLS-Segmentabrufe tatsächlich ab.
* - Unfertige File-System-Access-Dateien abbrechen, nicht finalisieren;
* blaue Fortschrittsanzeige und gewohnter Downloadpfeil bleiben erhalten.
* - Nutzerbestätigter Browser-Praxistest; zur Veröffentlichung freigegeben.
* ---------------------------------------------------------------------------
* xLoader v1.0.32-dev (TEST – HLS-Netzwerkzugriff)
* - Tampermonkey-@connect für alle pscp.tv-Subdomains dokumentationskonform.
* - Bei Fehler im Hintergrund-XHR HLS-Quelle über Browser-Fetch versuchen.
* - Ursächliche HTTP-/Host-/Netzwerk-Fehler ohne vertrauliche URL-Parameter ausgeben.
* - Direkten MP4-Stream und bestehende HLS-Zusammenführung nicht geändert.
* - Noch kein Live-Nachweis am RealAmVoice-Beitrag.
* ---------------------------------------------------------------------------
* xLoader v1.0.31-dev (TEST – Broadcast-Replay-Erkennung)
* - X-Broadcast-ID aus Card/DOM aufloesen, Playback-URL abfragen.
* - Wiedergabequellen der Broadcast-Infrastruktur unter *.pscp.tv erkennen.
* - Abgeschlossene, unverschluesselte HLS-MPEG-TS-Aufzeichnungen als .ts
* speichern (nicht falsch als MP4 deklarieren). Bestehende MP4- und
* fMP4-HLS-Pfade beibehalten. Nicht live am Beispiel-Tweet verifiziert.
* ---------------------------------------------------------------------------
* xLoader v1.0.30-dev (TEST – HLS/fMP4, nicht veröffentlicht)
* - HLS-Player-Cards und Video-/Audio-Varianten erkennen.
* - Abgeschlossene HLS-Aufnahmen als echte fragmentierte MP4 mit Tonspur
* streamen. Nicht unterstützte/verschlüsselte Streams sicher ablehnen.
* - Geprüfte direkte MP4-Downloads beibehalten.
* ---------------------------------------------------------------------------
* xLoader v1.0.29-dev (TEST – nicht veröffentlicht)
* - Player-Card-Felder (unified_card/media_entities und player_stream_url)
* mit Prüfung auf vollständige progressive MP4 auswerten.
* - Doppelte Tweet-Objekte mit vollständigerer Medienliste bevorzugen.
* - Fehler als sichtbare Meldung statt ausschließlich kurzem Tooltip zeigen.
* - Streaming-Dateiauswahl und Downloaddatenpfad unverändert lassen.
* ---------------------------------------------------------------------------
* xLoader v1.0.28 (Lokale Testfassung: vollständige progressive MP4)
* ---------------------------------------------------------------------------
* v1.0.28:
* - HLS-/DASH-Playlisten, Initialisierungssegmente und Fragmente ausschließen.
* - Vorhandene Metadaten des exakt passenden gerenderten Tweets nutzen;
* progressive MP4 mit höchster Bitrate statt Playback-Initsegment wählen.
* - Quellenprüfung auch vor Streaming und GM_download; schneller Picker,
* GIFs, Bilder, Mehrfachmedien und bisheriger Fallback bleiben erhalten.
* - Grundlage unverändert veröffentlichtes v1.0.26, nicht v1.0.27-dev.
* ---------------------------------------------------------------------------
* xLoader v1.0.26 (Lokales Build, 2026-10-21)
* ---------------------------------------------------------------------------
* v1.0.26:
* - Neue eindeutige Patchversion fuer das korrigierte lokale Build.
* - Streamingfix der lokalen v1.0.25 uebernommen; Downloadlogik unveraendert.
* ---------------------------------------------------------------------------
* xLoader v1.0.25 (Dateiauswahl vor Datentransfer, Release 2026-10-21)
* ---------------------------------------------------------------------------
* v1.0.25:
* - Erstes bekanntes Medium: nativer Dateidialog vor dem DOM-/Video-Merge;
* anschließend GM_xmlhttpRequest-Stream in die gewählte Datei.
* - Tampermonkey-Window-Proxy: nativer Picker mit document.defaultView als
* Receiver. Ohne Picker/Benutzeraktivierung bleibt GM_download erhalten.
* - Fortschritt, Stillstands-Watchdog, HTTP-/Netzwerkfehler und Dateiabbruch;
* weitere Medien behalten die bestehenden first/all-Speicheroptionen.
* - Vom Nutzer im Browser erfolgreich getestet: schneller Speichern-Dialog.
* - Streaming-Korrektur: Status 0 beim Start zulassen; HTTP-Abschluss vor
* Dateiübernahme prüfen. Kein timeout:0 beim Fetch; Stream-Ende anfordern.
* - Syntaxprüfung, 43 Regressionstests und 28 gezielte Downloadtests; darunter
* realer HTTP-Stream mit Byte-/SHA-256-Vergleich der gespeicherten Datei.
* Native Dialogzeiten wurden nicht automatisiert vermessen.
* Beide Videos des gemeldeten Tweets mit echtem Tampermonkey 5.5.1
* übertragen; Größen und SHA-256 unabhängig verglichen (Datei-Adapter).
*
* xLoader v1.0.24 (Download-Fixes, Watchdog, klarere Fehlermeldungen)
* ---------------------------------------------------------------------------
* v1.0.24:
* - Zehn lokale Korrekturen übernommen: getHandle nutzt den Article des
* Medien-Elements (quoted Tweets), Recheck auch bei fehlender Aktionsleiste,
* loadItemsLive liefert {items, apiStatus} lokal, gemeinsamer
* Video-Poster-Resolver mit einer 5-s-Deadline, mergeDomItems nur einmal,
* try/catch/finally in onDownloadClick, failPrefetch ohne Retry bei
* 401/403/429, prefetchSeen-Freigabe nach Erfolg, gmGet mit Timeout,
* releaseDetachedTweets und scheduleProcess mit closest-Filter.
* - Download-Watchdog: 5-min-Stillstands-Timeout (onprogress setzt zurück),
* abort() und klare Timeout-Meldung; keine starre Gesamtgrenze.
* - Klarere GM_download-Fehler: not_enabled/not_permitted/not_whitelisted/
* not_succeeded mit Handlungshinweis (Download-Modus auf Nativ/Browser-API).
* - Getestet: 13/13 Fix-, 11/11 DOM-, 19/19 Watchdog-Tests; portabler Chrome
* und Yandex (Browser-API).
* ---------------------------------------------------------------------------
* v1.0.23:
* - Kurzbeschreibung (@description) lokalisiert: deutsche Default-Zeile
* (= GF-Skript-Locale de) + @description:en + @description:ru — das Skript
* ist damit in der Greasy-Fork-Suche unter DE/EN/RU auffindbar. Grund: Die
* GF-Suche filtert standardmäßig nach Sprache; die bisherige einzeilige
* DE/EN/RU-Mischbeschreibung machte das Skript nur in der deutschen Suche
* auffindbar (EN/RU-Suche: unsichtbar). Kein @description:de (wird bei
* GF-Skript-Locale de ignoriert), keine @name:xx-Zeilen.
* ---------------------------------------------------------------------------
* v1.0.22:
* - Projektumzug: Repository liegt jetzt unter F:\001_Coding_Projekte\xLoader
* (vorher Downloadhilfe); alle Pfade/Verweise (Skills, Memory, Doku)
* entsprechend aktualisiert.
* - README: Platzhalter-Angabe korrigiert ({id} statt {tweetId}), Abschnitt
* „How it works" um @connect/@noframes/@run-at/@updateURL ergänzt, neuer
* Abschnitt „Development" (dreisprachig) mit Repo-/Release-Erläuterung.
* - ru-board-Post: Versionsstand auf v1.0.21 aktualisiert.
* ---------------------------------------------------------------------------
* v1.0.21:
* - Fix: extractDomMedia verwarf ALLE Medien des eigenen Tweets — der Guard
* filterte auf div[data-testid="tweet"] (den Body-Container, in dem auch
* die Medien liegen) statt auf eingebettete Tweets. DOM-Fallback und
* mergeDomItems (DOM-Poster/Standbilder ergänzen) waren damit funktionslos.
* Der Guard prüft jetzt gegen das nächstgelegene article[data-testid="tweet"]:
* eingebettete Tweets werden ausgeschlossen, die eigenen Medien gefunden.
* ---------------------------------------------------------------------------
* v1.0.20:
* - Kurzbeschreibung (@description) jetzt EN/DE/RU — die Greasy-Fork-Skript-
* seite ist damit dreisprachig (konsistent zur GitHub-README).
* ---------------------------------------------------------------------------
* v1.0.17 (zweisprachige Beschreibung)
* ---------------------------------------------------------------------------
* v1.0.17:
* - Beschreibung (@description) zweisprachig (Englisch + Deutsch) — die
* Greasy-Fork-Skriptseite zeigt die Kurzbeschreibung damit immer EN/DE.
* ---------------------------------------------------------------------------
* v1.0.16 (Dateinamen-Template)
* ---------------------------------------------------------------------------
* v1.0.16:
* - Neu: Konfigurierbares Dateinamen-Muster (Tampermonkey-Menü, persistiert
* in localStorage). Platzhalter: {handle} {id} {index} {ext} {date} {time}
* {type}. Default: @{handle}_{id}_{index}.{ext} (bisheriges Verhalten).
* ---------------------------------------------------------------------------
* v1.0.15 (Artikel-Card-Bilder)
* ---------------------------------------------------------------------------
* v1.0.15:
* - Fix: Bilder von Artikel-Cards (verlinkte Artikel) wurden nicht geladen —
* sie liegen auf pbs.twimg.com/card_img/... (nicht pbs.twimg.com/media/)
* und wurden gefiltert; außerdem fehlte der Medien-Indikator, sodass bei
* solchen Tweets gar kein Download-Button erschien. card_img wird jetzt
* als Medium erkannt und heruntergeladen (beste Auflösung via srcset).
* ---------------------------------------------------------------------------
* v1.0.14 (mehrere Medien pro Tweet)
* ---------------------------------------------------------------------------
* v1.0.14:
* - Fix: Bei Tweets mit eingebetteten Medien (Quoted-/Link-Cards auf
* Detailseiten) wurde der falsche Tweet adressiert: getTweetId nahm den
* ersten Zeitstempel (äußerer Tweet), obwohl die sichtbaren Poster zum
* eingebetteten Tweet gehörten. getTweetId/getHandle orientieren sich
* jetzt am Medien-Element (findMediaElement) und dessen /status/-Link.
* - Fix: Es wurden nicht mehr ALLE sichtbaren Medien eines Tweets geladen —
* nur noch die, die die X-API liefert. X.com kürzt Medien in Konversations-
* Antworten (v. a. Videos verlinkter Tweets). Jetzt werden die API-Medien
* um die im DOM sichtbaren Poster/Standbilder ergänzt (mergeDomItems, mit
* Dedup: gleiche Media-ID oder URL-Basis) — auch bei Cache-Treffern.
* - Neu: Echte Video-MP4s für Poster, die die API nicht liefert: Das Poster
* wird angetippt (X.com startet die Wiedergabe), die Voll-MP4 wird aus dem
* Performance-Buffer gelesen (video.twimg.com/amplify_video/<id>/vid/avc1/
* 0/0/<res>/<hash>.mp4) und das Video sofort stummgeschaltet/pausiert.
* Schlägt das fehl, bleibt das Poster als Bild-Notnagel.
* ---------------------------------------------------------------------------
* v1.0.12 (Fehlerbehebungen)
* ---------------------------------------------------------------------------
* v1.0.12:
* - Fix: normalizeMediaUrl akzeptierte wieder ALLE URLs — der isMediaUrl-
* Filter ging bei der m3u8-Bereinigung in v1.0.9 verloren. Der DOM-
* Fallback hätte dadurch auch Avatare/Icons/Emojis/externe Bilder als
* Medien heruntergeladen.
* - Fix: GM_download ohne timeout — ein hängender Download (Server ohne
* Antwort) deaktivierte den Button dauerhaft ("Lädt Medien …"). Jetzt
* greift nach CONFIG.requestTimeoutMs (60 s) der ontimeout-Pfad.
* - Fix: Live-Token-Retry wurde auch bei HTTP 429 (Rate-Limit) ausgelöst —
* der Token ist dort nicht das Problem, der Retry verschärfte das Limit
* nur. Token-Retry jetzt nur bei Auth-/Netzwerkfehlern.
* - Fix: Tweets im Prefetch-Retry-Cooldown wurden bei erneutem Erscheinen
* im DOM sofort neu gestartet (Doppel-Request). schedulePrefetch wartet
* jetzt auf den geplanten Retry.
* - Verbessert: Medien-Recheck läuft bis zu 3× (vorher genau 1×) — Tweets,
* deren Medien erst später laden (Lazy-Loading), bekommen nun doch einen
* Download-Button.
* - Fix: GIF-Tweets lieferten im DOM-Fallback (API nicht erreichbar) nichts
* ("Keine Medien gefunden") — das Poster (tweet_video_thumb) wurde nicht
* erkannt. Aus dem Poster wird jetzt die echte MP4-URL abgeleitet
* (pbs.twimg.com/tweet_video_thumb/<id> -> video.twimg.com/tweet_video/<id>.mp4,
* live verifiziert) — GIFs sind auf X.com MP4-Streams.
* ---------------------------------------------------------------------------
* v1.0.11 (Bugfix: "NaN%" im Fortschritt)
* ---------------------------------------------------------------------------
* v1.0.11:
* - Fix: Bei Downloads ohne brauchbare Größenangabe zeigte das Fortschritts-
* Badge "NaN%" — Tampermonkey liefert in onprogress teils fehlende oder
* NaN-Werte für done/total (z. B. unbekannte Gesamtgröße). Fehlende/nicht-
* endliche Werte werden jetzt ignoriert (Badge bleibt unverändert), nur
* gültige Prozente werden angezeigt.
* - Badge jetzt nur noch bei Videodateien ab 5 MB (CONFIG.progressBadgeMinBytes):
* Bei Bildern und kurzen Videos erscheint keine Fortschrittsanzeige mehr.
* ---------------------------------------------------------------------------
* v1.0.10 (Bugfix Fortschrittsanzeige)
* ---------------------------------------------------------------------------
* v1.0.10:
* - Fix: Fortschrittsanzeige funktionierte nicht — Tampermonkey liefert im
* GM_download-onprogress nur { done, total } (Bytes), kein percent. Der
* Prozentwert wird jetzt daraus berechnet (progress.percent wird weiterhin
* unterstützt, falls ein Manager ihn liefert).
* - Neu: Sichtbares Fortschritts-Badge über dem Button (vorher lief die
* Anzeige nur über btn.title = Hover-Tooltip und war praktisch nicht
* wahrnehmbar). Das Badge zeigt "42%" und verschwindet nach dem Download
* automatisch.
* ---------------------------------------------------------------------------
* v1.0.9 (Optimierungen)
* ---------------------------------------------------------------------------
* v1.0.9:
* - Bugfix: x-guest-token-Header hing fälschlich an der ct0-Länge (32) statt
* am Guest-Token selbst — der Header wird jetzt immer mitgesendet, wenn ein
* Guest-Token existiert.
* - Bugfix: Kaputter Live-Token-Fallback (erfundene main.{Timestamp}.js-URL,
* garantiert 404) entfernt; der Token wird zuerst direkt im HTML der
* Startseite gesucht, erst dann in den echten Script-URLs. Zusätzlich
* @connect abs.twimg.com ergänzt (X.com-Scripts liegen dort und wurden von
* GM_xmlhttpRequest bisher blockiert).
* - Bugfix: Prefetch-Fehler (401/429/Timeout/leere Antwort) blockierten den
* Tweet dauerhaft (prefetchSeen blieb gesetzt). Jetzt max. 2 automatische
* Retries mit 10-s-Cooldown, danach Aufgeben bis zum nächsten Klick.
* - Bugfix: m3u8→mp4-Umbenennung im DOM-Fallback entfernt (erzeugte
* potenziell kaputte Downloads — Playlisten lassen sich nicht einfach
* umbenennen).
* - Neu: Fortschrittsanzeige beim Download (GM_download onprogress) im
* Button-Titel ("Lade i/n … 42%").
* - Neu: Prefetch nur für sichtbare Tweets (IntersectionObserver,
* rootMargin 200px) — deutlich weniger API-Calls beim schnellen Scrollen.
* - Fehlermeldungen differenziert: 429 (Rate-Limit) und 401/403 werden
* explizit angezeigt statt "Keine Medien gefunden".
* - Konsistenz: Log-Präfix [xLoader], localStorage-Keys, data-Attribute und
* CSS-Klassen auf xLoader umgestellt (alter Live-Token-Key wird beim
* ersten Lesen migriert).
* - Metadaten: @homepageURL/@supportURL/@updateURL/@downloadURL auf GitHub
* (immerzu/xLoader) ergänzt.
* ---------------------------------------------------------------------------
* v1.0.8:
* - Live-Token-Retry wird nicht mehr durch einen gecachten (möglicherweise
* veralteten) Live-Token blockiert: Bei API-Fehlern (401/403, z. B. nach
* Token-Rotation durch X.com) wird jetzt IMMER versucht, einen frischen
* Bearer-Token zu holen. Vorher brachen Video-/GIF-Downloads nach einer
* Token-Rotation bis zum Ablauf der 1-h-TTL des Caches („Keine Medien
* gefunden“).
* - Prefetch-Cache-Verhalten korrigiert: Nach einem Download (Cache wird
* invalidiert) wird der Tweet jetzt wieder im Hintergrund vorgeladen, wenn
* er erneut im DOM erscheint (prefetchSeen-Eintrag wird geleert). Zudem
* blockieren abgelaufene Cache-Einträge (TTL 5 min) keinen neuen Prefetch
* mehr (getCachedItems statt mediaCache.has).
* ---------------------------------------------------------------------------
* v1.0.7 (Optionale Verbesserungen)
* ---------------------------------------------------------------------------
* v1.0.7:
* - Tampermonkey-Menü (GM_registerMenuCommand):
* * "Speichern unter-Dialog" umschalten: nur erstes Medium (Default) oder
* alle Medien (persistiert in localStorage)
* * "Medien-Cache leeren" (Prefetch-Cache + Queue zurücksetzen)
* - Tweet-ID-Extraktion robuster: bevorzugt den Zeitstempel-Link (garantiert
* die Haupt-Tweet-ID), dann Links ohne /photo|/video|/analytics-Suffix.
* Behebt Randfälle bei Quoted-Tweets und Analytics-Links.
* - saveAs-Logik: Bei Modus "nur erster" bekommt nur das erste Medium den
* "Speichern unter"-Dialog, weitere Medien gehen direkt in den Download-
* Ordner (deutlich weniger Klicks bei Mehrfachmedien).
* ---------------------------------------------------------------------------
* v1.0.6: Projekt-/Skriptname "xLoader" (zuvor Downloadhilfe); @author und
* @namespace auf die Veröffentlichungs-Identität gesetzt. Keine
* Funktionsänderungen gegenüber v1.0.5.
* ---------------------------------------------------------------------------
* v1.0.5 (Layout-Fix: Button "verrutscht"):
* v1.0.5: Der Download-Button wird nicht mehr als eigenständiges Element mit
* fester Größe gebaut, sondern durch Klonen eines vorhandenen X.com-
* Aktionsleisten-Buttons (bewährter Ansatz aus X Media Downloader). Damit
* werden Größe, Höhe, Abstände und responsives Verhalten der Leiste exakt
* übernommen (vorher: fester 36px-Button, der bei manchen Tweet-Fenstern
* über die Leiste hinausragte). Nur das Icon (SVG) und der Zähler werden
* ersetzt bzw. ausgeblendet.
* ---------------------------------------------------------------------------
* v1.0.4: Keine Funktionsänderungen — nur Metadaten für die Veröffentlichung:
* - @license MIT ergänzt (Greasy Fork zeigt sonst "Alle Rechte vorbehalten")
* - @description überarbeitet
* - @author / @namespace sind Platzhalter: vor dem Einfügen bei Greasy Fork
* durch eigene Werte ersetzen, z. B.
* @author <dein Name/Handle>
* @namespace https://greasyfork.org/users/<deine-User-ID>
* (Die User-ID steht in der URL deines Greasy-Fork-Profils.)
* Injektion: Ein Download-Button (Pfeil-nach-unten) wird in die
* Aktionsleiste (div[role="group"]) jedes Tweets eingefügt,
* sobald der Tweet ein Medien-Indiz trägt (Bild, Video-
* Poster, <video>-Element). Initialer Scan + MutationObserver
* decken vorhandene und nachgeladene Tweets ab.
*
* HINTERGRUND (live auf X.com verifiziert):
* X.com rendert Videos/GIFs NUR als Poster-<img>; die direkten MP4-URLs
* liefert ausschließlich die X-API (conversation.json mit Bearer-/csrf-
* Headern). window.__INITIAL_STATE__ existiert nicht mehr.
*
* Änderungen in v1.0.3 (sofortiger "Speichern unter"-Dialog):
* - PREFETCH: Sobald ein Tweet mit Medien-Indikator im DOM erscheint, wird
* der API-Call asynchron im Hintergrund gestartet (max. 3 parallel,
* Queue begrenzt). Die Medien-URLs landen in einem Cache
* (Map: tweetId -> { items, fetchedAt }), TTL 5 Minuten (X.com-URLs sind
* temporär). 401/429-Fehler beim Prefetch werden still verworfen — der
* Klick lädt dann live nach.
* - KLICK: Zuerst Cache-Check -> bei Treffer sofortiger Download ohne
* API-Call. Cache-Miss -> Live-Fallback (API -> Live-Token-Retry ->
* DOM). Nach dem Download wird der Cache-Eintrag invalidiert.
* - DOWNLOAD: Direkter GM_download mit der URL (saveAs: true) statt
* Blob-Umweg — der "Speichern unter"-Dialog öffnet sich unmittelbar
* nach dem Klick; die Datei wird im Hintergrund geladen.
* (Blob-Pfad entfernt: er verzögerte den Dialog um die Ladezeit.)
* - RACE-SCHUTZ: Button wird beim Klick sofort deaktiviert (Doppelklick-
* Guard); parallele Klicks auf verschiedene Tweets sind unabhängig.
*
* Fallback-Kette beim Klick (Cache-Miss): API -> Live-Token-Retry -> DOM
* (Bilder) -> Fehlermeldung.
* ---------------------------------------------------------------------------
*/
(function () {
'use strict';
// ============================ Konfiguration ============================
const CONFIG = {
tweetSelector: 'article[data-testid="tweet"]',
actionBarSelector: 'div[role="group"]',
// Kandidaten für Medien-URLs im DOM (Fallback für Bilder)
mediaSelectors: ['img', 'video', 'source'],
containerSelector: [
'[data-testid="videoPlayer"]',
'[data-testid="videoComponent"]',
'[data-testid="tweetPhoto"]',
'[data-testid="tweetVideo"]'
].join(','),
dataUrlAttrs: ['data-src', 'data-url', 'data-image-url', 'data-video-url', 'data-media-url'],
requestTimeoutMs: 60000,
recheckDelayMs: 2000,
errorFlashMs: 2500,
// Fortschritts-Badge nur bei größeren Videodateien (bei Bildern/
// kurzen Videos ist die Anzeige sinnlos)
progressBadgeMinBytes: 5 * 1024 * 1024,
logPrefix: '[xLoader]',
btnAttribute: 'data-xloader',
// Speichern-Dialog-Modus (persistiert): 'first' (nur erstes Medium) | 'all'
saveAsModeKey: 'xloader-saveas-mode',
// Dateinamen-Muster (persistiert), Platzhalter:
// {handle} {id} {index} {ext} {date} {time} {type}
filenameTemplateKey: 'xloader-filename-template',
defaultFilenameTemplate: '@{handle}_{id}_{index}.{ext}',
// Prefetch-Cache
cacheTtlMs: 5 * 60 * 1000, // X.com-Medien-URLs sind temporär
maxParallelPrefetch: 3, // Rate-Limiting vermeiden
maxPrefetchQueue: 30, // Schutz bei schnellem Scrollen
maxCacheEntries: 200,
maxPrefetchSeenEntries: 500,
maxHlsManifestBytes: 2 * 1024 * 1024,
maxHlsSegmentBytes: 256 * 1024 * 1024,
maxHlsSegments: 12000,
performanceEntryMaxAgeMs: 30000,
// X-API (bewährte Strategie aus funktionierendem Referenz-Skript)
api: {
endpoint: function (statusId) {
return location.origin + '/i/api/2/timeline/conversation/' + statusId +
'.json?tweet_mode=extended&include_entities=true&include_user_entities=false';
},
// Öffentlicher Guest-Bearer-Token (aus der x.com main.js)
guestBearer: 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
liveTokenRe: /["'](A{20,}[a-zA-Z0-9%_\-]{40,})["']/,
liveTokenTtlMs: 3600000,
localStorageKey: 'xloader-live-token'
}
};
const CSS = [
// Layout kommt vom geklonten X.com-Button; hier nur Zähler ausblenden,
// Hover-/Fehlerfarben und Icon-Stil.
'.xloader-wrap > button > div > div:nth-child(2) { display: none; }',
'.xloader-wrap:hover > button > div,',
'.xloader-btn:hover > div { color: rgb(29, 155, 240); }',
'.xloader-wrap:hover > button > div > div > div,',
'.xloader-btn:hover > div > div > div { background-color: rgba(29, 155, 240, 0.1); }',
'.xloader-wrap:active > button > div > div > div,',
'.xloader-btn:active > div > div > div { background-color: rgba(29, 155, 240, 0.2); }',
'.xloader-btn:disabled { opacity: 0.5; cursor: default; }',
'.xloader-wrap.is-error > button > div,',
'.xloader-btn.is-error > div { color: rgb(244, 33, 46); }',
'.xloader-wrap.is-error > button > div > div > div,',
'.xloader-btn.is-error > div > div > div { background-color: rgba(244, 33, 46, 0.1); }',
'.xloader-btn svg path {',
' fill: none;',
' stroke: currentColor;',
' stroke-width: 2;',
' stroke-linecap: round;',
' stroke-linejoin: round;',
'}',
// Fortschritts-Badge: sichtbare Prozent-Anzeige während des Downloads
'.xloader-progress {',
' position: absolute;',
' top: -8px;',
' right: -8px;',
' min-width: 22px;',
' height: 18px;',
' padding: 0 5px;',
' border-radius: 9999px;',
' background: rgb(29, 155, 240);',
' color: #fff;',
' font-size: 11px;',
' font-weight: 700;',
' line-height: 18px;',
' text-align: center;',
' z-index: 5;',
' pointer-events: none;',
' box-sizing: border-box;',
'}'
].join('\n');
// Download-Symbol: Pfeil nach unten mit Basislinie (X-Design angelehnt).
// Wird in das geklonte X.com-SVG eingesetzt (dessen Größenklassen bleiben).
const SVG_ICON_INNER =
'<path d="M12 3.5v10.5"/><path d="M6.5 9.5 12 15l5.5-5.5"/><path d="M4.5 19.5h15"/>';
// ================================ Logging ==============================
const log = {
info: function () {
console.log.apply(console, [CONFIG.logPrefix].concat(Array.prototype.slice.call(arguments)));
},
warn: function () {
console.warn.apply(console, [CONFIG.logPrefix].concat(Array.prototype.slice.call(arguments)));
},
error: function () {
console.error.apply(console, [CONFIG.logPrefix].concat(Array.prototype.slice.call(arguments)));
}
};
function downloadAbortError() {
var e = new Error('Download vom Benutzer abgebrochen');
e.name = 'AbortError';
return e;
}
function ensureNotCancelled(signal) {
if (signal && signal.aborted) throw downloadAbortError();
}
function isCancellation(e, signal) {
return (signal && signal.aborted) || (e && e.name === 'AbortError');
}
function onDownloadAbort(signal, fn) {
if (!signal) return function () {};
if (signal.aborted) { fn(); return function () {}; }
signal.addEventListener('abort', fn, { once: true });
return function () { signal.removeEventListener('abort', fn); };
}
function abortableDelay(ms, signal) {
ensureNotCancelled(signal);
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () { cleanup(); resolve(); }, ms);
var cleanup = onDownloadAbort(signal, function () {
clearTimeout(timer);
cleanup();
reject(downloadAbortError());
});
});
}
// ============================ DOM-Medien-Extraktion ====================
// (nur als Fallback für statische Bilder, wenn die API nicht erreichbar ist)
// HLS VOD herunterladen: Video- und AAC-Spur als fMP4-Fragmentstrom
// in eine echte gemeinsame MP4-Datei zusammenführen, ohne große RAM-Blobs.
function allowedVideoHost(host) {
return host === 'video.twimg.com' || host === 'video.pscp.tv' ||
host.endsWith('.video.pscp.tv');
}
function allowedImageUrl(url) {
try {
var u = new URL(url);
return u.protocol === 'https:' && u.hostname === 'pbs.twimg.com' &&
/^\/(?:media|card_img|amplify_video_thumb|ext_tw_video_thumb|tweet_video_thumb)\//.test(u.pathname);
} catch (e) { return false; }
}
function isVideoPosterUrl(url) {
try {
var u = new URL(url);
return u.protocol === 'https:' && u.hostname === 'pbs.twimg.com' &&
/^\/(?:amplify_video_thumb|ext_tw_video_thumb)\//.test(u.pathname);
} catch (e) { return false; }
}
function downloadableImageUrl(url) {
try {
var u = new URL(url);
return u.protocol === 'https:' && u.hostname === 'pbs.twimg.com' &&
/^\/(?:media|card_img)\//.test(u.pathname);
} catch (e) { return false; }
}
function responseHeader(headers, name) {
var match = new RegExp('(?:^|\\r?\\n)' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
':\\s*([^\\r\\n]+)', 'i').exec(headers || '');
return match ? match[1].trim() : '';
}
function assertFullHttpResponse(status, headers, label) {
if (status !== 200) throw new Error(label + ' HTTP ' + status +
(status === 206 ? ' (unerwartete Teilantwort)' : ''));
if (responseHeader(headers, 'content-range')) throw new Error(label + ': unerwartete Teilantwort');
}
function assertResponseSize(headers, actual, limit, label) {
var declared = Number(responseHeader(headers, 'content-length'));
if (isFinite(declared) && declared > limit) throw new Error(label + ': Antwort zu groß');
if (actual > limit) throw new Error(label + ': Antwort zu groß');
if (declared > 0 && actual !== declared) throw new Error(label + ': unvollständige Antwort');
}
function assertMediaContentType(headers, url) {
var type = responseHeader(headers, 'content-type').split(';')[0].trim().toLowerCase();
if (!type) return;
var valid = allowedImageUrl(url) ? (/^image\//.test(type) || type === 'application/octet-stream') :
(type === 'video/mp4' || type === 'application/mp4' || type === 'application/octet-stream');
if (!valid) throw new Error('Download: unzulässiger Inhaltstyp ' + type);
}
function assertMediaPrefix(bytes, url) {
if (!bytes || bytes.length < 12) throw new Error('Download: unplausibler Medieninhalt');
if (allowedImageUrl(url)) {
var jpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
var png = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
var gif = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 &&
(bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61;
var webp = bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
if (!jpeg && !png && !gif && !webp) throw new Error('Download: ungültige Bildsignatur');
return;
}
var declared = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);
var type = String.fromCharCode(bytes[4], bytes[5], bytes[6], bytes[7]);
if (type !== 'ftyp' || declared < 8) throw new Error('Download: ungültige MP4-Signatur');
}
function hlsUrl(url) {
try {
var u = new URL(url);
return u.protocol === 'https:' && allowedVideoHost(u.hostname) && /\.m3u8$/i.test(u.pathname);
} catch (e) { return false; }
}
function mp4Boxes(bytes, start, end) {
start = start || 0;
end = end == null ? bytes.length : end;
var found = [], view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
while (start + 8 <= end) {
var size = view.getUint32(start), header = 8;
if (size === 1) {
if (start + 16 > end) throw new Error('MP4: ungültige Boxgröße');
size = view.getUint32(start + 8) * 4294967296 + view.getUint32(start + 12);
header = 16;
}
if (size === 0) size = end - start;
if (size < header || start + size > end) throw new Error('MP4: beschädigtes Fragment');
found.push({ start: start, end: start + size, head: header,
type: String.fromCharCode.apply(null, Array.from(bytes.slice(start + 4, start + 8))) });
start += size;
}
if (start !== end) throw new Error('MP4: überzählige Bytes');
return found;
}
function mp4First(bytes, type, start, end) {
return mp4Boxes(bytes, start, end).find(function (b) { return b.type === type; });
}
function mp4Children(bytes, b) { return mp4Boxes(bytes, b.start + b.head, b.end); }
function mp4Path(bytes, types) {
var b = { start: 0, end: bytes.length, head: 0 };
for (var i = 0; i < types.length; i++) {
b = mp4First(bytes, types[i], b.start + b.head, b.end);
if (!b) throw new Error('MP4: fehlende ' + types[i] + '-Box');
}
return b;
}
function mp4Piece(bytes, b) { return bytes.slice(b.start, b.end); }
function mp4Concat(parts) {
var length = 0;
for (var i = 0; i < parts.length; i++) length += parts[i].length;
var out = new Uint8Array(length), offset = 0;
for (var j = 0; j < parts.length; j++) { out.set(parts[j], offset); offset += parts[j].length; }
return out;
}
function mp4Box(type, payload) {
var out = new Uint8Array(payload.length + 8), view = new DataView(out.buffer);
view.setUint32(0, out.length);
for (var i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
out.set(payload, 8);
return out;
}
function mp4GetId(bytes, b, offset) {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(b.start + offset);
}
function mp4SetId(bytes, b, offset, id) {
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(b.start + offset, id);
}
function mp4TrackId(bytes, trak) {
var tkhd = mp4First(bytes, 'tkhd', trak.start + trak.head, trak.end);
if (!tkhd) throw new Error('HLS: Trackheader fehlt');
var offset = bytes[tkhd.start + 8] === 1 ? 28 : 20;
var id = mp4GetId(bytes, tkhd, offset);
if (!id) throw new Error('HLS: ungültige Track-ID');
return id;
}
function mp4ValidateInit(bytes) {
var ftyp = mp4First(bytes, 'ftyp'), moov = mp4First(bytes, 'moov');
if (!ftyp || !moov) throw new Error('HLS: ungültige MP4-Initialisierung');
var children = mp4Children(bytes, moov);
var tracks = children.filter(function (b) { return b.type === 'trak'; });
var mvex = children.find(function (b) { return b.type === 'mvex'; });
if (!tracks.length || !mvex) throw new Error('HLS: Initialisierung ohne Track/mvex');
var ids = tracks.map(function (t) { return mp4TrackId(bytes, t); });
if (new Set(ids).size !== ids.length) throw new Error('HLS: doppelte Track-ID');
var trexIds = mp4Children(bytes, mvex).filter(function (b) { return b.type === 'trex'; })
.map(function (b) { return mp4GetId(bytes, b, 12); });
if (!ids.every(function (id) { return trexIds.indexOf(id) !== -1; }))
throw new Error('HLS: Track ohne TREX');
return ids;
}
function mp4ValidateFragment(bytes, allowedIds) {
var boxes = mp4Boxes(bytes);
var moofs = boxes.filter(function (b) { return b.type === 'moof'; });
var mdats = boxes.filter(function (b) { return b.type === 'mdat'; });
if (moofs.length !== 1 || !mdats.length || !mdats.some(function (b) { return b.end - b.start > b.head; }))
throw new Error('HLS: unvollständiges MP4-Medienfragment');
var mediaBytes = mdats.reduce(function (sum, b) { return sum + b.end - b.start - b.head; }, 0);
var trafs = mp4Children(bytes, moofs[0]).filter(function (b) { return b.type === 'traf'; });
if (!trafs.length) throw new Error('HLS: Fragment ohne Track');
var sampleCount = 0, decodeTimes = [];
trafs.forEach(function (traf) {
var parts = mp4Children(bytes, traf);
var tfhd = parts.find(function (b) { return b.type === 'tfhd'; });
var tfdt = parts.find(function (b) { return b.type === 'tfdt'; });
var truns = parts.filter(function (b) { return b.type === 'trun'; });
if (!tfhd || !tfdt || !truns.length) throw new Error('HLS: Fragment ohne tfhd/tfdt/trun');
var id = mp4GetId(bytes, tfhd, 12);
if (allowedIds.indexOf(id) === -1) throw new Error('HLS: unbekannte Fragment-Track-ID');
var tfdtView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
var version = bytes[tfdt.start + 8];
var time = version === 1 ? tfdtView.getUint32(tfdt.start + 12) * 4294967296 +
tfdtView.getUint32(tfdt.start + 16) : tfdtView.getUint32(tfdt.start + 12);
if (!Number.isSafeInteger(time) || time < 0) throw new Error('HLS: ungültige Fragmentzeit');
decodeTimes.push(time);
truns.forEach(function (trun) {
var count = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(trun.start + 12);
if (!count) throw new Error('HLS: Fragment ohne Samples');
sampleCount += count;
});
});
if (!sampleCount) throw new Error('HLS: Fragment ohne Samples');
if (sampleCount > mediaBytes) throw new Error('HLS: Sampleanzahl passt nicht zu den Mediendaten');
return { decodeTime: Math.min.apply(null, decodeTimes), samples: sampleCount };
}
function validateTsSegment(bytes) {
if (bytes.length < 188 || bytes.length % 188 !== 0) throw new Error('HLS: ungültiges MPEG-TS-Segment');
var hasPayload = false;
for (var i = 0; i < bytes.length; i += 188) {
if (bytes[i] !== 0x47 || (bytes[i + 1] & 0x80)) throw new Error('HLS: ungültiges MPEG-TS-Segment');
var pid = ((bytes[i + 1] & 0x1f) << 8) | bytes[i + 2];
var control = (bytes[i + 3] >> 4) & 3;
if (!control) throw new Error('HLS: ungültiges MPEG-TS-Paket');
if (pid !== 0x1fff && (control & 1)) hasPayload = true;
}
if (!hasPayload) throw new Error('HLS: TS-Segment ohne Nutzdaten');
}
function mp4CombinedInit(videoInit, audioInit) {
// X liefert oft zwei einzelne MP4-Tracks. Die Boxen der Audio-Spur
// werden mit eigener Track-ID in den gemeinsamen moov übernommen.
var vMoov = mp4Path(videoInit, ['moov']);
var aMoov = mp4Path(audioInit, ['moov']);
var vTracks = mp4Children(videoInit, vMoov).filter(function (b) { return b.type === 'trak'; });
var aTracks = mp4Children(audioInit, aMoov).filter(function (b) { return b.type === 'trak'; });
if (vTracks.length !== 1 || aTracks.length !== 1) throw new Error('HLS: unerwartete Track-Anzahl');
var videoTkhd = mp4Path(videoInit, ['moov', 'trak', 'tkhd']);
var audioTkhd = mp4Path(audioInit, ['moov', 'trak', 'tkhd']);
var trackOffset = function (bytes, b) { return bytes[b.start + 8] === 1 ? 28 : 20; };
var videoId = mp4GetId(videoInit, videoTkhd, trackOffset(videoInit, videoTkhd));
var audioId = mp4GetId(audioInit, audioTkhd, trackOffset(audioInit, audioTkhd));
var newAudioId = videoId === 1 ? 2 : 1;
var audioTrack = mp4Piece(audioInit, aTracks[0]);
var tkh = mp4First(audioTrack, 'tkhd', 8, audioTrack.length);
if (!tkh) throw new Error('HLS: Audio-Trackheader fehlt');
mp4SetId(audioTrack, tkh, trackOffset(audioTrack, tkh), newAudioId);
var videoMvex = mp4Path(videoInit, ['moov', 'mvex']);
var audioMvex = mp4Path(audioInit, ['moov', 'mvex']);
var aTrex = mp4Children(audioInit, audioMvex).find(function (b) { return b.type === 'trex'; });
if (!aTrex) throw new Error('HLS: Audio-TREX fehlt');
var audioTrex = mp4Piece(audioInit, aTrex);
mp4SetId(audioTrex, { start: 0 }, 12, newAudioId);
var mvexParts = mp4Children(videoInit, videoMvex).map(function (b) { return mp4Piece(videoInit, b); });
mvexParts.push(audioTrex);
var newMvex = mp4Box('mvex', mp4Concat(mvexParts));
var moovParts = [];
mp4Children(videoInit, vMoov).forEach(function (b) {
moovParts.push(b.type === 'mvex' ? newMvex : mp4Piece(videoInit, b));
if (b.type === 'trak') moovParts.push(audioTrack);
});
var ftyp = mp4First(videoInit, 'ftyp');
if (!ftyp) throw new Error('HLS: ftyp fehlt');
return { init: mp4Concat([mp4Piece(videoInit, ftyp), mp4Box('moov', mp4Concat(moovParts))]),
originalAudioId: audioId, newAudioId: newAudioId };
}
function mp4AudioFragment(bytes, originalId, newId) {
var out = bytes.slice();
mp4Boxes(out).forEach(function (box) {
if (box.type !== 'moof') return;
mp4Children(out, box).forEach(function (traf) {
if (traf.type !== 'traf') return;
mp4Children(out, traf).forEach(function (tfhd) {
if (tfhd.type === 'tfhd') {
if (mp4GetId(out, tfhd, 12) !== originalId) throw new Error('HLS: unbekannte Audio-Track-ID');
mp4SetId(out, tfhd, 12, newId);
}
});
});
});
return out;
}
function hlsAttributes(value) {
var result = {}, re = /([A-Z0-9-]+)=("[^"]*"|[^,]*)/g, m;
while ((m = re.exec(value))) result[m[1]] = m[2].charAt(0) === '"' ? m[2].slice(1, -1) : m[2];
return result;
}
function hlsPlaylist(text, baseUrl) {
if (typeof text !== 'string' || text.length > CONFIG.maxHlsManifestBytes)
throw new Error('HLS: Playlist zu groß');
if (!/^#EXTM3U(?:\s|$)/.test(text)) throw new Error('HLS: ungültige Playlist');
var lines = text.replace(/\r/g, '').split('\n').map(function (s) { return s.trim(); });
var variants = [], audio = [], entries = [], initUrl = null, endlist = false;
var pendingInf = null, duration = 0, discontinuities = 0, gapPending = false;
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (!line) continue;
if (line.indexOf('#EXT-X-STREAM-INF:') === 0) {
var attr = hlsAttributes(line.slice(18));
var candidate = lines[++i];
if (!candidate || candidate.charAt(0) === '#') throw new Error('HLS: ungültige Variant-Playlist');
variants.push({ url: new URL(candidate, baseUrl).href, attr: attr });
} else if (line.indexOf('#EXT-X-MEDIA:') === 0) {
var at = hlsAttributes(line.slice(13));
if (at.TYPE === 'AUDIO' && at.URI) audio.push({ url: new URL(at.URI, baseUrl).href, attr: at });
} else if (line.indexOf('#EXT-X-MAP:') === 0) {
var ma = hlsAttributes(line.slice(11));
if (!ma.URI || ma.BYTERANGE) throw new Error('HLS: nicht unterstütztes EXT-X-MAP');
var nextInit = new URL(ma.URI, baseUrl).href;
if (initUrl && initUrl !== nextInit) throw new Error('HLS: EXT-X-MAP-Wechsel nicht unterstützt');
initUrl = nextInit;
} else if (line.indexOf('#EXTINF:') === 0) {
pendingInf = Number(line.slice(8).split(',')[0]);
if (!isFinite(pendingInf) || pendingInf <= 0) throw new Error('HLS: ungültige Segmentdauer');
} else if (line.indexOf('#EXT-X-KEY:') === 0 && !/METHOD=NONE(?:,|$)/.test(line)) {
throw new Error('HLS: verschlüsselter Stream nicht unterstützt');
} else if (line.indexOf('#EXT-X-BYTERANGE:') === 0) {
throw new Error('HLS: Byte-Range nicht unterstuetzt');
} else if (line.indexOf('#EXT-X-DISCONTINUITY') === 0) {
discontinuities++;
} else if (line === '#EXT-X-GAP') {
gapPending = true;
} else if (line === '#EXT-X-ENDLIST') {
endlist = true;
} else if (line.charAt(0) !== '#' && pendingInf !== null) {
if (gapPending) throw new Error('HLS: Playlist enthält eine Medienlücke');
entries.push({ url: new URL(line, baseUrl).href, time: duration, duration: pendingInf });
duration += pendingInf;
pendingInf = null;
gapPending = false;
}
}
if (pendingInf !== null || gapPending) throw new Error('HLS: unvollständiger Playlist-Eintrag');
return { variants: variants, audio: audio, initUrl: initUrl, segments: entries, endlist: endlist, discontinuities: discontinuities };
}
// GM_xmlhttpRequest läuft im Tampermonkey-Hintergrund. Für Broadcast-
// CDNs kann die Browser-Seite einen anderen, funktionierenden Netzpfad
// besitzen. Nach einem erfolgreichen Browser-Fallback diesen bevorzugen.
var hlsPreferBrowserFetch = false;
function hlsFetchBrowser(url, kind, signal) {
var browserFetch = typeof fetch === 'function' ? fetch :
(typeof window !== 'undefined' && typeof window.fetch === 'function' ? window.fetch.bind(window) : null);
if (!browserFetch) return Promise.reject(new Error('Browser-Fetch nicht verfügbar'));
ensureNotCancelled(signal);
var controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
var timer = controller ? setTimeout(function () { controller.abort(); }, CONFIG.requestTimeoutMs) : null;
var cleanupAbort = onDownloadAbort(signal, function () { if (controller) controller.abort(); });
return Promise.resolve().then(function () {
ensureNotCancelled(signal);
return browserFetch(url, { method: 'GET', mode: 'cors', credentials: 'omit',
signal: controller ? controller.signal : undefined });
}).then(async function (response) {
if (!response) throw new Error('Browser HTTP ?');
assertFullHttpResponse(response.status, response.headers && response.headers.get ?
Array.from(response.headers.entries()).map(function (v) { return v[0] + ': ' + v[1]; }).join('\n') : '', 'Browser');
var limit = kind === 'text' ? CONFIG.maxHlsManifestBytes : CONFIG.maxHlsSegmentBytes;
var declared = Number(response.headers && response.headers.get ? response.headers.get('content-length') : 0);
if (isFinite(declared) && declared > limit) throw new Error('Browser-Fetch: Antwort zu groß');
if (kind === 'text') {
var value = await response.text();
if (value.length > limit) throw new Error('Browser-Fetch: Antwort zu groß');
return value;
}
var data = new Uint8Array(await response.arrayBuffer());
assertResponseSize('content-length: ' + (declared || ''), data.length, limit, 'Browser-Fetch');
if (!data.length) throw new Error('Browser-Fetch: leeres Segment');
return data;
}).finally(function () {
if (timer) clearTimeout(timer);
cleanupAbort();
});
}
function hlsFetchGM(url, kind, signal) {
ensureNotCancelled(signal);
return new Promise(function (resolve, reject) {
var handle = null, settled = false;
var cleanupAbort = function () {};
function settle(err, data) {
if (settled) return;
settled = true;
cleanupAbort();
if (err) reject(err); else resolve(data);
}
function abortRequest() {
try { if (handle && typeof handle.abort === 'function') handle.abort(); } catch (_) {}
settle(downloadAbortError());
}
cleanupAbort = onDownloadAbort(signal, abortRequest);
if (signal && signal.aborted) { abortRequest(); return; }
var opts = { method: 'GET', url: url, timeout: CONFIG.requestTimeoutMs,
onload: function (r) {
if (signal && signal.aborted) { settle(downloadAbortError()); return; }
try {
assertFullHttpResponse(r.status, r.responseHeaders, 'GM');
var limit = kind === 'text' ? CONFIG.maxHlsManifestBytes : CONFIG.maxHlsSegmentBytes;
if (kind === 'text') {
var text = r.responseText || r.response;
if (typeof text !== 'string' || !text.length) throw new Error('GM: leere Playlist');
assertResponseSize(r.responseHeaders, text.length, limit, 'GM');
settle(null, text);
} else if (r.response && r.response.byteLength) {
var bytes = new Uint8Array(r.response);
assertResponseSize(r.responseHeaders, bytes.length, limit, 'GM');
settle(null, bytes);
} else throw new Error('GM: leeres Segment');
} catch (e) { settle(e); }
},
onerror: function (r) {
if (signal && signal.aborted) { settle(downloadAbortError()); return; }
var detail = String((r && (r.error || r.statusText)) || '');
var category = /not.permitted|not.whitelisted|refused to connect|@connect/i.test(detail) ? 'Zugriff nicht erlaubt' :
/blacklist/i.test(detail) ? 'Domain gesperrt' : 'Netzwerkfehler';
settle(new Error('GM: ' + category));
},
ontimeout: function () { settle(new Error('GM: Timeout')); },
onabort: function () { settle(signal && signal.aborted ? downloadAbortError() : new Error('GM: abgebrochen')); }
};
if (kind !== 'text') opts.responseType = 'arraybuffer';
try {
handle = GM_xmlhttpRequest(opts);
if (signal && signal.aborted) abortRequest();
} catch (e) { settle(new Error('GM: Aufruf gescheitert')); }
});
}
async function hlsFetch(url, kind, signal) {
ensureNotCancelled(signal);
if (!hlsUrl(url) && !hlsMediaPartUrl(url)) throw new Error('HLS: unzulässige Medienadresse');
var host = new URL(url).hostname;
var first = hlsPreferBrowserFetch ? hlsFetchBrowser : hlsFetchGM;
var second = hlsPreferBrowserFetch ? hlsFetchGM : hlsFetchBrowser;
try { return await first(url, kind, signal); }
catch (firstError) {
if (isCancellation(firstError, signal)) throw downloadAbortError();
try {
var value = await second(url, kind, signal);
ensureNotCancelled(signal);
if (!hlsPreferBrowserFetch) hlsPreferBrowserFetch = true;
return value;
} catch (secondError) {
if (isCancellation(secondError, signal)) throw downloadAbortError();
throw new Error('HLS-Zugriff auf ' + host + ' fehlgeschlagen: ' +
firstError.message + '; zweiter Abruf: ' + secondError.message);
}
}
}
function hlsMediaPartUrl(url) {
try {
var u = new URL(url);
return u.protocol === 'https:' && allowedVideoHost(u.hostname) &&
/\.(?:m4s|mp4|cmfv|cmfa|ts|m4a)$/i.test(u.pathname);
} catch (e) { return false; }
}
async function hlsResolve(url, signal) {
var master = hlsPlaylist(await hlsFetch(url, 'text', signal), url);
var selected = null, audioUrl = null;
if (master.variants.length) {
var isAudioOnly = function (v) {
return (/mp4a|aac/i.test(v.attr.CODECS || '') && !/avc1|hvc1|hev1/i.test(v.attr.CODECS || '')) ||
/\/pl\/mp4a\//.test(v.url);
};
var videoVariants = master.variants.filter(function (v) { return !isAudioOnly(v); });
var audioVariants = master.variants.filter(isAudioOnly);
videoVariants.sort(function (a, b) {
return Number(b.attr['AVERAGE-BANDWIDTH'] || b.attr.BANDWIDTH || 0) -
Number(a.attr['AVERAGE-BANDWIDTH'] || a.attr.BANDWIDTH || 0);
});
audioVariants.sort(function (a, b) { return Number(b.attr.BANDWIDTH || 0) - Number(a.attr.BANDWIDTH || 0); });
selected = videoVariants[0];
if (!selected) throw new Error('HLS: keine Video-Variante');
var group = selected.attr.AUDIO;
var candidateAudio = master.audio.filter(function (v) { return !group || v.attr['GROUP-ID'] === group; });
candidateAudio.sort(function (a, b) { return Number(b.attr.DEFAULT === 'YES') - Number(a.attr.DEFAULT === 'YES'); });
audioUrl = candidateAudio.length ? candidateAudio[0].url :
(audioVariants.length ? audioVariants[0].url : null);
}
var videoUrl = selected ? selected.url : url;
var video = selected ? hlsPlaylist(await hlsFetch(videoUrl, 'text', signal), videoUrl) : master;
// Broadcast-Replays werden haeufig als MPEG-TS statt fMP4 ausgeliefert.
var transportStream = !video.initUrl && video.segments.length && video.segments.every(function (seg) {
return /\.ts$/i.test(new URL(seg.url).pathname);
});
if ((!video.initUrl && !transportStream) || !video.segments.length)
throw new Error('HLS: weder fMP4- noch TS-Videosegmente');
if (video.discontinuities)
throw new Error('HLS: Zeitachsenwechsel nicht unterstützt');