-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmain.ts
More file actions
6125 lines (5193 loc) · 290 KB
/
main.ts
File metadata and controls
6125 lines (5193 loc) · 290 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { App, Plugin, PluginSettingTab, Setting, Notice, Modal, Platform, DropdownComponent, TextComponent, ExtraButtonComponent, TFile } from 'obsidian';
import { YouTubeTranscriptExtractor, TranscriptSegment } from './src/youtube-transcript';
import { TranscriptSummarizer } from './src/llm/transcript-summarizer';
import { sanitizeFilename } from './src/utils/filename-sanitizer';
import { handleApiError, getSafeErrorMessage } from './src/utils/error-utils';
import { getLogger, LogLevel, setGlobalLogLevel, clearLogs, getLogsForCallout } from './src/utils/logger';
import { normalizePath, ensureFolder, joinPaths, sanitizePathComponent } from './src/utils/path-utils';
import { validateRequired, validateYouTubeUrl, ValidationResult, displayValidationResult } from './src/utils/form-utils';
import { getPromptConfig, cleanTranscript, SummaryMode, getTimestampLinkConfig } from './src/utils/prompt-utils';
import { showNotice, isYoutubeUrl, isYoutubeChannelOrPlaylistUrl, extractChannelName } from './src/utils/youtube-utils';
import { obsidianFetch } from './src/utils/fetch-shim';
import {
extractDocumentComponents,
reconstructDocument,
validateEnhancedContent,
createOptimizedChunks,
countTimestampLinks,
ensureTrailingNewline,
hasProperHeading,
hasTimestampLinks,
convertTimestampToSeconds,
convertTimeIndexToWatchUrls
} from './src/utils/timestamp-utils';
import type { Provider } from './src/utils/model-limits-registry';
import { getEffectiveLimits, isModelSupported, upsertModel } from './src/utils/model-limits-registry';
// Initialize logger here
const logger = getLogger('PLUGIN');
const transcriptLogger = getLogger('TRANSCRIPT');
const llmLogger = getLogger('LLM');
function truncateForLogs(text: string, maxLength: number = 500): string {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...[truncated]';
}
// Define a minimal folder item interface
interface FolderItem {
path: string;
name: string;
}
interface Closeable {
close: () => void;
}
type TranscriptInputSegment = TranscriptSegment & {
tStartMs?: string;
segs?: Array<{ utf8?: string }>;
};
type UnknownRecord = Record<string, unknown>;
const isRecord = (value: unknown): value is UnknownRecord =>
typeof value === 'object' && value !== null;
type AppWithPlugins = App & {
plugins?: {
plugins?: Record<string, unknown>;
manifest?: Record<string, { id?: string }>;
};
};
type TemplaterContext = {
user?: Record<string, unknown>;
};
type TemplaterApi = {
current_functions_object?: unknown;
create_running_config: (templateFile: TFile, targetFile: TFile, mode: number) => unknown;
functions_generator: {
generate_object: (config: unknown) => Promise<TemplaterContext>;
};
parser: {
parse_commands: (content: string, ctx: TemplaterContext) => Promise<string>;
};
};
type TemplaterSettings = {
templates_folder?: string;
};
type TemplaterPlugin = {
templater: TemplaterApi;
settings?: TemplaterSettings;
};
const getPluginRegistry = (app: App): Record<string, unknown> | null => {
const plugins = (app as AppWithPlugins).plugins?.plugins;
if (!plugins || typeof plugins !== 'object') {
return null;
}
return plugins;
};
const getTemplaterPlugin = (app: App): TemplaterPlugin | null => {
const registry = getPluginRegistry(app);
const candidate = registry?.['templater-obsidian'];
if (!isRecord(candidate)) {
return null;
}
const templater = candidate['templater'];
if (!isRecord(templater)) {
return null;
}
return candidate as TemplaterPlugin;
};
const getTemplaterSettings = (app: App): TemplaterSettings | null => {
const registry = getPluginRegistry(app);
const candidate = registry?.['templater-obsidian'];
if (!isRecord(candidate)) {
return null;
}
const settings = candidate['settings'];
if (!isRecord(settings)) {
return null;
}
return settings as TemplaterSettings;
};
const getPluginIdFromManifest = (app: App, fallback: string): string => {
const manifest = (app as AppWithPlugins).plugins?.manifest;
if (!manifest || typeof manifest !== 'object') {
return fallback;
}
const entry = manifest[fallback];
if (!isRecord(entry)) {
return fallback;
}
const id = entry['id'];
return typeof id === 'string' && id.trim() ? id : fallback;
};
interface YouTubePlaylistItem {
snippet?: {
title?: string;
resourceId?: {
videoId?: string;
};
};
}
interface PlaylistItemsResponse {
items?: YouTubePlaylistItem[];
nextPageToken?: string;
}
interface PlaylistResponse {
items?: Array<{
snippet?: {
title?: string;
};
}>;
}
interface ChannelResponse {
items?: Array<{
snippet?: {
title?: string;
};
contentDetails?: {
relatedPlaylists?: {
uploads?: string;
};
};
}>;
}
interface ChannelIdResponse {
items?: Array<{
id?: string;
}>;
}
interface YouTubeTranscriptSettings {
// Template settings
templaterTemplateFile: string;
// LLM Settings
selectedLLM: string;
apiKeys: Record<string, string>;
selectedModels: Record<string, string>;
temperature: number;
maxTokens: number;
// Custom model parameters (for models not in registry)
customModelLimits: Record<string, {
contextK: number; // Context window in thousands (e.g., 400 for 400K)
maxOutputK: number; // Max output in thousands (e.g., 128 for 128K)
inputMaxK?: number; // Optional explicit input cap in thousands
reservePct?: number; // Optional reserve percentage (default: 0.10 for cloud, 0.15 for local)
}>;
// Prompt settings
systemPrompt: string;
userPrompt: string;
// Extensive prompt settings
extensiveSystemPrompt: string;
extensiveUserPrompt: string;
// Summary mode
useFastSummary: boolean;
// Second pass - timestamp linking prompt
timestampSystemPrompt: string;
timestampUserPrompt: string;
// Timestamp links
addTimestampLinks: boolean;
// Transcript settings
translateLanguage: string;
translateCountry: string;
youtubeApiKey: string;
// Folder settings
transcriptRootFolder: string;
// Date format settings
dateFormat: string;
prependDate: boolean;
// Debug settings
debugLogging: boolean;
// License settings
licenseAccepted: boolean;
// Cookie management settings
youtubeCookies?: {
desktop?: string;
mobile?: string;
lastBootstrap?: number;
timestamp?: number;
};
}
const DEFAULT_SETTINGS: YouTubeTranscriptSettings = {
templaterTemplateFile: 'Templates/YouTubeTranscript.md',
selectedLLM: 'openai',
apiKeys: {
openai: '',
anthropic: '',
google: '',
ollama: 'http://localhost:11434'
},
selectedModels: {
openai: 'gpt-4-turbo',
anthropic: 'claude-3-sonnet-20240229',
google: 'gemini-1.5-pro',
ollama: 'llama3.1'
},
temperature: 0.7,
maxTokens: 1000,
// Custom model parameters (for models not in registry)
customModelLimits: {},
// Short (Fast) Summary prompt
systemPrompt: `You are a helpful assistant that summarizes YouTube transcripts clearly and concisely using Markdown.
When you reply output plain Markdown only.
Do NOT wrap responses in \`\`\` markdown code fences.
Use code fences ONLY for code snippets that should appear as code.
Do not label any fence with "markdown"`,
userPrompt: `Extract structured notes from the transcript below without explanation or preface. Extract key points, main ideas, and important details.
FORMAT USING PROPER MARKDOWN HEADINGS with # syntax (not bold text).
Specifically:
2. Use markdown numbered subheadings (e.g., "## 1. Topic")
3. Use markdown numbered section headings (e.g., "### 1.1. Sub Topic")
4. Do NOT use bold text (**text**) for headings
5. Use bullet points for lists
This document will be processed as Markdown for Obsidian, so proper heading syntax is essential.
Provide only the summary notes.
Do not explain what you're doing or include any introductory sentence.
Your output should be clean Markdown content only. Do not introduce, explain, or narrate anything about the task. Begin directly with content.
Start the output with the actual summary content only, no headers, no preamble, no postamble.
Respond only with the raw answer, no intro or outro text.
Start the response immediately with a short paragraph summarizing the main themes. Do not label it or describe it.
At the end, have a conclusion section and list any books, people, or resources mentioned, along with a short explanation of their relevance.`,
// Extensive Summary prompt
extensiveSystemPrompt: `You are a highly analytical assistant that produces comprehensive, structured, and insightful notes from transcripts in proper Obsidian Markdown format.
You specialize in creating deep, paragraph-level breakdowns of complex material with clarity and nuance.
Your notes help readers understand both what is said and the reasoning or implications behind it.
The objective is to extract all meaningful content, ideas, and knowledge from the transcript
so that a reader can fully understand and review the material through structured notes without needing to watch or re-watch the video.
IMPORTANT: Always use proper Markdown heading syntax with # characters (not bold text) for all headings and section titles.
When you reply output plain Markdown only.
Do NOT wrap responses in \`\`\` markdown code fences.
Use code fences ONLY for code snippets that should appear as code.
Do not label any fence with "markdown"`,
extensiveUserPrompt: `From the transcript below, create detailed and structured notes for someone who wants to understand the material in depth.
Organize the content into clearly numbered sections based on major topic or theme changes.
Extract structured notes from the transcript below without explanation or preface, extract key points, main ideas, and important details.
FORMAT USING PROPER MARKDOWN HEADINGS with # syntax (not bold text).
Specifically:
2. Use markdown numbered subheadings (e.g., "## 1. Topic")
3. Use markdown numbered section headings (e.g., "### 1.1. Sub Topic")
4. Do NOT use bold text (**text**) for headings
5. Use bullet points for lists
This document will be processed as Markdown for Obsidian, so proper heading syntax is essential. Treat this as a document for training future analysts in this field.
Provide only the summary notes.
Do not explain what you're doing or include any introductory sentence.
Your output should be clean Markdown content only. Do not introduce, explain, or narrate anything about the task. Begin directly with content.
Start the output with the actual summary content only, no headers, no preamble, no postamble.
Respond only with the raw answer, no intro or outro text.
For each section:
- Number sections sequentially (1, 2, 3, etc.). IMPORTANT: Use actual Obsidian Markdown heading syntax with # symbols, not bold text.
- Write multiple detailed paragraphs, that explain the content and any theory, technical terms or definitions, models and frameworks thoroughly and in great detail drawn from the transcript.
- Include below the paragraphs key concepts, terms, taxonomy, ontology , or ideas, and explain them clearly with examples where relevant.
- Incorporate and explain important quotations direct from subject (person) , analogies, or references.
- Explore the reasoning, implications, or broader significance behind the ideas.
- Explicitly identify and analyze any contrasts, tensions, contradictions, or shifts in perspective throughout the discussion. Pay special attention to dialectical relationships between concepts.
Start the response immediately with a short paragraph summarizing the main themes. Do not label it or describe it.
At the end, have a conclusion section and list any books, people, or resources mentioned, along with a short explanation of their relevance.`,
// Second pass - timestamp linking prompt
timestampSystemPrompt: 'You are a highly analytical assistant that adds TimeIndex markers to section headings by deeply analyzing the content under each heading. Your expertise is in content analysis - reading the detailed content of each section and matching it to where that specific content is substantially discussed in the transcript. You focus on semantic content matching, not superficial title matching. You never include any reference material (like video IDs or transcripts) in your output.',
timestampUserPrompt: `TASK: Add TimeIndex markers to each section heading in this document.
CRITICAL: You must output TimeIndex markers in format [TimeIndex:SECONDS] - NOT YouTube Watch URLs!
RULES:
1. NEVER summarize or modify the content unless translation is requested
2. NEVER remove any content
3. ALWAYS return the FULL original content PLUS TimeIndex markers at the end of section headings
4. If processing multiple sections, add TimeIndex markers to ALL headings
5. ONLY process markdown numbered headings
a. for subheadings (e.g., "## 1. Topic")
b. for section headings (e.g., "## 1.1. Sub Topic")
6. DO NOT process headings without numbers or dots
7. DO NOT process horizontal rules (single #)
8. Do NOT add a preamble or postamble or headers or titles, ONLY ADD TimeIndex markers to headings
9. Respond only with the raw answer, no intro or outro text.
10. NEVER include any reference material marked by ----- REFERENCE MATERIAL ----- blocks in your response.
EXACTLY HOW TO DO THIS:
1. Identify ALL section headings in the document that follow the markedown format
2. Look at the transcript which has timestamps in format: [HH:MM:SS] [TimeIndex:X] where X is the exact seconds value
3. For each section heading, THOROUGHLY READ AND ANALYZE THE ENTIRE CONTENT UNDER THAT HEADING:
- Read every paragraph, bullet point, and detail in that section
- Identify the key concepts, specific examples, and main arguments discussed
- Note specific terminology, names, numbers, or unique phrases used
- The heading title alone is NOT sufficient - you must understand what the section actually covers
4. Then find where in the transcript this SPECIFIC CONTENT is BEST and MOST COMPREHENSIVELY DISCUSSED:
- Look for transcript segments that contain the same specific details, examples, and concepts
- Find where the speaker begins to substantively address the topics covered in that section
- The goal is to link to where the content actually starts being discussed, not just mentioned
5. When matching section content to transcript timestamps:
- Match based on CONTENT SUBSTANCE, not just heading titles or keyword mentions
- A section about "Investment Strategies" should link to where investment strategies are actually explained, not just where the phrase appears
- Look for where the speaker begins the detailed discussion that led to the content in that section
- Simply use the TimeIndex value from the relevant transcript section
- Example: If you find the relevant transcript section has [TimeIndex:175], add [TimeIndex:175] to the heading
- DO NOT calculate seconds manually - just use the TimeIndex value directly
- IMPORTANT: Only use TimeIndex values that actually appear in the transcript
- ENSURE the TimeIndex value does not exceed the length of the video
6. Add the TimeIndex marker in the format: [TimeIndex:SECONDS] where SECONDS is the number of seconds
- Example: If transcript shows [TimeIndex:175], add [TimeIndex:175] to the heading
- Always use the exact seconds value from the transcript's TimeIndex
- Transform heading "## 1. Introduction" to "## 1. Introduction [TimeIndex:175]"
- Another example: "### 3.1. The Scam of Government Bonds [TimeIndex:338]"
7. Place the TimeIndex marker at the end of the heading line, after the heading text`,
// Default to Extensive Summary
useFastSummary: false,
translateLanguage: 'en',
translateCountry: 'US',
youtubeApiKey: '',
transcriptRootFolder: 'Inbox', // Default to Inbox for backward compatibility
dateFormat: 'YYYY-MM-DD',
prependDate: true,
addTimestampLinks: true,
debugLogging: false,
// License settings
licenseAccepted: false,
// Cookie management - undefined means no cookies stored yet
youtubeCookies: undefined,
};
// Define a simple interface for the model object from OpenAI API
interface OpenAIModel {
id: string;
object: string;
created: number;
owned_by: string;
// Add other relevant properties if needed in the future
}
// Define a simple interface for the model object from Google Generative AI API
interface GoogleModel {
name: string; // e.g., "models/gemini-1.5-pro-latest"
displayName?: string; // e.g., "Gemini 1.5 Pro"
version?: string;
description?: string;
supportedGenerationMethods?: string[];
// Add other relevant properties if needed
}
interface ApiErrorResponse {
error?: {
message?: string;
};
message?: string;
}
interface OpenAIModelsResponse {
data?: OpenAIModel[];
}
interface GoogleModelsResponse {
models?: GoogleModel[];
}
export default class YouTubeTranscriptPlugin extends Plugin {
settings: YouTubeTranscriptSettings;
private summarizer: TranscriptSummarizer;
private fileWatcher: Closeable | null = null;
// Replace the duplicated showNotice method with a wrapper that calls the shared utility
showNotice(message: string, timeout: number = 5000): void {
showNotice(message, timeout);
}
// Get plugin version from manifest
getVersion(): string {
return this.manifest.version || 'Unknown';
}
async onload() {
await this.loadSettings();
// Set appropriate max tokens based on current provider and model using registry
const effectiveMaxTokens = this.getEffectiveMaxTokens();
// Only update if the current setting is a legacy hardcoded value
if (this.settings.maxTokens === 4096 || this.settings.maxTokens === 8192 || this.settings.maxTokens === 1000) {
this.settings.maxTokens = effectiveMaxTokens;
await this.saveSettings();
if (this.settings.debugLogging) {
logger.debug(`[onload] Updated maxTokens from legacy value to ${effectiveMaxTokens} for ${this.settings.selectedLLM}:${this.settings.selectedModels[this.settings.selectedLLM]}`);
}
}
// Set log level based on settings
if (this.settings.debugLogging) {
setGlobalLogLevel(LogLevel.DEBUG);
} else {
setGlobalLogLevel(LogLevel.INFO);
}
this.initializeSummarizer();
this.addSettingTab(new YouTubeTranscriptSettingTab(this.app, this));
this.checkDependencies();
// Add ribbon icon
this.addRibbonIcon('youtube', 'Tubesage: create note from YouTube transcript', () => {
// Check if license has been accepted
if (!this.settings.licenseAccepted) {
// Show license required modal if not accepted
new LicenseRequiredModal(this.app).open();
return;
}
// Check if API key is set for the selected LLM provider
const selectedLlm = this.settings.selectedLLM;
if (!this.settings.apiKeys[selectedLlm] || this.settings.apiKeys[selectedLlm].trim() === '') {
// Show error notice
new Notice(`Youtube Transcript Plugin: No API key configured for ${selectedLlm}. Please add your API key in the plugin settings.`);
return;
}
// If license is accepted and API key is set, proceed with the usual workflow
new YouTubeTranscriptModal(this.app, this).open();
});
// Add command
this.addCommand({
id: 'extract-youtube-transcript',
name: 'Extract YouTube transcript',
callback: () => {
// Check if license has been accepted
if (!this.settings.licenseAccepted) {
// Show license required modal if not accepted
new LicenseRequiredModal(this.app).open();
return;
}
// Check if API key is set for the selected LLM provider
const selectedLlm = this.settings.selectedLLM;
if (!this.settings.apiKeys[selectedLlm] || this.settings.apiKeys[selectedLlm].trim() === '') {
// Show error notice
new Notice(`Youtube transcript plugin: No API key configured for ${selectedLlm}. Please add your API key in the plugin settings.`);
return;
}
// If license is accepted and API key is set, proceed with the usual workflow
new YouTubeTranscriptModal(this.app, this).open();
}
});
// Note: The file watcher setup has been removed as it was dependent on the anthropic proxy
}
onunload() {
logger.debug('Unloading youtube transcript plugin');
// Clean up file watcher if it exists
if (this.fileWatcher) {
try {
this.fileWatcher.close();
this.fileWatcher = null;
logger.debug('Closed file watcher');
} catch (error) {
logger.error('Error closing file watcher:', error);
}
}
// Any other cleanup needed
logger.info('Youtube transcript plugin unloaded');
}
private initializeSummarizer() {
const selectedProvider = this.settings.selectedLLM;
const selectedModel = this.getModelForProvider(selectedProvider);
logger.debug(`[initializeSummarizer] Selected provider: '${selectedProvider}'`);
logger.debug(`[initializeSummarizer] Selected model: '${selectedModel}'`);
logger.debug(`[initializeSummarizer] Temperature: ${this.settings.temperature}, MaxTokens: ${this.settings.maxTokens}`);
logger.debug(`[initializeSummarizer] API Keys present:`, Object.keys(this.settings.apiKeys).reduce((acc, key) => {
acc[key] = !!this.settings.apiKeys[key];
return acc;
}, {} as Record<string, boolean>));
this.summarizer = new TranscriptSummarizer({
model: selectedModel,
temperature: this.settings.temperature,
maxTokens: this.getEffectiveMaxTokens(), // Use dynamic calculation for all models
systemPrompt: this.settings.systemPrompt,
userPrompt: this.settings.userPrompt
}, this.settings.apiKeys);
}
private getModelForProvider(provider: string): string {
logger.debug(`[getModelForProvider] Getting model for provider: '${provider}'`);
logger.debug(`[getModelForProvider] selectedModels object:`, JSON.stringify(this.settings.selectedModels, null, 2));
if (this.settings.selectedModels[provider]) {
const selectedModel = this.settings.selectedModels[provider];
logger.debug(`[getModelForProvider] Found selected model for ${provider}: '${selectedModel}'`);
return selectedModel;
}
// Fallback to defaults if no selection exists
logger.debug(`[getModelForProvider] No selected model found for ${provider}, using fallback`);
switch (provider) {
case 'openai':
logger.debug(`[getModelForProvider] Using OpenAI fallback: 'gpt-4-turbo'`);
return 'gpt-4-turbo';
case 'anthropic':
logger.debug(`[getModelForProvider] Using Anthropic fallback: 'claude-3-sonnet-20240229'`);
return 'claude-3-sonnet-20240229';
case 'google':
logger.debug(`[getModelForProvider] Using Google fallback: 'gemini-1.5-pro'`);
return 'gemini-1.5-pro';
case 'ollama':
return 'llama3.1';
default:
throw new Error(`Unsupported LLM provider: ${provider}`);
}
}
async loadSettings() {
const loadedData: unknown = await this.loadData();
logger.debug('[SETTINGS DEBUG] Loaded data from storage:', loadedData);
logger.debug('[SETTINGS DEBUG] DEFAULT_SETTINGS.selectedLLM:', DEFAULT_SETTINGS.selectedLLM);
const loadedSettings: Partial<YouTubeTranscriptSettings> = isRecord(loadedData)
? (loadedData as Partial<YouTubeTranscriptSettings>)
: {};
this.settings = { ...DEFAULT_SETTINGS, ...loadedSettings };
logger.debug('[SETTINGS DEBUG] Final settings.selectedLLM:', this.settings.selectedLLM);
logger.debug('[SETTINGS DEBUG] All settings keys:', Object.keys(this.settings));
// --- Fix legacy string booleans (mobile settings files might contain "true"/"false" strings) ---
const coerceBool = (val: unknown, defaultVal: boolean): boolean => {
if (typeof val === 'boolean') return val;
if (typeof val === 'string') return val.toLowerCase() === 'true';
return defaultVal;
};
// Ensure all boolean flags are actual booleans
this.settings.debugLogging = coerceBool(this.settings.debugLogging, DEFAULT_SETTINGS.debugLogging);
this.settings.prependDate = coerceBool(this.settings.prependDate, DEFAULT_SETTINGS.prependDate);
this.settings.addTimestampLinks = coerceBool(this.settings.addTimestampLinks, DEFAULT_SETTINGS.addTimestampLinks);
this.settings.useFastSummary = coerceBool(this.settings.useFastSummary, DEFAULT_SETTINGS.useFastSummary);
this.settings.licenseAccepted = coerceBool(this.settings.licenseAccepted, DEFAULT_SETTINGS.licenseAccepted);
// ---------------------------------------------------------------------------
}
async saveSettings() {
await this.saveData(this.settings);
this.initializeSummarizer();
}
async extractTranscript(videoUrl: string): Promise<string> {
const result = await this.extractTranscriptWithMetadata(videoUrl);
return result.transcript;
}
async extractTranscriptWithMetadata(videoUrl: string): Promise<{transcript: string, metadata: {title?: string, author?: string}}> {
return await this.extractTranscriptsWithMetadata([videoUrl]).then(results => results[0]);
}
async extractTranscriptsWithMetadata(videoUrls: string[]): Promise<Array<{transcript: string, metadata: {title?: string, author?: string}}>> {
try {
transcriptLogger.debug(`Starting transcript extraction for ${videoUrls.length} URLs`);
const results: Array<{transcript: string, metadata: {title?: string, author?: string}}> = [];
// Process each URL
for (let i = 0; i < videoUrls.length; i++) {
const videoUrl = videoUrls[i];
try {
transcriptLogger.debug(`Processing video ${i + 1}/${videoUrls.length}: ${videoUrl}`);
// Extract video ID from URL
const videoId = YouTubeTranscriptExtractor.extractVideoId(videoUrl);
if (!videoId) {
throw new Error(`Invalid youtube URL: '${videoUrl}'. Please ensure the URL is properly formatted without extra characters like quotes.`);
}
// Get transcript segments and metadata using direct ScrapeCreators method
const result = await YouTubeTranscriptExtractor.fetchTranscript(videoId, {
lang: this.settings.translateLanguage,
country: this.settings.translateCountry
});
// Format transcript with timestamps
const formattedTranscript = this.formatTranscriptForYaml(result.segments);
results.push({
transcript: formattedTranscript,
metadata: result.metadata
});
} catch (error) {
transcriptLogger.error(`Failed to extract transcript for video ${i + 1}/${videoUrls.length}:`, error);
const errorMessage = getSafeErrorMessage(error);
// Continue with other videos, but include error result
results.push({
transcript: `[TRANSCRIPT EXTRACTION FAILED: ${errorMessage}]`,
metadata: { title: `Error extracting from URL: ${videoUrl}`, author: 'Unknown' }
});
}
}
return results;
} catch {
// Use the new error handling utility
throw handleApiError('Unknown error', 'Youtube API', 'Transcript extraction');
}
}
// Helper method to format transcript segments for YAML frontmatter
private formatTranscriptForYaml(segments: TranscriptInputSegment[]): string {
// Process segments into formatted text with timestamps
let formattedTranscript = '';
transcriptLogger.debug("Formatting transcript segments:",
(Array.isArray(segments) ? `${segments.length} segments` : 'not an array'));
if (Array.isArray(segments)) {
// Create exactly 1-minute chunks based on actual timestamps
const ONE_MINUTE_SECONDS = 60; // 1 minute in seconds
let chunks: {time: string, text: string, seconds: number}[] = [];
// Track the current chunk being built
let currentChunk = {
time: '',
text: '',
seconds: 0,
startSeconds: 0
};
let isFirstSegment = true;
// Function to parse and convert timestamp to seconds
const timestampToSeconds = (time: number | string): number => {
// If time is already a number (seconds), return it
if (typeof time === 'number') {
return time;
}
// Handle milliseconds (convert to seconds)
if (typeof time === 'string' && time.includes('ms')) {
return parseInt(time) / 1000;
}
// If we don't recognize the format, return 0
return 0;
};
// Sort segments by timestamp if needed
const sortedSegments = [...segments].sort((a, b) => {
const aTime = timestampToSeconds(a.start || (a.tStartMs ? parseInt(a.tStartMs) / 1000 : 0));
const bTime = timestampToSeconds(b.start || (b.tStartMs ? parseInt(b.tStartMs) / 1000 : 0));
return aTime - bTime;
});
// Process and group segments into chunks based on actual timestamps
sortedSegments.forEach((segment, index) => {
// Get segment start time in seconds
let segmentTimeSeconds = 0;
if (typeof segment.start === 'number') {
segmentTimeSeconds = segment.start;
} else if (segment.tStartMs) {
segmentTimeSeconds = parseInt(segment.tStartMs) / 1000;
}
// Format segment time as HH:MM:SS
const segmentTimeFormatted = this.formatTimestamp(segmentTimeSeconds);
// Extract text from segment
let segmentText = '';
if (segment.text) {
segmentText = segment.text.trim();
} else if (segment.segs && Array.isArray(segment.segs)) {
segmentText = segment.segs.map((s) => s.utf8 || '').join('').trim();
}
// Skip empty segments
if (!segmentText) return;
// If this is the first segment or we've reached/exceeded a minute boundary
if (isFirstSegment ||
(segmentTimeSeconds - currentChunk.startSeconds >= ONE_MINUTE_SECONDS)) {
// Add the previous chunk if it exists and isn't the first segment
if (!isFirstSegment && currentChunk.text) {
chunks.push({
time: this.formatTimestamp(currentChunk.startSeconds),
text: currentChunk.text,
seconds: currentChunk.startSeconds
});
}
// Start a new chunk
currentChunk = {
time: segmentTimeFormatted,
text: segmentText,
seconds: segmentTimeSeconds,
startSeconds: segmentTimeSeconds
};
isFirstSegment = false;
} else {
// Add to current chunk with a space
currentChunk.text += ' ' + segmentText;
}
});
// Add the last chunk if it has content
if (currentChunk.text) {
chunks.push({
time: this.formatTimestamp(currentChunk.startSeconds),
text: currentChunk.text,
seconds: currentChunk.startSeconds
});
}
transcriptLogger.debug(`Created ${chunks.length} exactly time-based chunks`);
// Format chunks for YAML frontmatter
// Start with a newline to ensure proper YAML block format
formattedTranscript = "\n";
chunks.forEach((chunk) => {
// Create the TimeIndex marker with unescaped colon
const timeIndexMarker = `[TimeIndex:${Math.round(chunk.seconds)}]`;
// Handle escaping of colons in the text portion only
let textContent = chunk.text;
// Remove any existing TimeIndex markers from the text
const timeIndexRegex = /\[TimeIndex:(\d+)\]/g;
textContent = textContent.replace(timeIndexRegex, '');
// Now escape colons only in the text
const escapedText = textContent.replace(/:/g, "\\:");
// Position the TimeIndex marker right after the timestamp
formattedTranscript += ` [${chunk.time}] ${timeIndexMarker} ${escapedText}\n`;
});
} else {
// Fallback if segments is not an array
formattedTranscript = "\n Unable to format transcript properly";
}
return formattedTranscript;
}
// Format seconds into HH:MM:SS format
private formatTimestamp(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return [
h.toString().padStart(2, '0'),
m.toString().padStart(2, '0'),
s.toString().padStart(2, '0')
].join(':');
}
async summarizeTranscript(transcript: string): Promise<string> {
try {
// Clean the transcript using our utility function
const cleanedTranscript = cleanTranscript(transcript);
// Determine which prompt to use based on settings
const summaryMode = this.settings.useFastSummary ? SummaryMode.FAST : SummaryMode.EXTENSIVE;
// Get the prompt configuration with dynamic max tokens
const promptConfig = getPromptConfig(this.settings, summaryMode, this.getEffectiveMaxTokens());
// --- Add logging for the summarization step ---
llmLogger.debug(`[summarizeTranscript] Starting ${summaryMode} summary.`);
llmLogger.debug(`[summarizeTranscript] Using model: ${this.settings.selectedLLM} - ${this.getModelForProvider(this.settings.selectedLLM)}`);
llmLogger.debug(`[summarizeTranscript] Max Tokens: ${promptConfig.maxTokens}, Temperature: ${promptConfig.temperature}`);
if (this.settings.debugLogging) { // Only log prompts/transcript in debug mode
llmLogger.debug("--- System Prompt ---");
llmLogger.debug(truncateForLogs(promptConfig.systemPrompt, 200));
llmLogger.debug("--- User Prompt ---");
llmLogger.debug(truncateForLogs(promptConfig.userPrompt, 200));
// Log truncated transcript to avoid excessive length
llmLogger.debug("--- Cleaned Transcript (Excerpt) ---");
llmLogger.debug(truncateForLogs(cleanedTranscript, 300));
llmLogger.debug("---------------------------------");
}
// --- End added logging ---
llmLogger.debug("Using token limit:", promptConfig.maxTokens); // Keep existing token log
// Store provider value before any potential context corruption
const selectedProvider = this.settings.selectedLLM;
// Debug the config being passed to TranscriptSummarizer
const model = this.getModelForProvider(selectedProvider);
llmLogger.debug(`[DEBUG] Model: ${model}`);
llmLogger.debug(`[DEBUG] Temperature: ${promptConfig.temperature}`);
llmLogger.debug(`[DEBUG] MaxTokens: ${promptConfig.maxTokens}`);
llmLogger.debug(`[DEBUG] SystemPrompt length: ${promptConfig.systemPrompt?.length || 'undefined'}`);
llmLogger.debug(`[DEBUG] UserPrompt length: ${promptConfig.userPrompt?.length || 'undefined'}`);
llmLogger.debug(`[DEBUG] SelectedLLM: ${selectedProvider}`);
// Safety check for settings and API keys
llmLogger.debug(`[DEBUG] Checking settings - exists: ${!!this.settings}`);
if (!this.settings) {
throw new Error('Plugin settings are not loaded');
}
llmLogger.debug(`[DEBUG] Checking apiKeys - exists: ${!!this.settings.apiKeys}`);
llmLogger.debug(`[DEBUG] ApiKeys type: ${typeof this.settings.apiKeys}`);
const apiKeyKeys = this.settings.apiKeys ? Object.keys(this.settings.apiKeys) : [];
llmLogger.debug(`[DEBUG] ApiKeys keys: ${apiKeyKeys.length ? apiKeyKeys.join(', ') : 'none'}`);
if (!this.settings.apiKeys) {
throw new Error('API keys are not configured in settings');
}
// Create a summarizer with the prompt configuration
llmLogger.debug(`[DEBUG] About to create TranscriptSummarizer...`);
const tempSummarizer = new TranscriptSummarizer({
model: model,
temperature: promptConfig.temperature,
maxTokens: promptConfig.maxTokens,
systemPrompt: promptConfig.systemPrompt,
userPrompt: promptConfig.userPrompt
}, this.settings.apiKeys);
llmLogger.debug(`[DEBUG] TranscriptSummarizer created successfully`);
llmLogger.debug(`[DEBUG] About to call summarize with provider: '${selectedProvider}'`);
llmLogger.debug(`[DEBUG] this object type:`, typeof this, this.constructor.name);
llmLogger.debug(`[DEBUG] this.settings exists:`, !!this.settings);
llmLogger.debug(`[DEBUG] Full settings.selectedLLM value:`, this.settings?.selectedLLM);
llmLogger.debug(`[DEBUG] Settings object keys:`, this.settings ? Object.keys(this.settings) : 'settings is null/undefined');
const summary = await tempSummarizer.summarize(cleanedTranscript, selectedProvider);
// Add the creator support message at the beginning of the summary
const supportMessage = "Support content creators: If you found this content valuable, please consider supporting the Youtube creator by liking 👍 the video and subscribing to their channel. ";
// Sanitize the beginning of the summary to ensure clean paragraph flow
let sanitizedSummary = summary;
// Remove leading newlines, spaces, and markdown formatting from the summary
sanitizedSummary = sanitizedSummary.replace(/^[\s\n\r]*/, '');
// If the summary starts with list markers or headers, we need a line break
if (/^(#|-|\*|\d+\.)/.test(sanitizedSummary)) {
// Summary starts with Markdown formatting, need to keep them separated
return supportMessage + "\n\n" + sanitizedSummary;
} else {
// Get the first paragraph from the summary (up to first double newline)
const firstParagraphMatch = sanitizedSummary.match(/^([^\n]+(?:\n[^\n]+)*)/);
if (firstParagraphMatch) {
const firstParagraph = firstParagraphMatch[0];
// Replace any single newlines with spaces in the first paragraph
const cleanFirstParagraph = firstParagraph.replace(/\n/g, ' ');
// Rest of the summary after the first paragraph
const restOfSummary = sanitizedSummary.substring(firstParagraph.length);
// Combine: support message + clean first paragraph + rest of summary
return supportMessage + cleanFirstParagraph + restOfSummary;
}
// Fallback if we can't match a first paragraph
return supportMessage + sanitizedSummary;
}
} catch (error) {
// Use the error handling utility
throw handleApiError(error, this.settings.selectedLLM, 'Summarization');
} finally {
// If using Anthropic provider and fast summary mode or not adding timestamp links,
// log information about completion
if (this.settings.selectedLLM === 'anthropic' &&
(this.settings.useFastSummary || !this.settings.addTimestampLinks)) {
llmLogger.info('[summarizeTranscript] Completed Anthropic processing (fast summary or no timestamp links)');
}
}
}
async applyTemplate(title: string, videoUrl: string, transcript: string, summary: string, folder?: string, contentType?: string): Promise<void> {
// Check if Templater plugin is available
const templaterPlugin = getTemplaterPlugin(this.app);
if (!templaterPlugin) {
this.showNotice('Error: Templater plugin is required but not installed or enabled', 5000);
throw new Error('Templater plugin is required but not installed or enabled.');
}
try {
// Get the Templater instance
const templater = templaterPlugin.templater;
// If Templater has never run, do a dummy parse to initialize.
if (!templater.current_functions_object) {
// We'll initialize with the actual template processing below
}
// Sanitize the title for use as a filename
const sanitizedTitle = sanitizeFilename(title);
// Normalize video URL data for templating (e.g., shorts/playlist URLs)
const videoId = YouTubeTranscriptExtractor.extractVideoId(videoUrl);
const watchUrl = videoId ? `https://www.youtube.com/watch?v=${videoId}` : videoUrl;
const thumbnailUrl = videoId ? `https://img.youtube.com/vi/${videoId}/hqdefault.jpg` : '';
// Format date according to settings
let datePrefix = '';
if (this.settings.prependDate) {
const now = new Date();
// Format date based on the selected format
switch (this.settings.dateFormat) {
case 'YYYY-MM-DD':
datePrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `;
break;
case 'MM-DD-YYYY':
datePrefix = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}-${now.getFullYear()} `;
break;
case 'DD-MM-YYYY':
datePrefix = `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()} `;
break;
default:
datePrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} `;
}
}
// REDO THE TRANSCRIPT FORMATTING FOR YAML
// We'll re-process the transcript no matter what format it's in
logger.debug("Processing transcript for YAML format");
// Format the transcript with original timestamps preserved
let formattedTranscript = "";
// Check if the transcript already has timestamps in format [HH:MM:SS]
if (transcript.includes('[00:') || transcript.includes('[01:') || transcript.match(/\[\d{2}:\d{2}:\d{2}\]/)) {
logger.debug("Transcript contains timestamps, organizing into ≥60 second blocks");