Repository navigation
Expand file tree
/
Copy pathYouTube.js_20260914.html
More file actions
979 lines (916 loc) · 198 KB
/
Copy pathYouTube.js_20260914.html
File metadata and controls
979 lines (916 loc) · 198 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Code Audit — Interactive</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{color-scheme:dark}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e5e7eb;font-size:14px;line-height:1.5}
.app{max-width:1100px;margin:0 auto;padding:1.5rem 1rem}
.top{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
h1{font-size:1.5rem;font-weight:700;color:#f1f5f9}
.meta{font-size:.8rem;color:#6b7280;margin-top:.2rem}
.top-right{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}
.project-info{margin-bottom:1rem;padding:.75rem .9rem;background:#1e293b;border:1px solid #334155;border-radius:8px}
.project-info .pi-name{font-size:.95rem;font-weight:700;color:#f1f5f9}
.project-info .pi-desc{font-size:.82rem;color:#d1d5db;margin-top:.25rem}
.project-info .pi-row{display:flex;flex-wrap:wrap;gap:.4rem .9rem;margin-top:.45rem;font-size:.78rem;color:#9ca3af}
.project-info .pi-row .pi-k{color:#6b7280}
.project-info a{color:#60a5fa;text-decoration:none}
.project-info a:hover{text-decoration:underline}
.project-info code{font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-size:.74rem;color:#93c5fd}
.pi-repo-btn{display:inline-flex;align-items:center;gap:.3rem;padding:.18rem .5rem;background:#0f172a;border:1px solid #3b82f6;border-radius:6px;color:#60a5fa !important;font-weight:600}
.pi-repo-btn:hover{background:#1d283a;text-decoration:none !important}
#search{background:#1e293b;border:1px solid #334155;color:#e5e7eb;padding:.4rem .75rem;border-radius:6px;font-size:.875rem;width:220px;outline:none}
#search:focus{border-color:#3b82f6}
.group-label{font-size:.8rem;color:#9ca3af;display:flex;align-items:center;gap:.4rem}
select{background:#1e293b;border:1px solid #334155;color:#e5e7eb;padding:.35rem .6rem;border-radius:6px;font-size:.8rem;outline:none;cursor:pointer}
select:focus{border-color:#3b82f6}
.filterbar{display:flex;flex-direction:column;gap:.4rem;margin-bottom:.6rem;padding:.6rem .75rem;background:#1e293b;border:1px solid #334155;border-radius:8px}
.filter-row{display:flex;flex-wrap:wrap;gap:.3rem;align-items:center}
.filter-lbl{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#4b5563;white-space:nowrap;margin-right:.25rem}
.filter-toggle{font-size:.65rem;font-weight:600;padding:.18rem .5rem;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#9ca3af;cursor:pointer;margin-left:auto;white-space:nowrap;letter-spacing:.04em}
.filter-toggle:hover{background:#1e293b;color:#e5e7eb;border-color:#4b5563}
.badge{font-size:.68rem;font-weight:700;padding:.18rem .5rem;border-radius:4px;border:1px solid transparent;cursor:pointer;letter-spacing:.03em;transition:opacity .12s,filter .12s;user-select:none}
.badge.off{opacity:.28;filter:grayscale(.5)}
.sev-critical{background:#450a0a;color:#fca5a5;border-color:#991b1b}
.sev-high {background:#431407;color:#fdba74;border-color:#9a3412}
.sev-medium {background:#422006;color:#fcd34d;border-color:#854d0e}
.sev-low {background:#0c1a3a;color:#93c5fd;border-color:#1d4ed8}
.sev-negligible{background:#1e293b;color:#6b7280;border-color:#334155}
.type-badge{background:#0c1e33;color:#7dd3fc;border-color:#0369a1}
.diff-very-easy{background:#052e16;color:#86efac;border-color:#16a34a}
.diff-easy{background:#064e3b;color:#6ee7b7;border-color:#059669}
.diff-medium{background:#1e293b;color:#9ca3af;border-color:#4b5563}
.diff-hard{background:#2e1065;color:#c4b5fd;border-color:#7c3aed}
.st-open{background:#1e293b;color:#4b5563;border-color:#334155}
.st-fixed{background:#052e16;color:#4ade80;border-color:#16a34a}
.st-wont{background:#431407;color:#fdba74;border-color:#9a3412}
.st-fp{background:#2e1065;color:#c4b5fd;border-color:#7c3aed}
#progress-wrap{display:flex;align-items:center;gap:.75rem;margin-bottom:.6rem;flex-wrap:wrap}
#progress-bar{flex:1;min-width:120px;height:7px;background:#1e293b;border-radius:4px;overflow:hidden;border:1px solid #334155}
#progress-fill{height:100%;background:#22c55e;border-radius:4px;transition:width .35s ease;width:0}
#progress-text{font-size:.75rem;color:#6b7280;white-space:nowrap}
.action-btn{font-size:.72rem;padding:.25rem .6rem;border-radius:5px;border:1px solid #334155;background:#1e293b;color:#9ca3af;cursor:pointer;white-space:nowrap}
.action-btn:hover{background:#334155;color:#e5e7eb}
.action-btn.active{background:#1d4ed8;color:#fff;border-color:#2563eb}
.action-btn.active:hover{background:#2563eb}
.search-wrap{display:flex;align-items:center;gap:.35rem}
#hidden-bar{margin-bottom:.6rem}
.hidden-inner{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
.hidden-lbl{font-size:.72rem;color:#4b5563}
.show-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#1e293b;color:#9ca3af;border:1px solid #334155;cursor:pointer}
.show-btn:hover{background:#334155;color:#e5e7eb}
.show-all-btn{font-size:.7rem;padding:.15rem .45rem;border-radius:4px;background:#2563eb;color:#fff;border:none;cursor:pointer}
.show-all-btn:hover{background:#1d4ed8}
.group-section{margin-bottom:1.5rem}
.group-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;padding-bottom:.35rem;border-bottom:1px solid #334155}
.group-title{font-size:.95rem;font-weight:700;color:#d1d5db}
.group-title.all-done{color:#4ade80}
.group-count{font-size:.72rem;color:#4b5563;background:#1e293b;padding:.1rem .4rem;border-radius:10px;white-space:nowrap}
.icon-btn{background:transparent;border:none;color:#334155;cursor:pointer;font-size:.85rem;padding:.05rem .2rem;line-height:1}
.icon-btn:hover{color:#6b7280}
.hide-btn{margin-left:auto;font-size:.68rem;padding:.12rem .45rem;border-radius:4px;background:transparent;color:#334155;border:1px solid #1e293b;cursor:pointer}
.hide-btn:hover{background:#1e293b;color:#6b7280}
.card{background:#1e293b;border:1px solid #334155;border-radius:8px;margin-bottom:.6rem;overflow:hidden;transition:opacity .15s}
.card.resolved{opacity:.55;border-left:3px solid #16a34a}
.card.resolved:hover{opacity:.85}
.card-header{display:flex;align-items:center;gap:.5rem;padding:.45rem .85rem;background:#162032;border-bottom:1px solid #334155;flex-wrap:wrap;cursor:pointer;user-select:none}
.card-header:hover{background:#1a2840}
.card-arrow{font-size:.65rem;color:#334155;flex-shrink:0}
.card-id{font-size:.75rem;font-weight:700;color:#6b7280;font-family:monospace;letter-spacing:.04em}
.card-loc{font-size:.72rem;color:#6b7280;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
.status-btn{margin-left:auto;font-size:.68rem;font-weight:600;padding:.15rem .5rem;border-radius:4px;border:1px solid transparent;cursor:pointer;white-space:nowrap;transition:filter .1s}
.status-btn:hover{filter:brightness(1.25)}
.copy-btn{font-size:.68rem;font-weight:600;padding:.15rem .5rem;border-radius:4px;border:1px solid #334155;background:#0f172a;color:#9ca3af;cursor:pointer;white-space:nowrap;transition:background .12s,color .12s}
.copy-btn:hover{background:#334155;color:#e5e7eb}
.card-body{padding:.85rem .9rem}
.field-lbl{font-size:.85rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#7dd3fc;margin:1rem 0 .35rem;padding-bottom:.18rem;border-bottom:1px solid #1e293b}
.field-lbl:first-child{margin-top:0}
.desc,.fix-txt{font-size:.875rem;color:#d1d5db;line-height:1.55}
.desc p,.fix-txt p{margin:.4rem 0}
.desc p:first-child,.fix-txt p:first-child{margin-top:0}
.desc p:last-child,.fix-txt p:last-child{margin-bottom:0}
.loc-block{margin:.3rem 0 .6rem}
.loc-ref{font-size:.8rem;color:#38bdf8;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-weight:600;margin-bottom:.22rem}
a.loc-link{color:inherit;text-decoration:none}
a.loc-link:hover{text-decoration:underline}
.loc-lines{color:#4b5563;font-weight:400}
pre{background:#0f172a;color:#cdd6f4;border-radius:5px;padding:.6rem .8rem;overflow-x:auto;white-space:pre;font-size:.8rem;line-height:1.55;font-family:"JetBrains Mono","Fira Code",Consolas,monospace;border:1px solid #1e293b;max-height:320px}
.desc pre.md-code,.fix-txt pre.md-code{margin:.5rem 0;font-size:.82rem;color:#e5e7eb;background:#0b1224;border:1px solid #1e293b;max-height:none}
code.md-inline{font-family:"JetBrains Mono","Fira Code",Consolas,monospace;font-size:.86em;background:#0b1224;color:#fca5a5;border:1px solid #1e293b;border-radius:3px;padding:.04rem .32rem;white-space:pre-wrap;word-break:break-word}
.not-found{font-size:.8rem;color:#334155;font-style:italic}
mark{background:#854d0e;color:#fef9c3;border-radius:2px;padding:0 .1rem}
.no-results{text-align:center;color:#334155;padding:3rem 1rem;font-size:.9rem}
</style>
</head>
<body>
<script type="application/json" id="D">[
{"id":"PANIC_1","type":"panic","type_label":"Panic","severity":"high","difficulty":"easy","description":"applyCommentsMutations does not guard the parsing loop when mutations are missing. When comment_view_items.length > 0 and mutations is falsy, it logs a 'mutation_data_missing' error but then falls through into the `for (const comment_view ...)` loop which calls `mutations.find(...)`. Since mutations is undefined here, this throws 'Cannot read properties of undefined (reading find)', crashing the entire parseResponse call. Note applyMutations (the sibling function above) correctly uses an if/else to avoid exactly this, so this is an inconsistency/copy-paste oversight.","fix":"After logging the mutation_data_missing error, `return;` (or wrap the loop in an `else` like applyMutations does) so the function does not proceed to call `mutations.find` when mutations is undefined.","locations":[{"ref":"src/parser/parser.ts:856-885","code":" 856 | export function applyCommentsMutations(memo: Memo, mutations: RawNode[]) {\n 857 | const comment_view_items = memo.getType(CommentView);\n 858 | \n 859 | if (comment_view_items.length > 0) {\n 860 | if (!mutations) {\n 861 | ERROR_HANDLER({\n 862 | error_type: 'mutation_data_missing',\n 863 | classname: 'CommentView'\n 864 | });\n 865 | }\n 866 | \n 867 | for (const comment_view of comment_view_items) {\n 868 | const comment_mutation = mutations\n 869 | .find((mutation) => mutation.payload?.commentEntityPayload?.key === comment_view.keys.comment)\n 870 | ?.payload?.commentEntityPayload;\n 871 | \n 872 | const toolbar_state_mutation = mutations\n 873 | .find((mutation) => mutation.payload?.engagementToolbarStateEntityPayload?.key === comment_view.keys.toolbar_state)\n 874 | ?.payload?.engagementToolbarStateEntityPayload;\n 875 | \n 876 | const engagement_toolbar = mutations.find((mutation) => mutation.entityKey === comment_view.keys.toolbar_surface)\n 877 | ?.payload?.engagementToolbarSurfaceEntityPayload;\n 878 | \n 879 | const comment_surface_mutation = mutations\n 880 | .find((mutation) => mutation.payload?.commentSurfaceEntityPayload?.key === comment_view.keys.comment_surface)\n 881 | ?.payload?.commentSurfaceEntityPayload;\n 882 | \n 883 | comment_view.applyMutations(comment_mutation, toolbar_state_mutation, engagement_toolbar, comment_surface_mutation);\n 884 | }\n 885 | }","lines":30,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/parser.ts#L856-L885"}]},
{"id":"PANIC_2","type":"panic","type_label":"Panic","severity":"high","difficulty":"very_easy","description":"`tab` obtained from `getType(Tab)[0]` is used as `tab.content` without checking that `tab` exists. If no Tab node is present in contents_memo, `[0]` is undefined and `tab.content` throws. The subsequent InnertubeError('Content tab has no content', tab) is never reached for the missing-tab case.","fix":"Guard tab first: `if (!tab) throw new InnertubeError('Content tab not found', this.page);` before accessing `tab.content`.","locations":[{"ref":"src/parser/youtube/HashtagFeed.ts:22-24","code":" 22 | const tab = this.page.contents_memo.getType(Tab)[0];\n 23 | \n 24 | if (!tab.content)","lines":3,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/HashtagFeed.ts#L22-L24"}]},
{"id":"PANIC_3","type":"panic","type_label":"Panic","severity":"high","difficulty":"medium","description":"When removing a video that is a LockupView, deeply chained property accesses are performed with no null guards: `content.metadata?.menu_button?.on_tap?.payload.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems` (each `.payload.panelLoadingStrategy...` after the last `?.` is unchecked), then `listItems.find(...)` whose result `listItem` is dereferenced as `listItem.listItemViewModel.rendererContext...` without verifying the find succeeded. Any structural change from YouTube, or a LockupView lacking these fields, throws a raw TypeError instead of a meaningful error.","fix":"Use optional chaining throughout and check that `listItems` and the found `listItem` are defined before dereferencing; throw an InnertubeError if the remove token cannot be extracted.","locations":[{"ref":"src/parser/youtube/History.ts:48-53","code":" 48 | } else if (content.is(LockupView)) {\n 49 | if (content.content_id === video_id) {\n 50 | const listItems = content.metadata?.menu_button?.on_tap?.payload.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems;\n 51 | const listItem = listItems.find((video: { listItemViewModel: { title: { content: string; }; }; }) => video.listItemViewModel?.title.content === 'Remove from watch history');\n 52 | feedbackToken = listItem.listItemViewModel.rendererContext.commandContext.onTap.innertubeCommand.feedbackEndpoint.feedbackToken;\n 53 | break;","lines":6,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/History.ts#L48-L53"}]},
{"id":"PANIC_4","type":"panic","type_label":"Panic","severity":"high","difficulty":"very_easy","description":"`this.page.contents_memo?.getType(SectionList)[0].contents` accesses `.contents` on `[0]` without a guard. getType always returns an array (never undefined), so the optional chain `?.` only guards contents_memo; if the SectionList array is empty, `[0]` is undefined and `.contents` throws 'Cannot read properties of undefined'. The fallback chain with `||` is never reached because the throw happens first.","fix":"Use optional access on the index: `this.page.contents_memo?.getType(SectionList)[0]?.contents || ...` (the second branch already does this).","locations":[{"ref":"src/parser/youtube/Search.ts:29-32","code":" 29 | const contents =\n 30 | this.page.contents_memo?.getType(SectionList)[0].contents ||\n 31 | this.page.on_response_received_commands_memo?.getType(SectionList)[0]?.contents ||\n 32 | this.page.on_response_received_commands?.[0].as(AppendContinuationItemsAction, ReloadContinuationItemsCommand).contents;","lines":4,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/Search.ts#L29-L32"}]},
{"id":"PANIC_5","type":"panic","type_label":"Panic","severity":"high","difficulty":"very_easy","description":"`this.#page.contents_memo.getType(MusicShelf)?.[0].contents` dereferences `.contents` on the array element. getType returns an array (never undefined), so `?.` does not guard the index; if there is no MusicShelf, `[0]` is undefined and `.contents` throws before the `|| observe([])` fallback can apply.","fix":"Add optional chaining on the index: `getType(MusicShelf)?.[0]?.contents || observe([])`.","locations":[{"ref":"src/parser/ytmusic/Album.ts:31","code":" 31 | this.contents = this.#page.contents_memo.getType(MusicShelf)?.[0].contents || observe([]);","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/ytmusic/Album.ts#L31"}]},
{"id":"PANIC_6","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"getSearchSuggestions parses the suggestion response with JSON.parse on a string built by stripping a JSONP wrapper, then immediately does `data[1].map(...)`. There is no try/catch and no validation that `data` is an array of the expected shape. A malformed/unexpected response (empty body, layout change, error page) throws a raw SyntaxError or TypeError out of the public API method.","fix":"Wrap parse + extraction in try/catch (or validate Array.isArray(data) && Array.isArray(data[1])) and throw an InnertubeError with context, or return an empty array.","locations":[{"ref":"src/Innertube.ts:300-303","code":" 300 | const text = await response.text();\n 301 | \n 302 | const data = JSON.parse(text.replace('window.google.ac.h(', '').slice(0, -1));\n 303 | return data[1].map((suggestion: (string | number)[]) => suggestion[0]);","lines":4,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/Innertube.ts#L300-L303"}]},
{"id":"PANIC_7","type":"panic","type_label":"Panic","severity":"medium","difficulty":"medium","description":"#getSessionData parses YouTube's /sw.js_data JSPB blob and indexes deeply into untrusted arrays without validation: `data[0][2]`, destructuring `[ [ device_info ], api_key ]`, `device_info[61]`, then `config_info[config_info.length - 1]`. If YouTube changes the layout or returns an unexpected payload, `config_info.length` (when device_info[61] is undefined) or the destructuring throws a raw TypeError, defeating the fail_fast/local-fallback design (the throw is caught by the caller only when !fail_fast, but produces an opaque error).","fix":"Validate the structure after JSON.parse (check array shapes and that device_info[61] exists) and throw a SessionError with context, so the local-fallback path is reliably taken.","locations":[{"ref":"src/core/Session.ts:564-597","code":" 564 | const data = JSON.parse(text.replace(/^\\)\\]\\}'/, ''));\n 565 | \n 566 | const ytcfg = data[0][2];\n 567 | \n 568 | const api_version = Constants.CLIENTS.WEB.API_VERSION;\n 569 | \n 570 | const [ [ device_info ], api_key ] = ytcfg;\n 571 | \n 572 | const config_info = device_info[61];\n 573 | const app_install_data = config_info[config_info.length - 1];\n 574 | \n 575 | const context_info = {\n 576 | hl: options.lang || device_info[0],\n 577 | gl: options.location || device_info[1],\n 578 | remote_host: device_info[3],\n 579 | visitor_data: options.visitor_data || device_info[13],\n 580 | user_agent: options.user_agent,\n 581 | client_name: options.client_name,\n 582 | client_version: options.client_name === 'WEB' ? device_info[16] : Object.values(Constants.CLIENTS).find(\n 583 | (c) => c.NAME === options.client_name\n 584 | )?.VERSION || device_info[16],\n 585 | os_name: device_info[17],\n 586 | os_version: device_info[18],\n 587 | time_zone: device_info[79] || options.time_zone,\n 588 | device_category: options.device_category,\n 589 | browser_name: device_info[86],\n 590 | browser_version: device_info[87],\n 591 | device_make: device_info[11],\n 592 | device_model: device_info[12],\n 593 | app_install_data: app_install_data,\n 594 | device_experiment_id: device_info[103],\n 595 | rollout_token: device_info[107],\n 596 | enable_safety_mode: options.enable_safety_mode\n 597 | };","lines":34,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/Session.ts#L564-L597"}]},
{"id":"PANIC_8","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"blockChannel dereferences `response.data.command.confirmDialogEndpoint` and then `popup.content` with no checks. If the response has no `command` or no `confirmDialogEndpoint` (e.g. API change or error response), this throws a raw TypeError rather than a descriptive InnertubeError.","fix":"Validate `response.data.command?.confirmDialogEndpoint?.content` and throw an InnertubeError with the response attached if missing.","locations":[{"ref":"src/core/clients/Kids.ts:94-95","code":" 94 | const popup = response.data.command.confirmDialogEndpoint;\n 95 | const popup_fragment = { contents: popup.content, engagementPanels: [] };","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/clients/Kids.ts#L94-L95"}]},
{"id":"PANIC_9","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"translate() blindly dereferences a deep chain on the raw response: `response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload` and then `mutation.translatedContent.content`. Any missing intermediate (no frameworkUpdates, empty mutations array, missing translatedContent) throws a raw TypeError instead of a meaningful error.","fix":"Guard each level (optional chaining + explicit checks) and throw an InnertubeError('Unexpected translate response', response) when the expected fields are absent.","locations":[{"ref":"src/core/managers/InteractionManager.ts:157-165","code":" 157 | const response = await perform_comment_action_endpoint.call(this.#actions);\n 158 | const mutation = response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload;\n 159 | \n 160 | return {\n 161 | success: response.success,\n 162 | status_code: response.status_code,\n 163 | translated_content: mutation.translatedContent.content,\n 164 | data: response.data\n 165 | };","lines":9,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/managers/InteractionManager.ts#L157-L165"}]},
{"id":"PANIC_10","type":"panic","type_label":"Panic","severity":"medium","difficulty":"very_easy","description":"page_contents accesses `this.#memo.getType(Tab)?.[0].content`. Memo.getType always returns an array (never null/undefined), so the optional chain is misleading and provides no protection: when no Tab exists the array is empty and `[0]` is undefined, so `.content` throws `Cannot read properties of undefined`. Pages without a Tab will crash instead of falling through to the reload/append continuation branches.","fix":"Use `this.#memo.getType(Tab)[0]?.content` so an empty result yields undefined and the `||` chain falls through to reload_continuation_items / append_continuation_items.","locations":[{"ref":"src/core/mixins/Feed.ts:147-152","code":" 147 | get page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand {\n 148 | const tab_content = this.#memo.getType(Tab)?.[0].content;\n 149 | const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand)[0];\n 150 | const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction)[0];\n 151 | return tab_content || reload_continuation_items || append_continuation_items;\n 152 | }","lines":6,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/mixins/Feed.ts#L147-L152"}]},
{"id":"PANIC_11","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"Same unguarded `image.sources[0].clientResource.imageName` pattern as ThumbnailBadgeView, in multiple view-model parsers. An empty/missing `sources` array or missing `clientResource` throws during parse.","fix":"Use optional chaining on `.sources?.[0]?.clientResource?.imageName` (and `imageColor`).","locations":[{"ref":"src/parser/classes/ButtonCardView.ts:15","code":" 15 | this.icon_name = data.image.sources[0].clientResource.imageName;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/ButtonCardView.ts#L15"},{"ref":"src/parser/classes/ThumbnailHoverOverlayView.ts:15","code":" 15 | this.icon_name = data.icon.sources[0].clientResource.imageName;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/ThumbnailHoverOverlayView.ts#L15"},{"ref":"src/parser/classes/livechat/items/BumperUserEduContentView.ts:15-16","code":" 15 | this.image_name = data.image.sources[0].clientResource.imageName;\n 16 | this.image_color = data.image.sources[0].clientResource.imageColor;","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/livechat/items/BumperUserEduContentView.ts#L15-L16"},{"ref":"src/parser/classes/livechat/items/CreatorHeartView.ts:26-27","code":" 26 | this.hearted_icon_name = data.heartedIcon.sources[0].clientResource.imageName;\n 27 | this.unhearted_icon_name = data.unheartedIcon.sources[0].clientResource.imageName;","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/livechat/items/CreatorHeartView.ts#L26-L27"}]},
{"id":"PANIC_12","type":"panic","type_label":"Panic","severity":"medium","difficulty":"medium","description":"`data.rendererContext.commandContext...` (line 41) and `new RendererContext(data.rendererContext)` (line 56) dereference `data.rendererContext` without a null check; absent rendererContext throws. Additionally line 47 chains `endpoint.engagementPanelPresentationConfigs.engagementPanelPopupPresentationConfig.popupType` and line 49 `endpoint.identifier.surface` without guards.","fix":"Guard `data.rendererContext` and the nested config/identifier objects with optional chaining before access.","locations":[{"ref":"src/parser/classes/DescriptionPreviewView.ts:41-49","code":" 41 | if (data.rendererContext.commandContext?.onTap?.innertubeCommand?.showEngagementPanelEndpoint) {\n 42 | const endpoint = data.rendererContext.commandContext?.onTap?.innertubeCommand?.showEngagementPanelEndpoint;\n 43 | \n 44 | this.more_endpoint = {\n 45 | show_engagement_panel_endpoint: {\n 46 | engagement_panel: Parser.parseItem(endpoint.engagementPanel, EngagementPanelSectionList),\n 47 | engagement_panel_popup_type: endpoint.engagementPanelPresentationConfigs.engagementPanelPopupPresentationConfig.popupType,\n 48 | identifier: {\n 49 | surface: endpoint.identifier.surface,","lines":9,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/DescriptionPreviewView.ts#L41-L49"},{"ref":"src/parser/classes/DescriptionPreviewView.ts:56","code":" 56 | this.renderer_context = new RendererContext(data.rendererContext);","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/DescriptionPreviewView.ts#L56"}]},
{"id":"PANIC_13","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"Several runs use `run.endpoint.payload.browseId.startsWith(...)` where `payload.browseId` may be undefined, causing `.startsWith` to throw. These are guarded by `isTextRun(run) && run.endpoint` but not by the presence of `browseId`.","fix":"Guard browseId before calling startsWith, e.g. `run.endpoint.payload.browseId?.startsWith('UC')`.","locations":[{"ref":"src/parser/classes/MusicResponsiveListItem.ts:208","code":" 208 | run.endpoint.payload.browseId.startsWith('MPR')","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/MusicResponsiveListItem.ts#L208"},{"ref":"src/parser/classes/MusicResponsiveListItem.ts:208","code":" 208 | run.endpoint.payload.browseId.startsWith('MPR')","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/MusicResponsiveListItem.ts#L208"},{"ref":"src/parser/classes/MusicResponsiveListItem.ts:225","code":" 225 | (run) => (isTextRun(run) && run.endpoint) && run.endpoint.payload.browseId.startsWith('UC')","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/MusicResponsiveListItem.ts#L225"},{"ref":"src/parser/classes/MusicResponsiveListItem.ts:245","code":" 245 | run.endpoint.payload.browseId.startsWith('UC')","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/MusicResponsiveListItem.ts#L245"},{"ref":"src/parser/classes/MusicResponsiveListItem.ts:298","code":" 298 | run.endpoint.payload.browseId.startsWith('UC')","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/MusicResponsiveListItem.ts#L298"},{"ref":"src/parser/classes/MusicResponsiveListItem.ts:325","code":" 325 | (isTextRun(run) && run.endpoint) &&","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/MusicResponsiveListItem.ts#L325"}]},
{"id":"PANIC_14","type":"panic","type_label":"Panic","severity":"medium","difficulty":"very_easy","description":"Inconsistent optional-chaining on `browseId.startsWith`. Lines 89 and 112 use `run.endpoint?.payload?.browseId.startsWith('UC')` (no `?.` before `.startsWith`), so when a run has an endpoint+payload but no `browseId`, `.startsWith` throws. Line 103 in the same file correctly uses `?.browseId?.startsWith`.","fix":"Add the missing optional chain: `run.endpoint?.payload?.browseId?.startsWith('UC')`.","locations":[{"ref":"src/parser/classes/MusicTwoRowItem.ts:89","code":" 89 | const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/MusicTwoRowItem.ts#L89"},{"ref":"src/parser/classes/MusicTwoRowItem.ts:112","code":" 112 | const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/MusicTwoRowItem.ts#L112"}]},
{"id":"PANIC_15","type":"panic","type_label":"Panic","severity":"medium","difficulty":"very_easy","description":"`this.subtitle.runs?.slice(-1)[0].text` will throw when `runs` is an empty array: `slice(-1)` returns `[]`, `[0]` is undefined, and `.text` throws. The optional chain only guards `runs` being nullish, not an empty array.","fix":"Use optional chaining on the element: `this.subtitle.runs?.slice(-1)[0]?.text`.","locations":[{"ref":"src/parser/classes/MusicTwoRowItem.ts:97","code":" 97 | this.year = this.subtitle.runs?.slice(-1)[0].text;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/MusicTwoRowItem.ts#L97"}]},
{"id":"PANIC_16","type":"panic","type_label":"Panic","severity":"medium","difficulty":"very_easy","description":"Same empty-array hazard as MusicTwoRowItem: `new Text(data.longBylineText).runs?.slice(-1)[0].text` throws when `runs` is an empty array (`[0]` is undefined, `.text` throws).","fix":"Add optional chaining: `...runs?.slice(-1)[0]?.text`.","locations":[{"ref":"src/parser/classes/PlaylistPanelVideo.ts:65","code":" 65 | year: new Text(data.longBylineText).runs?.slice(-1)[0].text,","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/PlaylistPanelVideo.ts#L65"}]},
{"id":"PANIC_17","type":"panic","type_label":"Panic","severity":"medium","difficulty":"very_easy","description":"`data.title.accessibility.accessibilityData.label` is dereferenced unconditionally even though `accessibility_label` is declared optional. If `data.title` has no `accessibility` object (common), this throws during construction.","fix":"Use optional chaining: `data.title?.accessibility?.accessibilityData?.label`.","locations":[{"ref":"src/parser/classes/PlaylistVideo.ts:46","code":" 46 | this.accessibility_label = data.title.accessibility.accessibilityData.label;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/PlaylistVideo.ts#L46"}]},
{"id":"PANIC_18","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"`data.likeCountEntity.key` and `data.dynamicLikeCountUpdateData.*` are dereferenced without null checks; these required-but-untyped objects, if absent in a given payload variant, throw during parse.","fix":"Guard with optional chaining and only build the objects when the source data is present.","locations":[{"ref":"src/parser/classes/SegmentedLikeDislikeButtonView.ts:46-55","code":" 46 | this.like_count_entity = {\n 47 | key: data.likeCountEntity.key\n 48 | };\n 49 | \n 50 | this.dynamic_like_count_update_data = {\n 51 | update_status_key: data.dynamicLikeCountUpdateData.updateStatusKey,\n 52 | placeholder_like_count_values_key: data.dynamicLikeCountUpdateData.placeholderLikeCountValuesKey,\n 53 | update_delay_loop_id: data.dynamicLikeCountUpdateData.updateDelayLoopId,\n 54 | update_delay_sec: data.dynamicLikeCountUpdateData.updateDelaySec\n 55 | };","lines":10,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/SegmentedLikeDislikeButtonView.ts#L46-L55"}]},
{"id":"PANIC_19","type":"panic","type_label":"Panic","severity":"medium","difficulty":"very_easy","description":"`data.subscribeState.subscribed` dereferences `subscribeState` without a null check inside #parseButtonContent. If a button content object lacks `subscribeState`, parsing throws.","fix":"Use `data.subscribeState?.subscribed`.","locations":[{"ref":"src/parser/classes/SubscribeButtonView.ts:79","code":" 79 | subscribe_state_subscribed: data.subscribeState.subscribed,","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/SubscribeButtonView.ts#L79"}]},
{"id":"PANIC_20","type":"panic","type_label":"Panic","severity":"medium","difficulty":"very_easy","description":"`data.icon.sources[0].clientResource.imageName` dereferences `sources[0]` and `.clientResource` without any null check. If `data.icon.sources` is empty, or the first source lacks `clientResource`, this throws during parsing. (Recently changed file per task notes.)","fix":"Guard the chain: `this.icon_name = data.icon.sources?.[0]?.clientResource?.imageName;`.","locations":[{"ref":"src/parser/classes/ThumbnailBadgeView.ts:28-30","code":" 28 | if (data.icon) {\n 29 | this.icon_name = data.icon.sources[0].clientResource.imageName;\n 30 | }","lines":3,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/ThumbnailBadgeView.ts#L28-L30"}]},
{"id":"PANIC_21","type":"panic","type_label":"Panic","severity":"medium","difficulty":"very_easy","description":"`acc_label` can be undefined (all three fallback sources may be missing), but when `icon_type === 'LIKE'` the code calls `acc_label.replace(/\\D/g, '')` unconditionally, which throws on undefined.","fix":"Guard: `this.like_count = acc_label ? parseInt(acc_label.replace(/\\D/g, '')) : undefined;`.","locations":[{"ref":"src/parser/classes/ToggleButton.ts:33-40","code":" 33 | const acc_label =\n 34 | data?.defaultText?.accessibility?.accessibilityData?.label ||\n 35 | data?.accessibilityData?.accessibilityData?.label ||\n 36 | data?.accessibility?.label;\n 37 | \n 38 | if (this.icon_type == 'LIKE') {\n 39 | this.like_count = parseInt(acc_label.replace(/\\D/g, ''));\n 40 | this.short_like_count = new Text(data.defaultText).toString();","lines":8,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/ToggleButton.ts#L33-L40"}]},
{"id":"PANIC_22","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"`toHTML()` reads `this.emoji.image[0].url`, but `image` comes from `Thumbnail.fromResponse(data.emoji.image)` which returns `[]` when there are no sources. An emoji with no image then makes `image[0]` undefined and `.url` throws when rendering HTML.","fix":"Guard the access, e.g. `const src = this.emoji.image[0]?.url; if (!src) return escape(this.text);` before building the img tag.","locations":[{"ref":"src/parser/classes/misc/EmojiRun.ts:37-39","code":" 37 | toHTML(): string {\n 38 | const escaped_text = escape(this.text);\n 39 | return `<img src=\"${this.emoji.image[0].url}\" alt=\"${escaped_text}\" title=\"${escaped_text}\" style=\"display: inline-block; vertical-align: text-top; height: var(--yt-emoji-size, 1rem); width: var(--yt-emoji-size, 1rem);\" loading=\"lazy\" crossorigin=\"anonymous\" />`;","lines":3,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/misc/EmojiRun.ts#L37-L39"}]},
{"id":"PANIC_23","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"Several continuation constructors use `data.continuations?.[0].xxx` which only guards against a missing/nullish `continuations` array, not an empty one. If `continuations` is an empty array, `?.[0]` evaluates to undefined and the subsequent `.nextContinuationData` / `.timedContinuationData` etc. property access throws a TypeError. The mixed style is visible right next to safer `?.[0]?.` usages in sibling classes, confirming the unguarded ones are bugs.","fix":"Use fully optional chaining on every hop, e.g. `data.continuations?.[0]?.nextContinuationData?.continuation`, consistently as done in ItemSectionContinuation/SectionListContinuation/PlaylistPanelContinuation.","locations":[{"ref":"src/parser/continuations.ts:89","code":" 89 | this.continuation = data.continuations?.[0].nextContinuationData.continuation || null;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/continuations.ts#L89"},{"ref":"src/parser/continuations.ts:103-104","code":" 103 | data.continuations?.[0].nextContinuationData?.continuation ||\n 104 | data.continuations?.[0].reloadContinuationData?.continuation || null;","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/continuations.ts#L103-L104"},{"ref":"src/parser/continuations.ts:117","code":" 117 | this.continuation = data.continuations?.[0].nextContinuationData.continuation || null;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/continuations.ts#L117"},{"ref":"src/parser/continuations.ts:197-205","code":" 197 | if (data.continuations?.[0].timedContinuationData) {\n 198 | type = 'timed';\n 199 | continuation = data.continuations?.[0].timedContinuationData;\n 200 | } else if (data.continuations?.[0].invalidationContinuationData) {\n 201 | type = 'invalidation';\n 202 | continuation = data.continuations?.[0].invalidationContinuationData;\n 203 | } else if (data.continuations?.[0].liveChatReplayContinuationData) {\n 204 | type = 'replay';\n 205 | continuation = data.continuations?.[0].liveChatReplayContinuationData;","lines":9,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/continuations.ts#L197-L205"}]},
{"id":"PANIC_24","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"For the Video branch, `content.menu.top_level_buttons[0].as(Button).endpoint.payload.feedbackToken` indexes `top_level_buttons[0]` without checking the array is non-empty. A video whose menu has an empty top_level_buttons array throws a TypeError.","fix":"Guard the array: `const btn = content.menu.top_level_buttons[0]?.as(Button); if (btn?.endpoint) feedbackToken = btn.endpoint.payload.feedbackToken;`.","locations":[{"ref":"src/parser/youtube/History.ts:45","code":" 45 | feedbackToken = content.menu.top_level_buttons[0].as(Button).endpoint.payload.feedbackToken;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/History.ts#L45"}]},
{"id":"PANIC_25","type":"panic","type_label":"Panic","severity":"medium","difficulty":"medium","description":"In applySort, `options` may be undefined (when MusicSortFilterButton is absent: `button?.menu?.options` short-circuits to undefined, and `.filter` is then called on undefined and throws). Even if it does not throw there, when target_item is not found the error builder calls `options.map((item) => item.title)` which throws if options is undefined.","fix":"Default options to an empty array: `const options = (button?.menu?.options?.filter(...) ?? []) as MusicMultiSelectMenuItem[];`.","locations":[{"ref":"src/parser/ytmusic/Library.ts:48-58","code":" 48 | const button = this.#page.contents_memo?.getType(MusicSortFilterButton)[0];\n 49 | \n 50 | const options = button?.menu?.options\n 51 | .filter(\n 52 | (item: MusicMultiSelectMenuItem | MusicMenuItemDivider) => item instanceof MusicMultiSelectMenuItem\n 53 | ) as MusicMultiSelectMenuItem[];\n 54 | \n 55 | target_item = options?.find((item) => item.title === sort_by);\n 56 | \n 57 | if (!target_item)\n 58 | throw new InnertubeError(`Sort option \"${sort_by}\" not found`, { available_filters: options.map((item) => item.title) });","lines":11,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/ytmusic/Library.ts#L48-L58"}]},
{"id":"PANIC_26","type":"panic","type_label":"Panic","severity":"medium","difficulty":"medium","description":"In sort_options getter, `options` is computed as `button?.menu?.options.filter(...)`. The optional chain stops at `button?.menu?` but `.options.filter` is then called unconditionally; if `menu.options` is undefined this throws. More importantly `options` can be undefined (when button or menu is missing) and `options.map(...)` on the next line throws 'Cannot read properties of undefined'.","fix":"Use `button?.menu?.options?.filter(...)` and guard: `return (options ?? []).map((item) => item.title);`.","locations":[{"ref":"src/parser/ytmusic/Library.ts:137-139","code":" 137 | const button = this.#page.contents_memo?.getType(MusicSortFilterButton)[0];\n 138 | const options = button?.menu?.options.filter((item: MusicMultiSelectMenuItem | MusicMenuItemDivider) => item instanceof MusicMultiSelectMenuItem) as MusicMultiSelectMenuItem[];\n 139 | return options.map((item) => item.title);","lines":3,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/ytmusic/Library.ts#L137-L139"}]},
{"id":"PANIC_27","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"`this.header.panels[0].text_on_tap_endpoint` indexes panels[0] without verifying the array is non-empty. A HighlightsCarousel with no panels throws a TypeError instead of a meaningful error.","fix":"Guard: `const endpoint = this.header.panels[0]?.text_on_tap_endpoint; if (!endpoint) throw new InnertubeError('Recap playlist endpoint not found');`.","locations":[{"ref":"src/parser/ytmusic/Recap.ts:54","code":" 54 | const endpoint = this.header.panels[0].text_on_tap_endpoint;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/ytmusic/Recap.ts#L54"}]},
{"id":"PANIC_28","type":"panic","type_label":"Panic","severity":"medium","difficulty":"medium","description":"getTab falls back to `this.tabs?.[0]` (line 53) as the target tab when neither a title nor pageType match. This means getRelated/getLyrics, which pass a pageType string, will silently return the first tab's contents if the requested type is absent, rather than failing. Combined with the cast `as ObservedArray<MusicDescriptionShelf>` in getRelated/getLyrics, callers can receive wrongly-typed nodes. The `if (!target_tab)` guard at line 55 is effectively dead because the fallback almost always yields a tab.","fix":"Only fall back to tabs[0] for the title-lookup convenience case, not for pageType lookups; or remove the fallback so a missing pageType throws the InnertubeError as intended.","locations":[{"ref":"src/parser/ytmusic/TrackInfo.ts:50-56","code":" 50 | const target_tab =\n 51 | this.tabs.find((tab) => tab.title === title_or_page_type) ||\n 52 | this.tabs.find((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === title_or_page_type) ||\n 53 | this.tabs?.[0];\n 54 | \n 55 | if (!target_tab)\n 56 | throw new InnertubeError(`Tab \"${title_or_page_type}\" not found`, { available_tabs: this.available_tabs });","lines":7,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/ytmusic/TrackInfo.ts#L50-L56"}]},
{"id":"PANIC_29","type":"panic","type_label":"Panic","severity":"medium","difficulty":"very_easy","description":"`globalThis.crypto?.randomUUID()` only optional-chains the `crypto` access, not the method call.\nIf `crypto` exists but `randomUUID` is undefined (older RN/browser environments — the exact case this\nfallback is meant to handle), `crypto.randomUUID()` throws \"randomUUID is not a function\" and the\nmanual UUID fallback below is never reached. Additionally the check invokes `randomUUID()` purely to\ntest truthiness and then calls it a second time, wasting a call.","fix":"Test the method reference without calling it: `if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();`.","locations":[{"ref":"src/platform/react-native.ts:47-49","code":" 47 | if (globalThis.crypto?.randomUUID()) {\n 48 | return globalThis.crypto.randomUUID();\n 49 | }","lines":3,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/platform/react-native.ts#L47-L49"},{"ref":"src/platform/web.ts:100-102","code":" 100 | if (globalThis.crypto?.randomUUID()) {\n 101 | return globalThis.crypto.randomUUID();\n 102 | }","lines":3,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/platform/web.ts#L100-L102"}]},
{"id":"PANIC_30","type":"panic","type_label":"Panic","severity":"medium","difficulty":"easy","description":"`getColorInfo` does `new URL(format.url as string)` only to read the InnerTube client param for a\nwarning log. `format.url` is frequently undefined for ciphered/streaming-data formats (the URL lives in\n`signature_cipher`/`cipher`), so `new URL(undefined)` throws. This turns a benign \"unknown matrix\ncoefficients\" warning path into a hard exception that aborts the entire manifest/streaming-info build.","fix":"Guard with `format.url ? new URL(format.url) : null` (and skip the client param) before constructing the URL, or wrap in try/catch.","locations":[{"ref":"src/utils/StreamingInfo.ts:561-571","code":" 561 | if (!matrix_coefficients) {\n 562 | const url = new URL(format.url as string);\n 563 | \n 564 | const anonymisedFormat = JSON.parse(JSON.stringify(format));\n 565 | anonymisedFormat.url = 'REDACTED';\n 566 | anonymisedFormat.signature_cipher = 'REDACTED';\n 567 | anonymisedFormat.cipher = 'REDACTED';\n 568 | \n 569 | Log.warn(TAG_, `Unknown matrix coefficients \"${color_info.matrix_coefficients}\". The DASH manifest is still usable without this.\\n`\n 570 | + `Please report it at ${packageInfo.bugs.url} so we can add support for it.\\n`\n 571 | + `InnerTube client: ${url.searchParams.get('c')}\\nformat:`, anonymisedFormat);","lines":11,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/StreamingInfo.ts#L561-L571"}]},
{"id":"PANIC_31","type":"panic","type_label":"Panic","severity":"low","difficulty":"very_easy","description":"getUnseenNotificationsCount accesses `response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount`. The optional chaining stops at `actions?.[0]` but `[0]` itself is then dereferenced with a non-optional `.updateNotificationsUnseenCountAction`; if `actions` is an empty array, `actions[0]` is undefined and the property access throws. A FIXME comment already acknowledges this is not properly parsed.","fix":"Use `response.data?.actions?.[0]?.updateNotificationsUnseenCountAction?.unseenCount` so an empty actions array yields undefined and falls back to 0.","locations":[{"ref":"src/Innertube.ts:402-406","code":" 402 | async getUnseenNotificationsCount(): Promise<number> {\n 403 | const response = await this.actions.execute('/notification/get_unseen_count');\n 404 | // FIXME: properly parse this.\n 405 | return response.data?.unseenCount || response.data?.actions?.[0].updateNotificationsUnseenCountAction?.unseenCount || 0;\n 406 | }","lines":5,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/Innertube.ts#L402-L406"}]},
{"id":"PANIC_32","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"`data.thumbnailOverlays.find(...)` and (in CompactMovie/Movie) the subsequent `?.thumbnailOverlayTimeStatusRenderer.text` access assume `data.thumbnailOverlays` exists and that the matched overlay's renderer has a `text`. If `thumbnailOverlays` is undefined, `.find` throws; the `?.` only guards the find result, not `.thumbnailOverlayTimeStatusRenderer.text` when the renderer lacks `text`.","fix":"Guard the array (`data.thumbnailOverlays?.find(...)`) and the nested `.text` access.","locations":[{"ref":"src/parser/classes/Movie.ts:34-36","code":" 34 | const overlay_time_status = data.thumbnailOverlays\n 35 | .find((overlay: RawNode) => overlay.thumbnailOverlayTimeStatusRenderer)\n 36 | ?.thumbnailOverlayTimeStatusRenderer.text || 'N/A';","lines":3,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/Movie.ts#L34-L36"},{"ref":"src/parser/classes/CompactMovie.ts:33-35","code":" 33 | const overlay_time_status = data.thumbnailOverlays\n 34 | .find((overlay: RawNode) => overlay.thumbnailOverlayTimeStatusRenderer)\n 35 | ?.thumbnailOverlayTimeStatusRenderer.text || 'N/A';","lines":3,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/CompactMovie.ts#L33-L35"},{"ref":"src/parser/classes/GridVideo.ts:31","code":" 31 | const length_alt = data.thumbnailOverlays.find((overlay: RawNode) => overlay.hasOwnProperty('thumbnailOverlayTimeStatusRenderer'))?.thumbnailOverlayTimeStatusRenderer;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/GridVideo.ts#L31"}]},
{"id":"PANIC_33","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"When `data.thumbnail` is absent the fallback unconditionally calls `data.thumbnails.map(...)`. If both `thumbnail` and `thumbnails` are missing, this throws. The fallback assumes `thumbnails` always exists whenever `thumbnail` does not.","fix":"Guard the fallback: `data.thumbnail || { thumbnails: (data.thumbnails ?? []).map(...).flat(1) }`.","locations":[{"ref":"src/parser/classes/Playlist.ts:37","code":" 37 | this.thumbnails = Thumbnail.fromResponse(data.thumbnail || { thumbnails: data.thumbnails.map((th: any) => th.thumbnails).flat(1) });","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/Playlist.ts#L37"}]},
{"id":"PANIC_34","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"`data.continuations[0]` is indexed without checking the array is non-empty. The outer `Reflect.has(data, 'continuations')` only confirms the key exists, not that the array has elements; an empty `continuations` array makes `Reflect.has(data.continuations[0], ...)` throw (Reflect.has on undefined).","fix":"Add a length/element guard: `const cont = data.continuations?.[0]; if (cont) { ... }`.","locations":[{"ref":"src/parser/classes/SectionList.ts:21-26","code":" 21 | if (Reflect.has(data, 'continuations')) {\n 22 | if (Reflect.has(data.continuations[0], 'nextContinuationData')) {\n 23 | this.continuation = data.continuations[0].nextContinuationData.continuation;\n 24 | } else if (Reflect.has(data.continuations[0], 'reloadContinuationData')) {\n 25 | this.continuation = data.continuations[0].reloadContinuationData.continuation;\n 26 | }","lines":6,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/SectionList.ts#L21-L26"}]},
{"id":"PANIC_35","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"`data.sticker.accessibility.accessibilityData.label` and `data.contextMenuAccessibility.accessibilityData.label` are dereferenced without null checks. If accessibility data is missing the constructor throws. (Same pattern in LiveChatPaidMessage.ts:55.)","fix":"Use optional chaining (e.g. `data.sticker.accessibility?.accessibilityData?.label`) consistent with LiveChatTextMessage.ts which guards these with Reflect.has.","locations":[{"ref":"src/parser/classes/livechat/items/LiveChatPaidSticker.ts:45","code":" 45 | this.sticker_accessibility_label = data.sticker.accessibility.accessibilityData.label;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/livechat/items/LiveChatPaidSticker.ts#L45"},{"ref":"src/parser/classes/livechat/items/LiveChatPaidSticker.ts:51","code":" 51 | this.context_menu_accessibility_label = data.contextMenuAccessibility.accessibilityData.label;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/livechat/items/LiveChatPaidSticker.ts#L51"},{"ref":"src/parser/classes/livechat/items/LiveChatPaidMessage.ts:55","code":" 55 | this.context_menu_accessibility_label = data.contextMenuAccessibility.accessibilityData.label;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/livechat/items/LiveChatPaidMessage.ts#L55"}]},
{"id":"PANIC_36","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"Inconsistent null-safety on `data.emoji`. The `text` assignment uses `data.emoji?.emojiId` etc., but the `emoji` object literal reads `data.emoji.emojiId` and `data.emoji.image` directly (lines 25 and 28). If `data.emoji` is undefined these throw, contradicting the optional chaining used a few lines above.","fix":"Use `data.emoji?.emojiId` and `Thumbnail.fromResponse(data.emoji?.image)` for consistency, or early-return when `data.emoji` is absent.","locations":[{"ref":"src/parser/classes/misc/EmojiRun.ts:24-30","code":" 24 | this.emoji = {\n 25 | emoji_id: data.emoji.emojiId,\n 26 | shortcuts: data.emoji?.shortcuts || [],\n 27 | search_terms: data.emoji?.searchTerms || [],\n 28 | image: Thumbnail.fromResponse(data.emoji.image),\n 29 | is_custom: !!data.emoji?.isCustomEmoji\n 30 | };","lines":7,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/misc/EmojiRun.ts#L24-L30"}]},
{"id":"PANIC_37","type":"panic","type_label":"Panic","severity":"low","difficulty":"medium","description":"`TextRun.toHTML()` deep-dereferences `this.attachment.element.type.imageType.image.sources[0]` and `this.attachment.element.properties.layoutProperties.height.value` based only on a truthy `this.attachment` check. `attachment` is assigned raw, untyped `data.attachment`; any structural mismatch (e.g. missing `properties.layoutProperties`) throws while rendering HTML.","fix":"Validate each level with optional chaining before building the image element, and bail out to the plain wrapped_text on mismatch.","locations":[{"ref":"src/parser/classes/misc/TextRun.ts:82-91","code":" 82 | if (this.attachment) {\n 83 | if (this.attachment.element.type.imageType.image.sources.length) {\n 84 | if (this.endpoint) {\n 85 | const { url } = this.attachment.element.type.imageType.image.sources[0];\n 86 | \n 87 | let image_el = '';\n 88 | \n 89 | if (url) {\n 90 | image_el = `<img src=\"${url}\" style=\"vertical-align: middle; height: ${this.attachment.element.properties.layoutProperties.height.value}px; width: ${this.attachment.element.properties.layoutProperties.width.value}px;\" alt=\"\">`;\n 91 | }","lines":10,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/misc/TextRun.ts#L82-L91"}]},
{"id":"PANIC_38","type":"panic","type_label":"Panic","severity":"low","difficulty":"medium","description":"isMiscType treats any object whose key ends with 'Endpoint'/'Command' (or equals 'endpoint') as a NavigationEndpoint and immediately constructs `new NavigationEndpoint(value)` during introspection of unknown JIT classes. If `value` is an object that is not a valid endpoint shape, the NavigationEndpoint constructor may throw, propagating out of inferType/introspect and aborting JIT class generation (which is itself the fallback path for unknown nodes). Endpoint/Text/Author construction during pure type inference couples introspection to runtime parsing correctness.","fix":"Wrap the speculative `new NavigationEndpoint`/`new Text` construction in try/catch (treating failures as a plain object type), since this runs on arbitrary unknown data during introspection.","locations":[{"ref":"src/parser/generator.ts:163-184","code":" 163 | export function isMiscType(key: string, value: unknown): MiscInferenceType | false {\n 164 | if (typeof value === 'object' && value !== null) {\n 165 | // NavigationEndpoint\n 166 | if (key.endsWith('Endpoint') || key.endsWith('Command') || key === 'endpoint') {\n 167 | return {\n 168 | type: 'misc',\n 169 | endpoint: new NavigationEndpoint(value),\n 170 | optional: false,\n 171 | misc_type: 'NavigationEndpoint'\n 172 | };\n 173 | }\n 174 | // Text\n 175 | if (Reflect.has(value, 'simpleText') || Reflect.has(value, 'runs')) {\n 176 | const textNode = new Text(value);\n 177 | return {\n 178 | type: 'misc',\n 179 | misc_type: 'Text',\n 180 | optional: false,\n 181 | endpoint: textNode.endpoint,\n 182 | text: textNode.toString()\n 183 | };\n 184 | }","lines":22,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L163-L184"}]},
{"id":"PANIC_39","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"Maybe.node() builds its error message with `this.#value.constructor.name` inside the failure branch. When #value is null or undefined (a common shape for missing InnerTube keys), `null.constructor` throws 'Cannot read properties of null', masking the intended descriptive TypeError. The same pattern exists in Maybe.instanceof().","fix":"Guard the message, e.g. use `typeof this.#value` / `this.#value?.constructor?.name ?? String(this.#value)` when constructing the error string.","locations":[{"ref":"src/parser/helpers.ts:203-206","code":" 203 | node() {\n 204 | if (!(this.#value instanceof YTNode)) {\n 205 | throw new TypeError(`Expected YTNode, got ${this.#value.constructor.name}`);\n 206 | }","lines":4,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/helpers.ts#L203-L206"},{"ref":"src/parser/helpers.ts:289-293","code":" 289 | instanceof<T extends object>(type: Constructor<T>): T {\n 290 | if (!this.isInstanceof(type)) {\n 291 | throw new TypeError(`Expected instance of ${type.name}, got ${this.#value.constructor.name}`);\n 292 | }\n 293 | return this.#value;","lines":5,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/helpers.ts#L289-L293"}]},
{"id":"PANIC_40","type":"panic","type_label":"Panic","severity":"low","difficulty":"very_easy","description":"playback_tracking parsing accesses `data.playbackTracking.videostatsWatchtimeUrl.baseUrl` and `...videostatsPlaybackUrl.baseUrl` with no optional chaining. The outer `playbackTracking` is guarded, but if YouTube drops `videostatsWatchtimeUrl`/`videostatsPlaybackUrl` (API drift) the `.baseUrl` access throws and aborts the whole response parse. Most other optional fields in this function use parseItem/`?.` defensively; this block does not.","fix":"Use optional chaining: `data.playbackTracking.videostatsWatchtimeUrl?.baseUrl` etc., or validate presence before building the object.","locations":[{"ref":"src/parser/parser.ts:380-383","code":" 380 | const playback_tracking = data.playbackTracking ? {\n 381 | videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl,\n 382 | videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl\n 383 | } : null;","lines":4,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/parser.ts#L380-L383"}]},
{"id":"PANIC_41","type":"panic","type_label":"Panic","severity":"low","difficulty":"very_easy","description":"`account_section_list.contents[0]` is assigned to `this.contents` (typed `AccountItemSection | null`) but `contents[0]` will be undefined (not null) if the section list is empty, violating the declared type and potentially causing downstream undefined access in consumers expecting null.","fix":"Normalize to null: `this.contents = account_section_list.contents[0] ?? null;`.","locations":[{"ref":"src/parser/youtube/AccountInfo.ts:25","code":" 25 | this.contents = account_section_list.contents[0];","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/AccountInfo.ts#L25"}]},
{"id":"PANIC_42","type":"panic","type_label":"Panic","severity":"low","difficulty":"very_easy","description":"`firstChip.is(ChipView)` is checked (line 215) before reading display_type in Channel.filter_nodes, but `chips[0]` may be undefined when memo.has('ChipView') is true yet the array is somehow empty after observe; calling `.is` on undefined would throw. Minor, but the has()/getType() pair does not guarantee a non-empty array.","fix":"Add `if (!firstChip) return this.#filter_nodes = { primary_filters, secondary_filters };` guard before `firstChip.is(ChipView)`.","locations":[{"ref":"src/parser/youtube/Channel.ts:212-215","code":" 212 | const chips = this.memo.getType(ChipView);\n 213 | const firstChip = chips[0];\n 214 | \n 215 | if (firstChip.is(ChipView)) {","lines":4,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/Channel.ts#L212-L215"}]},
{"id":"PANIC_43","type":"panic","type_label":"Panic","severity":"low","difficulty":"very_easy","description":"`firstChip` from `chipBarView.chips[0]` is used as `firstChip.display_type` without checking the chips array is non-empty. If a ChipBarView is present but has an empty chips array, `firstChip` is undefined and `.display_type` throws. (Channel.filter_nodes at line 214 guards firstChip with `.is()`, but FilteredChannelList does not.)","fix":"Guard: `const firstChip = chipBarView.chips[0]; if (!firstChip) return;` (or skip filter extraction) before reading display_type.","locations":[{"ref":"src/parser/youtube/Channel.ts:469-472","code":" 469 | const firstChip = chipBarView.chips[0];\n 470 | \n 471 | const hasDropdown =\n 472 | firstChip.display_type === 'CHIP_VIEW_MODEL_DISPLAY_TYPE_DROP_DOWN' ||","lines":4,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/Channel.ts#L469-L472"}]},
{"id":"PANIC_44","type":"panic","type_label":"Panic","severity":"low","difficulty":"very_easy","description":"`this.#page.contents_memo?.getType(ChipCloud)?.[0].chips` dereferences `.chips` on element `[0]`. getType returns an array so `?.` does not guard the index; an empty ChipCloud array makes `[0]` undefined and `.chips` throws before the `|| []` fallback applies.","fix":"Use `getType(ChipCloud)?.[0]?.chips.map(...) || []`.","locations":[{"ref":"src/parser/ytmusic/Library.ts:143","code":" 143 | return this.#page.contents_memo?.getType(ChipCloud)?.[0].chips.map((chip: ChipCloudChip) => chip.text) || [];","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/ytmusic/Library.ts#L143"}]},
{"id":"PANIC_45","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"`getStoryboardInfo` does `new URL(storyboard.template_url)` and `.pathname.split('.').pop()` to derive\nthe file extension with no try/catch. A malformed/relative `template_url` makes `new URL` throw,\naborting storyboard processing. Likewise `getImageRepresentation`/`getStoryboardMimeType` construct\n`new URL(url.replace('$M', ...))` directly from server data.","fix":"Wrap URL construction in try/catch (or validate the template_url) and skip boards with unparseable URLs.","locations":[{"ref":"src/utils/StreamingInfo.ts:647-650","code":" 647 | for (const storyboard of boards) {\n 648 | const extension = new URL(storyboard.template_url).pathname.split('.').pop();\n 649 | \n 650 | const mime_type = `image/${extension === 'jpg' ? 'jpeg' : extension}`;","lines":4,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/StreamingInfo.ts#L647-L650"}]},
{"id":"PANIC_46","type":"panic","type_label":"Panic","severity":"low","difficulty":"medium","description":"`getStreamingInfo` accesses `formats[0]` in several places (lines 856, 859, 873, 937, 939) without\nchecking that the (possibly format-filtered) `formats` array is non-empty. If `format_filter` rejects\nevery adaptive format, `formats` is empty and `formats[0].approx_duration_ms` / `formats[0].has_audio`\nthrow \"Cannot read properties of undefined\". The reduce over `groups` would be empty so no sets are\nproduced, but the duration / first-format reads happen before that.","fix":"Throw an explicit InnertubeError when `formats.length === 0` right after computing `formats`.","locations":[{"ref":"src/utils/StreamingInfo.ts:858-862","code":" 858 | } else {\n 859 | const duration = formats[0].approx_duration_ms / 1000;\n 860 | \n 861 | getDuration = () => Promise.resolve(duration);\n 862 | }","lines":5,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/StreamingInfo.ts#L858-L862"},{"ref":"src/utils/StreamingInfo.ts:873-878","code":" 873 | if (formats[0].has_audio) {\n 874 | // Some videos with multiple audio tracks, have a broken one, that doesn't have any audio track information\n 875 | // It seems to be the same as default audio track but broken\n 876 | // We want to ignore it, as it messes up audio track selection in players and YouTube ignores it too\n 877 | // At the time of writing, this video has a broken audio track: https://youtu.be/UJeSWbR6W04\n 878 | if (has_multiple_audio_tracks && !formats[0].audio_track)","lines":6,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/StreamingInfo.ts#L873-L878"}]},
{"id":"PANIC_47","type":"panic","type_label":"Panic","severity":"low","difficulty":"easy","description":"`base64ToU8` computes padding as `(4 - standard_base64.length % 4) % 4` and `atob`s the result. If the\ninput contains characters outside the base64 alphabet (after the `-`/`_` substitution) — e.g. a\ntruncated/garbage visitor-data string passed to decodeVisitorData — `atob` throws an\n\"InvalidCharacterError\" that is not caught, surfacing as an unhandled exception in ProtoUtils.decode*.","fix":"Validate the input matches the base64url alphabet (or wrap atob in try/catch) before decoding.","locations":[{"ref":"src/utils/Utils.ts:258-262","code":" 258 | export function base64ToU8(base64: string): Uint8Array {\n 259 | const standard_base64 = base64.replace(/-/g, '+').replace(/_/g, '/');\n 260 | const padded_base64 = standard_base64.padEnd(standard_base64.length + (4 - standard_base64.length % 4) % 4, '=');\n 261 | return new Uint8Array(atob(padded_base64).split('').map((char) => char.charCodeAt(0)));\n 262 | }","lines":5,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/Utils.ts#L258-L262"}]},
{"id":"PANIC_48","type":"panic","type_label":"Panic","severity":"low","difficulty":"medium","description":"In `analyzeAst`, when an identifier assignment matches a previously declared variable, the code does\n`existingVariable.node.init = right`. `node` is typed `any` and for a predeclared `var x;` the stored\nnode is a VariableDeclarator (has `.init`), but nothing guarantees `node` is defined/assignable for\nevery metadata shape created elsewhere (e.g. alias members store an ExpressionStatement as `node`).\nMutating `.init` on a non-declarator node silently attaches a bogus property that later rendering may\nrely on, and assigning to `undefined.init` would throw.","fix":"Assert the node type (`node.type === 'VariableDeclarator'`) before assigning `.init`, or store the init on metadata instead of mutating the AST node.","locations":[{"ref":"src/utils/javascript/JsAnalyzer.ts:181-188","code":" 181 | const existingVariable = this.declaredVariables.get(left.name);\n 182 | if (!existingVariable) continue;\n 183 | \n 184 | existingVariable.node.init = right;\n 185 | \n 186 | if (this.needsDependencyAnalysis(right)) {\n 187 | existingVariable.dependencies = this.findDependencies(assignment.right, left.name);\n 188 | }","lines":8,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/javascript/JsAnalyzer.ts#L181-L188"}]},
{"id":"RACE_1","type":"race_condition","type_label":"Race Condition","severity":"high","difficulty":"medium","description":"pollForAccessToken runs an async callback inside setInterval with no try/catch. If `this.#http.fetch_function` or `response.json()` throws (network error, non-JSON body), the promise rejects unhandled, the interval is never cleared, and polling continues indefinitely (a leaked timer that also keeps emitting failing requests). A transient network blip during device-code polling leaks the interval forever.","fix":"Wrap the interval body in try/catch; on unexpected errors clearInterval(connInterval) and emit 'auth-error'. Consider awaiting nothing inside setInterval and using a self-scheduling setTimeout to avoid overlapping in-flight requests.","locations":[{"ref":"src/core/OAuth2.ts:144-183","code":" 144 | const connInterval = setInterval(async () => {\n 145 | const response = await this.#http.fetch_function(this.AUTH_SERVER_TOKEN_URL, {\n 146 | body: JSON.stringify(payload),\n 147 | method: 'POST',\n 148 | headers: {\n 149 | 'Content-Type': 'application/json'\n 150 | }\n 151 | });\n 152 | \n 153 | const response_data = await response.json();\n 154 | \n 155 | if (response_data.error) {\n 156 | switch (response_data.error) {\n 157 | case 'access_denied':\n 158 | this.#session.emit('auth-error', new OAuth2Error('Access was denied.', response_data));\n 159 | clearInterval(connInterval);\n 160 | break;\n 161 | case 'expired_token':\n 162 | this.#session.emit('auth-error', new OAuth2Error('The device code has expired.', response_data));\n 163 | clearInterval(connInterval);\n 164 | break;\n 165 | case 'authorization_pending':\n 166 | case 'slow_down':\n 167 | Log.info(TAG, 'Polling for access token...');\n 168 | break;\n 169 | default:\n 170 | this.#session.emit('auth-error', new OAuth2Error('Server returned an unexpected error.', response_data));\n 171 | clearInterval(connInterval);\n 172 | break;\n 173 | }\n 174 | return;\n 175 | }\n 176 | \n 177 | this.setTokens(response_data);\n 178 | \n 179 | this.#session.emit('auth', { credentials: this.oauth2_tokens });\n 180 | \n 181 | clearInterval(connInterval);\n 182 | }, interval * 1000);\n 183 | }","lines":40,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/OAuth2.ts#L144-L183"}]},
{"id":"RES_1","type":"resource_leak","type_label":"Resource Leak","severity":"low","difficulty":"very_easy","description":"revokeCredentials mutates the shared, long-lived URL instance `this.AUTH_SERVER_REVOKE_TOKEN_URL` by calling `url.searchParams.set('token', ...)`. The OAuth2 instance keeps this URL object; if revoke were ever attempted again (or the object inspected later) it carries the previous access_token in its query string. Reusing a stored mutable URL for per-call parameters is fragile and leaks token state onto the instance.","fix":"Construct a fresh URL inside the method (`const url = new URL(this.AUTH_SERVER_REVOKE_TOKEN_URL)`) before setting the token search param.","locations":[{"ref":"src/core/OAuth2.ts:191-194","code":" 191 | const url = this.AUTH_SERVER_REVOKE_TOKEN_URL;\n 192 | url.searchParams.set('token', this.oauth2_tokens.access_token);\n 193 | \n 194 | return this.#session.http.fetch_function(url, { method: 'POST' });","lines":4,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/OAuth2.ts#L191-L194"}]},
{"id":"OVF_1","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","difficulty":"medium","description":"fromCache compares the cached library_version (a number written by #storeSession via parseInt(packageInfo.version.split('.',1)[0])) against `parseInt(packageInfo.version.split('.', 1)[0])`. If package.json version ever lacks a leading numeric major (or the field is malformed), parseInt yields NaN; NaN !== NaN is true so cache is always discarded silently. More importantly the version is parsed every call from package.json with no error handling on a malformed string.","fix":"Compute the major version once with validation (guard against NaN) and reuse it; log clearly if the version string is unparseable.","locations":[{"ref":"src/core/Session.ts:344-347","code":" 344 | if (session_data.library_version !== parseInt(packageInfo.version.split('.', 1)[0])) {\n 345 | Log.warn(TAG, `Cached session data is from a different library version (${session_data.library_version}). Regenerating session data.`);\n 346 | return null;\n 347 | }","lines":4,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/Session.ts#L344-L347"}]},
{"id":"OVF_2","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","difficulty":"medium","description":"`getPostLiveDvrInfo` parses `X-Head-Time-Millis` and `X-Head-Seqnum` response headers with `parseInt`\nand uses them in arithmetic (`duration_ms / 1000`) and as `repeat_count`. Although NaN is checked, the\nvalues are not bounds-checked; an attacker-controlled / malformed CDN response with an absurdly large\nseqnum is propagated unbounded into the segment timeline `repeat_count`, which can blow up DASH\ngeneration. Same pattern for `parseInt(response.headers.get('Content-Length') || '0')` in\ngetStoryboardBitrate.","fix":"Validate header-derived integers are within sane bounds (Number.isSafeInteger and reasonable max) before using them.","locations":[{"ref":"src/utils/StreamingInfo.ts:285-303","code":" 285 | async function getPostLiveDvrInfo(transformed_url: string, actions: Actions): Promise<PostLiveDvrInfo> {\n 286 | const response = await actions.session.http.fetch_function(`${transformed_url}&rn=0&sq=0`, {\n 287 | method: 'HEAD',\n 288 | headers: Constants.STREAM_HEADERS,\n 289 | redirect: 'follow'\n 290 | });\n 291 | \n 292 | const duration_ms = parseInt(response.headers.get('X-Head-Time-Millis') || '');\n 293 | const segment_count = parseInt(response.headers.get('X-Head-Seqnum') || '');\n 294 | \n 295 | if (isNaN(duration_ms) || isNaN(segment_count)) {\n 296 | throw new InnertubeError('Failed to extract the duration or segment count for this Post Live DVR video');\n 297 | }\n 298 | \n 299 | return {\n 300 | duration: duration_ms / 1000,\n 301 | segment_count\n 302 | };\n 303 | }","lines":19,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/StreamingInfo.ts#L285-L303"}]},
{"id":"OVF_3","type":"overflow_underflow","type_label":"Overflow Underflow","severity":"low","difficulty":"medium","description":"`getStoryboardBitrate` computes `Math.max(...content_lengths) / (board.rows * board.columns)`. If a\nboard reports `rows` or `columns` of 0 the result is `Infinity`/`NaN`, and if `responses` is empty\n(can happen if `request_limit` resolves to 0 because `storyboard_count` is 0) `Math.max()` returns\n`-Infinity`, yielding a nonsensical negative/Infinity bitrate that is propagated into the manifest's\n`bandwidth` attribute. Content-Length values come straight from `parseInt` on response headers with no\nupper-bound validation.","fix":"Guard against zero rows/columns and empty content_lengths; clamp/round the result and fall back to a sane default when inputs are invalid.","locations":[{"ref":"src/utils/StreamingInfo.ts:719-727","code":" 719 | const content_lengths = [];\n 720 | \n 721 | for (const response of responses) {\n 722 | content_lengths.push(parseInt(response.headers.get('Content-Length') || '0'));\n 723 | }\n 724 | \n 725 | // This is a rough estimate, so it probably won't reflect that actual peak bitrate\n 726 | // Hopefully it's close enough, because figuring out the actual peak bitrate would require downloading and analysing all storyboard tiles\n 727 | return Math.ceil((Math.max(...content_lengths) / (board.rows * board.columns)) * 8);","lines":9,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/StreamingInfo.ts#L719-L727"}]},
{"id":"LOGIC_1","type":"logic_error","type_label":"Logic Error","severity":"high","difficulty":"very_easy","description":"setName() builds the playlistEditEndpoint payload with the key `playlist_id` (snake_case) instead of `playlistId` (camelCase) used by every other method in this file (e.g. setDescription at line 288, addVideos at 121, moveVideo at 205). InnerTube expects `playlistId`, so the playlist ID is never sent and the rename request will target an unidentified/wrong playlist or be rejected.","fix":"Change `const payload = { playlist_id, actions: [] ... }` to `const payload = { playlistId: playlist_id, actions: [] ... }` to match the field name InnerTube expects.","locations":[{"ref":"src/core/managers/PlaylistManager.ts:261-269","code":" 261 | const payload = { playlist_id, actions: [] as Record<string, any>[] };\n 262 | \n 263 | payload.actions.push({\n 264 | action: 'ACTION_SET_PLAYLIST_NAME',\n 265 | playlistName: name\n 266 | });\n 267 | \n 268 | const playlist_edit_endpoint = new NavigationEndpoint({ playlistEditEndpoint: payload });\n 269 | const response = await playlist_edit_endpoint.call(this.#actions);","lines":9,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/managers/PlaylistManager.ts#L261-L269"}]},
{"id":"LOGIC_2","type":"logic_error","type_label":"Logic Error","severity":"high","difficulty":"medium","description":"mergeKeyInfo merges renderer name lists with object spread on arrays. RendererInferenceType.renderers and the array-renderer `renderers` are typed `string[]` (see generator.ts:46 and ArrayInferenceType), but the union is built as `{ ...type.renderers, ...new_type.renderers }`. Spreading arrays into an object produces an index-keyed object ({0:'A',1:'B'}), and merging ['A','B'] with ['C'] yields {0:'C',1:'B'} — i.e. renderers are overwritten/dropped by positional index instead of unioned by name. The resolved key_info then carries a malformed `renderers` (an object, not an array), corrupting JIT class generation and the regenerated TypeScript output.","fix":"Union the names by value, e.g. `renderers: Array.from(new Set([ ...type.renderers, ...new_type.renderers ]))`, instead of object-spreading the arrays.","locations":[{"ref":"src/parser/generator.ts:776-784","code":" 776 | const union_map = {\n 777 | ...type.renderers,\n 778 | ...new_type.renderers\n 779 | };\n 780 | const either_optional = type.optional || new_type.optional;\n 781 | const resolved_key: InferenceType = {\n 782 | type: 'renderer',\n 783 | renderers: union_map,\n 784 | optional: either_optional","lines":9,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L776-L784"},{"ref":"src/parser/generator.ts:815-825","code":" 815 | const union_map = {\n 816 | ...type.renderers,\n 817 | ...new_type.renderers\n 818 | };\n 819 | const either_optional = type.optional || new_type.optional;\n 820 | const resolved_key: InferenceType = {\n 821 | type: 'array',\n 822 | array_type: 'renderer',\n 823 | renderers: union_map,\n 824 | optional: either_optional\n 825 | };","lines":11,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L815-L825"}]},
{"id":"LOGIC_3","type":"logic_error","type_label":"Logic Error","severity":"high","difficulty":"medium","description":"In the Node cache, `get` returns `data.buffer as ArrayBuffer` where `data` is a Buffer from\n`fs.readFile`. For small files Node allocates the Buffer from a shared internal pool, so `data.buffer`\nis the entire (e.g. 8 KiB) pool ArrayBuffer, not just the file's bytes, and `data.byteOffset` is\nnon-zero. Callers that wrap the returned ArrayBuffer (e.g. BinarySerializer.deserialize via\n`new DataView`/`Uint8Array`) will read garbage/adjacent data and the length will be wrong. The Deno\nand web/react-native caches have the same `.buffer` over-read pattern for typed-array views.","fix":"Return only the file's bytes, e.g. `data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)` for Buffers, and `result.buffer.slice(result.byteOffset, result.byteOffset + result.byteLength)` for views.","locations":[{"ref":"src/platform/node.ts:58-59","code":" 58 | const data: Buffer = await fs.readFile(file);\n 59 | return data.buffer as ArrayBuffer;","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/platform/node.ts#L58-L59"},{"ref":"src/platform/deno.ts:52-53","code":" 52 | const data: Uint8Array = await Deno.readFile(file);\n 53 | return data.buffer as ArrayBuffer;","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/platform/deno.ts#L52-L53"},{"ref":"src/platform/web.ts:62-63","code":" 62 | } else if (ArrayBuffer.isView(result)) {\n 63 | resolve(result.buffer as ArrayBuffer);","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/platform/web.ts#L62-L63"}]},
{"id":"LOGIC_4","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"moveVideo's recursive getSetVideoIds only stops when BOTH set_video_id_0 and set_video_id_1 are found; otherwise it fetches the next continuation. If one of the supplied video IDs is not in the playlist, the loop walks every page until pl.getContinuation() throws 'Could not get continuation data', surfacing a confusing error instead of a clear 'video not found'. removeVideos has the analogous issue: it recurses while payload.actions.length < video_ids.length, so any unmatched id forces a full walk ending in a continuation error.","fix":"After exhausting continuations (catch the no-continuation case or check has_continuation), throw a clear InnertubeError naming the video id(s) that could not be located.","locations":[{"ref":"src/core/managers/PlaylistManager.ts:209-222","code":" 209 | const getSetVideoIds = async (pl: Feed): Promise<void> => {\n 210 | const video_0 = pl.videos.find((video) => moved_video_id === video.key('id').string());\n 211 | const video_1 = pl.videos.find((video) => predecessor_video_id === video.key('id').string());\n 212 | \n 213 | set_video_id_0 = set_video_id_0 || video_0?.key('set_video_id').string();\n 214 | set_video_id_1 = set_video_id_1 || video_1?.key('set_video_id').string();\n 215 | \n 216 | if (!set_video_id_0 || !set_video_id_1) {\n 217 | const next = await pl.getContinuation();\n 218 | return getSetVideoIds(next);\n 219 | }\n 220 | };\n 221 | \n 222 | await getSetVideoIds(playlist);","lines":14,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/managers/PlaylistManager.ts#L209-L222"}]},
{"id":"LOGIC_5","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"very_easy","description":"`is_premiere` compares `badge.style === 'PREMIERE'`, but `style` holds raw badge style strings of the form `BADGE_STYLE_TYPE_*` (never the bare word `PREMIERE`). The label, not the style, carries `PREMIERE` — see Video.ts:137 which correctly uses `badge.label === 'PREMIERE'`. As written CompactVideo.is_premiere is always false.","fix":"Match on the label like Video.ts: `return this.badges.some((badge) => badge.label === 'PREMIERE');`.","locations":[{"ref":"src/parser/classes/CompactVideo.ts:115-117","code":" 115 | get is_premiere(): boolean {\n 116 | return this.badges.some((badge) => badge.style === 'PREMIERE');\n 117 | }","lines":3,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/CompactVideo.ts#L115-L117"}]},
{"id":"LOGIC_6","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"very_easy","description":"Regex operator-precedence bug. `/\\d+ songs|song/` parses as (`\\d+ songs`) OR (`song`), so it matches any text merely containing the substring `song` (e.g. the artist word 'Songwriter'), not only a count like '5 songs'. The grouping was almost certainly intended.","fix":"Group the alternation: `/\\d+ (?:songs|song)/`.","locations":[{"ref":"src/parser/classes/MusicTwoRowItem.ts:86","code":" 86 | const item_count_run = this.subtitle.runs?.find((run) => run.text.match(/\\d+ songs|song/));","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/MusicTwoRowItem.ts#L86"}]},
{"id":"LOGIC_7","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"`EventEmitterLike` stores listener wrappers in `#legacy_listeners` keyed by the *listener function\nonly*, not by (type, listener). Registering the same listener for two different event types overwrites\nthe first wrapper in the map, so a later `off(typeA, listener)` removes nothing for typeA's wrapper\n(the stored wrapper is typeB's) and the typeA listener can never be detached — a listener/memory leak.\nRegistering the same listener twice for the same type has the same effect.","fix":"Key the map by a composite of type+listener (e.g. a Map<string, Map<listener, wrapper>>), or store an array of {type, wrapper} per listener.","locations":[{"ref":"src/utils/EventEmitterLike.ts:4-46","code":" 4 | #legacy_listeners = new Map<(...args: any[]) => void, EventListener>();\n 5 | \n 6 | constructor() {\n 7 | super();\n 8 | }\n 9 | \n 10 | emit(type: string, ...args: any[]) {\n 11 | const event = new Platform.shim.CustomEvent(type, { detail: args });\n 12 | this.dispatchEvent(event);\n 13 | }\n 14 | \n 15 | on(type: string, listener: (...args: any[]) => void) {\n 16 | const wrapper: EventListener = (ev) => {\n 17 | if (ev instanceof Platform.shim.CustomEvent) {\n 18 | listener(...ev.detail);\n 19 | } else {\n 20 | listener(ev);\n 21 | }\n 22 | };\n 23 | this.#legacy_listeners.set(listener, wrapper);\n 24 | this.addEventListener(type, wrapper);\n 25 | }\n 26 | \n 27 | once(type: string, listener: (...args: any[]) => void) {\n 28 | const wrapper: EventListener = (ev) => {\n 29 | if (ev instanceof Platform.shim.CustomEvent) {\n 30 | listener(...ev.detail);\n 31 | } else {\n 32 | listener(ev);\n 33 | }\n 34 | this.off(type, listener);\n 35 | };\n 36 | this.#legacy_listeners.set(listener, wrapper);\n 37 | this.addEventListener(type, wrapper);\n 38 | }\n 39 | \n 40 | off(type: string, listener: (...args: any[]) => void) {\n 41 | const wrapper = this.#legacy_listeners.get(listener);\n 42 | if (wrapper) {\n 43 | this.removeEventListener(type, wrapper);\n 44 | this.#legacy_listeners.delete(listener);\n 45 | }\n 46 | }","lines":43,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/EventEmitterLike.ts#L4-L46"}]},
{"id":"LOGIC_8","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"very_easy","description":"The non-whole-name branch of the cookie matcher builds the regex `(^|s?)${name}=([^;]+)`. The intent\nwas clearly `(^|\\s?)` (optional whitespace) but it reads `s?` — an optional literal letter \"s\" that\ncan match the empty string. As a result the regex matches `${name}=` anywhere in the cookie string,\nincluding when `name` is a suffix of a longer cookie name. e.g. searching for `SAPISID` could match\nthe tail of `__Secure-3PSAPISID=...` and return the wrong cookie value. This is the default code path\n(`matchWholeName = false`) and is used for the SAPISID auth cookie in HTTPClient.","fix":"Use `(^|\\s)` (or a proper boundary like `(?:^|;\\s*)`) instead of `(^|s?)`, and prefer matchWholeName=true for auth cookies.","locations":[{"ref":"src/utils/Utils.ts:268-272","code":" 268 | export function getCookie(cookies: string, name: string, matchWholeName = false): string | undefined {\n 269 | const regex = matchWholeName ? `(^|\\\\s?)\\\\b${name}\\\\b=([^;]+)` : `(^|s?)${name}=([^;]+)`;\n 270 | const match = cookies.match(new RegExp(regex));\n 271 | return match ? match[2] : undefined;\n 272 | }","lines":5,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/Utils.ts#L268-L272"}]},
{"id":"LOGIC_9","type":"logic_error","type_label":"Logic Error","severity":"medium","difficulty":"medium","description":"`parseFunctionArguments` builds the positional argument list for the generated wrapper, but for any\nargument it cannot map to a name/literal it pushes the placeholder `'input'` only if `'input'` is not\nalready present. When a target function has two or more such unmappable arguments, only a single\n`input` param is emitted, so the generated wrapper has fewer parameters than the call site expects and\npositional alignment of all subsequent arguments is wrong, producing an incorrect decipher wrapper.","fix":"Emit a unique placeholder per unmapped argument (e.g. `input0`, `input1`, ...) preserving arity.","locations":[{"ref":"src/utils/javascript/helpers.ts:267-288","code":" 267 | function parseFunctionArguments(analyzer: JsAnalyzer, args: ESTree.Node[]) {\n 268 | const params: string[] = [];\n 269 | \n 270 | for (const arg of args) {\n 271 | if (arg.type === 'Identifier' && analyzer.declaredVariables.has(arg.name)) {\n 272 | params.push(arg.name);\n 273 | } else if (arg.type === 'Literal' && (typeof arg.value === 'string' || typeof arg.value === 'number')) {\n 274 | params.push(JSON.stringify(arg.value));\n 275 | } else if (arg.type === 'UnaryExpression') {\n 276 | const argSource = extractNodeSource(arg, analyzer.getSource());\n 277 | if (argSource) {\n 278 | params.push(argSource.trim());\n 279 | }\n 280 | } else if (arg.type === 'AssignmentPattern' && arg.left.type === 'Identifier') {\n 281 | params.push(arg.left.name);\n 282 | } else if (arg.type === 'Identifier') {\n 283 | params.push(arg.name);\n 284 | } else if (!params.includes('input'))\n 285 | params.push('input');\n 286 | }\n 287 | \n 288 | return params;","lines":22,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/javascript/helpers.ts#L267-L288"}]},
{"id":"LOGIC_10","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"In search(), params are computed as `filters ? encodeURIComponent(...) : undefined`, but `filters` defaults to `{}` and is always an object, so the ternary is always truthy. Even when the caller passes no filters, an encoded (empty) SearchFilter is always sent as `params`. The intended guard (only send params when filters were actually provided) does not work.","fix":"Check whether any filter fields were actually set (e.g. Object.keys(search_filter.filters).length || search_filter.prioritize) before encoding, otherwise pass undefined.","locations":[{"ref":"src/Innertube.ts:266-271","code":" 266 | const search_endpoint = new NavigationEndpoint({\n 267 | searchEndpoint: {\n 268 | query,\n 269 | params: filters ? encodeURIComponent(u8ToBase64(SearchFilter.encode(search_filter).finish())) : undefined\n 270 | }\n 271 | });","lines":6,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/Innertube.ts#L266-L271"}]},
{"id":"LOGIC_11","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"getContinuationData sets `this.#continuation = this.#getBodyContinuations()` then `if (this.#continuation) return this.getContinuationData();`. #getBodyContinuations always returns an (possibly empty) ObservedArray, which is always truthy, so the guard is always taken and the recursion always re-enters—on the second pass the length===0 branch throws. The `if` is effectively dead and the control flow only works by accident; an empty continuation array produces a throw on the recursive call rather than a clean return.","fix":"Drop the redundant `if` (it is always true) and document that an empty continuation array intentionally triggers the 'no continuations' throw, or handle the empty case explicitly before recursing.","locations":[{"ref":"src/core/mixins/Feed.ts:204-216","code":" 204 | async getContinuationData(): Promise<T | undefined> {\n 205 | if (this.#continuation) {\n 206 | if (this.#continuation.length === 0)\n 207 | throw new InnertubeError('There are no continuations');\n 208 | \n 209 | return await this.#continuation[0].endpoint.call<T>(this.#actions, { parse: true });\n 210 | }\n 211 | \n 212 | this.#continuation = this.#getBodyContinuations();\n 213 | \n 214 | if (this.#continuation)\n 215 | return this.getContinuationData();\n 216 | }","lines":13,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/mixins/Feed.ts#L204-L216"}]},
{"id":"LOGIC_12","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"FeedFilterChipBar count guard uses `this.memo.getType(FeedFilterChipBar)?.length > 1`. getType never returns null/undefined so the `?.` is dead, and more subtly the subsequent branch uses `this.memo.has('FeedFilterChipBar')` (a string key on the Map) rather than the typed getType result, mixing two different lookup mechanisms for the same node and making the >1 guard and the has() branch potentially inconsistent.","fix":"Use `this.memo.getType(FeedFilterChipBar).length` consistently and reuse that result for both the >1 check and the presence check.","locations":[{"ref":"src/core/mixins/FilterableFeed.ts:38-42","code":" 38 | if (this.memo.getType(FeedFilterChipBar)?.length > 1)\n 39 | throw new InnertubeError('There are too many feed filter chipbars, you\\'ll need to find the correct one yourself in this.page');\n 40 | \n 41 | if (this.memo.has('FeedFilterChipBar')) {\n 42 | primary_filters = this.memo.getType(ChipCloudChip);;","lines":5,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/mixins/FilterableFeed.ts#L38-L42"}]},
{"id":"LOGIC_13","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"very_easy","description":"`this.author = data.shortBylineText && new Author(...)`. When `shortBylineText` is falsy, `author` is set to the falsy value (e.g. undefined/empty string) rather than an Author, but the property is typed non-optional `author: Author`. Downstream code accessing `author.*` would then throw.","fix":"Either make `author` optional and guard, or always construct: `new Author(data.shortBylineText, data.ownerBadges)` (Author already tolerates missing input).","locations":[{"ref":"src/parser/classes/GridVideo.ts:40","code":" 40 | this.author = data.shortBylineText && new Author(data.shortBylineText, data.ownerBadges);","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/GridVideo.ts#L40"}]},
{"id":"LOGIC_14","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"very_easy","description":"Type/semantic inconsistency: `timestamp_usec` is declared `number` and assigned the raw `data.timestampUsec` (which is a microsecond string, as evidenced by `parseInt(data.timestampUsec)` used for `timestamp`). Sibling classes LiveChatPaidMessage/LiveChatPaidSticker declare `timestamp_usec: string`. The value is never converted to a number, so the declared type is wrong and downstream numeric use would misbehave.","fix":"Declare `timestamp_usec: string` to match the other livechat item classes (the value is a string).","locations":[{"ref":"src/parser/classes/livechat/items/LiveChatTextMessage.ts:17","code":" 17 | timestamp_usec: number;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/livechat/items/LiveChatTextMessage.ts#L17"},{"ref":"src/parser/classes/livechat/items/LiveChatTextMessage.ts:30","code":" 30 | this.timestamp_usec = data.timestampUsec;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/livechat/items/LiveChatTextMessage.ts#L30"}]},
{"id":"LOGIC_15","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"very_easy","description":"introspectKeysSecondPass verifies author badges with `Reflect.has(badge_key_info[1].renderers, 'MetadataBadge')`. `renderers` is a string[] of renderer class names, so Reflect.has checks for a *property/index* named 'MetadataBadge' on the array (always false for a normal string array) rather than membership. The badge detection therefore never succeeds, so author metadata badges are effectively never attached.","fix":"Use membership: `badge_key_info[1].renderers.includes('MetadataBadge')`.","locations":[{"ref":"src/parser/generator.ts:341-346","code":" 341 | const badge_key_info = key_info.find(([ key ]) => key === canonical_badges);\n 342 | const is_badges = badge_key_info ?\n 343 | badge_key_info[1].type === 'array' && badge_key_info[1].array_type === 'renderer' && Reflect.has(badge_key_info[1].renderers, 'MetadataBadge') :\n 344 | false;\n 345 | \n 346 | if (is_badges && canonical_badges) excluded_keys.add(canonical_badges);","lines":6,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L341-L346"}]},
{"id":"LOGIC_16","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"In mergeKeyInfo, the 'misc' branch sets changed_keys to an unknown primitive on a misc_type mismatch but does not `continue`/`break`. Execution then falls into `switch (type.misc_type)`; for the Author case it re-checks `new_type.misc_type !== 'Author'` and breaks, but if both are misc-Author with different params the earlier unknown-primitive assignment may already be overwritten or vice-versa, and the control flow is fragile. The intent (mismatch => mark unknown and stop) is not enforced.","fix":"Add `break;` (or `continue;`) immediately after setting changed_keys on the misc_type mismatch, mirroring the primitive/renderer mismatch branches.","locations":[{"ref":"src/parser/generator.ts:916-945","code":" 916 | case 'misc':\n 917 | {\n 918 | if (new_type.type !== 'misc') continue;\n 919 | if (type.misc_type !== new_type.misc_type) {\n 920 | // We've got a type mismatch, this is unknown, we do not resolve unions\n 921 | changed_keys.set(key, {\n 922 | type: 'primitive',\n 923 | typeof: [ 'unknown' ],\n 924 | optional: true\n 925 | });\n 926 | }\n 927 | switch (type.misc_type) {\n 928 | case 'Author':\n 929 | {\n 930 | if (new_type.misc_type !== 'Author') break;\n 931 | const had_optional_param = type.params[1] || new_type.params[1];\n 932 | const either_optional = type.optional || new_type.optional;\n 933 | const resolved_key: MiscInferenceType = {\n 934 | type: 'misc',\n 935 | misc_type: 'Author',\n 936 | optional: either_optional,\n 937 | params: [ new_type.params[0], had_optional_param ]\n 938 | };\n 939 | const did_change = JSON.stringify(resolved_key) !== JSON.stringify(type);\n 940 | if (did_change) changed_keys.set(key, resolved_key);\n 941 | }\n 942 | break;\n 943 | // Other cases can not change\n 944 | }\n 945 | }","lines":30,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L916-L945"}]},
{"id":"LOGIC_17","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"very_easy","description":"sanitizeClassName replaces 'Renderer'/'Model'/'Radio' globally and anywhere in the string (`.replace(/Renderer|Model/g, '')`, `.replace(/Radio/g, 'Mix')`). These substrings are only meaningful as suffixes/tokens; a hypothetical key containing them mid-word would be mangled (e.g. a class with 'Radio' inside its name becomes 'Mix'). The intent (per isRenderer) is to strip the trailing Renderer/Model suffix only.","fix":"Anchor the replacements to the end, e.g. `.replace(/(Renderer|Model)$/,'')`, and reconsider the global Radio->Mix substitution.","locations":[{"ref":"src/parser/parser.ts:197-201","code":" 197 | export function sanitizeClassName(input: string) {\n 198 | return (input.charAt(0).toUpperCase() + input.slice(1))\n 199 | .replace(/Renderer|Model/g, '')\n 200 | .replace(/Radio/g, 'Mix').trim();\n 201 | }","lines":5,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/parser.ts#L197-L201"}]},
{"id":"LOGIC_18","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"medium","description":"In SmoothedQueue.emitSmoothedActions, when delay == 1 the next delay is computed as `this.#estimated_update_interval as number / this.#action_queue.length`. If estimated_update_interval is still null (no prior interval established) this yields `null / n === 0`, and if action_queue is empty after the splice it yields NaN/Infinity. The cast `as number` hides that estimated_update_interval may be null. Math.max(80, ...) clamps NaN poorly (Math.max(80, NaN) === NaN).","fix":"Guard against null/zero: use `(this.#estimated_update_interval ?? 1E4)` and ensure action_queue.length is non-zero (the outer `if (this.#action_queue.length)` is checked before the splice, but length can become 0 after splice).","locations":[{"ref":"src/parser/youtube/SmoothedQueue.ts:110-118","code":" 110 | if (this.#action_queue !== null) {\n 111 | if (delay == 1) {\n 112 | delay = this.#estimated_update_interval as number / this.#action_queue.length;\n 113 | delay *= Math.random() + 0.5;\n 114 | delay = Math.min(1E3, delay);\n 115 | delay = Math.max(80, delay);\n 116 | } else {\n 117 | delay = 80;\n 118 | }","lines":9,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/SmoothedQueue.ts#L110-L118"}]},
{"id":"LOGIC_19","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"`if (this.#action_queue !== null)` is always true: #action_queue is initialized to `[]` and clear() resets it to `[]`, never null (its type is YTNode[][], not nullable). This dead guard suggests the intent was to check emptiness (`.length`) or that the queue could be nulled, neither of which holds.","fix":"Remove the always-true null check, or replace with the intended condition (e.g. re-schedule only if there is remaining work).","locations":[{"ref":"src/parser/youtube/SmoothedQueue.ts:110","code":" 110 | if (this.#action_queue !== null) {","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/SmoothedQueue.ts#L110"}]},
{"id":"LOGIC_20","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"easy","description":"`timeToSeconds` strips all non-digits per segment with `replace(/\\D/g, '')` then `parseInt`. For an\nempty or malformed segment (e.g. input \"::\" or \"1::\") a segment becomes \"\" and `parseInt('')` is NaN,\nwhich silently propagates into the arithmetic giving a NaN result rather than the documented error.\nThe `default` case throws only for >3 segments; 0/empty inputs (e.g. \"\") produce `[NaN]` -> returns NaN.","fix":"Validate each parsed component with Number.isFinite and throw on NaN; handle empty input explicitly.","locations":[{"ref":"src/utils/Utils.ts:140-152","code":" 140 | export function timeToSeconds(time: string): number {\n 141 | const params = time.split(':').map((param) => parseInt(param.replace(/\\D/g, '')));\n 142 | switch (params.length) {\n 143 | case 1:\n 144 | return params[0];\n 145 | case 2:\n 146 | return params[0] * 60 + params[1];\n 147 | case 3:\n 148 | return params[0] * 3600 + params[1] * 60 + params[2];\n 149 | default:\n 150 | throw new Error('Invalid time string');\n 151 | }\n 152 | }","lines":13,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/Utils.ts#L140-L152"}]},
{"id":"LOGIC_21","type":"logic_error","type_label":"Logic Error","severity":"low","difficulty":"very_easy","description":"The debug fetch logger's message is built with broken string concatenation. A template literal is\nopened at ` headers:\\n${headers_serialized}\\n' + ` and only closed at the end of the next physical\nline. The `' + ` and the opening `'` of ` body:` end up as *literal text inside the template literal*\nrather than being JS string concatenation. The emitted log therefore contains the raw characters\n`' + ` and a stray `'` in the middle of the headers/body section, producing malformed output.","fix":"Replace the mixed quoting with a single template literal: ` headers:\\n${headers_serialized}\\n body:\\n${body_contents}`.","locations":[{"ref":"src/utils/Utils.ts:235-241","code":" 235 | Log.warn(TAG_,\n 236 | 'Fetch:\\n' +\n 237 | ` url: ${url.toString()}\\n` +\n 238 | ` method: ${init?.method || 'GET'}\\n` +\n 239 | ` headers:\\n${headers_serialized}\\n' + \n 240 | ' body:\\n${body_contents}`\n 241 | );","lines":7,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/Utils.ts#L235-L241"}]},
{"id":"CPY_1","type":"copy_paste","type_label":"Copy Paste","severity":"high","difficulty":"very_easy","description":"Naming bug: the guards check snake_case keys that never exist on the raw InnerTube data. `Reflect.has(data, 'tv_banner')` and `Reflect.has(data, 'mobile_banner')` will always be false because the raw keys are camelCase `tvBanner`/`mobileBanner` (the assignment bodies even read `data.tvBanner`/`data.mobileBanner`, and CompactChannel.ts uses `data.tvBanner` directly). Consequently `tv_banner` and `mobile_banner` are never populated.","fix":"Change the guards to `Reflect.has(data, 'tvBanner')` and `Reflect.has(data, 'mobileBanner')`.","locations":[{"ref":"src/parser/classes/C4TabbedHeader.ts:39-45","code":" 39 | if (Reflect.has(data, 'tv_banner')) {\n 40 | this.tv_banner = Thumbnail.fromResponse(data.tvBanner);\n 41 | }\n 42 | \n 43 | if (Reflect.has(data, 'mobile_banner')) {\n 44 | this.mobile_banner = Thumbnail.fromResponse(data.mobileBanner);\n 45 | }","lines":7,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/C4TabbedHeader.ts#L39-L45"}]},
{"id":"CPY_2","type":"copy_paste","type_label":"Copy Paste","severity":"high","difficulty":"very_easy","description":"Copy-paste bug: the field is gated on the wrong key. `if ('shortBylineText' in data)` guards the assignment of `this.view_count = new Text(data.viewCountText)`. The condition checks `shortBylineText` but the value comes from `viewCountText`. As a result `view_count` is only populated when `shortBylineText` happens to be present (and may be left undefined when `viewCountText` exists without `shortBylineText`, or set to `new Text(undefined)` otherwise). Should check `'viewCountText' in data`.","fix":"Change the guard to `if ('viewCountText' in data)` and assign `this.view_count = new Text(data.viewCountText);`.","locations":[{"ref":"src/parser/classes/CompactVideo.ts:49-50","code":" 49 | if ('shortBylineText' in data)\n 50 | this.view_count = new Text(data.viewCountText);","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/CompactVideo.ts#L49-L50"}]},
{"id":"CPY_3","type":"copy_paste","type_label":"Copy Paste","severity":"high","difficulty":"very_easy","description":"`hoistNumberAttributeIfPossible` takes a `property` parameter ('audio_sample_rate' | 'fps') and is\nsupposed to hoist that attribute when it is identical across all formats. However the uniqueness\ncheck is hard-coded to `format.fps` instead of `format[property]`. When called for 'audio_sample_rate'\n(StreamingInfo.ts:508) it actually tests whether all formats share the same fps (which for audio is\nusually undefined for every format, so the Set size is 1), then incorrectly hoists\n`Number(formats[0].audio_sample_rate)` to the AdaptationSet even when the per-representation sample\nrates differ. This produces wrong audioSamplingRate values in the generated DASH manifest. Conversely\ntwo audio formats with the same sample rate but different (undefined) fps will not be hoisted.","fix":"Use `new Set(formats.map((format) => format[property]))` instead of `format.fps`.","locations":[{"ref":"src/utils/StreamingInfo.ts:213-222","code":" 213 | function hoistNumberAttributeIfPossible(\n 214 | formats: Format[],\n 215 | property: 'audio_sample_rate' | 'fps',\n 216 | hoisted: string[]\n 217 | ) {\n 218 | if (formats.length > 1 && new Set(formats.map((format) => format.fps)).size === 1) {\n 219 | hoisted.push(property);\n 220 | return Number(formats[0][property]);\n 221 | }\n 222 | }","lines":10,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/StreamingInfo.ts#L213-L222"}]},
{"id":"ERR_1","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","difficulty":"medium","description":"signIn wraps logic in `new Promise(async (resolve, reject) => {...})`. Using an async executor is an anti-pattern: any throw inside the async executor (other than the explicitly caught oauth.init) becomes an unhandled rejection rather than rejecting the returned promise. Here the catch covers oauth.init, but the pattern is fragile and the eslint no-async-promise-executor rule flags it.","fix":"Use a non-async executor and call oauth.init().catch(reject) inside it, or refactor to register the once-listeners then `await this.oauth.init(credentials)` without the Promise wrapper.","locations":[{"ref":"src/core/Session.ts:662-679","code":" 662 | async signIn(credentials?: OAuth2Tokens): Promise<void> {\n 663 | return new Promise(async (resolve, reject) => {\n 664 | const error_handler: OAuth2AuthErrorEventHandler = (err) => reject(err);\n 665 | \n 666 | this.once('auth-error', error_handler);\n 667 | \n 668 | this.once('auth', () => {\n 669 | this.off('auth-error', error_handler);\n 670 | this.logged_in = true;\n 671 | resolve();\n 672 | });\n 673 | \n 674 | try {\n 675 | await this.oauth.init(credentials);\n 676 | } catch (err) {\n 677 | reject(err);\n 678 | }\n 679 | });","lines":18,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/Session.ts#L662-L679"}]},
{"id":"ERR_2","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","difficulty":"medium","description":"parseCommand swallows the 'class not found' case silently. When the key matches the command/endpoint/action regex but no parser exists (has_target_class is false), the function neither generates a runtime class nor invokes ERROR_HANDLER (unlike parseItem, which JIT-generates and reports). It simply continues the loop and ultimately returns undefined, so unknown commands disappear with no diagnostic, making parser-coverage regressions invisible.","fix":"When !has_target_class for a matched command key, either call generateRuntimeClass(...) as parseItem does, or at minimum emit an ERROR_HANDLER 'class_not_found' so missing commands are reported.","locations":[{"ref":"src/parser/parser.ts:709-721","code":" 709 | try {\n 710 | const has_target_class = hasParser(classname);\n 711 | if (has_target_class)\n 712 | return new (getParserByName(classname))(value);\n 713 | } catch (error) {\n 714 | ERROR_HANDLER({\n 715 | error,\n 716 | classname,\n 717 | classdata: value,\n 718 | error_type: 'parse'\n 719 | });\n 720 | }\n 721 | }","lines":13,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/parser.ts#L709-L721"}]},
{"id":"ERR_3","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","difficulty":"easy","description":"In #pollLivechat, when the response has no continuation_contents the code emits 'error', 'end' and calls stop() but does NOT return. Execution falls through to the `if (!(contents instanceof LiveChatContinuation))` block, which calls stop() and emits 'end' a second time. This results in duplicate 'end' (and the listener seeing the stream end twice) and relies on `undefined instanceof LiveChatContinuation` being false to avoid a crash.","fix":"Add a `return;` after the stop() call in the `if (!contents)` branch so the function does not continue into the instanceof check.","locations":[{"ref":"src/parser/youtube/LiveChat.ts:149-159","code":" 149 | if (!contents) {\n 150 | this.emit('error', new InnertubeError('Unexpected live chat incremental continuation response', response));\n 151 | this.emit('end');\n 152 | this.stop();\n 153 | }\n 154 | \n 155 | if (!(contents instanceof LiveChatContinuation)) {\n 156 | this.stop();\n 157 | this.emit('end');\n 158 | return;\n 159 | }","lines":11,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/LiveChat.ts#L149-L159"}]},
{"id":"ERR_4","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"medium","difficulty":"easy","description":"`#processJsonPayload` calls `JSON.parse(json_body)` with no try/catch. It is only reached when the\ncaller-supplied `Content-Type` header equals `application/json` and the body is a string, but the body\nitself is not validated to be valid JSON. A malformed body (or a non-JSON body mislabelled as JSON)\nthrows a raw SyntaxError out of `fetch`, bypassing the library's InnertubeError contract.","fix":"Wrap the JSON.parse in try/catch and throw a descriptive InnertubeError on failure.","locations":[{"ref":"src/utils/HTTPClient.ts:160-161","code":" 160 | #processJsonPayload(json_body: string, session: Session): ProcessedJsonPayload {\n 161 | const parsed_payload = JSON.parse(json_body);","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/HTTPClient.ts#L160-L161"}]},
{"id":"ERR_5","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"medium","description":"In Actions.execute, when args.parse is falsy the method returns `{ success: response.ok, status_code: response.status, data: await response.json() }`. response.json() is called unconditionally even for non-OK responses and for endpoints that may return empty/non-JSON bodies, throwing a raw SyntaxError that masks the real HTTP status. Several managers then read response.data.* assuming success regardless of response.ok.","fix":"Guard JSON parsing (check content-type / response.ok, or wrap in try/catch) and surface the HTTP status when the body is not valid JSON.","locations":[{"ref":"src/core/Actions.ts:181-186","code":" 181 | // Mimics the Axios API using Fetch's Response object.\n 182 | return {\n 183 | success: response.ok,\n 184 | status_code: response.status,\n 185 | data: await response.json()\n 186 | };","lines":6,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/Actions.ts#L181-L186"}]},
{"id":"ERR_6","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"medium","description":"refreshAccessToken checks `response_data.error_code` to detect server errors, but getDeviceAndUserCode/pollForAccessToken use `response_data.error` (the OAuth2 standard field). The Google token endpoint returns the error under `error`, so a failed refresh that returns HTTP 200 with an `error` body (e.g. invalid_grant when the refresh token was revoked) is NOT detected; the code then sets access_token = undefined and silently emits update-credentials with broken tokens.","fix":"Check `response_data.error` (and/or error_code) and throw OAuth2Error when present before assigning the new tokens.","locations":[{"ref":"src/core/OAuth2.ts:225-233","code":" 225 | const response_data = await response.json();\n 226 | \n 227 | if (response_data.error_code)\n 228 | throw new OAuth2Error('Authorization server returned an error', response_data);\n 229 | \n 230 | this.oauth2_tokens.access_token = response_data.access_token;\n 231 | this.oauth2_tokens.expiry_date = new Date(Date.now() + response_data.expires_in * 1000).toISOString();\n 232 | \n 233 | this.#session.emit('update-credentials', { credentials: this.oauth2_tokens });","lines":9,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/OAuth2.ts#L225-L233"}]},
{"id":"ERR_7","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"medium","description":"getParserByName throws a generic Error with a custom `code = 'MODULE_NOT_FOUND'` for unknown classes. generator.parse() (renderer/array-renderer branches) and createRuntimeClass map renderer names to constructors via getParserByName without catching; if a renderer dependency failed to be generated (e.g. its introspection threw), this throw escapes the JIT-generated constructor and is only caught by parseItem's broad try/catch, turning a recoverable missing-dependency into a dropped node with a generic 'parse' error rather than the more informative class_not_found path.","fix":"In generator.parse, fall back to generateRuntimeClass for renderer names lacking a parser (or report class_not_found) instead of letting getParserByName throw mid-construction.","locations":[{"ref":"src/parser/parser.ts:203-213","code":" 203 | export function getParserByName(classname: string) {\n 204 | const ParserConstructor = RUNTIME_NODES.get(classname);\n 205 | \n 206 | if (!ParserConstructor) {\n 207 | const error = new Error(`Module not found: ${classname}`);\n 208 | (error as any).code = 'MODULE_NOT_FOUND';\n 209 | throw error;\n 210 | }\n 211 | \n 212 | return ParserConstructor;\n 213 | }","lines":11,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/parser.ts#L203-L213"},{"ref":"src/parser/generator.ts:685","code":" 685 | return should_optional ? Parser.parseItem(accessDataFromKeyPath({ data }, [ ...key_path, key ]), inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L685"},{"ref":"src/parser/generator.ts:690","code":" 690 | return should_optional ? Parser.parse(accessDataFromKeyPath({ data }, [ ...key_path, key ]), true, inference_type.renderers.map((type) => Parser.getParserByName(type))) : undefined;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L690"}]},
{"id":"ERR_8","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"easy","description":"#pollMetadata swallows all errors in a bare `catch {}` and silently retries forever (no retry counter, unlike #pollLivechat which caps at 10). A persistently failing /updated_metadata endpoint causes an infinite 2-second-interval request loop with no 'error' event surfaced to the consumer.","fix":"Emit an 'error' event and apply a bounded retry count similar to #pollLivechat, or at least surface the error so consumers can react.","locations":[{"ref":"src/parser/youtube/LiveChat.ts:243-247","code":" 243 | } catch {\n 244 | await this.#wait(2000);\n 245 | if (this.running)\n 246 | this.#pollMetadata();\n 247 | }","lines":5,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/LiveChat.ts#L243-L247"}]},
{"id":"ERR_9","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"very_easy","description":"In `download`, the chunked path declares `let cancel: AbortController;` but only assigns it inside the\n`pull` callback. The stream's `cancel(reason)` handler calls `cancel.abort(reason)` unconditionally. If\nthe consumer cancels the stream before the first `pull` ever runs, `cancel` is still `undefined` and\n`cancel.abort` throws a TypeError from within the cancel handler.","fix":"Guard the abort: `cancel?.abort(reason)`, or initialize the AbortController in `start()`.","locations":[{"ref":"src/utils/FormatUtils.ts:64-124","code":" 64 | let cancel: AbortController;\n 65 | \n 66 | return new Platform.shim.ReadableStream<Uint8Array>({\n 67 | start() {\n 68 | },\n 69 | pull: async (controller) => {\n 70 | if (must_end) {\n 71 | controller.close();\n 72 | return;\n 73 | }\n 74 | \n 75 | if ((chunk_end >= (format.content_length ? format.content_length : 0)) || options.range) {\n 76 | must_end = true;\n 77 | }\n 78 | \n 79 | return new Promise(async (resolve, reject) => {\n 80 | try {\n 81 | cancel = new AbortController();\n 82 | \n 83 | const response = await actions.session.http.fetch_function(`${format_url}&cpn=${cpn}&range=${chunk_start}-${chunk_end || ''}`, {\n 84 | method: 'GET',\n 85 | headers: {\n 86 | ...Constants.STREAM_HEADERS\n 87 | // XXX: use YouTube's range parameter instead of a Range header.\n 88 | // Range: `bytes=${chunk_start}-${chunk_end}`\n 89 | },\n 90 | signal: cancel.signal\n 91 | });\n 92 | \n 93 | // Throw if the response is not 2xx\n 94 | if (!response.ok)\n 95 | throw new InnertubeError('The server responded with a non 2xx status code', {\n 96 | error_type: 'FETCH_FAILED',\n 97 | response\n 98 | });\n 99 | \n 100 | const body = response.body;\n 101 | \n 102 | if (!body)\n 103 | throw new InnertubeError('Could not get ReadableStream from fetch Response.', {\n 104 | error_type: 'FETCH_FAILED',\n 105 | response\n 106 | });\n 107 | \n 108 | for await (const chunk of streamToIterable(body)) {\n 109 | controller.enqueue(chunk);\n 110 | }\n 111 | \n 112 | chunk_start = chunk_end + 1;\n 113 | chunk_end += chunk_size;\n 114 | \n 115 | resolve();\n 116 | \n 117 | } catch (e: any) {\n 118 | reject(e);\n 119 | }\n 120 | });\n 121 | },\n 122 | async cancel(reason) {\n 123 | cancel.abort(reason);\n 124 | }","lines":61,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/FormatUtils.ts#L64-L124"}]},
{"id":"ERR_10","type":"incorrect_error_handling","type_label":"Incorrect Error Handling","severity":"low","difficulty":"medium","description":"`getStringBetweenStrings` builds a RegExp with the `s` (dotAll) flag from escaped delimiters. While the\ndelimiters are escaped, the helper is used widely on external response bodies (OTF segment headers,\nstoryboard responses). When no match is found it returns `undefined`, and several callers\n(StreamingInfo getColorInfo, getFormatGroupings) chain `?.split(...)` / `.startsWith(...)` — those use\noptional chaining, but `getStringBetweenStrings(format.mime_type, 'codecs=\"', '\"')` may return\nundefined and is then assigned directly to a typed `codecs?: string` field that the DASH layer\nstringifies, silently emitting `codecs=\"undefined\"` is avoided only because undefined props are skipped.\nThe OTF parse path (parseInt on `trimmed_segment_duration`) can yield NaN durations with no validation.","fix":"Validate parseInt results (skip/throw on NaN) when building the OTF segment timeline.","locations":[{"ref":"src/utils/StreamingInfo.ts:258-276","code":" 258 | const segment_durations = [];\n 259 | for (const segment_duration_string of segment_duration_strings) {\n 260 | const trimmed_segment_duration = segment_duration_string.trim();\n 261 | if (trimmed_segment_duration.length === 0) {\n 262 | continue;\n 263 | }\n 264 | \n 265 | let repeat_count;\n 266 | \n 267 | const repeat_count_string = getStringBetweenStrings(trimmed_segment_duration, '(r=', ')');\n 268 | if (repeat_count_string) {\n 269 | repeat_count = parseInt(repeat_count_string);\n 270 | }\n 271 | \n 272 | segment_durations.push({\n 273 | duration: parseInt(trimmed_segment_duration),\n 274 | repeat_count\n 275 | });\n 276 | }","lines":19,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/StreamingInfo.ts#L258-L276"}]},
{"id":"API_1","type":"api_misuse","type_label":"Api Misuse","severity":"medium","difficulty":"very_easy","description":"setTokens aliases the caller's object (`const tokensMod = tokens;` is not a copy) and then mutates it: it overwrites `expiry_date` and `delete`s `expires_in` from the user-supplied tokens object. Callers that reuse the same OAuth2Tokens object (e.g. passing it to multiple sessions or re-passing after refresh) get silently mutated state, and the `expires_in` they provided disappears.","fix":"Clone the input first: `const tokensMod = { ...tokens };` so the caller's object is never mutated.","locations":[{"ref":"src/core/OAuth2.ts:85-103","code":" 85 | setTokens(tokens: OAuth2Tokens): void {\n 86 | const tokensMod = tokens;\n 87 | \n 88 | // Convert access token remaining lifetime to ISO string\n 89 | if (tokensMod.expires_in) {\n 90 | tokensMod.expiry_date = new Date(Date.now() + tokensMod.expires_in * 1000).toISOString();\n 91 | delete tokensMod.expires_in; // We don't need this anymore\n 92 | }\n 93 | \n 94 | if (!this.validateTokens(tokensMod))\n 95 | throw new OAuth2Error('Invalid tokens provided.');\n 96 | \n 97 | this.oauth2_tokens = tokensMod;\n 98 | \n 99 | if (tokensMod.client) {\n 100 | Log.info(TAG, 'Using provided client id and secret.');\n 101 | this.client_id = tokensMod.client;\n 102 | }\n 103 | }","lines":19,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/OAuth2.ts#L85-L103"}]},
{"id":"API_2","type":"api_misuse","type_label":"Api Misuse","severity":"medium","difficulty":"medium","description":"parseActions and LiveChatContinuation mutate the caller's raw response objects via `delete action.clickTrackingParams`. parseResponse passes the original IRawResponse subobjects, so this deletes keys from the user's input data in place. If the same raw response is parsed twice or inspected by the caller afterwards, fields are silently gone; it also makes parsing non-idempotent.","fix":"Copy the relevant fields instead of deleting from the source, e.g. map to `{ ...action, clickTrackingParams: undefined }` or destructure out clickTrackingParams without mutating the original.","locations":[{"ref":"src/parser/parser.ts:788-796","code":" 788 | export function parseActions(data: RawData) {\n 789 | if (Array.isArray(data)) {\n 790 | return parse(data.map((action) => {\n 791 | delete action.clickTrackingParams;\n 792 | return action;\n 793 | }));\n 794 | }\n 795 | return new SuperParsedResult(parseItem(data));\n 796 | }","lines":9,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/parser.ts#L788-L796"},{"ref":"src/parser/continuations.ts:176-179","code":" 176 | this.actions = Parser.parse(data.actions?.map((action: any) => {\n 177 | delete action.clickTrackingParams;\n 178 | return action;\n 179 | }), true) || observe<YTNode>([]);","lines":4,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/continuations.ts#L176-L179"}]},
{"id":"API_3","type":"api_misuse","type_label":"Api Misuse","severity":"medium","difficulty":"medium","description":"The Cloudflare Worker cache uses a bare string `key` with the Cache API. `cache.match(key)`,\n`cache.put(key, ...)` and `cache.delete(key)` require a full URL string or Request; a bare key like\n`youtube_session_data` is not a valid request URL and Workers will throw \"Cache API got a request\nwith a non-http(s) URL\". Additionally `set` does not `await cache.put(...)`, so write failures are\nsilently swallowed and the write may not have completed when `set` resolves.","fix":"Wrap the key in a synthetic URL (e.g. `new Request('https://ytjs.cache/' + encodeURIComponent(key))`) for match/put/delete, and `await cache.put(...)` in set.","locations":[{"ref":"src/platform/cf-worker.ts:19-38","code":" 19 | async get(key: string) {\n 20 | const cache = await caches.open('yt-api');\n 21 | \n 22 | const response = await cache.match(key);\n 23 | if (!response) return undefined;\n 24 | \n 25 | return response.arrayBuffer();\n 26 | }\n 27 | \n 28 | async set(key: string, value: ArrayBuffer) {\n 29 | \n 30 | const cache = await caches.open('yt-api');\n 31 | cache.put(key, new Response(value));\n 32 | }\n 33 | \n 34 | async remove(key: string) {\n 35 | const cache = await caches.open('yt-api');\n 36 | \n 37 | await cache.delete(key);\n 38 | }","lines":20,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/platform/cf-worker.ts#L19-L38"}]},
{"id":"API_4","type":"api_misuse","type_label":"Api Misuse","severity":"low","difficulty":"very_easy","description":"music_tracks uses `info_row.info_row_expand_status_key?.indexOf(...) !== -1`. When info_row_expand_status_key is undefined the optional chain yields undefined, and `undefined !== -1` is true, so the artist/license branch is entered erroneously. However this branch is only reached inside the `else` where info_row_expand_status_key was already confirmed defined (line 444 checks `=== undefined`), so it happens to be safe — but the pattern is fragile and `indexOf(...) !== -1` should be `.includes(...)`.","fix":"Use `info_row.info_row_expand_status_key.includes('...')` for clarity; the surrounding else already guarantees it is defined.","locations":[{"ref":"src/parser/youtube/VideoInfo.ts:455-462","code":" 455 | if (info_row.info_row_expand_status_key?.indexOf('structured-description-music-section-artists-row-state-id') !== -1) {\n 456 | artist = info_row.default_metadata?.toString() || info_row.expanded_metadata?.toString();\n 457 | if (channelId === undefined) {\n 458 | const endpoint = info_row.default_metadata?.endpoint || info_row.expanded_metadata?.endpoint;\n 459 | channelId = endpoint?.payload?.browseId;\n 460 | }\n 461 | }\n 462 | if (info_row.info_row_expand_status_key?.indexOf('structured-description-music-section-licenses-row-state-id') !== -1) {","lines":8,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/VideoInfo.ts#L455-L462"}]},
{"id":"PERF_1","type":"performance","type_label":"Performance","severity":"low","difficulty":"easy","description":"`new Text(data.longBylineText)` is constructed up to three separate times (lines 56, 57, 65), and `new Text(data.lengthText)` twice (lines 52-53), re-parsing the same raw node repeatedly. Text construction maps/joins runs and builds NavigationEndpoints, so this is wasted work per item.","fix":"Build each Text once into a local const and reuse it.","locations":[{"ref":"src/parser/classes/PlaylistPanelVideo.ts:51-65","code":" 51 | this.duration = {\n 52 | text: new Text(data.lengthText).toString(),\n 53 | seconds: timeToSeconds(new Text(data.lengthText).toString())\n 54 | };\n 55 | \n 56 | const album = new Text(data.longBylineText).runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('MPR'));\n 57 | const artists = new Text(data.longBylineText).runs?.filter((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));\n 58 | \n 59 | this.author = new Text(data.shortBylineText).toString();\n 60 | \n 61 | if (album) {\n 62 | this.album = {\n 63 | id: (album as TextRun).endpoint?.payload?.browseId,\n 64 | name: (album as TextRun).text,\n 65 | year: new Text(data.longBylineText).runs?.slice(-1)[0].text,","lines":15,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/PlaylistPanelVideo.ts#L51-L65"}]},
{"id":"PERF_2","type":"performance","type_label":"Performance","severity":"low","difficulty":"hard","description":"mergeKeyInfo detects changes via repeated full JSON.stringify comparisons of inference subtrees (object/array-object/array-primitive/misc/primitive branches). For deeply nested object key_info this serializes the entire subtree on every merge at every recursion level, giving roughly O(n^2) serialization cost during JIT class evolution. This only runs on the unknown-class path but can be noticeable for large responses with many novel renderers.","fix":"Track `did_change` structurally from the recursive merge results (e.g. propagate a changed flag / compare changed_keys length) instead of stringifying whole subtrees.","locations":[{"ref":"src/parser/generator.ts:769","code":" 769 | const did_change = JSON.stringify(resolved_key) !== JSON.stringify(type);","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L769"},{"ref":"src/parser/generator.ts:867","code":" 867 | const did_change = JSON.stringify(resolved_key) !== JSON.stringify(type);","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L867"},{"ref":"src/parser/generator.ts:907","code":" 907 | const did_change = JSON.stringify(resolved_key) !== JSON.stringify(type);","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L907"},{"ref":"src/parser/generator.ts:955","code":" 955 | const did_change = JSON.stringify(resolved_key) !== JSON.stringify(type);","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/generator.ts#L955"}]},
{"id":"PERF_3","type":"performance","type_label":"Performance","severity":"low","difficulty":"easy","description":"Settings.setting_options and getSettingOption each iterate every section/element and call `el.as(SettingsOptions).options` repeatedly; setting_options builds the list via repeated `options.concat(...)` (O(n^2) array copies). getSettingOption is also invoked by the error path of itself only, but setting_options is recomputed on every access (getter) and again inside getSettingOption's failure path, doubling the traversal on errors.","fix":"Use a single push-based accumulation (`options.push(...el.as(SettingsOptions).options)`) and consider memoizing the parsed options list.","locations":[{"ref":"src/parser/youtube/Settings.ts:104-119","code":" 104 | get setting_options(): string[] {\n 105 | if (!this.sections)\n 106 | throw new InnertubeError('Sections not available');\n 107 | \n 108 | let options: any[] = [];\n 109 | \n 110 | for (const section of this.sections) {\n 111 | if (!section.contents) continue;\n 112 | for (const el of section.contents) {\n 113 | if (el.as(SettingsOptions).options)\n 114 | options = options.concat(el.as(SettingsOptions).options);\n 115 | }\n 116 | }\n 117 | \n 118 | return options.map((opt) => opt.title?.toString()).filter((el) => el);\n 119 | }","lines":16,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/Settings.ts#L104-L119"}]},
{"id":"PERF_4","type":"performance","type_label":"Performance","severity":"low","difficulty":"very_easy","description":"`deepCompare` uses `keys.some(...)` and returns `true` as soon as ANY key matches, despite the name and\ndoc implying a full deep comparison. For object-valued keys it does a `JSON.stringify` equality which is\norder-sensitive and O(n) per key. The use of `some` (vs `every`) means it returns true when objects\nare mostly different but share one equal field — almost certainly an inverted/loose semantics bug for a\nfunction named \"deepCompare\".","fix":"Use `every` for true equality semantics (or rename/clarify), and avoid JSON.stringify-based comparison for unordered objects.","locations":[{"ref":"src/utils/Utils.ts:67-76","code":" 67 | export function deepCompare(obj1: any, obj2: any): boolean {\n 68 | const keys = Reflect.ownKeys(obj1);\n 69 | return keys.some((key) => {\n 70 | const is_text = obj2[key] instanceof Text;\n 71 | if (!is_text && typeof obj2[key] === 'object') {\n 72 | return JSON.stringify(obj1[key]) === JSON.stringify(obj2[key]);\n 73 | }\n 74 | return obj1[key] === (is_text ? obj2[key].toString() : obj2[key]);\n 75 | });\n 76 | }","lines":10,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/Utils.ts#L67-L76"}]},
{"id":"DEAD_1","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"very_easy","description":"Player.LIBRARY_VERSION static getter returns the literal 14 and is never referenced anywhere in the source. Player cache versioning actually uses packageInfo.version (a string) in cache()/fromCache(). The stale numeric constant is dead and misleading—it suggests a versioning scheme that is no longer used.","fix":"Remove the LIBRARY_VERSION getter, or wire it back into Player cache (de)serialization if numeric versioning is intended.","locations":[{"ref":"src/core/Player.ts:289-291","code":" 289 | static get LIBRARY_VERSION(): number {\n 290 | return 14;\n 291 | }","lines":3,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/Player.ts#L289-L291"}]},
{"id":"DEAD_2","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"medium","description":"getAll's ObservedArray type declares `del_items?: boolean` (optional) but the proxy implementation signature is `(rule: object, del_items: boolean)`. More importantly, callers using `getAll(rule)` pass del_items as undefined which is falsy (fine), but the inconsistency with `get` (which declares del_item optional and is) is a latent API mismatch. Also the `arrayProps` array in Maybe.arrayOfMaybe is always empty, so `Reflect.has(arrayProps, prop)` is effectively dead — every property (including array methods like length/map) is wrapped in a Maybe, which is likely not intended.","fix":"For arrayOfMaybe, only wrap numeric index access in Maybe and pass through real array properties/methods (length, iterator, etc.). Align getAll's implementation signature with its optional declaration.","locations":[{"ref":"src/parser/helpers.ts:178-188","code":" 178 | arrayOfMaybe(): Maybe[] {\n 179 | const arrayProps: any[] = [];\n 180 | return new Proxy(this.array(), {\n 181 | get(target, prop) {\n 182 | if (Reflect.has(arrayProps, prop)) {\n 183 | return Reflect.get(target, prop);\n 184 | }\n 185 | return new Maybe(Reflect.get(target, prop));\n 186 | }\n 187 | });\n 188 | }","lines":11,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/helpers.ts#L178-L188"},{"ref":"src/parser/helpers.ts:443-453","code":" 443 | if (prop == 'getAll') {\n 444 | return (rule: object, del_items: boolean) => (\n 445 | target.filter((obj, index) => {\n 446 | const match = deepCompare(rule, obj);\n 447 | if (match && del_items) {\n 448 | target.splice(index, 1);\n 449 | }\n 450 | return match;\n 451 | })\n 452 | );\n 453 | }","lines":11,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/helpers.ts#L443-L453"}]},
{"id":"DEAD_3","type":"dead_code","type_label":"Dead Code","severity":"low","difficulty":"very_easy","description":"The `return false;` at the end of removeVideo is unreachable. The while loop only ever exits by returning true (token found and processed) or by throwing (either 'Failed to remove...' or 'Unable to find video...'). pagesToLoad decrements to 0 inside the loop and the else branch throws, so control never leaves the loop normally.","fix":"Remove the unreachable trailing `return false;` or restructure the loop so the false case is reachable/meaningful.","locations":[{"ref":"src/parser/youtube/History.ts:38-86","code":" 38 | while (pagesToLoad > 0) {\n 39 | let feedbackToken;\n 40 | \n 41 | for (const section of this.sections) {\n 42 | for (const content of section.contents) {\n 43 | if (content.is(Video)) {\n 44 | if (content.video_id === video_id && content.menu) {\n 45 | feedbackToken = content.menu.top_level_buttons[0].as(Button).endpoint.payload.feedbackToken;\n 46 | break;\n 47 | }\n 48 | } else if (content.is(LockupView)) {\n 49 | if (content.content_id === video_id) {\n 50 | const listItems = content.metadata?.menu_button?.on_tap?.payload.panelLoadingStrategy.inlineContent.sheetViewModel.content.listViewModel.listItems;\n 51 | const listItem = listItems.find((video: { listItemViewModel: { title: { content: string; }; }; }) => video.listItemViewModel?.title.content === 'Remove from watch history');\n 52 | feedbackToken = listItem.listItemViewModel.rendererContext.commandContext.onTap.innertubeCommand.feedbackEndpoint.feedbackToken;\n 53 | break;\n 54 | }\n 55 | }\n 56 | }\n 57 | \n 58 | if (feedbackToken) {\n 59 | break;\n 60 | }\n 61 | }\n 62 | \n 63 | if (feedbackToken) {\n 64 | const body = { feedbackTokens: [ feedbackToken ] };\n 65 | const response = await this.actions.execute('/feedback', body);\n 66 | const data = response.data;\n 67 | \n 68 | if (!data.feedbackResponses[0].isProcessed) {\n 69 | throw new Error('Failed to remove video from watch history');\n 70 | }\n 71 | \n 72 | return true;\n 73 | }\n 74 | \n 75 | if (--pagesToLoad > 0) {\n 76 | try {\n 77 | Object.assign(this, await this.getContinuation());\n 78 | } catch {\n 79 | throw new Error('Unable to find video in watch history');\n 80 | }\n 81 | } else {\n 82 | throw new Error('Unable to find video in watch history');\n 83 | }\n 84 | }\n 85 | \n 86 | return false;","lines":49,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/History.ts#L38-L86"}]},
{"id":"DEAD_4","type":"dead_code","type_label":"Dead Code","severity":"negligible","difficulty":"easy","description":"`hasOwnProperty`, `isPrototypeOf`, `toString`, `valueOf`, `split`, `parse`, `stringify`, `log`,\n`MemberExpression`, `function` and `prototype` are listed in `jsBuiltIns` alongside genuine globals.\nSeveral of these (e.g. `MemberExpression`, `function`, `log`, `split`, `parse`, `stringify`) are not\nglobal identifiers and treating them as built-ins means a YouTube player variable literally named one\nof these would be wrongly skipped as a resolved dependency, potentially dropping a real dependency\nfrom the extracted script.","fix":"Restrict the set to actual global identifiers; remove method/keyword names that are not globals to avoid masking real dependencies.","locations":[{"ref":"src/utils/javascript/helpers.ts:6-21","code":" 6 | export const jsBuiltIns = new Set([\n 7 | 'AbortController', 'AbortSignal', 'Array', 'ArrayBuffer', 'AsyncContext', 'Atomics', 'AudioContext', 'BigInt', 'BigInt64Array', 'BigUint64Array',\n 8 | 'Blob', 'Boolean', 'BroadcastChannel', 'Buffer', 'CanvasRenderingContext2D', 'clearImmediate', 'clearInterval', 'clearTimeout', 'confirm',\n 9 | 'console', 'Crypto', 'CustomEvent', 'DataView', 'Date', 'decodeURI', 'decodeURIComponent', 'document', 'Element', 'encodeURI',\n 10 | 'encodeURIComponent', 'Error', 'escape', 'eval', 'Event', 'EventTarget', 'fetch', 'File', 'FileReader', 'Float32Array', 'Float64Array',\n 11 | 'FormData', 'function', 'global', 'globalThis', 'hasOwnProperty', 'Headers', 'History', 'HTMLElement', 'HTMLCollection', 'IDBKeyRange',\n 12 | 'Infinity', 'Int16Array', 'Int32Array', 'Int8Array', 'Intl', 'IntersectionObserver', 'isFinite', 'isNaN', 'isPrototypeOf', 'JSON',\n 13 | 'location', 'log', 'Map', 'Math', 'MediaRecorder', 'MediaSource', 'MediaStream', 'MemberExpression', 'MutationObserver', 'NaN',\n 14 | 'navigator', 'Node', 'NodeList', 'Number', 'Object', 'OfflineAudioContext', 'parse', 'parseFloat', 'parseInt', 'Performance',\n 15 | 'process', 'Promise', 'prompt', 'prototype', 'Proxy', 'ReadableStream', 'Reflect', 'RegExp', 'requestAnimationFrame', 'requestIdleCallback',\n 16 | 'Request', 'Response', 'ResizeObserver', 'Screen', 'setImmediate', 'setInterval', 'setTimeout', 'SharedArrayBuffer', 'SharedWorker',\n 17 | 'SourceBuffer', 'split', 'String', 'stringify', 'structuredClone', 'SubtleCrypto', 'Symbol', 'TextDecoder', 'TextEncoder', 'this',\n 18 | 'toString', 'TransformStream', 'Uint16Array', 'Uint32Array', 'Uint8Array', 'Uint8ClampedArray', 'undefined', 'unescape', 'URL',\n 19 | 'URLSearchParams', 'valueOf', 'WeakMap', 'WeakSet', 'WebAssembly', 'WebGLRenderingContext', 'window', 'Worker', 'WritableStream',\n 20 | 'XMLHttpRequest', 'alert', 'arguments', 'atob', 'btoa', 'cancelAnimationFrame', 'cancelIdleCallback', 'queueMicrotask'\n 21 | ]);","lines":16,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/utils/javascript/helpers.ts#L6-L21"}]},
{"id":"CMT_1","type":"invalid_comment","type_label":"Invalid Comment","severity":"low","difficulty":"very_easy","description":"Misleading log message: the branch checks `command_run.onTap`, but the warning/skip text refers to a non-existent `\"doTap\"` property. The message will confuse anyone debugging skipped command runs.","fix":"Change the message to reference `onTap`, e.g. 'Skipping command run as it is missing the \"onTap\" property.'","locations":[{"ref":"src/parser/classes/misc/Text.ts:204-208","code":" 204 | } else {\n 205 | Log.debug(TAG, 'Skipping command run as it is missing the \"doTap\" property.', {\n 206 | command_run,\n 207 | input_data: data\n 208 | });","lines":5,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/classes/misc/Text.ts#L204-L208"}]},
{"id":"CMT_2","type":"invalid_comment","type_label":"Invalid Comment","severity":"negligible","difficulty":"hard","description":"Comment in getStreamingData/getBasicInfo path: extra_payload has a trailing-whitespace line `client: options?.client ` and the surrounding code duplicates the identical playbackContext/serviceIntegrityDimensions block across getInfo, getBasicInfo, Kids.getInfo, Music.#fetchInfoFromVideoId and #fetchInfoFromEndpoint. This is duplicated logic that should be factored into a helper; divergence between copies (e.g. one forgetting po_token) would be a silent bug.","fix":"Extract a buildPlaybackContextPayload(session, options) helper and reuse it across Innertube, Kids and Music to avoid copy-paste drift.","locations":[{"ref":"src/Innertube.ts:92-112","code":" 92 | const extra_payload: Record<string, any> = {\n 93 | playbackContext: {\n 94 | contentPlaybackContext: {\n 95 | vis: 0,\n 96 | splay: false,\n 97 | lactMilliseconds: '-1',\n 98 | signatureTimestamp: session.player?.signature_timestamp\n 99 | }\n 100 | },\n 101 | client: options?.client\n 102 | };\n 103 | \n 104 | if (options?.po_token) {\n 105 | extra_payload.serviceIntegrityDimensions = {\n 106 | poToken: options.po_token\n 107 | };\n 108 | } else if (session.po_token) {\n 109 | extra_payload.serviceIntegrityDimensions = {\n 110 | poToken: session.po_token\n 111 | };\n 112 | }","lines":21,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/Innertube.ts#L92-L112"},{"ref":"src/Innertube.ts:137-157","code":" 137 | const extra_payload: Record<string, any> = {\n 138 | playbackContext: {\n 139 | contentPlaybackContext: {\n 140 | vis: 0,\n 141 | splay: false,\n 142 | lactMilliseconds: '-1',\n 143 | signatureTimestamp: session.player?.signature_timestamp\n 144 | }\n 145 | },\n 146 | client: options?.client \n 147 | };\n 148 | \n 149 | if (options?.po_token) {\n 150 | extra_payload.serviceIntegrityDimensions = {\n 151 | poToken: options.po_token\n 152 | };\n 153 | } else if (session.po_token) {\n 154 | extra_payload.serviceIntegrityDimensions = {\n 155 | poToken: session.po_token\n 156 | };\n 157 | }","lines":21,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/Innertube.ts#L137-L157"}]},
{"id":"DUP_1","type":"duplicate_code","type_label":"Duplicate Code","severity":"medium","difficulty":"medium","description":"The SegmentedLikeDislikeButtonView vs SegmentedLikeDislikeButton handling in like(), dislike() and removeRating() is heavily copy-pasted (~25-40 lines each): same pattern of fetching the view button, validating default_button/on_tap, building a NavigationEndpoint from `on_tap.payload.commands.find((cmd) => cmd.innertubeCommand)`, then a parallel legacy ToggleButton path. Divergence risk is high (e.g. dislike() additionally checks `like_button` at line 278 while like() does not).","fix":"Extract a private helper that, given a target ('like'|'dislike'|'remove'), resolves the correct button/endpoint for both the view and legacy representations, and call it from the three public methods.","locations":[{"ref":"src/parser/youtube/VideoInfo.ts:232-362","code":" 232 | async like(): Promise<ApiResponse> {\n 233 | const segmented_like_dislike_button_view = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButtonView);\n 234 | \n 235 | if (segmented_like_dislike_button_view) {\n 236 | const button = segmented_like_dislike_button_view?.like_button?.toggle_button;\n 237 | \n 238 | if (!button || !button.default_button || !segmented_like_dislike_button_view.like_button)\n 239 | throw new InnertubeError('Like button not found', { video_id: this.basic_info.id });\n 240 | \n 241 | const like_status = segmented_like_dislike_button_view.like_button.like_status_entity.like_status;\n 242 | \n 243 | if (like_status === 'LIKE')\n 244 | throw new InnertubeError('This video is already liked', { video_id: this.basic_info.id });\n 245 | \n 246 | if (!button.default_button.on_tap)\n 247 | throw new InnertubeError('onTap command not found', { video_id: this.basic_info.id });\n 248 | \n 249 | const endpoint = new NavigationEndpoint(button.default_button.on_tap.payload.commands.find((cmd: RawNode) => cmd.innertubeCommand));\n 250 | \n 251 | return await endpoint.call(this.actions);\n 252 | }\n 253 | \n 254 | const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton);\n 255 | const button = segmented_like_dislike_button?.like_button;\n 256 | \n 257 | if (!button)\n 258 | throw new InnertubeError('Like button not found', { video_id: this.basic_info.id });\n 259 | \n 260 | if (!button.is(ToggleButton))\n 261 | throw new InnertubeError('Like button is not a toggle button. This action is likely disabled for this video.', { video_id: this.basic_info.id });\n 262 | \n 263 | if (button.is_toggled)\n 264 | throw new InnertubeError('This video is already liked', { video_id: this.basic_info.id });\n 265 | \n 266 | return await button.endpoint.call(this.actions);\n 267 | }\n 268 | \n 269 | /**\n 270 | * Dislikes the video.\n 271 | */\n 272 | async dislike(): Promise<ApiResponse> {\n 273 | const segmented_like_dislike_button_view = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButtonView);\n 274 | \n 275 | if (segmented_like_dislike_button_view) {\n 276 | const button = segmented_like_dislike_button_view?.dislike_button?.toggle_button;\n 277 | \n 278 | if (!button || !button.default_button || !segmented_like_dislike_button_view.dislike_button || !segmented_like_dislike_button_view.like_button)\n 279 | throw new InnertubeError('Dislike button not found', { video_id: this.basic_info.id });\n 280 | \n 281 | const like_status = segmented_like_dislike_button_view.like_button.like_status_entity.like_status;\n 282 | \n 283 | if (like_status === 'DISLIKE')\n 284 | throw new InnertubeError('This video is already disliked', { video_id: this.basic_info.id });\n 285 | \n 286 | if (!button.default_button.on_tap)\n 287 | throw new InnertubeError('onTap command not found', { video_id: this.basic_info.id });\n 288 | \n 289 | const endpoint = new NavigationEndpoint(button.default_button.on_tap.payload.commands.find((cmd: RawNode) => cmd.innertubeCommand));\n 290 | \n 291 | return await endpoint.call(this.actions);\n 292 | }\n 293 | \n 294 | const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton);\n 295 | const button = segmented_like_dislike_button?.dislike_button;\n 296 | \n 297 | if (!button)\n 298 | throw new InnertubeError('Dislike button not found', { video_id: this.basic_info.id });\n 299 | \n 300 | if (!button.is(ToggleButton))\n 301 | throw new InnertubeError('Dislike button is not a toggle button. This action is likely disabled for this video.', { video_id: this.basic_info.id });\n 302 | \n 303 | if (button.is_toggled)\n 304 | throw new InnertubeError('This video is already disliked', { video_id: this.basic_info.id });\n 305 | \n 306 | return await button.endpoint.call(this.actions);\n 307 | }\n 308 | \n 309 | /**\n 310 | * Removes like/dislike.\n 311 | */\n 312 | async removeRating(): Promise<ApiResponse> {\n 313 | let button;\n 314 | \n 315 | const segmented_like_dislike_button_view = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButtonView);\n 316 | \n 317 | if (segmented_like_dislike_button_view) {\n 318 | const toggle_button = segmented_like_dislike_button_view?.like_button?.toggle_button;\n 319 | \n 320 | if (!toggle_button || !toggle_button.default_button || !segmented_like_dislike_button_view.like_button)\n 321 | throw new InnertubeError('Like button not found', { video_id: this.basic_info.id });\n 322 | \n 323 | const like_status = segmented_like_dislike_button_view.like_button.like_status_entity.like_status;\n 324 | \n 325 | if (like_status === 'LIKE') {\n 326 | button = segmented_like_dislike_button_view?.like_button?.toggle_button;\n 327 | } else if (like_status === 'DISLIKE') {\n 328 | button = segmented_like_dislike_button_view?.dislike_button?.toggle_button;\n 329 | } else {\n 330 | throw new InnertubeError('This video is not liked/disliked', { video_id: this.basic_info.id });\n 331 | }\n 332 | \n 333 | if (!button || !button.toggled_button)\n 334 | throw new InnertubeError('Like/Dislike button not found', { video_id: this.basic_info.id });\n 335 | \n 336 | if (!button.toggled_button.on_tap)\n 337 | throw new InnertubeError('onTap command not found', { video_id: this.basic_info.id });\n 338 | \n 339 | const endpoint = new NavigationEndpoint(button.toggled_button.on_tap.payload.commands.find((cmd: RawNode) => cmd.innertubeCommand));\n 340 | \n 341 | return await endpoint.call(this.actions);\n 342 | }\n 343 | \n 344 | const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton);\n 345 | \n 346 | const like_button = segmented_like_dislike_button?.like_button;\n 347 | const dislike_button = segmented_like_dislike_button?.dislike_button;\n 348 | \n 349 | if (!like_button?.is(ToggleButton) || !dislike_button?.is(ToggleButton))\n 350 | throw new InnertubeError('Like/Dislike button is not a toggle button. This action is likely disabled for this video.', { video_id: this.basic_info.id });\n 351 | \n 352 | if (like_button?.is_toggled) {\n 353 | button = like_button;\n 354 | } else if (dislike_button?.is_toggled) {\n 355 | button = dislike_button;\n 356 | }\n 357 | \n 358 | if (!button)\n 359 | throw new InnertubeError('This video is not liked/disliked', { video_id: this.basic_info.id });\n 360 | \n 361 | return await button.toggled_endpoint.call(this.actions);\n 362 | }","lines":131,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/youtube/VideoInfo.ts#L232-L362"}]},
{"id":"DUP_2","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","difficulty":"medium","description":"The signed-in check `if (!this.#actions.session.logged_in) throw ...` is duplicated in nearly every InteractionManager and PlaylistManager method, with InteractionManager throwing a plain Error while PlaylistManager/AccountManager throw InnertubeError for the same condition. Inconsistent error types for the identical precondition make error handling for callers harder.","fix":"Factor the auth check into a shared private helper that throws a consistent InnertubeError.","locations":[{"ref":"src/core/managers/InteractionManager.ts:21-22","code":" 21 | if (!this.#actions.session.logged_in)\n 22 | throw new Error('You must be signed in to perform this operation.');","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/managers/InteractionManager.ts#L21-L22"},{"ref":"src/core/managers/PlaylistManager.ts:23-24","code":" 23 | if (!this.#actions.session.logged_in)\n 24 | throw new InnertubeError('You must be signed in to perform this operation.');","lines":2,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/managers/PlaylistManager.ts#L23-L24"}]},
{"id":"DUP_3","type":"duplicate_code","type_label":"Duplicate Code","severity":"low","difficulty":"medium","description":"The applyFilter method (string-or-ChipCloudChip resolution, is_selected early-return, endpoint guard, then construct a new feed) is duplicated nearly verbatim between ytmusic Search and ytmusic HomeFeed. The filters getter is also identical.","fix":"Move the shared chip-resolution + filters logic into a common base class or helper used by both Search and HomeFeed.","locations":[{"ref":"src/parser/ytmusic/Search.ts:86-111","code":" 86 | async applyFilter(target_filter: string | ChipCloudChip): Promise<Search> {\n 87 | let cloud_chip: ChipCloudChip | undefined;\n 88 | \n 89 | if (typeof target_filter === 'string') {\n 90 | cloud_chip = this.header?.chips?.as(ChipCloudChip).find((chip) => chip.text === target_filter);\n 91 | if (!cloud_chip)\n 92 | throw new InnertubeError('Could not find filter with given name.', { available_filters: this.filters });\n 93 | } else if (target_filter?.is(ChipCloudChip)) {\n 94 | cloud_chip = target_filter;\n 95 | }\n 96 | \n 97 | if (!cloud_chip)\n 98 | throw new InnertubeError('Invalid filter', { available_filters: this.filters });\n 99 | \n 100 | if (cloud_chip?.is_selected) return this;\n 101 | \n 102 | if (!cloud_chip.endpoint)\n 103 | throw new InnertubeError('Selected filter does not have an endpoint.');\n 104 | \n 105 | const response = await cloud_chip.endpoint.call(this.#actions, { client: 'YTMUSIC' });\n 106 | return new Search(response, this.#actions, true);\n 107 | }\n 108 | \n 109 | get filters(): string[] {\n 110 | return this.header?.chips?.as(ChipCloudChip).map((chip) => chip.text) || [];\n 111 | }","lines":26,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/ytmusic/Search.ts#L86-L111"},{"ref":"src/parser/ytmusic/HomeFeed.ts:61-85","code":" 61 | async applyFilter(target_filter: string | ChipCloudChip): Promise<HomeFeed> {\n 62 | let cloud_chip: ChipCloudChip | undefined;\n 63 | \n 64 | if (typeof target_filter === 'string') {\n 65 | cloud_chip = this.header?.chips?.as(ChipCloudChip).find((chip) => chip.text === target_filter);\n 66 | if (!cloud_chip)\n 67 | throw new InnertubeError('Could not find filter with given name.', { available_filters: this.filters });\n 68 | } else if (target_filter?.is(ChipCloudChip)) {\n 69 | cloud_chip = target_filter;\n 70 | }\n 71 | \n 72 | if (!cloud_chip)\n 73 | throw new InnertubeError('Invalid filter', { available_filters: this.filters });\n 74 | \n 75 | if (cloud_chip?.is_selected) return this;\n 76 | \n 77 | if (!cloud_chip.endpoint)\n 78 | throw new InnertubeError('Selected filter does not have an endpoint.');\n 79 | \n 80 | const response = await cloud_chip.endpoint.call(this.#actions, { client: 'YTMUSIC' });\n 81 | return new HomeFeed(response, this.#actions);\n 82 | }\n 83 | \n 84 | get filters(): string[] {\n 85 | return this.header?.chips?.as(ChipCloudChip).map((chip) => chip.text) || [];","lines":25,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/parser/ytmusic/HomeFeed.ts#L61-L85"}]},
{"id":"OTHER_1","type":"other","type_label":"Other","severity":"negligible","difficulty":"very_easy","description":"Stray double semicolon `primary_filters = this.memo.getType(ChipCloudChip);;` — a harmless typo but indicative of missing lint coverage on this line.","fix":"Remove the extra semicolon.","locations":[{"ref":"src/core/mixins/FilterableFeed.ts:42","code":" 42 | primary_filters = this.memo.getType(ChipCloudChip);;","lines":1,"link":"https://github.com/LuanRT/YouTube.js/blob/d252b36f7e0bf5926a52a589a73d4aa6392a9683/src/core/mixins/FilterableFeed.ts#L42"}]}
]</script>
<script type="application/json" id="PI">{"name":"YouTube.js","git_url":"https://github.com/LuanRT/YouTube.js.git","repo_url":"https://github.com/LuanRT/YouTube.js","commit":"d252b36f7e0bf5926a52a589a73d4aa6392a9683","commit_short":"d252b36f7e","commit_url":"https://github.com/LuanRT/YouTube.js/commit/d252b36f7e0bf5926a52a589a73d4aa6392a9683","branch":"main","generated":"2026-09-14","description":"Before installing, make sure your environment meets the prerequisites."}</script>
<div class="app">
<div class="top">
<div>
<h1>Code Audit Report — YouTube.js</h1>
<div class="meta">Generated 2026-09-14 — <span id="stats"></span></div>
</div>
<div class="top-right">
<div class="search-wrap">
<input type="search" id="search" placeholder="Search findings…" autocomplete="off">
<button id="search-scope" class="action-btn" type="button" title="Toggle: search everywhere vs. only in file/folder paths">📁 Paths only</button>
</div>
<label class="group-label">Group by
<select id="groupby">
<option value="type">Type</option>
<option value="severity">Severity</option>
<option value="file">File</option>
<option value="folder">Folder</option>
<option value="none">None (flat)</option>
</select>
</label>
</div>
</div>
<div class="project-info"><div class="pi-name">YouTube.js</div><div class="pi-desc">Before installing, make sure your environment meets the prerequisites.</div><div class="pi-row"><a class="pi-repo-btn" href="https://github.com/LuanRT/YouTube.js" target="_blank" rel="noopener">🔗 https://github.com/LuanRT/YouTube.js</a><span><span class="pi-k">branch</span> <code>main</code></span><span><span class="pi-k">commit</span> <a href="https://github.com/LuanRT/YouTube.js/commit/d252b36f7e0bf5926a52a589a73d4aa6392a9683" target="_blank" rel="noopener"><code>d252b36f7e</code></a></span></div></div>
<div class="filterbar" id="filterbar"></div>
<div id="progress-wrap">
<div id="progress-bar"><div id="progress-fill"></div></div>
<span id="progress-text"></span>
<button class="action-btn" data-action="collapse-all">⊟ Collapse all</button>
<button class="action-btn" data-action="expand-all">⊞ Expand all</button>
<button class="action-btn" data-action="download-md">⬇ MD</button>
<button class="action-btn" data-action="download-md-short">⬇ MD Short</button>
<button class="action-btn" data-action="download-toml">⬇ TOML</button>
</div>
<div id="hidden-bar"></div>
<div id="content"></div>
</div>
<script>
(function(){
'use strict';
// Data
const FINDINGS = JSON.parse(document.getElementById('D').textContent);
const PROJECT_INFO = JSON.parse(document.getElementById('PI').textContent);
const SEVS = ['critical','high','medium','low','negligible'];
const STATUSES = ['open','fixed','wont-fix','false-positive'];
const ALL_TYPES = [...new Set(FINDINGS.map(f => f.type))].sort(cmpTypes);
// Build lookup map for O(1) access
const FINDING_BY_ID = new Map(FINDINGS.map(f => [f.id, f]));
// Only severities actually present in the report — the filter bar hides the rest.
const PRESENT_SEVS = SEVS.filter(s => FINDINGS.some(f => (f.severity || 'medium') === s));
// Whether any finding is flagged easy — the "Quick wins" filter row is hidden otherwise.
const DIFFICULTY_ORDER = ['very_easy','easy','medium','hard'];
const DIFFICULTY_LBL = {very_easy:'⚡ Very Easy', easy:'Easy', medium:'Medium', hard:'Hard'};
const DIFFICULTY_CLS = {very_easy:'diff-very-easy', easy:'diff-easy', medium:'diff-medium', hard:'diff-hard'};
const PRESENT_DIFFS = DIFFICULTY_ORDER.filter(d => FINDINGS.some(f => f.difficulty === d));
const HAS_DIFFICULTY = PRESENT_DIFFS.length > 0;
const STORE_KEY = 'audit-status:' + location.href;
const STATUS_CLS = {open:'st-open', fixed:'st-fixed', 'wont-fix':'st-wont', 'false-positive':'st-fp'};
const STATUS_LBL = {open:'● open', fixed:'✓ fixed', 'wont-fix':"— won't fix", 'false-positive':'✗ false pos.'};
const STATUS_NEXT = {open:'fixed', fixed:'wont-fix', 'wont-fix':'false-positive', 'false-positive':'open'};
// State
const S = {
search: '', searchScope: 'all', groupBy: 'type',
hiddenSev: new Set(), hiddenType: new Set(),
hiddenStatus: new Set(), hiddenDiff: new Set(), hiddenGroup: new Set(),
collapsed: new Set(),
};
const statuses = {};
// Persistence
function loadStatuses() {
try { Object.assign(statuses, JSON.parse(localStorage.getItem(STORE_KEY) || '{}')); } catch {}
}
function saveStatuses() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(statuses)); } catch {}
}
function statusOf(id) { return statuses[id] || 'open'; }
// URL hash — debounced so rapid filter changes don't thrash history
let _hashTimer = 0;
function syncHash() {
clearTimeout(_hashTimer);
_hashTimer = setTimeout(() => {
const p = new URLSearchParams();
if (S.groupBy !== 'type') p.set('g', S.groupBy);
if (S.hiddenSev.size) p.set('hs', [...S.hiddenSev].join(','));
if (S.hiddenType.size) p.set('ht', [...S.hiddenType].join(','));
if (S.hiddenStatus.size) p.set('hst', [...S.hiddenStatus].join(','));
if (S.hiddenDiff.size) p.set('hd', [...S.hiddenDiff].join(','));
if (S.search) p.set('q', S.search);
if (S.searchScope !== 'all') p.set('qs', S.searchScope);
const str = p.toString();
history.replaceState(null, '', str ? '#' + str : location.pathname + location.search);
}, 300);
}
function loadHash() {
const p = new URLSearchParams(location.hash.slice(1));
if (p.has('g')) { S.groupBy = p.get('g'); document.getElementById('groupby').value = S.groupBy; }
if (p.has('q')) { S.search = p.get('q'); document.getElementById('search').value = S.search; }
if (p.has('qs')) { S.searchScope = p.get('qs'); }
document.getElementById('search-scope').classList.toggle('active', S.searchScope === 'locations');
if (p.has('hs')) p.get('hs') .split(',').filter(Boolean).forEach(v => S.hiddenSev.add(v));
if (p.has('ht')) p.get('ht') .split(',').filter(Boolean).forEach(v => S.hiddenType.add(v));
if (p.has('hst')) p.get('hst').split(',').filter(Boolean).forEach(v => S.hiddenStatus.add(v));
if (p.has('hd')) p.get('hd') .split(',').filter(Boolean).forEach(v => S.hiddenDiff.add(v));
}
// Helpers
function esc(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function highlight(text, q) {
if (!q) return esc(text);
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')', 'gi');
return String(text).split(re).map((p, i) => i % 2 ? '<mark>' + esc(p) + '</mark>' : esc(p)).join('');
}
// Render text with ```fenced``` code blocks and `inline` backticks,
// also paragraph-splitting on blank lines. Output is HTML-safe.
function renderMd(text, q) {
const src = String(text == null ? '' : text);
const FENCE = /```[ \t]*([A-Za-z0-9_+-]*)[ \t]*\r?\n([\s\S]*?)```/g;
const parts = [];
let last = 0, m;
while ((m = FENCE.exec(src)) !== null) {
parts.push({kind: 'text', value: src.slice(last, m.index)});
parts.push({kind: 'code', value: m[2].replace(/\r?\n$/, '')});
last = m.index + m[0].length;
}
parts.push({kind: 'text', value: src.slice(last)});
function renderInline(s) {
const re = /`([^`\n]+)`/g;
let out = '', i = 0, mm;
while ((mm = re.exec(s)) !== null) {
out += highlight(s.slice(i, mm.index), q);
out += '<code class="md-inline">' + highlight(mm[1], q) + '</code>';
i = mm.index + mm[0].length;
}
out += highlight(s.slice(i), q);
return out;
}
function renderTextChunk(s) {
const paragraphs = s.split(/\r?\n[ \t]*\r?\n/);
const out = [];
for (const p of paragraphs) {
const trimmed = p.replace(/^\s+|\s+$/g, '');
if (!trimmed) continue;
out.push('<p>' + renderInline(trimmed).replace(/\r?\n/g, '<br>') + '</p>');
}
return out.join('');
}
let html = '';
for (const p of parts) {
if (p.kind === 'text') html += renderTextChunk(p.value);
else html += '<pre class="md-code">' + highlight(p.value, q) + '</pre>';
}
return html;
}
function sevCls(s) { return SEVS.includes(s) ? 'sev-'+s : 'sev-negligible'; }
function sevLbl(s) { return s ? s[0].toUpperCase()+s.slice(1) : 'Medium'; }
function typeLabel(t) { return t.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase()); }
function isCustom(t) { return String(t).toLowerCase() === 'custom'; }
function typeCls(t) { return 'type-badge' + (isCustom(t) ? ' custom-type' : ''); }
// 'custom' findings are the user's own externally-sourced ones; pin them first
// everywhere types are listed so they never blend into the audit's own categories.
function cmpTypes(a, b) {
if (isCustom(a) !== isCustom(b)) return isCustom(a) ? -1 : 1;
return String(a).localeCompare(String(b));
}
// First sentence of text (up to and including the first period).
function firstSentence(text) {
const m = String(text).match(/\.(\s|$)/);
return m ? text.slice(0, text.indexOf(m[0]) + 1) : text;
}
// Render a "**Label:** value" line. GitHub/CommonMark only recognizes a
// ``` fence when it starts at the beginning of a line, so if the value
// itself opens with a fence, put it on its own line instead of right
// after the inline label - otherwise the fence marker gets glued onto
// "**Label:** ```" and never renders as a code block.
function mdField(label, value) {
value = value || '';
return /^```/.test(value.trim())
? '**' + label + ':**\n\n' + value
: '**' + label + ':** ' + value;
}
// Render the "**Locations:**" block. When a location has a commit-pinned
// permalink (GitHub/GitLab), just paste the bare URL on its own line -
// GitHub/GitLab auto-expand a line-range blob link into an inline code
// preview, so re-pasting the snippet as a fenced block would duplicate it.
// Forges without a detected permalink shape (or missing commit/repo info)
// fall back to the fenced ref+snippet so the location is still readable.
function mdLocations(locations) {
if (!locations.length) return '';
let out = '**Locations:**\n\n';
for (const l of locations) {
if (l.link) out += l.link + '\n\n';
else if (l.code != null) out += '```\n' + l.ref + '\n' + l.code + '\n```\n\n';
else out += '- `' + l.ref + '` _(file not found)_\n\n';
}
return out;
}
// Build full MD report for in-browser download.
function buildMdFull() {
const SORD = ['critical','high','medium','low','negligible'];
const today = new Date().toISOString().slice(0, 10);
let out = '# Code Audit Report\n\n_Generated ' + today + '_\n\n';
const typeMap = new Map();
for (const f of FINDINGS) {
if (!typeMap.has(f.type)) typeMap.set(f.type, []);
typeMap.get(f.type).push(f);
}
const sortedTypes = [...typeMap.keys()].sort(cmpTypes);
out += '## Summary\n\n| Type | Severity | Count |\n|------|----------|-------|\n';
for (const t of sortedTypes) {
const items = typeMap.get(t);
const topSev = items.map(f => f.severity || 'medium').reduce((best, s) => {
const bi = SORD.indexOf(best), si = SORD.indexOf(s);
return (si >= 0 && (bi < 0 || si < bi)) ? s : best;
}, 'negligible');
out += '| ' + typeLabel(t) + ' | ' + topSev + ' | ' + items.length + ' |\n';
}
for (const t of sortedTypes) {
out += '\n## ' + typeLabel(t) + '\n\n';
for (const f of typeMap.get(t)) {
const sevStr = f.severity ? ' `' + f.severity.toUpperCase() + '`' : '';
out += '### ' + f.id + sevStr + '\n\n';
out += mdField('Description', f.description) + '\n\n';
out += mdLocations(f.locations);
out += mdField('Fix', f.fix) + '\n\n---\n\n';
}
}
return out;
}
// Build MD short checklist for in-browser download.
function buildMdShort() {
const lines = [];
const ids = [];
const sorted = [...FINDINGS].sort((a, b) => a.type.localeCompare(b.type));
for (const f of sorted) {
ids.push(f.id);
const desc = firstSentence(f.description || '');
const locs = f.locations.map(l => l.ref);
const locStr = locs.length ? ' - [' + locs.join(', ') + ']' : '';
const sevStr = f.severity ? ' [' + f.severity.toUpperCase() + ']' : '';
lines.push('- [ ] - ' + f.id + sevStr + ' - ' + desc + locStr);
}
lines.push('', '## IDs only', '');
ids.forEach(id => lines.push('- [ ] - ' + id));
return lines.join('\n') + '\n';
}
function downloadText(text, filename, mime) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], {type: mime || 'text/plain'}));
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function tomlStr(s) {
return '"' + String(s == null ? '' : s)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t') + '"';
}
// Build a [project] header block from embedded PROJECT_INFO, so the exported
// findings.toml is self-contained (doesn't depend on a sibling project-info.toml
// that a second, separately-triggered download could silently fail to save).
// '' when there is no project info at all.
function buildProjectToml() {
const ORDER = ['name','description','git_url','repo_url','commit','commit_short','commit_url','branch','generated'];
const pi = PROJECT_INFO || {};
if (!Object.keys(pi).length) return '';
const lines = ['[project]'];
for (const k of ORDER) {
if (k in pi) lines.push(k + ' = ' + tomlStr(pi[k]));
}
return lines.join('\n') + '\n\n';
}
// Build TOML matching the original findings.toml format (re-parseable by toml_to_report.py).
function buildFindingsToml() {
function pad(key) { return (key + ' ').slice(0, 12); }
const chunks = [];
for (const f of FINDINGS) {
const lns = ['[[finding]]'];
// Pin the id so re-rendering this export - or filtering it down later - can't
// renumber findings that were already referenced by id elsewhere.
if (f.id && f.id !== '?') lns.push(pad('id') + '= ' + tomlStr(f.id));
lns.push(pad('type') + '= ' + tomlStr(f.type));
lns.push(pad('severity') + '= ' + tomlStr(f.severity || 'medium'));
if (f.difficulty) lns.push(pad('difficulty') + '= ' + tomlStr(f.difficulty));
lns.push(pad('description') + '= ' + tomlStr(f.description || ''));
const refs = f.locations.map(l => tomlStr(l.ref));
lns.push(pad('locations') + '= [' + refs.join(', ') + ']');
lns.push(pad('fix') + '= ' + tomlStr(f.fix || ''));
chunks.push(lns.join('\n'));
}
return buildProjectToml() + chunks.join('\n\n') + '\n';
}
// Build the GitHub-ready Markdown for one finding (mirrors findings.md).
function findingToMd(f) {
let out = '### ' + f.id + (f.severity ? ' `' + f.severity.toUpperCase() + '`' : '') + '\n\n';
out += mdField('Description', f.description) + '\n\n';
out += mdLocations(f.locations);
out += mdField('Fix', f.fix) + '\n';
return out;
}
function flashBtn(btn, msg) {
btn.textContent = msg;
setTimeout(() => { btn.textContent = '⧉ Copy'; }, 1200);
}
function copyText(text, btn) {
const ok = () => flashBtn(btn, '✓ Copied');
const no = () => flashBtn(btn, '✗ Failed');
const fallback = () => {
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.top = '-9999px';
document.body.appendChild(ta); ta.select();
const r = document.execCommand('copy');
document.body.removeChild(ta);
r ? ok() : no();
} catch { no(); }
};
if (navigator.clipboard && navigator.clipboard.writeText)
navigator.clipboard.writeText(text).then(ok, fallback);
else fallback();
}
// Filter logic
function getKey(f) {
if (S.groupBy === 'severity') return f.severity || 'medium';
if (S.groupBy === 'file') return f.locations.length ? f.locations[0].ref.replace(/:.*/,'') : '(no location)';
if (S.groupBy === 'folder') {
if (!f.locations.length) return '(no location)';
const path = f.locations[0].ref.replace(/:.*/,'');
const idx = path.lastIndexOf('/');
return idx === -1 ? '(root)' : path.slice(0, idx);
}
if (S.groupBy === 'none') return '';
return f.type_label;
}
// Pre-compute search haystacks per finding (cleared on full re-render).
// Two variants: everything (default), and file/folder paths only - for when a
// search term (e.g. a directory name like 'dlls/abc') also happens to appear
// inside description/fix prose and you only want path matches.
const _hay = new Map();
const _hayLoc = new Map();
function hayOf(f) {
if (!_hay.has(f.id)) {
_hay.set(f.id, [f.id, f.type_label, f.severity, f.description, f.fix,
f.difficulty ? (DIFFICULTY_LBL[f.difficulty] || f.difficulty) : '',
...f.locations.map(l => l.ref)].join(' ').toLowerCase());
}
return _hay.get(f.id);
}
function hayLocOf(f) {
if (!_hayLoc.has(f.id)) {
_hayLoc.set(f.id, f.locations.map(l => l.ref).join(' ').toLowerCase());
}
return _hayLoc.get(f.id);
}
function matches(f) {
if (S.hiddenDiff.size > 0 && f.difficulty && S.hiddenDiff.has(f.difficulty)) return false;
if (S.hiddenSev.has(f.severity || 'medium')) return false;
if (S.hiddenType.has(f.type)) return false;
const st = statusOf(f.id);
if (S.hiddenStatus.has(st)) return false;
if (S.search) {
const q = S.search.toLowerCase();
if (S.searchScope === 'locations') {
if (!hayLocOf(f).includes(q)) return false;
} else if (!hayOf(f).includes(q) && !st.includes(q)) {
return false;
}
}
return true;
}
function sortKeys(keys) {
if (S.groupBy === 'severity') {
return [...keys].sort((a, b) => {
const ai = SEVS.indexOf(a), bi = SEVS.indexOf(b);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
});
}
if (S.groupBy === 'type') return [...keys].sort(cmpTypes);
return [...keys].sort();
}
// Build body HTML for an expanded card (called lazily on first expand).
function renderCardBody(f, q) {
let locs = '';
for (const l of f.locations) {
const lineInfo = l.lines != null
? ' <span class="loc-lines">(' + l.lines + ' line' + (l.lines === 1 ? '' : 's') + ')</span>'
: '';
const refHtml = l.link
? '<a class="loc-link" href="' + esc(l.link) + '" target="_blank" rel="noopener" title="Open at this commit">' + highlight(l.ref, q) + ' ↗</a>'
: highlight(l.ref, q);
locs += '<div class="loc-block"><div class="loc-ref">' + refHtml + lineInfo + '</div>';
locs += l.code != null ? '<pre>' + esc(l.code) + '</pre>' : '<div class="not-found">(file not found)</div>';
locs += '</div>';
}
return (
'<div class="field-lbl">Description</div>' +
'<div class="desc">' + renderMd(f.description, q) + '</div>' +
(locs ? '<div class="field-lbl">Locations</div>' + locs : '') +
'<div class="field-lbl">Fix</div>' +
'<div class="fix-txt">' + renderMd(f.fix, q) + '</div>'
);
}
// Render card shell only — header + empty or populated body.
// When collapsed the body is empty (display:none) — avoids renderMd for 1800+ cards.
function renderCard(f, q) {
const sc = sevCls(f.severity || 'medium');
const sl = sevLbl(f.severity || 'medium');
const st = statusOf(f.id);
const sc2 = STATUS_CLS[st] || 'st-open';
const sl2 = STATUS_LBL[st] || '● open';
const isCollapsed = S.collapsed.has(f.id);
const isResolved = st !== 'open';
const locSummary = f.locations.length
? f.locations[0].ref + (f.locations.length > 1 ? ' \u2026' : '')
: '';
const bodyInner = isCollapsed ? '' : renderCardBody(f, q);
return (
'<div class="card' + (isResolved ? ' resolved' : '') + '" data-card-id="' + esc(f.id) + '">' +
'<div class="card-header" data-action="toggle-card" data-id="' + esc(f.id) + '">' +
'<span class="card-arrow">' + (isCollapsed ? '\u25b6' : '\u25bc') + '</span>' +
'<span class="card-id">' + esc(f.id) + '</span>' +
'<span class="badge ' + sc + '" style="cursor:default">' + esc(sl) + '</span>' +
'<span class="badge ' + typeCls(f.type) + '" style="cursor:default">' + esc(f.type_label) + '</span>' +
(f.difficulty ? '<span class="badge ' + (DIFFICULTY_CLS[f.difficulty]||'') + '" style="cursor:default" title="Difficulty: ' + esc(DIFFICULTY_LBL[f.difficulty]||f.difficulty) + '">' + esc(DIFFICULTY_LBL[f.difficulty]||f.difficulty) + '</span>' : '') +
(locSummary ? '<span class="card-loc" title="' + esc(locSummary) + '">' + esc(locSummary) + '</span>' : '') +
'<button class="status-btn ' + sc2 + '" data-action="cycle-status" data-id="' + esc(f.id) + '">' + sl2 + '</button>' +
'<button class="copy-btn" data-action="copy-md" data-id="' + esc(f.id) + '" title="Copy as Markdown for GitHub">\u29c9 Copy</button>' +
'</div>' +
'<div class="card-body" data-body-id="' + esc(f.id) + '"' + (isCollapsed ? ' style="display:none"' : '') + '>' +
bodyInner +
'</div></div>'
);
}
function initFilterBar() {
const fb = document.getElementById('filterbar');
let h = '<div class="filter-row"><span class="filter-lbl">Severity:</span>';
for (const s of PRESENT_SEVS)
h += '<button class="badge ' + sevCls(s) + '" data-sev="' + s + '">' + sevLbl(s) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="sev"></button></div>';
h += '<div class="filter-row"><span class="filter-lbl">Type:</span>';
for (const t of ALL_TYPES)
h += '<button class="badge ' + typeCls(t) + '" data-type="' + esc(t) + '">' + esc(typeLabel(t)) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="type"></button></div>';
h += '<div class="filter-row"><span class="filter-lbl">Status:</span>';
for (const st of STATUSES)
h += '<button class="badge ' + (STATUS_CLS[st]||'st-open') + '" data-status="' + st + '">' + (STATUS_LBL[st]||st) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="status"></button></div>';
if (HAS_DIFFICULTY) {
h += '<div class="filter-row"><span class="filter-lbl">Difficulty:</span>';
for (const d of PRESENT_DIFFS)
h += '<button class="badge ' + (DIFFICULTY_CLS[d]||'') + '" data-diff="' + d + '">' + esc(DIFFICULTY_LBL[d]||d) + '</button>';
h += '<button class="filter-toggle" data-toggle-all="diff"></button></div>';
}
fb.innerHTML = h;
}
function updateToggles() {
for (const b of document.querySelectorAll('[data-sev]')) b.classList.toggle('off', S.hiddenSev.has(b.dataset.sev));
for (const b of document.querySelectorAll('[data-type]')) b.classList.toggle('off', S.hiddenType.has(b.dataset.type));
for (const b of document.querySelectorAll('[data-status]')) b.classList.toggle('off', S.hiddenStatus.has(b.dataset.status));
for (const b of document.querySelectorAll('[data-diff]')) b.classList.toggle('off', S.hiddenDiff.has(b.dataset.diff));
for (const b of document.querySelectorAll('[data-toggle-all]')) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : k === 'status' ? S.hiddenStatus : k === 'diff' ? S.hiddenDiff : null;
if (set) b.textContent = set.size === 0 ? 'Hide all' : 'Show all';
}
}
function updateProgress() {
const total = FINDINGS.length;
const done = FINDINGS.filter(f => statusOf(f.id) !== 'open').length;
const pct = total ? Math.round(done / total * 100) : 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-text').textContent = done + ' / ' + total + ' resolved (' + pct + '%)';
}
// Surgical toggle: flip one card without touching the rest of the DOM.
function toggleCard(id) {
const wasCollapsed = S.collapsed.has(id);
if (wasCollapsed) S.collapsed.delete(id); else S.collapsed.add(id);
const body = document.querySelector('[data-body-id="' + id + '"]');
const arrow = document.querySelector('[data-card-id="' + id + '"] .card-arrow');
if (!body) { render(); return; }
if (wasCollapsed) {
if (!body.firstChild) {
const f = FINDING_BY_ID.get(id);
if (f) body.innerHTML = renderCardBody(f, S.search);
}
body.style.display = '';
if (arrow) arrow.textContent = '\u25bc';
} else {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
}
}
// Surgical status update: update one card's button + resolved class.
function cycleStatus(id) {
statuses[id] = STATUS_NEXT[statusOf(id)] || 'open';
saveStatuses();
const st = statusOf(id);
const btn = document.querySelector('.status-btn[data-id="' + id + '"]');
if (!btn) { render(); return; }
btn.className = 'status-btn ' + (STATUS_CLS[st] || 'st-open');
btn.textContent = STATUS_LBL[st] || '\u25cf open';
const card = document.querySelector('[data-card-id="' + id + '"]');
if (card) card.classList.toggle('resolved', st !== 'open');
updateProgress();
}
// Surgical collapse/expand for a group: toggle all bodies in the group without full re-render.
function collapseGroup(key, collapse) {
FINDINGS.forEach(f => {
if (getKey(f) !== key) return;
if (collapse) S.collapsed.add(f.id); else S.collapsed.delete(f.id);
const body = document.querySelector('[data-body-id="' + f.id + '"]');
const arrow = document.querySelector('[data-card-id="' + f.id + '"] .card-arrow');
if (!body) return;
if (collapse) {
body.style.display = 'none';
if (arrow) arrow.textContent = '\u25b6';
} else {
if (!body.firstChild) {
const ff = FINDING_BY_ID.get(f.id);
if (ff) body.innerHTML = renderCardBody(ff, S.search);
}
body.style.display = '';
if (arrow) arrow.textContent = '\u25bc';
}
});
}
// Full re-render: used for filter/group-by/search changes.
// Batched via requestAnimationFrame.
let _rafPending = false;
function scheduleRender() {
if (_rafPending) return;
_rafPending = true;
requestAnimationFrame(() => { _rafPending = false; render(); });
}
function render() {
_hay.clear();
_hayLoc.clear();
updateToggles();
updateProgress();
const q = S.search;
const visible = FINDINGS.filter(matches);
document.getElementById('stats').textContent = visible.length + ' / ' + FINDINGS.length + ' findings';
// Build groups
const map = new Map();
for (const f of visible) {
const k = getKey(f);
if (!map.has(k)) map.set(k, []);
map.get(k).push(f);
}
// Hidden-groups bar
const hbar = document.getElementById('hidden-bar');
if (S.hiddenGroup.size > 0) {
let hh = '<div class="hidden-inner"><span class="hidden-lbl">Hidden:</span>';
for (const g of [...S.hiddenGroup].sort())
hh += '<button class="show-btn" data-action="show-group" data-key="' + esc(g) + '">' + esc(g || '(all findings)') + '</button>';
hh += '<button class="show-all-btn" data-action="show-all">Show all</button></div>';
hbar.innerHTML = hh;
} else {
hbar.innerHTML = '';
}
// Content
const content = document.getElementById('content');
if (!visible.length) {
content.innerHTML = '<div class="no-results">No findings match the current filters.</div>';
syncHash();
return;
}
let hh = '';
for (const k of sortKeys([...map.keys()])) {
if (S.hiddenGroup.has(k)) continue;
const items = map.get(k);
const title = k || 'All Findings';
const done = items.filter(f => statusOf(f.id) !== 'open').length;
const allDone = done === items.length;
hh += '<div class="group-section">' +
'<div class="group-header">' +
'<span class="group-title' + (allDone ? ' all-done' : '') + '">' + esc(title) + '</span>' +
'<span class="group-count">' + done + '/' + items.length + '</span>' +
'<button class="icon-btn" data-action="collapse-group" data-key="' + esc(k) + '" title="Collapse all">\u229f</button>' +
'<button class="icon-btn" data-action="expand-group" data-key="' + esc(k) + '" title="Expand all">\u229e</button>' +
'<button class="hide-btn" data-action="hide-group" data-key="' + esc(k) + '">Hide</button>' +
'</div>';
for (const f of items) hh += renderCard(f, q);
hh += '</div>';
}
content.innerHTML = hh;
syncHash();
}
// Long-press solo: hold a filter badge 450 ms to isolate that value (hide all others in the group).
let _soloTimer = 0;
let _soloFired = false;
function _applySolo(b) {
_soloFired = true;
const sev = b.dataset.sev, typ = b.dataset.type, sta = b.dataset.status, dif = b.dataset.diff;
if (sev) { S.hiddenSev.clear(); PRESENT_SEVS.filter(v => v !== sev).forEach(v => S.hiddenSev.add(v)); }
else if (typ) { S.hiddenType.clear(); ALL_TYPES.filter(v => v !== typ).forEach(v => S.hiddenType.add(v)); }
else if (sta) { S.hiddenStatus.clear(); STATUSES.filter(v => v !== sta).forEach(v => S.hiddenStatus.add(v)); }
else if (dif) { S.hiddenDiff.clear(); PRESENT_DIFFS.filter(v => v !== dif).forEach(v => S.hiddenDiff.add(v)); }
scheduleRender();
}
const _fb = document.getElementById('filterbar');
_fb.addEventListener('pointerdown', e => {
const b = e.target.closest('[data-sev],[data-type],[data-status],[data-diff]');
if (!b) return;
_soloFired = false;
clearTimeout(_soloTimer);
_soloTimer = setTimeout(() => _applySolo(b), 450);
}, {passive: true});
_fb.addEventListener('pointerup', () => clearTimeout(_soloTimer), {passive: true});
_fb.addEventListener('pointercancel', () => clearTimeout(_soloTimer), {passive: true});
_fb.addEventListener('pointermove', e => {
if (!_soloTimer) return;
if (!e.target.closest('[data-sev],[data-type],[data-status],[data-diff]')) clearTimeout(_soloTimer);
}, {passive: true});
// Event delegation
_fb.addEventListener('click', e => {
if (_soloFired) { _soloFired = false; return; }
const b = e.target.closest('[data-sev],[data-type],[data-status],[data-toggle-all],[data-diff]');
if (!b) return;
if (b.dataset.toggleAll) {
const k = b.dataset.toggleAll;
const set = k === 'sev' ? S.hiddenSev : k === 'type' ? S.hiddenType : k === 'status' ? S.hiddenStatus : k === 'diff' ? S.hiddenDiff : null;
const full = k === 'sev' ? PRESENT_SEVS : k === 'type' ? ALL_TYPES : k === 'status' ? STATUSES : k === 'diff' ? PRESENT_DIFFS : [];
if (!set) return;
if (set.size === 0) full.forEach(v => set.add(v));
else set.clear();
}
else if (b.dataset.sev) { const s = S.hiddenSev; s.has(b.dataset.sev) ? s.delete(b.dataset.sev) : s.add(b.dataset.sev); }
else if (b.dataset.type) { const s = S.hiddenType; s.has(b.dataset.type) ? s.delete(b.dataset.type) : s.add(b.dataset.type); }
else if (b.dataset.status) { const s = S.hiddenStatus; s.has(b.dataset.status) ? s.delete(b.dataset.status) : s.add(b.dataset.status); }
else if (b.dataset.diff) { const s = S.hiddenDiff; s.has(b.dataset.diff) ? s.delete(b.dataset.diff) : s.add(b.dataset.diff); }
scheduleRender();
});
document.addEventListener('click', e => {
const b = e.target.closest('[data-action]');
if (!b) return;
const { action, key, id } = b.dataset;
if (action === 'hide-group') { S.hiddenGroup.add(key); scheduleRender(); }
else if (action === 'show-group') { S.hiddenGroup.delete(key); scheduleRender(); }
else if (action === 'show-all') { S.hiddenGroup.clear(); scheduleRender(); }
else if (action === 'collapse-all') { FINDINGS.forEach(f => S.collapsed.add(f.id)); scheduleRender(); }
else if (action === 'expand-all') { S.collapsed.clear(); scheduleRender(); }
else if (action === 'copy-md') {
e.stopPropagation();
const f = FINDING_BY_ID.get(id);
if (f) copyText(findingToMd(f), b);
}
else if (action === 'download-md') { downloadText(buildMdFull(), 'findings.md'); }
else if (action === 'download-md-short') { downloadText(buildMdShort(), 'findings-short.md'); }
else if (action === 'download-toml') {
// Single download (was two) - a second auto-triggered download is commonly
// blocked by the browser unless the user explicitly allows it, which silently
// drops project-info.toml. Embedding [project] in findings.toml itself avoids
// that failure mode entirely.
downloadText(buildFindingsToml(), 'findings.toml', 'application/toml');
}
else if (action === 'cycle-status') {
e.stopPropagation();
cycleStatus(id);
}
else if (action === 'toggle-card') {
toggleCard(id);
}
else if (action === 'collapse-group') {
collapseGroup(key, true);
}
else if (action === 'expand-group') {
collapseGroup(key, false);
}
});
// Debounced search — 180 ms after last keystroke
let _searchTimer = 0;
document.getElementById('search').addEventListener('input', e => {
S.search = e.target.value;
clearTimeout(_searchTimer);
_searchTimer = setTimeout(scheduleRender, 180);
});
document.getElementById('groupby').addEventListener('change', e => {
S.groupBy = e.target.value;
S.hiddenGroup.clear();
scheduleRender();
});
document.getElementById('search-scope').addEventListener('click', e => {
S.searchScope = S.searchScope === 'locations' ? 'all' : 'locations';
e.currentTarget.classList.toggle('active', S.searchScope === 'locations');
scheduleRender();
});
// Init
loadStatuses();
initFilterBar();
loadHash();
FINDINGS.forEach(f => S.collapsed.add(f.id));
render();
})();
</script>
</body></html>